feat: 更新内容管理方案

This commit is contained in:
R524809
2026-04-29 17:47:14 +08:00
parent 63fa08ff1a
commit 2cb9ec55d6
44 changed files with 1181 additions and 625 deletions
+5 -607
View File
@@ -1,608 +1,6 @@
# Doodle Mini Debug 模式内容发布方案
# Doodle Mini - Debug 发布与内容管理方案
> 版本:v1.0
> 最后更新:2026-04-23
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [产品设计文档](./产品设计文档.md)
---
## 一、方案定位与动机
### 1.1 当前痛点
每新增一种可打印资料,从开发完成到上线需要经历以下**手动步骤**:
```
开发页面 + Draw 逻辑
→ ① 在预览中截图或导出入口图原图
→ ② 放入 node-tools/entranceInput/<分类>/
→ ③ 运行 processEntrancePicture.js 裁剪缩放
→ ④ 从 entranceOuput/ 拷贝到 miniprogram/assets/entrancePicture/
→ ⑤ 手动编辑 category.data.ts 添加元数据(title、desc、path、previewImg 等)
→ ⑥ 提交代码 → 审核 → 发版
```
其中 ①~⑤ 是重复性机械劳动,且容易出错(路径拼错、忘记更新数据文件、图片尺寸不一致等)。
### 1.2 核心洞察
**当一个页面开发调试完成时,所有需要的信息已经就绪**
- **页面路径** — 就是当前正在调试的页面 URL
- **标题 / 副标题** — 已写在绘制逻辑或配置中
- **入口图片** — 预览 Canvas 上正在渲染的就是
- **分类 / 难度 / 年龄段 / 标签** — 已在页面配置中定义
在这个时刻一键上传,是**成本最低、数据最准确**的做法。
### 1.3 方案目标
在 **develop / devtools 环境**的预览页中增加「发布到云端」按钮,点击后自动:
1. 从 Canvas 导出入口图 → 裁剪 → 压缩
2. 上传图片到云存储
3. 组装页面元数据 → 写入云数据库 `worksheets` 集合
4. 线上小程序通过缓存策略拉取云端数据,新内容**无需发版即可上线**
**不需要开发独立后台系统,不需要手动录入数据。**
---
## 二、整体流程
```
┌──────────────────────────────────────────────────────────────────┐
│ Debug 模式发布流程 │
│ │
│ ① 开发者在 develop / devtools 环境下完成页面开发 │
│ (绘制逻辑调试完毕,预览效果满意) │
│ │
│ ② 预览区底部出现 [📤 发布到云端] 按钮 │
│ (仅 develop / devtools 环境可见,线上版本不显示) │
│ │
│ ③ 点击后弹出确认面板,预览即将上传的数据: │
│ ┌─────────────────────────────────────────┐ │
│ │ ID: letter-tracing-daily-checkin │ │
│ │ 标题: 每日打卡 │ │
│ │ 副标题: 四宫格每日字母打卡练习 │ │
│ │ 分类: english │ │
│ │ 路径: /englishPages/letterTracing/... │ │
│ │ 难度: 1 │ │
│ │ 年龄: 4-7岁 │ │
│ │ 标签: [字母, 描红, 打卡] │ │
│ │ [预览图缩略图] │ │
│ │ 裁剪图标:头部和底部、仅头部、不裁剪 │ │
│ │ │ │
│ │ ─ 可编辑字段(允许微调) ─ │ │
│ │ │ │
│ │ 状态: ○ draft(默认) ○ active │ │
│ │ │ │
│ │ [取消] [确认发布] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ④ 确认发布后,自动执行: │
│ a. Canvas 导出 → 裁剪页眉页脚 → 压缩至 ≤200KB │
│ b. 上传图片到云存储 │
│ cloud://xxx/assets/previews/<category>/<id>.jpg │
│ c. 组装 WorksheetConfig → upsert 到云数据库 worksheets 集合 │
│ d. 显示「发布成功 ✅」 │
│ │
│ ⑤ 线上小程序按缓存策略拉取新数据,内容自动出现 │
│ (或在 debug 管理页手动将 status 从 draft 切为 active
└──────────────────────────────────────────────────────────────────┘
```
---
## 三、技术设计
### 3.1 环境门控
发布功能**仅在开发环境**可见,**绝不暴露给普通用户**。
```typescript
/**
* 判断当前是否为开发环境,可执行 debug 发布操作。
* 复用 downloadPrint.ts 中 isDevBypassLimits 的判断模式。
*/
function isDebugPublishEnabled(): boolean {
const accountInfo = wx.getAccountInfoSync();
const { envVersion } = accountInfo.miniProgram;
// develop: 开发版;trial: 体验版;release: 正式版
return envVersion === 'develop';
}
```
WXML 中条件渲染:
```xml
<!-- 仅开发环境显示发布按钮 -->
<view wx:if="{{isDevEnv}}" class="debug-publish-bar">
<button bind:tap="onDebugPublish">📤 发布到云端</button>
</view>
```
### 3.2 页面元数据约定
每个 draw 页面需提供 `getPublishMeta()` 方法,返回标准化的发布元数据。
```typescript
/**
* 发布元数据接口
* 各 draw 页面实现此接口,提供自身的配置信息
*/
interface PublishMeta {
// ─── 必填字段 ───
id: string; // 唯一标识,如 'letter-tracing-daily-checkin'
title: string; // 显示标题
desc: string; // 显示描述
category: 'math' | 'chinese' | 'english' | 'puzzle' | 'craft';
subcategory: string; // 子分类
path: string; // 页面完整路径(含参数)
ageMin: number; // 适龄最小值
ageMax: number; // 适龄最大值
difficulty: 1 | 2 | 3 | 4; // 1-4:入门、基础、进阶、挑战
// ─── 选填字段(有默认值)───
previewImg?: string; // 上传入口图后回填的云存储 fileID
tags?: string[]; // 搜索标签
sortOrder?: number; // 排序权重
}
```
`pageMixin` 中约定调用方式:
```typescript
// pageMixin 中新增
debugPublishMixin: {
getPublishMeta(): PublishMeta {
// 子类覆写此方法
throw new Error('页面未实现 getPublishMeta()');
}
}
```
各页面实现示例(letterTracing):
```typescript
getPublishMeta(): PublishMeta {
const drawService = this.drawService;
return {
id: this.data.currentId,
title: drawService.title,
desc: drawService.desc,
category: 'english',
subcategory: 'letter-tracing',
path: `/englishPages/letterTracing/letterTracing?id=${this.data.currentId}`,
ageMin: 4,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '英语'],
};
}
```
### 3.3 入口图导出与处理
复用已有的 `preview-card` 组件的 `exportToTempFile` 能力,再做裁剪处理。
```typescript
/**
* 从预览 Canvas 导出入口图
*
* 处理流程:
* 1. 从 preview-card 导出完整 A4 临时文件
* 2. 用离屏 Canvas 裁剪掉页眉/页脚区域(与 processEntrancePicture.js 逻辑对齐)
* 3. 缩放至入口图标准宽度(600px)
* 4. 导出为 JPEGquality: 0.85
*/
async function exportEntranceImage(
canvas: WechatMiniprogram.Canvas,
ctx: CanvasRenderingContext2D,
paperConfig: {
headerHeight: number;
footerHeight: number;
width: number;
height: number;
},
): Promise<string> {
// 裁剪参数 — 与 node-tools/processEntrancePicture.js 保持一致
const cropTop = paperConfig.headerHeight;
const cropBottom = paperConfig.footerHeight;
const sourceW = paperConfig.width;
const sourceH = paperConfig.height - cropTop - cropBottom;
const ENTRANCE_WIDTH = 600;
const scale = ENTRANCE_WIDTH / sourceW;
const targetH = Math.round(sourceH * scale);
// 调整 Canvas 尺寸用于裁剪输出
canvas.width = ENTRANCE_WIDTH;
canvas.height = targetH;
ctx.drawImage(
canvas, // 自身作为源(需先 toDataURL 再 loadImage,实际实现需用临时文件中转)
0,
cropTop,
sourceW,
sourceH, // 源区域:去掉页眉页脚
0,
0,
ENTRANCE_WIDTH,
targetH, // 目标区域:缩放到标准宽度
);
const tempPath = await canvasToTempFilePath(canvas, {
fileType: 'jpg',
quality: 0.85,
destWidth: ENTRANCE_WIDTH,
destHeight: targetH,
});
return tempPath;
}
```
> **实际实现说明**:小程序 Canvas 不能直接自引用 `drawImage`,需要先导出为临时文件(`canvasToTempFilePath`),再用 `canvas.createImage()` 加载临时文件,然后在清空的 Canvas 上绘制裁剪区域。具体实现参考 `preview-card` 已有的 `exportToTempFile` 方法。
### 3.4 云端上传
#### 3.4.1 图片上传到云存储
```typescript
async function uploadEntranceImage(
tempFilePath: string,
category: string,
id: string,
): Promise<string> {
const cloudPath = `assets/previews/${category}/${id}.jpg`;
const res = await wx.cloud.uploadFile({
cloudPath,
filePath: tempFilePath,
});
return res.fileID; // cloud://doodle-xxx/assets/previews/english/letter-tracing-daily-checkin.jpg
}
```
#### 3.4.2 元数据写入云数据库
```typescript
async function publishWorksheet(
meta: PublishMeta,
imageFileID: string,
): Promise<void> {
const db = wx.cloud.database();
const collection = db.collection('worksheets');
const doc = {
...meta,
previewImage: imageFileID,
status: meta.status || 'draft',
publishedAt: db.serverDate(),
updatedAt: db.serverDate(),
version: 1,
publishedBy: 'debug', // 标记为 debug 发布
};
// upsert:如果已存在则更新,不存在则创建
const existing = await collection.where({ id: meta.id }).get();
if (existing.data.length > 0) {
const oldDoc = existing.data[0];
await collection.doc(oldDoc._id).update({
data: {
...doc,
version: (oldDoc.version || 0) + 1,
updatedAt: db.serverDate(),
},
});
} else {
await collection.add({ data: doc });
}
}
```
### 3.5 发布流程整合
```typescript
/**
* Debug 发布入口 — 挂载在 pageMixin 上
* 由预览页的「发布到云端」按钮触发
*/
async function onDebugPublish(this: any): Promise<void> {
if (!isDebugPublishEnabled()) return;
// 1. 获取页面元数据
const meta: PublishMeta = this.getPublishMeta();
// 2. 弹出确认面板(展示即将发布的数据,允许微调)
const confirmed = await showPublishConfirmDialog(meta);
if (!confirmed) return;
wx.showLoading({ title: '发布中...' });
try {
// 3. 导出入口图
const tempPath = await exportEntranceImage(
this.canvas,
this.ctx,
this.paperConfig,
);
// 4. 上传图片到云存储
const fileID = await uploadEntranceImage(
tempPath,
meta.category,
meta.id,
);
// 5. 写入云数据库
await publishWorksheet(meta, fileID);
wx.hideLoading();
wx.showToast({ title: '发布成功 ✅', icon: 'success' });
} catch (err) {
wx.hideLoading();
wx.showModal({
title: '发布失败',
content: JSON.stringify(err),
showCancel: false,
});
}
}
```
---
## 四、云端数据与现有架构的衔接
### 4.1 数据加载优先级(保持不变)
与 [技术架构设计文档](./技术架构设计文档.md) 第 6.3 节、[小程序云开发方案](./小程序云开发方案.md) 第五节一致:
```
优先级 1: 本地缓存(wx.Storage
优先级 2: 云端拉取(worksheets 集合) ← debug 发布的数据在此
优先级 3: 前端内置兜底(category.data.ts)← 保持稳定兜底
```
### 4.2 category.data.ts 的角色变化
| 阶段 | category.data.ts 的作用 |
| -------------- | ------------------------------------------------- |
| **当前** | 唯一数据源(硬编码所有题型信息) |
| **方案实施后** | 兜底数据源 + 离线保障(云端不可用时生效) |
| **长期** | 通过 `syncFromCloud` 脚本自动同步,保持与云端一致 |
### 4.3 worksheets 集合字段映射
debug 发布写入的字段,与 [小程序云开发方案](./小程序云开发方案.md) §3.3 `worksheets` 集合的字段**完全对齐**
| PublishMeta 字段 | worksheets 集合字段 | 说明 |
| ---------------- | ------------------- | ------------------------ |
| `id` | `_id` | 业务唯一标识 |
| `title` | `title` | 显示标题 |
| `desc` | `desc` | 副标题 |
| `category` | `category` | 所属大类 |
| `subcategory` | `subcategory` | 子分类,当前默认为空 |
| `path` | `path` | 小程序页面路径 |
| `previewImg` | `previewImg` | 云存储 fileID |
| `ageMin` | `ageMin` | 适龄最小值 |
| `ageMax` | `ageMax` | 适龄最大值 |
| `difficulty` | `difficulty` | 难度 1–4(入门、基础、) |
| `tags` | `tags` | 搜索标签 |
| `sortOrder` | `sortOrder` | 排列顺序 |
| — | `createdAt` | 创建时间 |
| — | `updatedAt` | 更新时间 |
---
## 五、云存储目录规划
与 [小程序云开发方案](./小程序云开发方案.md) §6 一致:
```
cloud://doodle-xxx/
├── assets/
│ └── previews/ ← debug 发布的入口图存放于此
│ ├── math/
│ │ ├── number-find.jpg
│ │ ├── addition-10.jpg
│ │ └── ...
│ ├── english/
│ │ ├── letter-tracing-single.jpg
│ │ ├── letter-tracing-daily-checkin.jpg
│ │ └── ...
│ ├── puzzle/
│ ├── chinese/
│ └── craft/
```
命名规则:`<id>.jpg`,与 `PublishMeta.id` 一致,便于查找和管理。
---
## 六、安全与权限控制
### 6.1 客户端门控
```typescript
// 三重保障
const canPublish =
isDebugPublishEnabled() && // ① envVersion === 'develop'
isDevBypassLimits() && // ② 与下载绕过逻辑一致
wx.getStorageSync('enableDebug'); // ③ debug 页面手动开启
```
### 6.2 云端安全规则
在云数据库安全规则中,`worksheets` 集合限制写入权限:
```json
{
"worksheets": {
".write": false,
".read": true
}
}
```
debug 发布通过**云函数**中转,云函数内校验 `openId` 白名单:
```javascript
// 云函数 debugPublish
exports.main = async (event, context) => {
const { OPENID } = cloud.getWXContext();
const ADMIN_OPENIDS = ['开发者的openid'];
if (!ADMIN_OPENIDS.includes(OPENID)) {
throw new Error('无权限执行此操作');
}
// 执行 upsert 操作...
};
```
### 6.3 数据保护
| 措施 | 说明 |
| ---------------- | --------------------------------------------------- |
| 默认 draft 状态 | 发布后默认 `status: 'draft'`,需手动激活为 `active` |
| 版本递增 | 每次更新 `version + 1`,可追踪变更历史 |
| publishedBy 标记 | `publishedBy: 'debug'` 区分来源 |
| 时间戳 | `publishedAt` / `updatedAt` 记录操作时间 |
---
## 七、辅助工具
### 7.1 syncFromCloud 脚本
`node-tools/` 中新增脚本,发版前从云数据库拉取最新数据,同步回 `category.data.ts` 作为兜底数据。
```
Debug 发布 → 云端数据 → 线上可见
syncFromCloud.js ← 发版前运行
category.data.ts 更新 ← 兜底数据同步
下次发版包含
```
```javascript
// node-tools/src/syncFromCloud.js(伪代码)
// 通过云开发 HTTP API 或管理端 SDK 拉取 worksheets 集合
// 按 category 分组 → 生成 TypeScript 代码 → 写入 category.data.ts
```
### 7.2 Debug 管理页扩展
在已有的 `supportPages/debug/debug` 页面中增加 tab,提供简易内容管理:
```
┌─────────────────────────────────────────────┐
│ Debug 工具页 │
│ │
│ [调试配置] [已发布内容] [系统信息] │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 已发布内容列表 │ │
│ │ │ │
│ │ ● letter-tracing-single │ │
│ │ 状态: active 版本: 3 英语 │ │
│ │ [编辑] [下架] │ │
│ │ │ │
│ │ ● letter-tracing-daily-checkin │ │
│ │ 状态: draft 版本: 1 英语 │ │
│ │ [激活] [编辑] [删除] │ │
│ │ │ │
│ │ ● addition-10 │ │
│ │ 状态: active 版本: 5 数学 │ │
│ │ [编辑] [下架] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
---
## 八、实施计划
### Phase 1:基础发布能力(约 0.5~1 天)
- [ ] `pageMixin` 中新增 `getPublishMeta()` 接口约定
- [ ] 实现 `isDebugPublishEnabled()` 环境判断
- [ ] 实现入口图导出 + 裁剪逻辑
- [ ] 实现云存储上传 + 云数据库写入
- [ ]`preview-card``preview-footer-actions` 中添加发布按钮(条件渲染)
### Phase 2:确认面板与安全(约 0.5 天)
- [ ] 发布前确认弹窗(数据预览 + 可编辑字段)
- [ ] 云函数安全校验(openId 白名单)
- [ ] 默认 draft 状态 + 版本管理
### Phase 3:数据消费侧适配(约 0.5~1 天)
- [ ] `category.ts`(或 `worksheet-service`)增加从云 DB 拉取逻辑
- [ ] 实现缓存策略(本地缓存 → 云端 → 内置兜底)
- [ ] 入口图从云存储 fileID 获取临时 URL 展示
### Phase 4:辅助工具(约 0.5 天)
- [ ] `syncFromCloud.js` 脚本
- [ ] Debug 管理页扩展(列表 + 状态管理)
**总计预估:2~3 天**
---
## 九、与模板引擎的衔接(远期)
当 [技术架构设计文档](./技术架构设计文档.md) 第五章所述模板引擎落地后,debug 发布方案可进一步升级:
```
┌─────────────────────────────────────────────────────────────────┐
│ 模板引擎 + Debug 发布(远期形态) │
│ │
│ ① 在通用 worksheet 页面中: │
│ 选择模板 + 配置生成器参数 → 实时预览 │
│ │
│ ② 效果满意后点击「发布」: │
│ 自动上传: │
│ • JSON 配置(template + generator + generatorConfig + layout)│
│ • 入口预览图 │
│ • 元数据(title、tags、ageRange 等) │
│ │
│ ③ 新题型无需任何前端代码改动即可上线 │
│ (前提:使用已有的 TemplateRenderer + Generator 组合) │
│ │
│ ④ 配合 80% 新题型可动态上线的目标 │
│ debug 发布成为主要的内容上线方式 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 十、风险与应对
| 风险 | 影响 | 应对 |
| ---------------------- | ----------------------- | --------------------------------------------------------- |
| **误操作发布测试数据** | 污染线上列表 | 默认 `status: 'draft'`;确认面板二次确认 |
| **入口图质量不一致** | 不同设备/DPR 下渲染差异 | 统一使用开发者工具发布;导出时固定 `destWidth/destHeight` |
| **云端数据丢失** | 已发布内容消失 | `category.data.ts` 兜底;`syncFromCloud` 定期同步 |
| **安全:非授权上传** | 恶意写入数据 | 云函数白名单校验;客户端三重门控 |
| **双数据源不一致** | 本地兜底与云端数据冲突 | 云端优先,发版前 `syncFromCloud` 对齐 |
| **云存储额度** | 图片累积占用存储 | 入口图约 50~200KB/张,100 张仅 ~20MB,远低于免费额度 |
---
## 附录 A:与现有代码的关系
| 现有模块 | 本方案的关联 |
| ------------------------------------------ | ------------------------------------------------------ |
| `preview-card` 组件 | 复用 `exportToTempFile`,新增裁剪逻辑 |
| `pageMixin.ts` | 新增 `getPublishMeta()` 约定和 `onDebugPublish()` 方法 |
| `downloadPrint.ts` / `isDevBypassLimits()` | 复用环境判断模式 |
| `category.data.ts` | 角色从「唯一数据源」变为「兜底数据源」 |
| `supportPages/debug/debug` | 扩展内容管理 tab |
| `node-tools/processEntrancePicture.js` | 裁剪参数对齐;流程被 debug 发布替代 |
| 云数据库 `worksheets` 集合 | 写入发布数据(字段与云开发方案对齐) |
| 云存储 `assets/previews/` | 存放入口图 |
> **本文档已拆分,请查看:**
>
> - [Worksheet 发布方案](./Worksheet发布方案.md) — worksheet 的 Debug 发布流程、图片处理、入库字段
> - [页面内容管理方案](./页面内容管理方案.md) — 首页 / 分类页 / 分龄页的内容配置、数据预拉取、管理方案
+124
View File
@@ -0,0 +1,124 @@
# Worksheet 发布方案
> 版本:v1.0 &nbsp;|&nbsp; 最后更新:2026-04-29
> 配套文档:[页面内容管理方案](./页面内容管理方案.md) &nbsp;|&nbsp; [小程序云开发方案](./小程序云开发方案.md)
---
## 概述
在小程序**开发版**的 worksheet 绘制页面中,开发者完成调试后可直接将当前 worksheet 发布为云端内容。发布能力只在 `develop` 环境可见,正式版不展示入口。
**核心链路:**
```
预览组件导出 A4 图 → 裁剪压缩为入口图 → 上传云存储 → 云函数 upsert worksheets → draft 入库
```
---
## 已接入页面
| 页面 | 元数据配置 | category | path 示例 |
|------|-----------|----------|-----------|
| 数学 worksheet | `mathPages/mathDraw/mathDraw.config.ts` | `math` | `/mathPages/mathDraw/mathDraw?id=number-find` |
| 专注力 / 益智 | `focusPages/focusDraw/focusDraw.config.ts` | `puzzle` | `/focusPages/focusDraw/focusDraw?id=dot-connect` |
| 英语字母描红 | `englishPages/letterTracing/letterTracing.config.ts` | `english` | `/englishPages/letterTracing/letterTracing?id=letter-tracing-single` |
> 新增 worksheet 页面只需复用 `pageMixin` 并实现 `getPublishMeta()` 即可接入。
---
## 发布流程
流程在 `miniprogram/base/pageMixin.ts` 中完成:
```
┌─────────────────────────────────────────────────────────────┐
│ 开发版页面 │
│ │ │
│ ├─ syncDebugPublishEnv 判断 envVersion === develop │
│ ├─ 点击 debug-publish-tools 悬浮发布按钮 │
│ ├─ getPublishMeta() 读取当前 worksheet 元数据 │
│ ├─ 弹窗微调 title / subtitle / tags / status / 图片参数 │
│ ├─ preview-card.exportToTempFile() 导出预览图 │
│ ├─ debug-publish-tools.processImage() 裁剪压缩入口图 │
│ ├─ wx.cloud.uploadFile → assets/previews/<category>/<id>.jpg │
│ └─ wx.cloud.callFunction('worksheetsPublish') upsert │
└─────────────────────────────────────────────────────────────┘
```
---
## 图片处理规则
处理由 `components3.0/debug-publish-tools` 完成,参数定义在 `utils/debugPublish.ts`
### 默认参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `cropMode` | `header-footer` | 裁掉页眉页脚,保留主体内容 |
| `width` | `600` | 输出入口图宽度(px |
| `quality` | `90` | 初始 JPEG 质量 |
| `maxSizeKB` | `200` | 超出后自动降低质量压缩 |
| 云存储路径 | `assets/previews/<category>/<id>.jpg` | 由 `buildWorksheetPreviewCloudPath` 生成 |
### 裁剪模式
| 模式 | 用途 |
|------|------|
| `header-footer` | **默认** — 只保留 worksheet 主体内容 |
| `header-only` | 保留底部品牌区 |
| `none` | 完整 A4 预览图压缩上传 |
---
## 入库字段
`DebugPublishMeta``worksheetsPublish` 云函数字段已对齐:
| 字段 | 来源 | 说明 |
|------|------|------|
| `_id` / `id` | 页面元数据 | worksheet 业务唯一 ID |
| `title` | 页面元数据(弹窗可改) | 展示标题 |
| `subtitle` | 页面元数据(弹窗可改) | 展示副标题 |
| `category` | 页面元数据 | 必须存在于 `categories` 集合 |
| `subcategory` | 页面元数据 | 如 `math-draw``focus-draw``letter-tracing` |
| `path` | 页面元数据 | 小程序跳转路径 |
| `previewImg` | 云存储上传结果 | 入口图 fileID |
| `ageMin` / `ageMax` | 页面元数据 | 适龄范围 |
| `grade` | `inferGradeFromAge` 推断 | 年级映射 |
| `difficulty` | 页面元数据 | 1=入门 2=基础 3=进阶 4=挑战 |
| `tags` | 页面元数据(弹窗可改) | 搜索和运营标签 |
| `isNew` / `isHot` | 页面元数据 | 首页和推荐位复用 |
| `sortOrder` | 页面元数据 | 列表排序权重 |
| `downloads` / `likes` | 发布 payload 或默认值 | 运营统计展示 |
| `status` | 弹窗选择(默认 `draft` | `draft` / `active` / `hidden` |
| `createdAt` / `updatedAt` | 云函数 | 创建和更新时间 |
> `worksheetsPublish` 是 **upsert** 语义:同 ID 已存在时更新,不存在时创建。
---
## 前置条件
发布前需确保:
1. 云开发环境已初始化
2. `categories` 集合中存在对应大类(`math``puzzle``english` 等)
3. 当前页面实现了 `getPublishMeta()`
4. 页面 WXML 已挂载 `debug-publish-tools` 组件
> 如果 `categories` 中没有对应分类,`worksheetsPublish` 会返回"分类不合法"。
---
## 关键文件索引
| 文件 | 职责 |
|------|------|
| `miniprogram/base/pageMixin.ts` | 发布流程主逻辑 |
| `miniprogram/components3.0/debug-publish-tools/` | 发布 UI 组件、图片处理 |
| `miniprogram/utils/debugPublish.ts` | 图片参数、云路径生成 |
| `cloudfunctions/worksheetsPublish/` | upsert `worksheets` 集合 |
+1 -1
View File
@@ -84,7 +84,7 @@
| `sortOrder` | int | 是 | 排序权重,默认 `0`。 |
| `downloads` | int | 是 | 下载次数,默认 `0`。 |
| `likes` | int | 是 | 收藏此时,默认 `0`。 |
| `status` | string | 是 | `active` \| `draft` \| `hidden`,默认 `draft`。 |
| `status` | string | 是 | `active` \| `draft`,默认 `draft` |
| `createdAt` | date | 是 | 创建时间。 |
| `updatedAt` | date | 是 | 更新时间。 |
+5
View File
@@ -0,0 +1,5 @@
# 推广方案
## 分享激励制度
用户每下载三次需要看广告,但分享成功一次(被分享者打开并下载一次)可以获得 10 次免广告下载次数,且可以累加。
View File
+454
View File
@@ -0,0 +1,454 @@
# 页面内容管理方案
> 版本:v1.0  |  最后更新:2026-04-29
> 配套文档:[Worksheet 发布方案](./Worksheet发布方案.md)  |  [小程序云开发方案](./小程序云开发方案.md)
---
## 一、背景与目标
worksheet 已可通过 Debug 发布进入云数据库,但三个展示页仍使用本地静态数据:
| 页面 | 当前数据来源 | 问题 |
| --- | --------------------------------- | ---------------------- |
| 首页 | `pages/home/home.data.ts` | featured、hot、分区靠代码手动维护 |
| 分类页 | `pages/category/category.data.ts` | 新 worksheet 发布后不会自动出现 |
| 分龄页 | `pages/age/age.ts` 内 Mock | 推荐内容和周路线无法运营配置 |
**目标:** 通过 Debug 管理页配置三个页面的内容,生成统一 JSON 上传云存储,小程序启动时通过**数据预拉取**获取并渲染。
---
## 二、整体架构
### 2.1 数据流
```
┌──────────────────────────────────────────────────────────────────┐
│ Debug 内容管理页(三个独立 tab) │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 首页管理 │ │ 分类页管理 │ │ 分龄页管理 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └──────┬──────┘──────┬──────┘ │
│ ▼ ▼ │
│ 点击「更新」按钮 → 调用云函数 pageContentUpdate │
│ │ │
│ ▼ │
│ 更新云存储 content/page-config.json 中对应页面的配置 │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 小程序启动 │
│ │
│ app.onLaunch │
│ └─ 数据预拉取 → 获取 content/page-config.json │
│ │
│ 页面 onLoad │
│ ├─ 1. 使用本地 *.data.ts / age.config.ts 渲染兜底首屏 │
│ ├─ 2. 读取预拉取结果中对应页面的配置 │
│ └─ 3. 成功则 setData 更新页面 │
└──────────────────────────────────────────────────────────────────┘
```
### 2.2 统一 JSON 格式
云存储路径:`content/page-config.json`
```ts
// content/page-config.json 结构
type PageConfig = {
home: HomePageData; // 首页配置
category: CategoryPageData; // 分类页配置
age: AgePageData; // 分龄页配置
version: number; // 版本号,每次更新递增
updatedAt: string; // 最后更新时间
};
```
每个管理页更新时,只修改 JSON 中自己负责的字段,通过云函数读取当前 JSON → 合并更新 → 写回云存储。
### 2.3 页面加载策略
三个页面统一采用**数据预拉取 + 本地兜底**的加载策略:
```
页面 onLoad
├─ Step 1: 使用本地静态数据渲染首屏(零延迟)
│ 首页 → home.data.ts
│ 分类页 → category.data.ts
│ 分龄页 → age.config.ts
├─ Step 2: 读取数据预拉取结果
│ wx.getBackgroundFetchData('pre') 获取 page-config.json
│ 取出当前页面对应的配置(home / category / age
└─ Step 3: 成功 → setData 更新页面内容
失败 → 保持本地兜底数据,用户无感知
```
> **数据预拉取**在 `app.json` 中配置 `fetchDataUrl`,小程序冷启动时由微信客户端自动发起,不占用页面加载时间。预拉取的数据指向云存储中的 `content/page-config.json`。
### 2.4 云函数设计
新增一个统一云函数 `pageContentUpdate`
```ts
// cloudfunctions/pageContentUpdate/index.js
type PageContentUpdatePayload = {
page: 'home' | 'category' | 'age'; // 要更新的页面
data: HomePageData | CategoryPageData | AgePageData; // 该页面的完整配置
};
```
执行逻辑:
```
接收参数 { page, data }
→ 从云存储读取当前 content/page-config.json
→ 合并:config[page] = data
→ config.version++
→ config.updatedAt = new Date()
→ 写回云存储 content/page-config.json
→ 返回 { success, version }
```
---
## 三、首页内容管理
### 3.1 管理 UI
首页管理 tab 分为四个区块:
| 区块 | 对应字段 | 管理方式 |
| ------ | -------------- | ----------------------------------- |
| 分类 tab | `categoryTabs` | 跟随 `CATEGORY_LIST_WITH_ALL`,一般不单独管理 |
| 年龄入口 | `ageBands` | 跟随 `AGE_BANDS`,可管理描述文案 |
| 今日推荐 | `featured` | 手动选择 3-5 个 active worksheet |
| 热门推荐 | `hot` | 手动选择或按 downloads 自动生成 |
| 分类分区 | `sections` | 每个分类选择若干 active worksheet,支持排序 |
交互流程:
```
首页 tab 展示当前配置
→ 每个位置点击「选择内容」
→ 弹出 worksheet 选择器(筛选 active 内容)
→ 保存配置
→ 点击「更新首页数据」
→ 调用 pageContentUpdate({ page: 'home', data: ... })
```
### 3.2 数据结构
```ts
type HomePageData = {
searchPlaceholder: string;
categoryTabs: Array<{ id: string; name: string; path?: string }>;
ageBands: Array<{ key: string; label: string; desc: string; path: string }>;
featured: HomeDisplayItem[];
hot: HomeDisplayItem[];
sections: HomeDisplaySection[];
};
```
自动生成规则:
| 模块 | 默认规则 |
| -------------- | ---------------------------------------------- |
| `categoryTabs` | 由 `CATEGORY_LIST_WITH_ALL` 生成 |
| `ageBands` | 由 `AGE_BANDS` 生成 |
| `featured` | 优先使用手动选择,不足时补 `isNew == true` |
| `hot` | 优先使用手动选择,不足时补 `isHot == true` 或 downloads 高的内容 |
| `sections` | 使用配置中的 worksheetIds,不足时从该分类 active 内容补齐 |
---
## 四、分类页内容管理
### 4.1 管理 UI
分类页管理 tab 顶部展示分类选择器(来源 `CATEGORY_LIST`),默认选中 `math`
分类头部统计:
| 指标 | 查询规则 |
| --- | ----------------------------- |
| 总数 | `worksheets.category == 当前分类` |
| 草稿 | `status == draft` |
| 线上 | `status == active` |
| 已隐藏 | `status == hidden` |
列表展示当前分类下所有 worksheet(按 `status``sortOrder``updatedAt` 排序),每项显示预览图、标题、ID、年龄/难度/标签、状态、排序权重。
操作按钮:
| 当前状态 | 可执行操作 |
| -------- | --------------- |
| `draft` | 激活上线 |
| `active` | 下架为 hidden |
| `hidden` | 恢复为 draft 或直接激活 |
| 任意 | 调整排序、预览跳转、刷新 |
### 4.2 数据结构
```ts
type CategoryPageData = {
searchPlaceholder: string;
categories: Array<{
id: string;
name: string;
icon: string;
items: CategoryItem[];
}>;
};
```
生成时从 `worksheets` 集合查询 `status == active` 的内容,按分类分组、按 `sortOrder` 排序,转换为 `CategoryItem`
### 4.3 更新流程
```
分类页管理 tab → 管理 worksheet 状态和排序
→ 点击「更新分类数据」
→ 查询所有 active worksheets,按 category 分组
→ 调用 pageContentUpdate({ page: 'category', data: ... })
→ 云函数更新 page-config.json 中的 category 字段
```
---
## 五、分龄页内容管理
### 5.1 新增 `age.config.ts`
`miniprogram/pages/age/` 下新增 `age.config.ts`,将年龄段划分、能力目标、默认 worksheet 写死在配置中,作为兜底数据和管理页的基础结构。
```ts
// miniprogram/pages/age/age.config.ts
import { AGE_BANDS, type AgeBandKey } from '../../core/data/difficulty';
export type AbilityItem = {
icon: string;
title: string;
desc: string;
};
export type WeekPlan = {
week: number;
theme: string;
worksheetIds: string[]; // worksheet 业务 ID
worksheetTitles: string[]; // 兜底展示标题
};
export type AgeBandConfig = {
key: AgeBandKey;
label: string;
subLabel: string;
abilities: AbilityItem[];
weeks: WeekPlan[];
};
export const AGE_CONFIG: AgeBandConfig[] = [
{
key: '3-4',
label: '3-4 岁',
subLabel: '启蒙认知',
abilities: [
{ icon: '🔢', title: '数感', desc: '认读 1-5、点数对应' },
{ icon: '✏️', title: '书写', desc: '涂鸦线条、简单描红' },
{ icon: '🧩', title: '思维', desc: '找相同、简单配对' },
],
weeks: [
{ week: 1, theme: '数感启蒙',
worksheetIds: [], worksheetTitles: ['找数字涂一涂', '5以内加法', '数数连一连'] },
{ week: 2, theme: '形状与连线',
worksheetIds: [], worksheetTitles: ['识别形状', '线条识别', '数字点连线'] },
{ week: 3, theme: '趣味专注',
worksheetIds: [], worksheetTitles: ['颜色找规律', '方格推理', '格子仿画'] },
{ week: 4, theme: '综合练习',
worksheetIds: [], worksheetTitles: ['10以内加减法', '识字卡', '连连看'] },
],
},
{
key: '4-5',
label: '4-5 岁',
subLabel: '基础练习',
abilities: [
{ icon: '🔢', title: '数感', desc: '10 以内数数、比大小' },
{ icon: '✏️', title: '书写', desc: '笔画模仿、图形描边' },
{ icon: '🧩', title: '思维', desc: '规律排序、图形分类' },
],
weeks: [
{ week: 1, theme: '数感启蒙',
worksheetIds: [], worksheetTitles: ['找数字涂一涂', '5以内加法', '数数连一连'] },
{ week: 2, theme: '形状与连线',
worksheetIds: [], worksheetTitles: ['识别形状', '线条识别', '数字点连线'] },
{ week: 3, theme: '趣味专注',
worksheetIds: [], worksheetTitles: ['颜色找规律', '方格推理', '格子仿画'] },
{ week: 4, theme: '综合练习',
worksheetIds: [], worksheetTitles: ['10以内加减法', '识字卡', '连连看'] },
],
},
// 5-6、6-7、7-8 结构相同,abilities 和 weeks 内容不同
// ... 完整配置见 age.config.ts 文件
];
```
> `worksheetIds` 初始为空数组,由分龄管理页选择后填入。`worksheetTitles` 作为兜底展示。
### 5.2 管理 UI
分龄管理 tab 按年龄段切换:`3-4 岁 | 4-5 岁 | 5-6 岁 | 6-7 岁 | 7-8 岁`
每个年龄段下的管理内容:
| 模块 | 操作 | 说明 |
| ---- | ------------ | -------------------------------- |
| 能力目标 | 只读展示 | 来自 `age.config.ts`,不需要在管理页修改 |
| 四周路线 | 选择 worksheet | 每周选择 4 个 worksheet,主题来自 config |
| 推荐内容 | 选择 worksheet | 动态数据,展示 likes 数量最高的 6 个worksheet |
worksheet 选择器默认筛选条件:
```
status == active
ageMin <= 当前年龄段 maxAge
ageMax >= 当前年龄段 minAge
```
**核心简化:** 分龄管理页不需要配置能力目标和周主题(这些写死在 `age.config.ts` 中),只需要为不同年龄段、不同周选择对应的 worksheet。
### 5.3 数据结构
```ts
type AgePageData = {
ageTabs: Array<{
key: AgeBandKey;
rangeText: string;
subLabel: string;
}>;
bands: Record<AgeBandKey, {
goalTitle: string;
abilityItems: AbilityItem[];
weekPlans: Array<{
week: number;
theme: string;
exercises: Array<{
id: string;
title: string;
path: string;
previewImg?: string;
}>;
}>;
recommendedItems: Array<{
id: string;
title: string;
image?: string;
path: string;
}>;
}>;
};
```
### 5.4 更新流程
```
分龄管理 tab → 选择年龄段
→ 为每周选择 worksheet
→ 选择推荐内容
→ 点击「更新分龄数据」
→ 合并 age.config.ts 的能力目标 + 管理页选择的 worksheet 详情
→ 调用 pageContentUpdate({ page: 'age', data: ... })
→ 云函数更新 page-config.json 中的 age 字段
```
---
## 六、Debug 页入口结构
```
supportPages/debug/debug
├─ 分类基础数据管理 (已有:同步 CATEGORY_LIST → categories
├─ Worksheet 内容池 (已有:状态、分类、上下架、预览)
└─ 页面内容管理 (新增)
├─ Tab: 首页管理 → 编排 featured / hot / sections
├─ Tab: 分类页管理 → 管理 worksheet 状态和排序
└─ Tab: 分龄页管理 → 按年龄段选择周 worksheet
```
---
## 七、实施计划
### Phase 1:基础设施
1. 配置 `app.json` 数据预拉取,指向 `content/page-config.json`
2. 实现 `pageContentUpdate` 云函数
3. 上传初始 `page-config.json`(从现有 `*.data.ts` 转换)
4. 三个页面统一接入预拉取加载逻辑
### Phase 2:分类页管理
1. 实现分类页管理 tab(worksheet 列表、状态管理、排序)
2. 实现「更新分类数据」→ 生成 category 配置 → 调用云函数更新
### Phase 3:首页管理
1. 实现首页管理 tabfeatured / hot / sections 编排)
2. 实现 worksheet 选择器弹窗
3. 实现「更新首页数据」→ 生成 home 配置 → 调用云函数更新
### Phase 4:分龄页管理
1. 新增 `age.config.ts` 配置文件
2. 实现分龄管理 tab(年龄段切换、周 worksheet 选择)
3. 实现「更新分龄数据」→ 合并 config + worksheet 详情 → 调用云函数更新
---
## 八、风险与约束
| 风险 | 处理方式 |
| ----------------- | ----------------------------------------- |
| 预拉取失败 | 本地 `*.data.ts` / `age.config.ts` 兜底,用户无感知 |
| JSON 生成失败 | 云函数返回错误,管理页提示重试,线上数据不受影响 |
| 首页配置引用了 hidden 内容 | 生成时只允许 active 内容,不足时返回警告 |
| 分龄内容不适龄 | 选择器按年龄段默认过滤 |
| 并发更新冲突 | 云函数使用 version 乐观锁,冲突时提示重新加载后重试 |
---
## 九、关键文件索引
| 文件 / 模块 | 职责 |
| ----------------------------------- | ------------------------------ |
| `supportPages/contentManage/` | 三 tab 内容管理页 |
| `cloudfunctions/pageContentUpdate/` | 更新 `page-config.json` 中指定页面的配置 |
| `pages/age/age.config.ts` | 分龄页兜底配置(年龄段、能力目标、默认 worksheet) |
| `pages/home/home.data.ts` | 首页兜底数据 |
| `pages/category/category.data.ts` | 分类页兜底数据 |
| `content/page-config.json`(云存储) | 三个页面的统一配置 JSON |
+4 -12
View File
@@ -1,29 +1,21 @@
import type { DifficultyLevel } from '../models/worksheet';
import type { Difficulty } from '../models/worksheet';
/** 产品文档:难度 入门 / 基础 / 进阶 / 挑战 → 云库 difficulty 14 */
export const DIFFICULTY_LEVEL_TO_STARS: Record<
DifficultyLevel,
1 | 2 | 3 | 4
> = {
export const DIFFICULTY_LEVEL_TO_STARS: Record<Difficulty, 1 | 2 | 3 | 4> = {
beginner: 1,
basic: 2,
intermediate: 3,
advanced: 4,
};
export const STARS_TO_DIFFICULTY_LEVEL: Record<
1 | 2 | 3 | 4,
DifficultyLevel
> = {
export const STARS_TO_DIFFICULTY_LEVEL: Record<1 | 2 | 3 | 4, Difficulty> = {
1: 'beginner',
2: 'basic',
3: 'intermediate',
4: 'advanced',
};
export function difficultyToStars(
level?: DifficultyLevel,
): 1 | 2 | 3 | 4 {
export function difficultyToStars(level?: Difficulty): 1 | 2 | 3 | 4 {
return level ? DIFFICULTY_LEVEL_TO_STARS[level] : 2;
}
+3
View File
@@ -3,6 +3,9 @@ import { CategoryId } from './category';
* 题型与模板引擎相关模型:对齐技术架构 §5.4 与云开发 worksheets 集合 §3.3
*/
/** 难度等级文案键(用于前端展示与映射) */
export type Difficulty = 'beginner' | 'basic' | 'intermediate' | 'advanced';
/** 与架构文档 WorksheetConfig 一致,供云端下发 / 本地内置 */
export interface WorksheetConfig {
id: string;
@@ -319,7 +319,7 @@ export function generateLetterTracing(
const letterImgPath = `/englishPages/shared/assets/letter/${key}.png`;
const wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
const wordImgPath = `/englishPages/shared/assets/letterImgs/${meta.word}.png`;
const teaching: TeachingBlock = {
upper,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -16,7 +16,7 @@ export const ALPHABET_BY_LETTER: Record<string, AlphabetEntry> = {
F: { word: 'Fish', emoji: '🐟' },
G: { word: 'Girl', emoji: '👧' },
H: { word: 'House', emoji: '🏠' },
I: { word: 'Ice', emoji: '🧊' },
I: { word: 'Icecream', emoji: '🍦' },
J: { word: 'Juice', emoji: '🧃' },
K: { word: 'Kite', emoji: '🪁' },
L: { word: 'Lion', emoji: '🦁' },
@@ -29,10 +29,10 @@ export const ALPHABET_BY_LETTER: Record<string, AlphabetEntry> = {
S: { word: 'Sun', emoji: '☀️' },
T: { word: 'Tree', emoji: '🌳' },
U: { word: 'Umbrella', emoji: '☂️' },
V: { word: 'Van', emoji: '🚐' },
V: { word: 'Vegetable', emoji: '🥬' },
W: { word: 'Water', emoji: '💧' },
X: { word: 'X-ray', emoji: '🦴' },
Y: { word: 'Yacht', emoji: '⛵' },
X: { word: 'Xylophone', emoji: '' },
Y: { word: 'Yak', emoji: '⛵' },
Z: { word: 'Zebra', emoji: '🦓' },
};
@@ -0,0 +1,187 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
interface FocusDrawDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
const FOCUS_DRAW_DEFINITIONS = [
{
id: 'color-shape-match',
title: '根据颜色画图形',
subtitle: '根据颜色画出对应图形',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '颜色', '图形'],
sortOrder: 201,
},
{
id: 'shape-symbol',
title: '图形符号配对',
subtitle: '根据图形画对应符号',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '图形', '符号'],
sortOrder: 202,
},
{
id: 'position-coloring',
title: '方位涂涂乐',
subtitle: '观察位置,在方格中涂色',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '方位', '涂色'],
sortOrder: 203,
},
{
id: 'color-pattern',
title: '颜色找规律',
subtitle: '观察颜色规律,在空白图形中涂色',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '颜色', '规律'],
sortOrder: 204,
},
{
id: 'match-connect',
title: '连连看',
subtitle: '根据物品连一连',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '连线', '匹配'],
sortOrder: 205,
},
{
id: 'line-recognition',
title: '线条识别',
subtitle: '认识不同线条,画出颜色对应的线条',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['专注力', '线条', '识别', '控笔'],
sortOrder: 206,
},
{
id: 'grid-reasoning',
title: '方格推理',
subtitle: '推理出合并方格并连线',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['专注力', '方格', '推理', '逻辑思维'],
sortOrder: 207,
},
{
id: 'code-connect',
title: '译码连线',
subtitle: '按数字顺序将数字对应颜色连线',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['专注力', '译码', '连线', '逻辑思维'],
sortOrder: 208,
},
{
id: 'dot-connect',
title: '数字点连线',
subtitle: '按数字顺序连点成图',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '数字', '连线'],
sortOrder: 209,
},
{
id: 'grid-drawing-3x3',
title: '格子仿画 3×3',
subtitle: '简单有趣,培养专注力',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['专注力', '格子仿画', '观察'],
sortOrder: 210,
},
{
id: 'grid-drawing-5x5',
title: '格子仿画 5×5',
subtitle: '创意挑战,提升观察力',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '格子仿画', '观察'],
sortOrder: 211,
},
{
id: 'grid-drawing-7x7',
title: '格子仿画 7×7',
subtitle: '大师挑战,锻炼耐心',
ageMin: 5,
ageMax: 8,
difficulty: 3,
tags: ['专注力', '格子仿画', '耐心'],
sortOrder: 212,
},
] as const satisfies ReadonlyArray<FocusDrawDefinition>;
type FocusDrawDefinitionItem = (typeof FOCUS_DRAW_DEFINITIONS)[number];
const FOCUS_DRAW_BY_ID = Object.fromEntries(
FOCUS_DRAW_DEFINITIONS.map((item) => [item.id, item]),
) as Record<string, FocusDrawDefinitionItem>;
function resolveFocusPublishId(
routeId: string,
selectedTypeId: string,
mode?: string,
): string {
if (selectedTypeId === 'grid-drawing') {
return `grid-drawing-${mode || '3x3'}`;
}
if (routeId in FOCUS_DRAW_BY_ID) {
return routeId;
}
return selectedTypeId;
}
export function getPublishMetaByFocusState(
routeId: string,
selectedTypeId: string,
mode?: string,
): DebugPublishMeta | null {
const id = resolveFocusPublishId(routeId, selectedTypeId, mode);
const item = FOCUS_DRAW_BY_ID[id];
if (!item) return null;
return {
id: item.id,
title: item.title,
subtitle: item.subtitle,
category: 'puzzle',
subcategory: 'focus-draw',
path: `/focusPages/focusDraw/focusDraw?id=${item.id}`,
ageMin: item.ageMin,
ageMax: item.ageMax,
grade: inferGradeFromAge(item.ageMin, item.ageMax),
difficulty: item.difficulty,
previewImg: '',
tags: [...item.tags],
isNew: false,
isHot: false,
sortOrder: item.sortOrder,
status: 'draft',
};
}
@@ -10,6 +10,7 @@
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
@@ -6,6 +6,8 @@ import {
type FocusTypeConfig,
type FocusTypeAction,
} from './registry';
import { getPublishMetaByFocusState } from './focusDraw.config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
const TYPE_LIST = FOCUS_TYPE_CONFIGS.map((t) => ({
id: t.id,
@@ -36,6 +38,10 @@ createFocusPage({
currentActions: [] as FocusTypeAction[],
currentMode: '',
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null as DebugPublishMeta | null,
},
onLoad(options: { id?: string; mode?: string }) {
@@ -43,6 +49,8 @@ createFocusPage({
const result = findTypeByRouteId(routeId);
if (!result) return;
this.syncDebugPublishEnv();
const { typeConfig, mode } = result;
const initialMode =
options.mode || mode || typeConfig.defaultMode || '';
@@ -196,4 +204,16 @@ createFocusPage({
icon: 'none',
});
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByFocusState(
this.data.functionId,
this.data.selectedTypeId,
this.data.currentMode,
);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
});
@@ -75,6 +75,16 @@
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<!-- 分享引导弹窗 -->
<share-guide-popup
show="{{showShareDialog}}"
@@ -0,0 +1,331 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
interface MathDrawDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
const MATH_DRAW_DEFINITIONS = [
{
id: 'number-find',
title: '找数字,涂一涂',
subtitle: '在数字方阵中找出目标数字并涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 101,
},
{
id: 'number-write',
title: '看数字,写一写',
subtitle: '按笔画顺序练习书写数字',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字书写', '描红'],
sortOrder: 102,
},
{
id: 'number-coloring',
title: '按数字,涂颜色',
subtitle: '按指定数字给对应圆圈涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 103,
},
{
id: 'counting-matching',
title: '数一数,连一连',
subtitle: '连线配对数字和对应数量图形',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '连线'],
sortOrder: 104,
},
{
id: 'number-object-match',
title: '数物连线',
subtitle: '连线相同数量的物品和数字',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数物对应', '连线'],
sortOrder: 105,
},
{
id: 'number-object-fill',
title: '数物填写',
subtitle: '数物品数量,填写对应数字',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数物对应', '填写'],
sortOrder: 106,
},
{
id: 'counting-select',
title: '数一数,选一选',
subtitle: '数出物品数量,圈出正确答案',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '选择'],
sortOrder: 107,
},
{
id: 'counting-fill',
title: '数一数,填一填',
subtitle: '数出物品数量,填写数字',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '填写'],
sortOrder: 108,
},
{
id: 'compare',
title: '数一数,比大小',
subtitle: '比较数量,填入 ><=',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '比较', '数量'],
sortOrder: 109,
},
{
id: 'number-sort',
title: '数字排序',
subtitle: '写出正确的数字顺序',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '数字排序', '序列'],
sortOrder: 110,
},
{
id: 'missing-number',
title: '填上缺少的数字',
subtitle: '在数列中找出并填写缺失数字',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '数字序列', '填写'],
sortOrder: 111,
},
{
id: 'number-decompose',
title: '10以内数的分与合',
subtitle: '把数字分一分,合一合',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '分与合', '10以内'],
sortOrder: 112,
},
{
id: 'number-decompose-20',
title: '20以内数的分与合',
subtitle: '把数字分一分,合一合',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '分与合', '20以内'],
sortOrder: 113,
},
{
id: 'one-digit-addition',
title: '一位数加法',
subtitle: '通过圆点学习一位数加法运算',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '加法', '一位数'],
sortOrder: 114,
},
{
id: 'addition-5',
title: '5以内加法',
subtitle: '图形化展示 5 以内加法',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '加法', '5以内', '计算题'],
sortOrder: 115,
},
{
id: 'addition-10',
title: '10以内加法',
subtitle: '图形化展示 10 以内加法',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '加法', '10以内', '计算题'],
sortOrder: 116,
},
{
id: 'subtraction-10',
title: '10以内减法',
subtitle: '图形化展示 10 以内减法',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '减法', '10以内', '计算题'],
sortOrder: 117,
},
{
id: 'addition-subtraction-10',
title: '10以内加减法',
subtitle: '加减法混合运算',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '加减法', '10以内', '计算题'],
sortOrder: 118,
},
{
id: 'make-ten',
title: '凑十法练习',
subtitle: '20 以内进位加法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '凑十法', '进位加法', '计算题'],
sortOrder: 119,
},
{
id: 'break-ten',
title: '破十法练习',
subtitle: '20 以内退位减法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '破十法', '退位减法', '计算题'],
sortOrder: 120,
},
{
id: 'flat-ten',
title: '平十法练习',
subtitle: '20 以内退位减法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '平十法', '退位减法', '计算题'],
sortOrder: 121,
},
{
id: 'borrow-ten',
title: '借十法练习',
subtitle: '20 以上退位减法',
ageMin: 6,
ageMax: 8,
difficulty: 4,
tags: ['数学', '借十法', '退位减法', '计算题'],
sortOrder: 122,
},
{
id: 'practice-addition',
title: '加法运算',
subtitle: '10/20/50/100 以内加法',
ageMin: 5,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加法', '计算题'],
sortOrder: 123,
},
{
id: 'practice-subtraction',
title: '减法运算',
subtitle: '10/20/50/100 以内减法',
ageMin: 5,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '减法', '计算题'],
sortOrder: 124,
},
{
id: 'practice-mixed',
title: '混合运算',
subtitle: '10/20/50/100 以内加减法混合',
ageMin: 5,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加减法', '计算题'],
sortOrder: 125,
},
{
id: 'multiplication-table',
title: '九九乘法表',
subtitle: '学习九九乘法口诀',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '乘法', '九九乘法表'],
sortOrder: 126,
},
] as const satisfies ReadonlyArray<MathDrawDefinition>;
type MathDrawDefinitionItem = (typeof MATH_DRAW_DEFINITIONS)[number];
const MATH_DRAW_BY_ID = Object.fromEntries(
MATH_DRAW_DEFINITIONS.map((item) => [item.id, item]),
) as Record<string, MathDrawDefinitionItem>;
function resolveMathPublishId(
routeId: string,
selectedTypeId: string,
mode?: string,
): string {
if (selectedTypeId === 'addition') {
return mode || 'addition-5';
}
if (selectedTypeId === 'number-object-match' && mode === 'fill') {
return 'number-object-fill';
}
if (routeId in MATH_DRAW_BY_ID) {
return routeId;
}
return selectedTypeId;
}
export function getPublishMetaByMathState(
routeId: string,
selectedTypeId: string,
mode?: string,
): DebugPublishMeta | null {
const id = resolveMathPublishId(routeId, selectedTypeId, mode);
const item = MATH_DRAW_BY_ID[id];
if (!item) return null;
return {
id: item.id,
title: item.title,
subtitle: item.subtitle,
category: 'math',
subcategory: 'math-draw',
path: `/mathPages/mathDraw/mathDraw?id=${item.id}`,
ageMin: item.ageMin,
ageMax: item.ageMax,
grade: inferGradeFromAge(item.ageMin, item.ageMax),
difficulty: item.difficulty,
previewImg: '',
tags: [...item.tags],
isNew: false,
isHot: false,
sortOrder: item.sortOrder,
status: 'draft',
};
}
@@ -10,6 +10,7 @@
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
@@ -6,6 +6,8 @@ import {
type MathTypeConfig,
type MathTypeAction,
} from './registry';
import { getPublishMetaByMathState } from './mathDraw.config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
const TYPE_LIST = MATH_TYPE_CONFIGS.map((t) => ({
id: t.id,
@@ -40,6 +42,10 @@ createMathPage({
numberList: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
selectedNumber: 0,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null as DebugPublishMeta | null,
},
onLoad(options: { id?: string; mode?: string }) {
@@ -47,6 +53,8 @@ createMathPage({
const result = findTypeByRouteId(routeId);
if (!result) return;
this.syncDebugPublishEnv();
const { typeConfig, mode, extra } = result;
const initialMode =
options.mode || mode || typeConfig.defaultMode || '';
@@ -260,4 +268,16 @@ createMathPage({
icon: 'none',
});
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMathState(
this.data.functionId,
this.data.selectedTypeId,
this.data.currentMode,
);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
});
@@ -94,6 +94,16 @@
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<!-- 分享引导弹窗 -->
<share-guide-popup
show="{{showShareDialog}}"