16 Commits

Author SHA1 Message Date
R524809 98dc338848 feat:作业纸 2026-10-23 16:53:22 +08:00
R524809 fe1074e3f0 feat: 增加环境切换 2026-10-19 17:49:19 +08:00
R524809 997042789d feat: 完成全部接口替换为新后端接口 2026-10-19 13:19:07 +08:00
R524809 8de06ce66b feat: 分类接口替换为api 接口 2026-10-16 17:33:24 +08:00
R524809 eddd7d75e2 docs: 3.5.0设计文档 2026-10-16 16:00:59 +08:00
R524809 08d9a2792a feat: 3.5版本规划 2026-09-24 17:14:38 +08:00
R524809 f82ac4d89d feat: 开发练习纸 2026-08-25 15:40:38 +08:00
R524809 c0b8666257 feat: 时钟连线和认识时钟合并 2026-07-29 17:15:25 +08:00
R524809 2ffd0b302d feat: 开发时钟连线 2026-07-29 16:41:29 +08:00
R524809 37586b7bf2 feat: 修改版本号和更新内容 2026-06-25 12:57:58 +08:00
R524809 783ebfb936 feat: 新增认识时钟绘制 2026-06-25 12:46:43 +08:00
R524809 b802709517 fix: worksheet 展示时间修改 2026-06-15 16:40:20 +08:00
R524809 0bcf846857 fix: 解决公告点击跳转路径不对的问题 2026-06-15 15:43:34 +08:00
R524809 11469f7dca feat: 优化分类页内容管理 2026-06-15 15:39:46 +08:00
R524809 25a37fc5e2 feat: 新增幼儿测字表 2026-06-11 14:39:06 +08:00
R524809 a6e8f4df02 feat: 新增控笔练习,增加首页公告 2026-05-29 10:21:39 +08:00
137 changed files with 10248 additions and 1935 deletions
+67
View File
@@ -2,3 +2,70 @@
直接使用view等原生代替canvas进行渲染,方便用户操作,只在最后导出图片时才渲染canvas。或者每次修改渲染隐藏canvas。
以此减少交互和适配成本。
### 公开预览图配置
预览图和固定字体的公开地址统一配置在 `miniprogram/config/assets.ts`:
```typescript
publicDomain: 'https://cdn.joeyone.cn',
keyPrefix: 'doodle-assets/',
```
首页预拉取、旧 pageConfig 缓存、新后端动态兜底,以及年龄推荐、收藏、下载记录和内容管理页的旧预览图,展示前统一通过 `resolvePreviewUrl` 转成七牛地址。旧云存储临时链接也会转换并去掉旧签名;其他环境、外部图片、本地图片和头像不参与此次预览图替换。分类页继续使用新后端返回的七牛地址。重新编译小程序即可使用,无需手工清除 pageConfig 缓存。
`cache/page-config.json` 中的 34 处预览引用已替换。兼容逻辑不修改线上云数据库、云函数或已上传的配置 JSON;旧数据由客户端读取时转换。新的预览上传由管理员上传接口写入七牛,再以 ready 资产 ID 发布元数据。
运行预览图回归测试:`npm run test:assets`;分类页回归:`npm run test:category`。
### 新后端静默登录与鉴权
启动及回到前台时通过 `utils/auth.ts` 非阻塞预热:`wx.login` 取一次性 code,调用 `POST /api/v1/auth/wechat/login`。首次使用 `start`,已有会话续期和 401 恢复使用 `resume`;明确无效 code 最多重新取一次。JWT 与用户摘要按 AppID、包类型、环境名称和接口地址分开保存,凭证到期前 60 秒按需续登。不缓存角色或微信 OpenID,不记录凭证、code 或完整登录响应。
统一请求层 `services/api-request.ts` 默认要求登录并携带 Bearer;公开分类与题型接口显式指定 `auth: 'none'`,不携带凭证、不触发登录。受保护请求遇到可恢复 401 时最多重登并重发一次,并发请求合并登录,迟到的旧 401 不会清掉新凭证。恢复后身份变化时不补发原业务写入。403、限流、服务错误或网络失败不触发自动重登链。
```typescript
import { apiRequest } from './services/api-request';
import { getUser, refreshUser } from './utils/auth';
// 获取新后端用户摘要;必要时静默登录。
const user = await getUser();
// 受保护的 GET /me,读取最新资料;不会先清掉有效凭证。
await refreshUser();
// 新增本人业务接口默认使用统一鉴权;公开接口显式 auth=none。
await apiRequest('/me');
```
账户不存在或禁用时停止自动恢复,不自动切回 start 重建账户;账户历史标记保留在同一个版本化缓存中。个人中心仅在需要用户主动重新开始时显示“开始使用账户”。登录失败不阻塞公开浏览、本地生成和导出。
用户资料、头像上传、收藏、下载记录和账户设置现已全部使用新后端及 `globalData.user`,旧登录兼容层已移除。界面保留少量 `_id` 展示字段映射,来源始终是新后端 ID,不再调用旧云业务。正式环境切换前仍需单独迁移历史用户及关联数据,线上 API 地址当前仍待配置。
验证:`npm run test:auth`;联合回归可执行 `node --import tsx --test tests/auth-api.test.mjs tests/category-api.test.mjs tests/preview-assets.test.mjs`。微信兑换在测试中使用模拟响应,真实一次性 code 需要在微信开发者工具或真机联调;没有加入绕过微信验证的测试登录接口。
### 业务接口切换
本地开发中的小程序运行代码已不再调用 `wx.cloud`;体验版与正式版首页继续使用 `wx.getBackgroundFetchData` 获取预拉取首屏,在后台使用新 `/page-config` 刷新。开发版跳过旧线上预拉取,使用所选接口环境自己的内容缓存及新后端;管理改动也会刷新首页。旧云端的预拉取地址、生成与发布链路、定时任务保持原样,新管理开关仅控制新后端的快照刷新任务。
当前已发布的线上版仍使用旧云开发接口。`cloudfunctions/` 中全部旧云函数源码、云开发配置及线上服务均须保留;本地开发版切换接口不代表线上旧版本已停止调用。未经另行明确安排,不删除、停用旧云函数,不改变其接口契约,不清理旧云数据库或云存储,也不迁移现有预拉取模式。
用户接口由 `services/member-api.ts` 与 `utils/auth.ts` 管理,管理页由 `services/admin-api.ts` 适配具名 REST 路由。图片通过本站 multipart 上传接口进入七牛,不暴露 AK/SK;同一次上传恢复沿用 UUID uploadId。管理页只能由数据库中当前角色为 admin 的账户操作,开发版不会绕过权限。
服务端路由、权限与数据迁移说明见 doodle-store 的 `docs/v3.5/小程序云接口迁移清单.md`。旧云端用户、收藏与下载历史本次未自动导入,正式切换前需要另行核对迁移。
### 开发版接口环境切换
入口:个人中心 → Debug 工具 → 接口环境。`develop` 可切换 `local / staging / production`,选择保存在本机,切换后自动重建页面返回首页,无需重新编译。开发版导航栏显示当前环境。`trial / release` 固定使用 `production`,忽略本机调试选择,配置层与切换入口均有校验。
地址在 `miniprogram/config/api.ts` 集中配置:
- `localBaseUrl`:本地开发接口。
- `stagingBaseUrl`:未来测试接口。
- `productionBaseUrl`:未来正式接口。
目前按要求,三个地址都暂填 `http://127.0.0.1:8800/api/v1`,客户端缓存和会话按环境隔离,但实际请求仍访问同一个本地后端和数据库。真机的 `127.0.0.1` 指向手机;调试时 local 需改为电脑局域网地址。部署时把 staging/production 占位地址改为对应 HTTPS 地址,不会因地址缺失自动回退 local。
切换会清空共享用户和内容状态,读取目标环境凭证,并强制从目标接口加载首页;原环境的缓存与会话保留。所有 API 响应按环境版本检查,即使快速 A→B→A,旧请求也不能覆盖当前数据。写请求(含登录等待及上传重试)未完成时禁止切换。目标接口不可用时提示连接失败并保留所选环境,不展示上一环境的内容。打印偏好继续共用。
本轮仅调整开发版数据来源;体验/正式包原预拉取模式、地址及全部旧云端服务保持原样。
环境切换回归:`node --import tsx --test tests/environment-switch.test.mjs`。
+34 -34
View File
@@ -52,7 +52,7 @@
"ageBand": "3-5岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
"available": true
@@ -65,7 +65,7 @@
"ageBand": "4-6岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
"available": true
@@ -78,7 +78,7 @@
"ageBand": "5-7岁",
"difficulty": "进阶",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
"available": true
@@ -91,7 +91,7 @@
"ageBand": "4-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-sheet.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/hanzi-sheet.jpg",
"badge": "",
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
"available": true
@@ -104,7 +104,7 @@
"ageBand": "6-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-daily-checkin.jpg",
"badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
"available": true
@@ -117,7 +117,7 @@
"ageBand": "4-6岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/dot-connect.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/dot-connect.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=dot-connect",
"available": true
@@ -132,7 +132,7 @@
"ageBand": "4-6岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
"available": true
@@ -145,7 +145,7 @@
"ageBand": "3-5岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
"available": true
@@ -158,7 +158,7 @@
"ageBand": "5-7岁",
"difficulty": "进阶",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/make-ten.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/make-ten.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=make-ten",
"available": true
@@ -171,7 +171,7 @@
"ageBand": "5-7岁",
"difficulty": "进阶",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
"available": true
@@ -184,7 +184,7 @@
"ageBand": "3-5岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/counting-matching.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/counting-matching.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=counting-matching",
"available": true
@@ -205,7 +205,7 @@
"ageBand": "4-6岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-sort.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-sort.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-sort",
"available": true
@@ -218,7 +218,7 @@
"ageBand": "3-5岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-coloring.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-coloring.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-coloring",
"available": true
@@ -231,7 +231,7 @@
"ageBand": "5-7岁",
"difficulty": "进阶",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/make-ten.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/make-ten.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=make-ten",
"available": true
@@ -244,7 +244,7 @@
"ageBand": "5-7岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-decompose.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-decompose.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-decompose",
"available": true
@@ -257,7 +257,7 @@
"ageBand": "3-5岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/math/number-find.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/math/number-find.jpg",
"badge": "",
"path": "/mathPages/mathDraw/mathDraw?id=number-find",
"available": true
@@ -278,7 +278,7 @@
"ageBand": "4-6岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/position-coloring.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/position-coloring.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=position-coloring",
"available": true
@@ -291,7 +291,7 @@
"ageBand": "3-6岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/line-recognition.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/line-recognition.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=line-recognition",
"available": true
@@ -304,7 +304,7 @@
"ageBand": "5-7岁",
"difficulty": "进阶",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/grid-reasoning.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/grid-reasoning.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=grid-reasoning",
"available": true
@@ -317,7 +317,7 @@
"ageBand": "5-7岁",
"difficulty": "进阶",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/code-connect.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/code-connect.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=code-connect",
"available": true
@@ -330,7 +330,7 @@
"ageBand": "4-6岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/puzzle/shape-symbol.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/puzzle/shape-symbol.jpg",
"badge": "",
"path": "/focusPages/focusDraw/focusDraw?id=shape-symbol",
"available": true
@@ -351,7 +351,7 @@
"ageBand": "5-7岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-tracing.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-tracing.jpg",
"badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing",
"available": true
@@ -364,7 +364,7 @@
"ageBand": "6-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-dictation.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-dictation.jpg",
"badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation",
"available": true
@@ -377,7 +377,7 @@
"ageBand": "5-7岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-tracing-v2.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-tracing-v2.jpg",
"badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-tracing-v2",
"available": true
@@ -390,7 +390,7 @@
"ageBand": "6-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-dictation-v2.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-dictation-v2.jpg",
"badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-dictation-v2",
"available": true
@@ -403,7 +403,7 @@
"ageBand": "6-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/pinyin/pinyin-daily.jpg",
"badge": "",
"path": "/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily",
"available": true
@@ -424,7 +424,7 @@
"ageBand": "3-6岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/word-coloring-grid.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/word-coloring-grid.jpg",
"badge": "",
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-grid",
"available": true
@@ -437,7 +437,7 @@
"ageBand": "3-6岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/word-coloring-find.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/word-coloring-find.jpg",
"badge": "",
"path": "/chinesePages/wordColoring/wordColoring?id=word-coloring-find",
"available": true
@@ -450,7 +450,7 @@
"ageBand": "4-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-sheet.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/chinese/hanzi-sheet.jpg",
"badge": "",
"path": "/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-sheet",
"available": true
@@ -471,7 +471,7 @@
"ageBand": "6-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-daily-checkin.jpg",
"badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin",
"available": true
@@ -484,7 +484,7 @@
"ageBand": "4-7岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-single.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-single.jpg",
"badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-single",
"available": true
@@ -497,7 +497,7 @@
"ageBand": "5-7岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-case-pairing.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-case-pairing.jpg",
"badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing",
"available": true
@@ -510,7 +510,7 @@
"ageBand": "5-8岁",
"difficulty": "基础",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-half.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-half.jpg",
"badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-half",
"available": true
@@ -523,7 +523,7 @@
"ageBand": "5-7岁",
"difficulty": "入门",
"icon": "",
"previewImg": "cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-two-column.jpg",
"previewImg": "https://cdn.joeyone.cn/doodle-assets/assets/previews/english/letter-tracing-two-column.jpg",
"badge": "",
"path": "/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column",
"available": true
+1 -1
View File
@@ -31,7 +31,7 @@ function toHomeDisplayItem(ws) {
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
icon: '',
img: ws.previewImg || '',
previewImg: ws.previewImg || '',
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
path: ws.path || '',
available: true,
+8 -3
View File
@@ -15,6 +15,13 @@ function ageBand(min, max) {
return `${min}-${max}岁`;
}
function toContentDate(ws) {
const ts = ws.contentUpdatedAt || ws.createdAt;
return ts
? new Date(ts).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
}
function toDisplayItem(ws) {
return {
id: ws._id,
@@ -30,9 +37,7 @@ function toDisplayItem(ws) {
available: true,
likes: (ws.likes || 0) + (ws.likes_seed || 0),
downloads: (ws.downloads || 0) + (ws.downloads_seed || 0),
date: ws.updatedAt
? new Date(ws.updatedAt).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
date: toContentDate(ws),
};
}
+13
View File
@@ -36,13 +36,26 @@ exports.main = async (event) => {
async function handleAdd(userId, event) {
const { worksheetId } = event;
const exportId = String(event.exportId || '').trim();
if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' };
const collection = db.collection('download_logs');
// 同一次成功导出重试时只能计一次,避免网络重试抬高下载量。
if (exportId) {
const { data: existing } = await collection
.where({ userId, exportId })
.limit(1)
.get();
if (existing.length > 0) {
return { code: 0, data: { ...existing[0], duplicate: true } };
}
}
const record = {
userId,
worksheetId,
...(exportId && { exportId }),
createdAt: db.serverDate(),
};
const { _id } = await collection.add({ data: record });
@@ -105,6 +105,7 @@ exports.main = async (event) => {
likes: payload.likes,
status: payload.status,
updatedAt: db.serverDate(),
contentUpdatedAt: db.serverDate(),
};
try {
@@ -127,6 +128,7 @@ exports.main = async (event) => {
data: {
...record,
createdAt: db.serverDate(),
contentUpdatedAt: db.serverDate(),
},
});
+4 -1
View File
@@ -19,6 +19,7 @@ exports.main = async (event) => {
const category = String(event.category || '').trim();
const status = String(event.status || '').trim();
const rawStats = event.rawStats === true;
// Build query condition
const where = {};
@@ -61,7 +62,9 @@ exports.main = async (event) => {
else if (item.status === 'hidden') stats.hidden++;
}
mergeSeedStats(data);
if (!rawStats) {
mergeSeedStats(data);
}
return {
success: true,
+171
View File
@@ -0,0 +1,171 @@
# AI 生成题目方案(对话式出题)
> 文档状态:方案讨论 / 待评审
> 创建日期:2026-06-16
> 背景:当前小程序与同类应用差异不明显,用户画像不够精准。探索引入 AI 能力——用户用自然语言描述需求,AI 将其翻译为对预置「技能(绘制方法)」的调用,并完成出题与绘制。
---
## 一、战略层面:先想清楚「为什么要做」
本方案在技术上完全可行,但要避免 AI 沦为「为差异化而加的功能」,而非「解决真实痛点的功能」。立项前需先回答:
- **用户的真实痛点是什么?** 是「找不到合适的题目」,还是「孩子不爱练」「不知道该练什么」?如果痛点不在出题,AI 出题器再酷也救不了留存。
- **家长真的会打字描述需求吗?** 「我想要培养数感的题目」这类话,家长未必说得出(多数人不知道「数感」是什么)。更真实的诉求往往是「我家娃 5 岁,给我今天该练的」。这意味着 AI 的价值可能不在「自然语言理解」,而在 **「帮不懂教育的家长做决策」**。
- **不用 AI 能否满足 80%?** 如果几个下拉框 + 模板就能满足,AI 的边际价值仅是「输入方式更自然」,通常撑不起差异化。
**结论 / 定位建议:** 把 AI 定位成 **「懂教育的助教」**(帮家长判断该练什么、循序渐进地推荐),而不是 **「自然语言转绘制指令的翻译器」**。前者是真差异,后者只是花哨的表单。
---
## 二、技术层面:经典的 Function Calling / Tool Use 架构
「把需求变成可调用的技能」在工程上的成熟范式叫 **工具调用(tool use / function calling)**:已有的「绘制方法」即工具,LLM 负责把人话翻译成「调用哪个工具 + 什么参数」。
### 2.1 整体数据流(小程序云开发)
```
用户输入(自然语言)
→ 小程序前端 (聊天式 UI)
→ 云函数 ai-orchestrator
→ 调用 LLM(带 tools 定义)
├─ 缺参数 → 返回追问("孩子几岁?") ← 多轮
└─ 参数齐全 → 返回结构化调用意图
→ 确认环节(用户点"确认生成")
→ 云函数 / 前端调用内置绘制方法
→ 生成题目数据 + 渲染(canvas/图片)
→ 返回前端展示 / 保存 / 下载
```
### 2.2 关键模块
**1)技能注册表(最核心)**
每个绘制方法描述成一个 tool schema,让 LLM 知道有哪些能力、各需要什么参数:
```json
{
"name": "generate_number_sense",
"description": "生成培养数感的练习题(比大小、数的分解、数数等)",
"parameters": {
"type": "object",
"properties": {
"age": { "type": "integer", "description": "孩子年龄 3-8" },
"sub_type": { "type": "string", "enum": ["比大小", "数的分解", "按数取物"] },
"count": { "type": "integer", "description": "题目数量" },
"number_range": { "type": "string", "enum": ["1-10", "1-20", "1-100"] }
},
"required": ["age", "sub_type", "count"]
}
}
```
**2)LLM 选型(国内合规很重要)**
云函数可发 HTTP 请求,能接任意 LLM。国内备案合规、且支持 OpenAI 兼容 function calling 的可选:**通义千问、豆包(火山方舟)、DeepSeek、文心、混元**。微信云开发本身也有「AI 能力 / 微信对话开放平台」。建议挑一个支持 `tools` 参数的,编排逻辑几乎无需自写。
**3)多轮对话 + 槽位填充(slot filling)**
「缺年龄就追问年龄」的逻辑交给 LLM 判断,比手写 if-else 更强。需要做的只是:
- 每轮带上对话历史(存云数据库或前端回传);
- 在 system prompt 里要求:「参数不全时先友好追问,不要瞎猜」。
**4)「决策」与「生成」必须分离 ⚠️(最重要的工程原则)**
让 LLM 决定「生成什么」(结构化参数),但题目实际内容由确定性代码生成:
- ❌ 不要让 LLM 直接吐出 20 道算术题——会算错、会重复、不可控。
- ✅ 让 LLM 输出 `{age:5, sub_type:"比大小", count:20, range:"1-10"}`,再由绘制方法按规则生成 + 渲染。
兼得 AI 的「听得懂人话」与传统代码的「100% 正确可控」。
**5)确认环节**
真正调用绘制方法前,把解析出的参数回显给用户确认(「将生成 20 道 1-10 比大小,确认?」)。既防误解,也是好体验。
### 2.3 小程序云开发的几个坑
| 坑 | 说明 / 对策 |
| -------------- | ------------------------------------------------------------------------------------- |
| **云函数超时** | 默认 20s,LLM 调用可能慢。先做**非流式**(一次返回);想要打字机效果,后期再用 WebSocket 或 HTTP 触发器 + SSE,复杂度高,别一开始就做。 |
| **内容安全(必须)** | 微信强制要求。用户输入和 AI 输出都要过 `security.msgSecCheck`,否则可能被封。合规硬要求,非可选。 |
| **API Key 保护** | LLM 的 key 只能放云函数环境变量,**绝不能进前端**。 |
| **成本与频率** | 每次对话烧 token。加缓存(相同需求复用)、限频,防刷。 |
| **冷启动延迟** | 云函数冷启动 + LLM 延迟叠加,首次可能 3-5s,UI 要有 loading 反馈。 |
---
## 三、要不要把绘制方法搬到服务端?
**短答:不需要把「绘制」搬到服务端。但要先把现有方法拆成两层——「出题数据」和「画图」——只有前者可能值得上服务端,后者留在小程序里。**
### 3.1 把「绘制方法」拆成三层
```
① 意图理解层 用户人话 → 结构化参数 ← 必须在服务端(云函数 + LLM)
② 出题数据层 参数 → 题目数据(JSON) ← 可服务端、可客户端
③ 渲染绘制层 题目数据 → canvas 画出来 ← 留在小程序前端
```
现有「绘制方法」大概率是 ②③ 混在一起(一个函数既算题目又调 `wx.canvas` 画图)。做 AI **真正要做的不是「搬到服务端」,而是「把 ② 和 ③ 解耦」**。
### 3.2 为什么渲染层(③)不该搬服务端
- 小程序 canvas 是**客户端 API**,云函数(Node 环境)里没有 DOM、没有原生 canvas;要画图得引 `node-canvas` 之类,又重又易踩坑,得不偿失。
- 渲染留前端:性能好、可交互(手写、橡皮擦等)、省服务器成本。
→ ③ **保持现状,一行都不用动**。
### 3.3 ② 出题数据层:搬不搬都行
**方案 A — 什么都不搬(最快上线,推荐起步)**
云函数只做 ①,返回结构化参数给前端:
```js
// 云函数返回
{ skill: "number_sense", age: 5, sub_type: "比大小", count: 20, range: "1-10" }
```
前端拿到参数,调已有的出题 + 绘制方法,照常跑。AI 是「加在前面的一层翻译」,老代码完全复用。
**方案 B — 把出题逻辑(②)搬服务端**
云函数直接算好题目数据返回:
```js
{ problems: [ {left:7, right:3, op:">"}, {left:2, right:8, op:"<"}, ... ] }
```
前端只负责画。
什么时候才值得选 B:
- 出题逻辑要**保密/防作弊**(题库、难度算法不想暴露前端);
- **多端复用**(以后有 H5、APP,出题逻辑只维护一份);
- 出题需要**服务端资源**(查数据库题库、调别的接口)。
以上都没有,**先选 A**。
### 3.4 关键动作
唯一的关键动作:**确保出题逻辑是个「纯数据函数」(输入参数、输出 JSON、不碰 canvas)**。做到这点,搬不搬服务端都是后话,随时可切。
---
## 四、最小可行起步(MVP)
别一上来做全套对话系统,先做最小闭环验证:
1. **单个技能**:只挑「数感题目」一个绘制方法接进来。
2. **单轮或两轮对话**:用户说需求 → AI 解析参数(缺了追问一次)→ 确认 → 生成。
3. **跑通链路**:「人话 → 结构化参数 → 已有绘制方法」。
跑通后回答关键问题:**家长真的会用自然语言输入吗?还是更想点几下就出题?** 用真实数据决定是否继续往「对话式」投入,而非凭感觉。
---
## 五、待办 / 下一步
- [ ] 盘点现有「绘制方法」清单及调用方式,判断 ②③ 缠绕程度。
- [ ] 确定 LLM 供应商(合规 + 支持 function calling)。
- [ ] 抽离一个出题逻辑为纯数据函数(以「数感题目」为试点)。
- [ ] 搭建 `ai-orchestrator` 云函数骨架 + 技能注册表。
- [ ] 接入内容安全 `msgSecCheck`。
- [ ] MVP 灰度,观察家长真实输入行为与转化数据。
+2
View File
@@ -1,5 +1,7 @@
# Doodle / 涂鸦丫 - PC Web 端技术预案
> **2026-10-12 决策更新**:本文保留为历史预案,DAU 启动门槛和浏览器承载完整生成器不作为当前决策。现有后端已决定迁移到自有服务器,见 [迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md);Web 范围和私有服务端生成见 [v3.5 策略](./v3.5/Web与服务端生成策略.md)。
> 状态:**远期规划**(当前聚焦小程序,暂不启动 Web 开发)
> 最后更新:2026-03-24
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [产品设计文档](./产品设计文档.md)
+33
View File
@@ -0,0 +1,33 @@
# Doodle Mini v3.5 规划索引
> 状态:产品规划待评审;云服务端迁移方向已确认,待实施
>
> 更新日期:2026-10-12
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
## 本阶段的四项决策
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
4. **现有业务后端迁移到自有云服务器。** 采用统一 API、MySQL 和七牛文件存储;CloudBase 只在切换期承担旧版兼容。业务后端迁移与存量 Canvas 服务端重写分别实施。
## 文档
| 文档 | 用途 |
| --- | --- |
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
| [云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md) | 在新项目统一维护:现有功能/API 映射、数据与文件迁移、实施待办、切换回滚、验收及 AI 辅助工时预算 |
| [埋点事件字典](./埋点事件字典.md) | 现有事件和成功保存口径;后端迁移继续沿用 |
## 不在 v3.5 范围内
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
- 在业务后端迁移中同时重写全部既有 Canvas 题型。
- 社区晒图、公开儿童信息、复杂学习报告。
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
这些项目需要在 v3.5 的内容需求、留存和服务端生成成本被验证后再启动。
+153
View File
@@ -0,0 +1,153 @@
# Doodle Mini v3.5 Web 与服务端生成策略
> 状态:迁移到自有云服务器的方向已确认;Web 与绘图试点待实施
>
> 更新日期:2026-10-12
> 现有业务后端的迁移范围、切换顺序和验收以[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)为准。本文说明迁移后 Web 与服务端生成的演进,不再以继续保留 CloudBase 业务后端为目标。
## 1. 决策结论
### 1.1 Web 的定位
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
v3.5 将现有业务后端迁移作为独立实施任务,小程序体验和小规模 Web/服务端生成按里程碑推进,不预设固定投入比例。
不等待小程序“达到很大用户量”才准备 Web,因为搜索内容、服务端渲染和内容生产机制都需要时间积累;但也不在内容库、内容生产和核心留存未验证时建设完整 Web 平台。
### 1.2 生成位置
未来 Web 的高价值题型采用**服务端生成与渲染优先**。浏览器负责选择参数、展示预览、发起下载和账户交互;不得承载完整题目生成规则、排版参数与私有素材。
小程序既有题型暂可保留客户端 Canvas 生成,以避免高风险的一次性重写。创意涂色纸及后续主题包、批量 PDF 等新能力必须按服务端模式实现。
## 2. 为什么不把核心生成放在 Web 客户端
Web 客户端代码、网络请求和静态资源可被用户查看、保存和分析。即使经过压缩或混淆,也只能提高复制成本,无法提供真正的保护。
服务端模式保护的是:
- 题目随机规则、难度控制、答案逻辑。
- 模板坐标、排版参数和批量内容生产能力。
- 原始 SVG/高清线稿、字体、商用素材以及素材关联关系。
- 未发布题型、主题包和会员规则。
服务端无法阻止他人观察最终 PNG/PDF 并模仿视觉效果;产品壁垒仍来自稳定的内容供给、素材版权、生成质量、运营数据和用户体验。
## 3. 目标架构
```text
小程序 / Web
└─ 仅提交受控参数:worksheetId、年龄、难度、主题、孩子姓名
↓
统一业务 API
↓
Render API(鉴权、配额、限流、参数校验)
↓
私有题型配置 + 私有素材 + 生成器 + 渲染器
↓
生成 PNG / PDF,写入对象存储
↓
返回短期签名预览/下载 URL 或异步任务状态
```
示例请求:
```json
{
"worksheetId": "coloring-animal-01",
"variant": "easy",
"childProfileId": "child_123",
"output": "png"
}
```
服务端根据 `worksheetId` 查询内部配置,生成随机种子和题目内容;客户端不能传素材 URL、任意渲染器名称、自由模板参数或数据库字段。
## 4. 数据与接口边界
### 4.1 平台无关的 worksheet 模型
当前内容数据中存在小程序专用 `path`,例如 `/mathPages/mathDraw/mathDraw?id=number-find`。v3.5 起应建立平台无关的核心字段:
```ts
type Worksheet = {
id: string;
category: string;
title: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
rendererKey: string; // 仅服务端可解释
generatorKey: string; // 仅服务端可解释
assetRefs: string[]; // 私有资源引用
status: 'draft' | 'active' | 'hidden';
};
```
小程序路由与 Web URL 是展示层映射,不能作为内容本体。旧字段可在迁移期保留为 `miniProgramPath`,但新服务端生成不依赖它。
### 4.2 业务服务接口
页面不得再散落调用 `wx.cloud.callFunction`。逐步收口为业务接口:
```ts
worksheetService.list(filters)
worksheetService.getDetail(id)
renderService.createJob(request)
renderService.getJob(jobId)
favoriteService.toggle(worksheetId)
historyService.list()
```
新版小程序与后续 Web 统一使用 HTTP 业务 API;旧版云函数只在过渡期转发同一业务实现。上层页面不感知数据库与文件存储实现。
## 5. 后端演进路径
### 阶段 A:现有业务后端迁移
- 按专项方案迁移现有云函数、数据库、文件和定时任务到自有腾讯云服务器 + MySQL + 七牛。
- 新版小程序经业务服务层调用统一 HTTP API;短期兼容桥照顾旧正式版本,只有一个业务写入源。
- 先保持现有生成与保存行为,再验收数据关联、文件、计数和切换;不同时重写所有绘图代码。
### 阶段 B:服务端生成样板
- 在同一后端增加受控 Render API,以创意涂色或一个简单现有题型验证生成、字体、延迟和文件交付。
- 原始素材和模板采用私有存储;最终文件通过受控地址交付,不依赖隐藏对象路径保护素材。
- 根据实际任务量增加缓存/异步处理,不预先搭建独立队列平台。
### 阶段 C:Web 小规模试点与扩张
- Web 复用统一 API 和内容源,内容页面使用适当的预生成或服务端渲染,生成规则留在服务器。
- 只建设下节规定的 MVP;旧题型按价值与实际适配成本逐步接入,批量 PDF/会员另行评估。
- 私有生成进程可按资源需求拆分,但不另建用户、收藏、内容数据库或第二套业务规则。
## 6. 安全、成本与体验基线
| 维度 | v3.5 最低要求 |
| --- | --- |
| 素材保护 | 原始素材不公开;仅输出最终位图/PDF;下载链接短时有效 |
| 接口控制 | 登录/游客策略、参数白名单、按用户/IP 限流、每日生成配额 |
| 缓存 | 以题型、变体、随机种子、个性化字段计算缓存键;个性化内容谨慎缓存 |
| 任务模式 | 单张同步生成;多页学习包/PDF 使用异步任务与状态查询 |
| 可观测性 | 记录生成耗时、失败原因、缓存命中、文件大小、单位成本 |
| 体验目标 | 单张预览 P95 小于 3 秒;异步任务需有明确进度和失败重试提示 |
## 7. Web MVP 范围与升级门槛
Web MVP 仅包含:首页、10–20 个高意图主题/详情页、2–3 类生成器、预览与打印/PDF 下载。首选试点为创意涂色纸。
满足以下条件后,才扩张为完整 Kiddoleap 风格的资源平台:
- 可稳定按周上新内容,并有明确的素材/审核/发布流程。
- Web 已获得自然搜索访问、有效下载与回访,不只是小程序导流。
- 生成服务的时延、成功率和成本达到产品目标。
- 主题包或批量导出表现出明确的付费/订阅意愿。
## 8. 与既有文档的关系
本策略更新了既有架构文档中“前端为主、核心生成保持客户端执行”的长期方向:该原则仍适用于存量小程序题型的成本与离线体验,但**不适用于未来 Web 的高价值题型**。
在 v3.5 评审通过后,应同步更新《技术架构设计文档》《PC-Web端技术预案》《小程序云开发方案和数据库设计》中的阶段说明,避免文档之间出现相反结论。
+67
View File
@@ -0,0 +1,67 @@
# 微信云存储资源目录清单
更新:2026-10-16。依据 doodle-mini 的 miniprogram 与 cloudfunctions 源码、路径构造函数及仓库内缓存分析,不是云端存储桶的实时文件列表。本次未下载、上传、迁移或删除云文件。
## 目录树
以下为代码涉及的云存储相对路径,仅列到最后一级目录,共 9 个末级目录:
```text
/
├── assets/
│ ├── fonts/
│ └── previews/
│ ├── chinese/
│ ├── english/
│ ├── math/
│ ├── papers/
│ ├── pinyin/
│ └── puzzle/
├── content/
└── user-avatars/
```
对象存储中的“目录”是文件 key 的前缀。此处表示代码引用或能够写入的路径,不保证每个目录已经存在文件,也不能据此排除云端的历史/未引用目录。
## 用途与代码依据
| 最后一级目录 | 用途与确认范围 | 代码依据 |
| ------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| assets/previews/chinese/ | 语文、控笔、识字等题型预览;首页静态项目也直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[语文发布配置](../../miniprogram/chinesePages/wordColoring/wordColoring.config.ts) |
| assets/previews/english/ | 英文字母题型预览;首页静态项目直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[英语发布配置](../../miniprogram/englishPages/letterTracing/letterTracing.config.ts) |
| assets/previews/math/ | 数学题型预览;由发布路径构造,旧页面配置缓存也有文件引用 | [数学发布配置](../../miniprogram/mathPages/mathDraw/mathDraw.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) |
| assets/previews/papers/ | 作业纸预览的动态上传路径;有发布配置,不能仅凭代码断言云端已有文件 | [作业纸发布配置](../../miniprogram/papersPages/paperSheet/paperSheet.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) |
| assets/previews/pinyin/ | 拼音题型预览;首页静态项目直接引用此路径 | [首页数据](../../miniprogram/pages/home/home.data.ts)、[拼音发布配置](../../miniprogram/pinyinPages/pinyinDictation/pinyinDictation.config.ts) |
| assets/previews/puzzle/ | 益智题型预览;由发布路径构造,旧页面配置缓存也有文件引用 | [益智发布配置](../../miniprogram/focusPages/focusDraw/focusDraw.config.ts)、[路径构造](../../miniprogram/utils/debugPublish.ts) |
| assets/fonts/ | ToneOZ 拼音字体;通过 cloud fileID 兑换临时链接,再加载字体 | [字体配置](../../miniprogram/core/font/fontProfiles.ts)、[字体加载](../../miniprogram/core/font/fontLoader.ts) |
| content/ | 首页展示配置 JSON;内容构建和定时刷新云函数上传到 content/page-config.json | [内容构建](../../cloudfunctions/pageContentBuild/index.js)、[定时刷新](../../cloudfunctions/homeAutoRefresh/index.js) |
| user-avatars/ | 用户头像;按用户 ID 和时间生成文件名上传,文件数量须另查用户数据或云端清单 | [头像上传](../../miniprogram/pages/profile/profile.ts) |
预览图统一写入规则为 `assets/previews/${category}/${id}.jpg`,由页面发布元数据传入 category,再通过 `base/pageMixin.ts` 调用 wx.cloud.uploadFile。扫描发布配置得到 chinese、english、math、papers、pinyin、puzzle 六个分类。类型或分类导航里存在 craft,不等于存在 craft 云预览目录,本清单不据此添加。
cloudfunctions 中另有两处实际上传调用,均写 content/page-config.json。头像上传是一个调用点,题型预览上传是一个公共调用点;未发现其他业务云文件上传路径。旧云数据库 previewImg/avatarUrl 字段可以保存代码未硬编码的 fileID,因此运行时数据仍可能增加清单以外的引用。
## 已走 CDN 的远程资源
以下引用使用 `https://cdn.joeyone.cn`,不是当前源码中的微信 cloud fileID,不混入上述云存储目录。仅凭域名不能确认底层存储服务商或 bucket,也不假定需要重复迁移。
| CDN 路径 | 内容 | 代码依据 |
| ----------------- | -------------------------------------- | ------------------------------------------------------------------------ |
| doodle/fonts/ | 六个字体文件,包含手写、描红和数字字体 | [字体配置](../../miniprogram/core/font/fontProfiles.ts) |
| doodle/hanzi/ | 汉字笔画及读音 JSON | [汉字数据加载](../../miniprogram/chinesePages/shared/getWordsSvgJson.ts) |
| doodle/share-img/ | 小程序分享图片 | [分享配置](../../miniprogram/config/config.ts) |
小程序包内的 assets/imgs、assets/tabBar、iconfont 等本地文件不属于云存储迁移范围;不能因为本地路径也以 assets 开头,就当作微信云文件。
## 后续快速同步建议
先做一次批量迁移,稳定后再考虑持续同步;当前仅为方案,未执行:
1. 生成文件级清单:题型预览从旧 worksheets.previewImg/新后端 assets.source_ref 获取,头像从用户当前引用获取,补上已确认的字体和配置路径。代码目录用于限定范围,不能替代实际文件枚举。旧缓存只作线索,不作为当前有效引用的唯一依据。
2. 按清单从原云环境授权下载到本地备份;记录相对路径、MIME、大小和 SHA-256,可失败重试。不将临时签名 URL 保存为长期资源地址。
3. 上传七牛并保留可追踪路径映射。例如公共资源可采用统一前缀加原相对路径,或按资产 ID/内容摘要生成不可变 key;目标空间、前缀和同名文件处理在执行前明确。头像按现有后端设计使用私有空间,不能与公共预览一并公开。
4. 回读校验后再更新引用:51 张已导入预览保留现有 assetId/preview_asset_id,回填七牛对象信息并标记 ready;字体更新配置 URL,头像按用户绑定并经服务端签名访问。
5. 首页配置 JSON 含嵌套预览地址,需在迁移图片后重建或替换这些引用,并处理小程序缓存。仅复制 JSON 文件不会自动转换其中的 cloud fileID。
6. 同时切换后续上传、发布和定时任务的目标,否则旧代码继续往微信云存储写入,七牛副本会再次落后。切换核对完成前保留源文件。
已有实施调研见 [云存储图片迁移七牛调研](../../../doodle-store/docs/v3.5/云存储图片迁移七牛调研.md)。本地未发现可直接使用的 tcb、cloudbase、qshell 命令;已有小程序 AppSecret 不等于源环境存储管理权限或七牛凭据。后续实施需确认可读源文件的授权方式,以及目标七牛 bucket、区域、HTTPS 域名和服务端凭据,不把凭据写入文档或前端。
+115
View File
@@ -0,0 +1,115 @@
# Doodle Mini v3.5 产品路线图
> 状态:待评审
>
> 更新日期:2026-09-24
## 1. 产品判断
涂鸦丫的核心用户不是“想要一道题”的孩子,而是希望快速安排孩子在家学习、又不愿每天筛选和购买练习册的家长。
当前产品已经具备动态生成 A4 练习纸、下载、收藏、分龄和题型分类能力。下一阶段应解决三件事:
1. **找得到:** 家长能按年龄、能力和主题快速找到需要的内容。
2. **用得起来:** 一次打开就能决定今天或本周打印什么,而不是在题型列表中犹豫。
3. **愿意回来:** 纸上完成的练习能形成轻量打卡、下次推荐和主题包复用。
因此,v3.5 的产品定位为:
> 为 3–8 岁家庭提供“今天就能打印、每周不必费心安排”的早教练习纸服务。
## 2. 用户问题与机会
| 用户问题 | 当前表现 | v3.5 机会 |
| --- | --- | --- |
| 不知道该给孩子练什么 | 有许多题型,但缺少任务化推荐 | 今日推荐、每周学习包、按年龄目标推荐 |
| 找一张特定练习纸费时 | 搜索逻辑存在,但界面未开放;分类偏题型 | 搜索、年龄/能力/主题筛选、主题集合页 |
| 低龄儿童可用内容与视觉吸引力不足 | 数学和书写题较多,3–5 岁的自由活动较少 | 创意涂色纸、颜色指令涂色、按数字涂色 |
| 打印后没有后续关系 | 仅有下载日志和收藏 | 完成打卡、连续练习、下次推荐 |
| 家长希望资料“属于孩子” | 练习纸为通用内容 | 孩子档案、姓名/日期/今日目标页眉 |
## 3. 功能优先级
### P0:先让核心体验完整
| 功能 | 用户价值 | v3.5 最小范围 | 成功指标 |
| --- | --- | --- | --- |
| 现有链路修复 | 防止用户打开错题、首页内容缺图 | 修复“识别形状”入口、时钟调试死链、首页推荐封面字段 | 关键入口 100% 可到达正确题型 |
| 搜索与筛选 | 快速定位练习纸 | 分类页恢复关键词搜索;增加年龄、能力/分类、主题标签 | 搜索后进入题型率;搜索零结果率 |
| 今日推荐 | 降低“今天练什么”的决策成本 | 每日 3 张:主练习、趣味活动、书写/语言;允许换一张 | 首页推荐点击率、首次导出率 |
| 创意涂色纸 MVP | 覆盖低龄、强化视觉吸引和传播 | 24–30 张原创/授权线稿;动物、食物水果、交通、恐龙、节日五主题 | 涂色纸导出率、收藏率、分享率 |
### P1:把单次下载变成每周复用
| 功能 | 用户价值 | v3.5/后续范围 |
| --- | --- | --- |
| 每周学习包 | 一键得到 5–7 天的打印安排 | 选择年龄、目标、每日时长;每天 2–3 张;可替换单张与收藏整包 |
| 孩子档案与个性化页眉 | 资料更有归属感、便于多孩家庭 | 孩子昵称、年龄、学习目标;导出时写入姓名、日期和练习主题 |
| 轻量完成打卡 | 建立线下练习后的回访理由 | 家长点击“已完成”;显示本周进度、连续天数和奖励贴纸 |
| 主题学习包 | 扩大内容复用与节日传播 | 如“恐龙周”“交通工具周”“入园准备包”“春节 7 日包” |
### P2:在数据验证后再做
| 功能 | 触发条件 | 说明 |
| --- | --- | --- |
| 家长难度反馈 | 推荐使用稳定后 | 每张练习只反馈“太简单 / 正好 / 有点难”,用于调整推荐 |
| 深度自定义 | 高意图用户占比明确 | 数字范围、题量、汉字词库、颜色主题等参数开放 |
| 教师/机构模式 | 有明确的机构试用用户 | 班级名单、姓名批量写入、随机变体、答案页 |
| 会员与 PDF 学习包 | 批量打印需求、支付意愿得到验证 | 多页 PDF、无广告、主题包、更多导出额度 |
## 4. 创意涂色纸 MVP
### 4.1 为什么优先做
- 面向 3–5 岁孩子,弥补“不会算、不愿写”的低龄使用场景。
- 首页预览和分享效果强,适合作为拉新入口。
- 与“打印后离开屏幕”的产品定位天然一致。
- 可作为 Web 服务端生成与私有素材管理的第一个试点。
### 4.2 第一版内容结构
| 类型 | 第一版内容 | 适龄 |
| --- | --- | --- |
| 自由创意涂色 | 动物、食物水果、交通、恐龙、节日五主题 | 3–6 岁 |
| 颜色指令涂色 | “把苹果涂红、鱼涂蓝”等识色/听指令任务 | 3–5 岁 |
| 按数字涂色 | 1–5 对应颜色,完成后呈现图案 | 4–6 岁 |
首发应准备 24–30 张高质量线稿,每主题 4–6 张。每张分为低龄版(轮廓大、元素少、留白多)与进阶版(背景、颜色任务或规律任务)。
### 4.3 非功能约束
- 仅使用原创、已购买商用授权,或经人工审核且可商用的素材;不得使用知名卡通 IP。
- 原始线稿、SVG 源文件和排版配置不得暴露给 Web 客户端。
- 预览可使用带水印低清图;下载生成清晰 A4 PNG/PDF。
- 每张内容具备主题、年龄、难度、关键词、版权来源和素材版本元数据。
## 5. 关键用户路径
```text
选择孩子/年龄
→ 首页看到“今天练什么”或主题包
→ 预览、替换或加入本周包
→ 生成并保存练习纸
→ 打印、纸上完成
→ 家长勾选已完成
→ 下次按年龄、目标与完成情况推荐
```
## 6. 验收指标
v3.5 不以“新增多少题型”验收,而以以下漏斗衡量:
| 指标 | 定义 | 初始目标 |
| --- | --- | --- |
| 首次导出转化率 | 新用户首次会话中成功保存练习纸的比例 | 持续提升;先建立基线 |
| 首次决策时长 | 打开首页至第一次点击“生成/导出”的时间 | 中位数低于 60 秒 |
| 7 日复用率 | 首次生成后 7 日内再次生成或下载的用户比例 | 持续提升;先建立基线 |
| 涂色内容表现 | 涂色纸的预览→导出、收藏、分享转化 | 高于当前题型中位数 |
| 学习包完成率 | 创建包后至少导出其中 3 张的比例 | 用于判断学习包是否成立 |
## 7. 明确暂不做
- 为了“看起来丰富”而新增大量同质数学题。
- 依赖儿童上传照片、公开晒图的社区功能。
- 没有验证批量使用需求前的多页 PDF 付费功能。
- 没有内容生产能力支撑前的完整 Web 分类站。
+25
View File
@@ -0,0 +1,25 @@
# Doodle Mini 埋点事件字典
> 更新日期:2026-09-24。事件名和字段名须先在微信小程序后台注册,再随版本发布。
## 通用字段
所有事件自动携带 `count`、`date_time`、`uuid`、`page_name`。内容事件使用稳定的 `worksheet_id`,而非可变的展示标题;可选字段包括 `worksheet_title`、`mode`、`source`、`category_id`、`export_id`、`duration_ms`、`fail_reason`。
| 事件 | 含义 | 触发时机 |
| --- | --- | --- |
| `page_view` | 页面浏览 | 首页、分类页首次进入 |
| `worksheet_click` | 点击某张练习纸 | 首页或分类列表点击有效内容 |
| `preview_view` | 打开或切换练习纸预览 | 练习纸页面初始化或切换题型 |
| `generate_start` | 开始生成 | 初始随机生成或点击“换一张” |
| `generate_success` / `generate_fail` | 生成结果 | Canvas 绘制结束;包含耗时和失败原因 |
| `export_click` | 点击保存 | 调用相册保存前 |
| `export_success` / `export_fail` | 导出结果 | 仅以相册保存 API 的结果为准 |
| `favorite_add` / `favorite_remove` | 收藏变化 | 云端收藏操作成功后 |
| `share_click` | 发起分享 | 调起小程序分享时 |
## 导出判定
`export_click` 不能作为下载量或转化。只有 `wx.saveImageToPhotosAlbum` 返回成功时才触发 `export_success`,并在同一时机递增本地下载配额、写入下载历史和更新内容下载量。
每次导出会生成 `export_id`。云端以 `userId + exportId` 去重,因此同一次保存的网络重试不会重复写下载历史或重复增加 `worksheets.downloads`。
+137
View File
@@ -0,0 +1,137 @@
# Doodle Mini v3.5 待办与里程碑
> 状态:待排期
>
> 更新日期:2026-10-12
## 0. 排期原则
按依赖关系推进:**先修复与可发现性 → 再验证涂色内容 → 再形成学习计划 → 同时建设小规模 Web/服务端样板。**
以下周期为相对迭代,不绑定具体人天;每个里程碑完成后应先检查数据,再决定是否扩大范围。
现有业务后端已决定迁移到自有云服务器。该专项按下列 S0–S5 执行,业务后端迁移与存量 Canvas 重写分别排期。详细清单、工时假设及切换步骤见[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)。
## 服务端迁移专项(S0–S5)
- [ ] S0:盘点实际线上集合、云文件、服务器、域名和权限,保留原始备份及功能样本。
- [ ] S1:完成统一 API/数据库骨架、微信登录、题型读取、收藏/下载幂等事务。
- [ ] S2:补齐现有管理接口、首页配置/推荐、七牛文件上传与定时任务。
- [ ] S3:完成小程序 HTTP 接入、试迁审计、旧版兼容桥、恢复演练和体验版真机验收。
- [ ] S4:冻结旧写入,最终导入后切换唯一写入源,再发布新版并核对线上行为。
- [ ] S5:旧版及旧文件依赖达到退场条件后,归档并完全退出 CloudBase。
服务端生成样板在 S3 验收基础上单独实施;完整 Web 与新产品功能不计入这次现有功能迁移的完成标准。
## M0:体验与数据基线(第 1 个迭代)
### 产品与研发
- [x] 兼容 `shape-recognition` 的旧通用页路径,并统一本地/旧入口至独立“识别形状”页面,避免回退到错误题型。
- [x] 修复未上线调试页中时钟连线的过期路由。
- [x] 统一首页推荐数据字段,确保自动刷新结果使用 `previewImg`。
- [x] 为首页、分类、预览、生成、导出、收藏、分享补齐统一埋点口径。
- [x] 明确“成功导出”的唯一判定:实际保存成功后才记录下载、计数和转化。
### 产品验收
- [ ] 核对所有分类入口、首页推荐、分龄推荐和收藏历史中的跳转路径。
- [ ] 建立当前题型的预览→生成→保存漏斗基线。
- [ ] 建立按年龄段、分类和题型的下载/收藏基线。
### 完成标准
- 所有正式入口可打开预期题型;无静默回退到其他题型。
- 首页定时/手动推荐均能展示预览图。
- 可以从统计中区分“点击生成”和“实际成功保存”。
## M1:发现与选择(第 2 个迭代)
### 搜索与筛选
- [ ] 恢复分类页关键词搜索 UI,复用已有标题/副标题过滤能力。
- [ ] 增加年龄筛选:3–4、4–5、5–6、6–7、7–8 岁。
- [ ] 增加主题/标签筛选,优先支持动物、食物水果、交通、恐龙、节日等。
- [ ] 设计无结果状态和“推荐相近内容”兜底。
- [ ] 首页搜索入口只在具备完整结果页后开放,避免假搜索框。
### 今日推荐
- [ ] 定义推荐位:主练习、趣味练习、语言/书写练习各一张。
- [ ] 为推荐增加替换动作和推荐原因,如“适合 4–5 岁”“本周热门”。
- [ ] 明确手工运营、定时热度榜与分龄规则的优先级。
### 完成标准
- 家长能从首页或分类页在一分钟内筛出一个适龄题型。
- 推荐位可跳转、可更换、可追踪点击与导出。
## M2:创意涂色纸 MVP(第 3–4 个迭代)
### 内容与设计
- [ ] 制定线稿素材规范:A4 留白、安全线宽、低龄/进阶分级、预览图规格。
- [ ] 建立素材授权登记表:来源、授权类型、作者、可用范围、版本、审核人。
- [ ] 完成五主题 24–30 张首发线稿及预览图。
- [ ] 设计涂色纸题型卡、主题入口、预览页和“换一张”交互。
### 研发
- [ ] 增加 `coloring`/`craft` 业务分类及 worksheet 元数据。
- [ ] 建立涂色纸服务端生成样板,详见《Web 与服务端生成策略》。
- [ ] 支持低龄自由涂色与一类结构化玩法(颜色指令或按数字涂色)。
- [ ] 实现预览图、收藏、导出日志、下载统计和首页推荐接入。
- [ ] 对生成接口进行鉴权、限流、参数白名单和短期下载地址控制。
### 完成标准
- 首发素材均可追溯商用权利。
- 用户可完成“主题 → 预览 → 生成 → 保存”的闭环。
- 原始素材与生成规则不向 Web 客户端返回。
## M3:从单张到每周学习(第 5 个迭代起)
- [ ] 定义孩子档案:昵称、年龄、学习目标;多孩支持作为后续增强。
- [ ] 设计学习包:年龄、目标、每日时长、天数、每天的练习组合。
- [ ] 实现单张替换、整包收藏、导出进度与“已完成”打卡。
- [ ] 定义推荐去重和难度分配规则,避免连续出现相同题型。
- [ ] 以轻量周进度和连续天数做回访,不做高负担的学习报告。
### 完成标准
- 用户可创建一套至少 5 天的练习安排。
- 创建后可完成至少“替换一张、导出一张、标记完成”三项动作。
- 能统计创建包后导出 3 张以上的比例。
## M4:Web 试点(与 M2 并行,受服务端样板依赖)
- [ ] 完成服务端 Render API 的认证、任务与文件交付能力。
- [ ] 搭建一个小型 Web 站点:首页、主题聚合页、详情/生成页、打印/PDF 下载页。
- [ ] 只上线 10–20 个高意图内容页和 2–3 类服务端生成器。
- [ ] 建立 Web 独立埋点:搜索来源、关键词、预览、生成、下载、回访。
- [ ] 制定 SEO 内容模板与发布流程,确保每新增内容具有独立标题、描述、主题和相关推荐。
### 完成标准
- Web 能完成一次独立于小程序的访问、生成和下载。
- 浏览器不包含核心生成规则、完整模板坐标或原始线稿素材。
- 先获得自然搜索和下载数据,再决定是否扩成完整资源平台。
## 决策检查点
| 检查点 | 决策问题 | 继续投入条件 |
| --- | --- | --- |
| 涂色 MVP 上线后 | 是否扩大内容库和主题包? | 预览→导出、收藏或分享表现显著高于现有题型中位数 |
| 学习包试点后 | 是否做 PDF/会员? | 用户确实会连续导出多张,且有明确的批量打印需求 |
| Web 试点后 | 是否建设完整 Web 平台? | 自然流量、下载和回访有增长;内容可稳定周更 |
| 服务端渲染试点后 | 是否迁移更多旧题型? | P95 生成时延、成功率和单位生成成本满足目标 |
## 风险清单
| 风险 | 应对 |
| --- | --- |
| 涂色素材版权不清晰 | 建立授权台账;不使用知名 IP;发布前人工审核 |
| 服务端生成成本失控 | 参数哈希缓存、热门主题预生成、限额与异步任务 |
| Web 内容薄、没有搜索价值 | 先做少量高意图页面,不以“站点页面数”作为目标 |
| 小程序和 Web 各自维护内容 | 以 `worksheetId` 和平台无关元数据作为单一内容源 |
| 过早会员化影响首次体验 | 先验证学习包和批量下载需求;单张基础内容保持可试用 |
+64
View File
@@ -0,0 +1,64 @@
# 小程序分类页接入新后端
更新:2026-10-16。分类页已由旧 CloudBase 查询切换为 doodle-store 的公开 HTTP 接口;首页、微信登录、收藏、下载计数及其他页面仍使用原有云服务,本次不做双写或隐式数据同步。
## 全局配置
入口:`miniprogram/config/api.ts`。
| 配置 | 当前值 | 用途 |
| ---------------------- | ---------------------------- | --------------------------------------------- |
| developmentEnvironment | local | 开发版选择 local 或 online |
| localBaseUrl | http://127.0.0.1:8800/api/v1 | 开发者工具连接本机后端 |
| onlineBaseUrl | 空串 | 线上地址待确定;填写完整 HTTPS 地址及 /api/v1 |
| timeout | 10000 | 请求超时,毫秒 |
环境通过 `wx.getAccountInfoSync().miniProgram.envVersion` 获取。develop 默认走 local,可以修改 developmentEnvironment 联调线上;trial、release 和未知版本只走 online。线上地址为空时明确显示“请先配置线上接口地址”,不会回退本地或云函数。该配置不保存 AppSecret、JWT 密钥或数据库凭据。
新后端请求统一使用 `services/api-request.ts`,分别校验 HTTP 状态、业务 code 和响应结构;`services/content-api.ts` 封装分类、完整题型分页与详情。旧 `utils/http.ts` 和旧云请求保留,以免影响其他页面。
## 如何切换接口环境
修改 [miniprogram/config/api.ts](../../miniprogram/config/api.ts):
```ts
export const apiConfig = {
developmentEnvironment: 'local' as ApiEnvironment,
localBaseUrl: 'http://127.0.0.1:8800/api/v1',
onlineBaseUrl: '',
timeout: 10000,
};
```
- **开发版连接本地**:将 `developmentEnvironment` 设置为 `'local'`,在 `localBaseUrl` 中填写本地接口地址。
- **开发版连接线上**:先填写 `onlineBaseUrl`,再将 `developmentEnvironment` 设置为 `'online'`。
- **体验版、正式版**:自动使用 `onlineBaseUrl`,不受 `developmentEnvironment` 影响。地址需要使用 HTTPS,并包含 `/api/v1`;目前线上地址尚未确定,保持空串,确定后再填写。
- **手机测试本地服务**:保持 `'local'`,将 `localBaseUrl` 中的 `127.0.0.1` 改为电脑局域网 IP;同时将后端 `doodle-store/apps/server/.env` 中的 `HOST` 设置为 `0.0.0.0` 并重启后端。手机和电脑需在同一局域网。
修改后重新编译小程序即可。当前此配置用于已迁移的分类页,其他页面仍使用原云服务。
## 本地运行
1. 在 doodle-store 执行 `pnpm dev`。实际 `apps/server/.env` 和缺省端口已改为 8800,默认 HOST 仍为 127.0.0.1。
2. 浏览器访问 `http://127.0.0.1:8800/api/v1/health` 或 `http://127.0.0.1:8800/api-docs`。
3. 微信开发者工具打开 doodle-mini,重新编译后进入分类页。本机 `project.private.config.json` 的 urlCheck 已设为 false,公共 project.config.json 保持域名校验;私有配置覆盖公共配置。
4. 真机测试时手机和电脑在同一局域网;后端 HOST 改为 0.0.0.0 并重启,将 localBaseUrl 的 IP 改为电脑局域网 IP,保留 8800 和 /api/v1。手机上的 127.0.0.1 指向手机自身。
开发者工具打开服务端口后才可使用微信官方 CLI 自动化。本机当前未开启该端口,本次没有修改 IDE 全局安全设置、上传代码或发布小程序。
## 分类页行为
- 分类导航读取 GET /categories,不再用写死目录决定上线状态;全部、热门推荐、下载最多是前端内置入口。现有后端目录包含 craft、不含 papers,页面按后端实际目录展示。
- GET /worksheets?page=1&pageSize=100,按 hasNext 继续读取,完整成功后才生成页面数据。分页变化、重复 ID 或缺页时展示重试,不返回局部榜单。
- 保留旧交互:全部及普通分类以 updatedAt 最近两条置顶,其余按下载数降序;热门推荐仍为收藏数前 12,下载最多为下载数前 12。前端“热门推荐”与服务端 sort=hot(下载热度)含义不同,因此不直接用后者替代。
- likes/downloads 已含展示 seed,保留十进制字符串;按字符串长度及字典序比较,不转换为 Number,不要求微信运行时支持 BigInt。
- previewImg 为空时显示所属分类图标;当前 51 张预览尚未迁移,不阻止题型数据展示。
- 每次 onShow 重新读取。加载失败清空旧数据,展示原因和“重新加载”;不恢复 category.data.ts、写死目录或旧云查询。
- 请求采用页面版本标记,旧请求或离开页面后的响应不会覆盖新数据。已下架的默认分类回到“全部”。
- 点击题型先查询 GET /worksheets/:id,再使用当前服务端 path 跳转;404 时刷新列表,网络失败不直接进入内置生成页。通过分享或其他页面直接进入生成页的访问检查另行迁移,不能只凭分类页迁移认定全站均受新后端发布控制。
## 验证
`npm run test:category` 运行环境切换、请求失败、121 条完整分页、分页变化、64 位计数、服务端目录、空图、页面重试、过期响应和点击下架检查。`TEST_LOCAL_API=1 npm run test:category` 额外连接真实本地 8800 服务。
本次 9 项含真实接口的测试全部通过,读取 6 个分类及 51 个题型,卡片数据及点击详情正常。小程序全量 TypeScript 检查原有 117 条报错仍存在,修改前后结果一致,本次相关文件无新增错误。后端 pnpm check 通过。微信开发者工具模拟器的实际编译/显示尚未通过 CLI 验证(服务端口关闭),不能把脚本联调等同于真机验收。
+2
View File
@@ -1,5 +1,7 @@
# Doodle Mini — 后端部署方案(NestJS + 自有云服务器)
> **2026-10-12 决策更新**:已确认迁移到自有腾讯云服务器。当前实施请使用 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md)。本文保留为历史技术预案,其中版本、数据模型与本地文件方案不可直接当作当前实施规格;“零增量成本/零成本 CDN”不作为成本事实。
> 版本:v1.0
> 最后更新:2026-03-27
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
@@ -1,5 +1,7 @@
# Doodle Mini — 小程序云开发方案
> **2026-10-12 决策更新**:本文用于了解现网 CloudBase 和历史数据设计。已确认迁移到自有云服务器,实施以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准。迁移集合和字段以实际代码及线上盘点为准,不直接按本文的设计集合导入。
> 版本:v1.1
> 最后更新:2026-03-27
> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。
+1 -1
View File
@@ -4,7 +4,7 @@
> 最后更新:2026-03-27
> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [后端部署方案](./后端部署方案.md) | [PC-Web端技术预案](./PC-Web端技术预案.md)
**后端阶段说明(重要)**:**现阶段**后端能力采用**微信小程序云开发**(云数据库、云存储、云函数),与 [小程序云开发方案](./小程序云开发方案.md) 一致。**当前阶段有效期至 2026 年 9 月 15 日**(到期前需结合用量、成本与产品路线,决定续用云开发、升级套餐或迁移至 [后端部署方案](./后端部署方案.md) 所述 NestJS + 自有服务器方案)。前端仍通过 `platform/cloud-adapter` 对接 `wx.cloud`,迁移时再抽象为 HTTP 适配层。
**后端阶段说明(2026-10-12 更新)**:现网仍使用微信云开发,已确认迁移到自有腾讯云服务器 + 统一 API + MySQL + 七牛。实施、兼容与退场以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准;下文旧阶段、免费额度及未来方案描述作为历史设计参考。当前云调用散落在多个工具和页面,不能仅修改 `platform/cloud-adapter` 完成迁移。存量小程序 Canvas 绘制与本次业务后端迁移分别实施。
---
@@ -0,0 +1,183 @@
# 认识钟表 — 预览、出题与绘制设计
> 配套文档:[产品设计文档](../产品设计文档.md) | [技术架构设计文档](../技术架构设计文档.md)
> 版本:v1.0
> 最后更新:2026-06-25
---
## 一、目标与范围
为幼儿园中班至小学一年级(约 4–7 岁)提供 **「看模拟钟表,填写数字时间」** 的可打印练习纸。
- 每页 **4 行 × 3 列**,共 12 题
- 每组:上方模拟钟表 + 下方带冒号的数字填写框
- 小程序内预览、换题、下载打印
- 排版归类为技术架构文档 **模式 H:时钟/特殊图形型**
**实现路径**:独立绘制页 `mathPages/clockReading/`,不并入 `mathDraw` 聚合页(时钟绘制逻辑特殊,独立维护更清晰)。
---
## 二、页面结构
```
mathPages/clockReading/
├── clockReading.ts
├── clockReading.wxml
├── clockReading.less
├── clockReading.json
├── clockReading.config.ts
├── generators/
│ └── clock-generator.ts # 纯数据出题
└── draw/
├── clockReadingDraw.ts # 整页编排
└── drawAnalogClock.ts # 模拟钟表(可复用)
```
**交互区(预览卡片下方)**
| 区域 | 说明 |
|------|------|
| 预览卡片 | Canvas 实时预览 A4 效果,支持换一换 |
| 时刻类型 chips | 随机(默认)/ 整点 / 半点 / 刻钟 |
| 底部操作 | 分享、下载打印(复用 `pageMixin`) |
---
## 三、练习纸版面
### 3.1 网格布局
```
┌──────────────────────────────────────────┐
│ [统一页眉] 认识钟表 │
│ 姓名:___ 日期:___ 得分:___ │
├──────────────────────────────────────────┤
│ [钟1] [钟2] [钟3] │
│ [__:__] [__:__] [__:__] │
│ [钟4] [钟5] [钟6] │
│ ... │
│ (4 行 × 3 列 = 12 题) │
└──────────────────────────────────────────┘
```
### 3.2 单题单元
1. **模拟钟表**:绿色外圈、1–12 数字、红色时针、黑色分针
2. **填写框**:绿色圆角描边,中间固定冒号 `:`,左右留白供手写
### 3.3 A4 尺寸参数(逻辑像素 595×842)
| 参数 | 值 |
|------|-----|
| 内容区起始 Y | 页眉后 ~110px |
| 左右边距 | 28px |
| 行数 / 列数 | 4 / 3 |
| 钟表半径 | ~58px |
| 钟表与填写框间距 | 10px |
| 填写框 | 宽 88px,高 30px,圆角 6px |
| 行高 | ~168px |
---
## 四、钟表视觉规范
| 元素 | 颜色 | 色值 |
|------|------|------|
| 外圈 | 绿色 | `#3D9E47` |
| 刻度 / 数字 | 黑色 | `#333333` |
| 时针 | 红色 | `#E53935` |
| 分针 / 中心点 | 黑色 | `#333333` |
| 填写框描边 | 绿色 | `#3D9E47` |
| 冒号 | 黑色 | `#333333` |
**指针角度**(12 点方向为 0°,顺时针):
```ts
const minuteAngle = minute * 6;
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// Canvas 绘制时减去 90° 偏移(0° 在 3 点方向)
```
---
## 五、时刻类型
| 模式 ID | 显示名称 | 分针位置 | 示例 | 难度 |
|---------|----------|----------|------|------|
| `random` | 随机 | 0–59 分均可 | 如 4:07、11:23 | ★★ |
| `whole-hour` | 整点 | 12 点(0 分) | 3:00, 9:00 | ★ |
| `half-hour` | 半点 | 6 点(30 分) | 6:30, 10:30 | ★★ |
| `quarter-hour` | 刻钟 | 3 或 9 点(15/45 分) | 2:15, 7:45 | ★★★ |
> **命名说明**:「刻钟」对应传统「一刻」「三刻」,比「一刻时」更符合小学钟表教学用语,且与「整点」「半点」形成清晰三元组。
**随机模式**:分钟在 0–59 之间均匀随机,不限制为整点/半点/刻钟。
---
## 六、出题规则(`clock-generator.ts`)
```ts
interface ClockProblem {
hour: number; // 1–12
minute: number; // 随机模式 0–59;其他模式见时刻类型表
}
interface ClockReadingData {
problems: ClockProblem[]; // 固定 12 个
timeMode: ClockReadingTimeMode;
}
```
1. 每页固定 12 题
2. 同一页内 `(hour, minute)` 不重复
3. 小时 1–12 均匀随机
4. 按所选时刻类型约束 `minute`
5. 纯函数输出 JSON,不依赖 Canvas
---
## 七、绘制服务拆分
```
clockReadingDraw.draw(data)
├── prepareDraw() / drawHeaderAndDivider({ title: '认识钟表' })
└── drawGrid(problems)
└── for each:
├── drawAnalogClock(cx, cy, radius, hour, minute)
└── drawTimeInputBox(x, y, w, h)
```
`drawAnalogClock.ts` 独立封装,便于后续「画指针」题型复用。
---
## 八、配置与注册
- Worksheet ID:`clock-reading`
- 分类:`math` / `clock-reading`
- 路径:`/mathPages/clockReading/clockReading?id=clock-reading`
- 注册:`app.json` 分包、`config/worksheets/clockReading.ts`、`category.data.ts`
---
## 九、验收标准
- [ ] 预览区 4×3 共 12 组「钟表 + 填写框」
- [ ] 绿圈、红时针、黑分针、1–12 数字
- [ ] 填写框中间有冒号
- [ ] 默认随机;切换整点/半点/刻钟后分针位置正确
- [ ] 换一换生成新题且不重复
- [ ] A4 下载打印清晰
- [ ] 分享、收藏、打印解锁链路正常
---
## 十、后续演进(非 MVP)
1. 定制页眉(日期/姓名/用时 + 装饰边框,更贴近教辅纸)
2. 对答案页 / 画指针题型
3. 5 分钟间隔、逐分钟进阶模式
4. AI 出题 tool:`generate_clock_reading({ timeMode, count: 12 })`
+115
View File
@@ -0,0 +1,115 @@
现在我已经全面了解了项目和搜索规则。让我整理分析并给出建议。
---
## 项目功能总结
**涂鸦丫** 是一款面向 3-8 岁儿童的微信小程序,核心能力是通过 Canvas 动态生成可打印的 A4 教育练习纸(PNG),涵盖:
| 模块 | 题型数 | 典型内容 |
| -------------- | ------ | -------------------------------------- |
| 数学启蒙 | 26种 | 数字认知、加减法、凑十破十、九九乘法表 |
| 专注力培养 | 13种 | 格子仿画、连连看、找规律、方格推理 |
| 中文识字 | 1种 | 涂色识字卡(含8+词库分类) |
| 中文练字 | 1种 | 田字格笔顺描红 |
| 拼音(新增中) | — | 拼音描红、每日一练 |
| 英语(新增中) | — | 字母描红 letter tracing |
核心差异点:**动态生成 + 无限随机换一批 + 手机直出打印图片**,而非预制PDF下载。
---
## 微信小程序搜索关键词逻辑(2026年最新)
根据搜索研究,2026年微信搜索排名的核心规则:
1. **名称权重最高**:名称中含关键词 > 简介中含关键词 > 页面标题含关键词
2. **关键词前置**:核心搜索词放在名称最前面,权重最高
3. **字数控制 8-12 字**:过长会被截断且权重分散
4. **完全匹配 > 前缀匹配 > 分词匹配**:用户搜啥,名字正好包含这个词,排名最靠前
5. **禁止无关堆砌**:堆与服务无关的热词会被降权
6. **用户行为信号**:点击率、停留时长、复访率影响长期排名
7. **唯一性**:微信小程序名称全局唯一,热门词需尽早注册
**命名公式**:`[核心关键词] + [品牌词]` 或 `[品牌词] + [核心功能词]`
---
## 竞品占位分析
| 竞品 | 名称策略 | 占领的关键词 |
| ---------- | -------------- | ---------------- |
| 奇多作业纸 | 品牌词+品类词 | 作业纸、幼小衔接 |
| 答印 | 品牌词即功能词 | 打印、练习题 |
| 字帖生成器 | 纯品类词 | 字帖、田字格 |
家长**高频搜索词**(按搜索意图排序):
- 🔥🔥🔥 **幼小衔接**(抖音该品类30天销量4.2万,极高热度)
- 🔥🔥🔥 **练习纸打印 / 幼儿练习纸**
- 🔥🔥 **数学练习题 / 口算题**
- 🔥🔥 **拼音练习 / 字帖**
- 🔥 **早教打印 / 启蒙练习**
---
## 推荐的 3 个名称方案
### 方案一(首推):`幼小衔接练习纸-涂鸦丫`
| 维度 | 分析 |
| ---------- | ----------------------------------------------------------- |
| 关键词覆盖 | ✅ 「幼小衔接」(超高热度词前置)+ 「练习纸」(精确品类词) |
| 搜索匹配 | 用户搜"幼小衔接"、"幼小衔接练习"、"练习纸"均可命中 |
| 字数 | 10字(含分隔符),在最优区间 |
| 品牌保留 | 「涂鸦丫」放后面保留辨识度 |
| 竞品差异 | 奇多占的是「作业纸」,你占「练习纸」+ 「幼小衔接」的组合位 |
| 风险 | 「幼小衔接」覆盖面最广(家长刚需场景),但偏向5-7岁段 |
**为什么首推**:「幼小衔接」是2026年家长群体最刚需、搜索量最大的教育关键词。你的产品实际内容(数学加减法、拼音、练字、专注力)本身就是幼小衔接的核心训练内容,定位天然匹配。
---
### 方案二:`幼儿练习纸打印-涂鸦丫`
| 维度 | 分析 |
| ---------- | --------------------------------------------------------- |
| 关键词覆盖 | ✅ 「幼儿」+ 「练习纸」+ 「打印」三词全命中 |
| 搜索匹配 | "幼儿练习纸"、"练习纸打印"、"幼儿打印"均可命中 |
| 字数 | 10字,在最优区间 |
| 品牌保留 | ✅ 涂鸦丫放后面 |
| 竞品差异 | 「打印」是你独有的核心差异(动态生成即时打印 vs 下载PDF) |
| 风险 | 「幼儿」偏低龄(3-5岁),可能覆盖不到6-8岁家长 |
**优势**:精确描述产品形态,搜索"幼儿练习纸"的用户转化率极高(搜索意图与产品完全匹配)。
---
### 方案三:`儿童早教打印-涂鸦丫`
| 维度 | 分析 |
| ---------- | ------------------------------------------------------------------------ |
| 关键词覆盖 | ✅ 「儿童」+ 「早教」+ 「打印」 |
| 搜索匹配 | "儿童早教"、"早教打印"、"儿童打印"可命中 |
| 字数 | 9字,简洁有力 |
| 品牌保留 | ✅ |
| 竞品差异 | 竞品没有人用"早教打印"这个组合占位 |
| 风险 | 「早教」稍显宽泛(可能联想到早教班、早教课),但"儿童"限定了年龄不会太偏 |
**优势**:覆盖面最广(3-8岁全覆盖),且"早教打印"是一个竞品空白的蓝海词组合,容易在搜索中脱颖而出。
---
## 最终建议
| 优先级 | 方案 | 核心理由 |
| ------ | ------------------------- | -------------------------------------------------------- |
| 🥇 | **幼小衔接练习纸-涂鸦丫** | 截获最大流量入口,家长搜索"幼小衔接"的意图最强、转化最高 |
| 🥈 | **幼儿练习纸打印-涂鸦丫** | 精准描述产品,搜索→转化路径最短 |
| 🥉 | **儿童早教打印-涂鸦丫** | 覆盖面最广,竞品蓝海 |
**额外建议**:无论选哪个名称,一定要在小程序简介中覆盖长尾词:
> 3-8岁幼儿启蒙打印练习纸|数学口算·识字练字·拼音描红·英语字母·专注力训练|A4一键生成,保存即可打印
这段简介会出现在微信搜索结果卡片上,相当于额外的 SEO 空间。同时记得在后台「推广」模块配置10个精准关键词(如:幼小衔接、练习纸、数学练习、拼音练习、打印练习题、口算题、幼儿启蒙、描红字帖、专注力训练、学前练习)。
+11 -3
View File
@@ -19,14 +19,15 @@
"homeContentManage/homeContentManage",
"mathIndex/mathIndex",
"focusIndex/focusIndex",
"worksheetSync/worksheetSync"
"worksheetSync/worksheetSync",
"unreleasedDebug/unreleasedDebug"
],
"independent": false
},
{
"root": "mathPages",
"name": "mathPages",
"pages": ["mathDraw/mathDraw"],
"pages": ["mathDraw/mathDraw", "clockReading/clockReading"],
"independent": false
},
{
@@ -53,9 +54,16 @@
"pages": [
"wordColoring/wordColoring",
"handwritingSheet/handwritingSheet",
"penControlSheet/penControlSheet"
"penControlSheet/penControlSheet",
"wordTestSheet/wordTestSheet"
],
"independent": false
},
{
"root": "papersPages",
"name": "papersPages",
"pages": ["paperSheet/paperSheet"],
"independent": false
}
],
"preloadRule": {
+125 -44
View File
@@ -1,8 +1,14 @@
import { fetchPageConfig } from './services/site-api';
import { normalizePreviewImages } from './config/assets';
import { defaultPrintConfig } from './config/config';
import { getUser } from './utils/auth';
import { apiContextKey, getApiConfig, isCurrentApiConfig } from './config/api';
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
let _refreshGeneration = 0;
let _loadGeneration = 0;
const pageConfigStorageKey = () => `doodle_page_config_v2:${apiContextKey()}`;
// Pending resolvers for getPageConfig() callers waiting on config
let _configResolvers: Array<(config: PageConfigData | null) => void> = [];
@@ -18,11 +24,12 @@ App<IAppOption>({
pageConfigReady: false,
},
onLaunch() {
wx.cloud.init({
env: 'cloud1-9gifs7a2756e2c87',
traceUser: true,
});
try {
wx.removeStorageSync('user_info');
wx.removeStorageSync('user_info_expire');
} catch {
/* 旧云登录缓存清理失败不影响新凭证命名空间。 */
}
const accountInfo = wx.getAccountInfoSync();
const env = accountInfo.miniProgram.envVersion || 'release';
this.globalData.env = env;
@@ -41,22 +48,42 @@ App<IAppOption>({
this.globalData.printConfig = printConfig;
}
// 静默登录(不阻塞页面渲染)
getUser()
.then((user) => {
this.globalData.user = user;
})
.catch((err) => {
console.error('静默登录失败', err);
});
// 启动与回到前台共用登录 Promise,不阻塞公开页面渲染。
this.warmAuthentication();
void this.loadPageConfig();
},
onShow() {},
onShow() {
this.warmAuthentication();
if (this.globalData.pageConfigReady) void this.refreshPageConfig();
},
warmAuthentication() {
void getUser().catch(() => {
console.warn('静默登录暂未完成,公开浏览仍可使用');
});
},
onHide() {},
/** 切环境不重启 App:作废旧加载、释放等待者并重置共享业务状态。 */
resetApiState() {
_refreshGeneration++;
_loadGeneration++;
this.globalData.user = null;
this.globalData.pageConfig = undefined;
this.globalData.pageConfigReady = false;
this.globalData.pendingCategoryTabId = undefined;
for (const resolve of _configResolvers) resolve(null);
_configResolvers = [];
try {
wx.removeStorageSync('favorites_active_tab');
} catch {
/* 临时页签偏好清理失败不影响环境与用户隔离。 */
}
},
getPrintConfig(): PrintConfig {
return this.globalData.printConfig || defaultPrintConfig;
},
@@ -66,32 +93,54 @@ App<IAppOption>({
wx.setStorageSync('printConfig', config);
},
async loadPageConfig() {
async loadPageConfig(forceRefresh = false) {
let context: ReturnType<typeof getApiConfig>;
try {
context = getApiConfig();
} catch {
// 地址被移除时结束首页等待,开发者仍可进入 Debug 改选可用环境。
this.globalData.pageConfig = undefined;
this.globalData.pageConfigReady = true;
for (const resolve of _configResolvers) resolve(null);
_configResolvers = [];
return false;
}
const generation = ++_loadGeneration;
const current = () =>
generation === _loadGeneration && isCurrentApiConfig(context);
const storageKey = pageConfigStorageKey();
let config: PageConfigData | null = null;
// L1: 数据预拉取
try {
const res = await new Promise<{ fetchedData: string }>(
(resolve, reject) => {
wx.getBackgroundFetchData({
fetchType: 'pre',
success: resolve as any,
fail: reject,
});
},
);
if (res.fetchedData) {
const parsed = JSON.parse(res.fetchedData);
config = parsed?.data || parsed;
// 仅开发版跳过旧线上预拉取,保证环境切换后的内容来自目标服务。
// 体验/正式版继续沿用原预拉取模式、地址与旧云端任务。
if (context.version !== 'develop')
try {
const res = await new Promise<{ fetchedData: string }>(
(resolve, reject) => {
wx.getBackgroundFetchData({
fetchType: 'pre',
success: resolve as any,
fail: reject,
});
},
);
if (res.fetchedData) {
const parsed = JSON.parse(res.fetchedData);
config = parsed?.data || parsed;
}
} catch {
// 预拉取失败,继续降级
}
} catch {
// 预拉取失败,继续降级
}
if (!current()) return false;
// L2: storage 缓存
if (!config) {
if (!config && !forceRefresh) {
try {
const cached = wx.getStorageSync(STORAGE_KEY_PAGE_CONFIG);
const cached =
wx.getStorageSync(storageKey) ||
(context.version !== 'develop'
? wx.getStorageSync('pageConfig')
: null);
if (cached && typeof cached === 'object') {
config = cached;
}
@@ -100,26 +149,26 @@ App<IAppOption>({
}
}
// L3: 云函数兜底
// L3: 新后端动态兜底;微信预拉取机制和地址保持不变。
const hadCachedConfig = !!config;
if (!config) {
try {
const res = (await wx.cloud.callFunction({
name: 'pageConfigFetch',
})) as { result?: { success?: boolean; data?: any } };
if (res.result?.success && res.result.data) {
config = res.result.data;
}
config = await fetchPageConfig();
} catch {
// 云函数也失败,config 保持 null
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
}
}
if (!current()) return false;
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
config = normalizePreviewImages(config);
// 写入 globalData
if (config) {
this.globalData.pageConfig = config;
// 成功获取后写入 storage 缓存
try {
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
wx.setStorageSync(storageKey, config);
} catch {
// storage 写入失败不影响主流程
}
@@ -131,6 +180,38 @@ App<IAppOption>({
resolve(config);
}
_configResolvers = [];
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
if (hadCachedConfig) void this.refreshPageConfig();
return !!config;
},
async refreshPageConfig() {
const context = getApiConfig();
const storageKey = pageConfigStorageKey();
const generation = ++_refreshGeneration;
try {
const config = normalizePreviewImages(await fetchPageConfig());
if (
generation !== _refreshGeneration ||
!isCurrentApiConfig(context)
)
return;
this.globalData.pageConfig = config;
try {
wx.setStorageSync(storageKey, config);
} catch {
/* 缓存失败不影响当前数据。 */
}
for (const page of getCurrentPages()) {
if (
page.route === 'pages/home/home' &&
typeof page.initData === 'function'
)
void page.initData(config);
}
} catch {
/* 保留已显示的数据,网络错误不清空预拉取首屏。 */
}
},
getPageConfig(): Promise<PageConfigData | null> {
-6
View File
@@ -1,6 +0,0 @@
/**
* 桥接文件 - 保持向后兼容
* 实际实现已迁移到 platform/cloud-adapter.ts
*/
import { callCloud } from '../platform/cloud-adapter';
export default callCloud;
+129 -38
View File
@@ -1,3 +1,5 @@
import { uploadAsset } from '../services/member-api';
import { apiRequest } from '../services/api-request';
import { PAPER_SIZE } from '../constants/colors';
import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint';
import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide';
@@ -5,7 +7,6 @@ import { BaseDrawService } from '../core/draw/baseDraw';
import tracker from '../utils/tracker';
import { defaultShareConfig } from '../config/config';
import {
buildWorksheetPreviewCloudPath,
isDebugPublishEnabled,
type DebugPublishConfirmDetail,
type DebugPublishMeta,
@@ -85,6 +86,7 @@ type SharePathData = {
functionId?: string;
worksheetId?: string;
currentMode?: string;
pageTitle?: string;
};
type SharePathPage = {
@@ -116,8 +118,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
const normalized = route.startsWith('/') ? route : `/${route}`;
const data = page?.data || {};
const functionId = (data.functionId || data.worksheetId) as
| string
| undefined;
string | undefined;
const currentMode = data.currentMode as string | undefined;
if (!functionId) {
@@ -125,9 +126,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
}
const modeQuery =
currentMode && currentMode !== functionId
? `&mode=${currentMode}`
: '';
currentMode && currentMode !== functionId ? `&mode=${currentMode}` : '';
return `${normalized}?id=${functionId}${modeQuery}`;
}
@@ -143,9 +142,13 @@ export function getAppShareAppMessage(
trackerLabel?: string,
page?: SharePathPage,
) {
if (trackerLabel) {
tracker.reportShare(trackerLabel);
}
tracker.reportShare(page?.route || trackerLabel || '', {
...(trackerLabel && { worksheet_title: trackerLabel }),
...(page?.data?.functionId || page?.data?.worksheetId
? { worksheet_id: page.data.functionId || page.data.worksheetId }
: {}),
...(page?.data?.currentMode ? { mode: page.data.currentMode } : {}),
});
return {
title: defaultShareConfig.title,
path: buildPageSharePath(page),
@@ -158,9 +161,13 @@ export function getAppShareTimeline(
trackerLabel?: string,
page?: SharePathPage,
) {
if (trackerLabel) {
tracker.reportShare(trackerLabel);
}
tracker.reportShare(page?.route || trackerLabel || '', {
...(trackerLabel && { worksheet_title: trackerLabel }),
...(page?.data?.functionId || page?.data?.worksheetId
? { worksheet_id: page.data.functionId || page.data.worksheetId }
: {}),
...(page?.data?.currentMode ? { mode: page.data.currentMode } : {}),
});
return {
title: defaultShareConfig.title,
query: buildPageShareTimelineQuery(page),
@@ -171,10 +178,10 @@ export function getAppShareTimeline(
/** 供 Page({}) 直接展开:统一转发 / 分享到朋友圈(path 为当前页) */
export const appSharePageMethods = {
onShareAppMessage(this: SharePathPage) {
return getAppShareAppMessage(undefined, this);
return getAppShareAppMessage(this.data?.pageTitle, this);
},
onShareTimeline(this: SharePathPage) {
return getAppShareTimeline(undefined, this);
return getAppShareTimeline(this.data?.pageTitle, this);
},
};
@@ -331,6 +338,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
errorToast: '请先生成内容',
trackerName: this.data.pageTitle,
trackerMode: this.data.currentMode,
trackerPage: this.route,
worksheetId,
};
@@ -394,6 +402,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
errorToast: '请先生成内容',
trackerName: this.data.pageTitle,
trackerMode: this.data.currentMode,
trackerPage: this.route,
worksheetId,
});
}
@@ -498,34 +507,28 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
settings: detail.settings,
});
const cloudPath = buildWorksheetPreviewCloudPath(
publishMeta.category,
publishMeta.id,
const asset = await uploadAsset(
processed.tempFilePath,
'preview',
);
const uploadRes = await wx.cloud.uploadFile({
cloudPath,
filePath: processed.tempFilePath,
});
const cloudCall = (await wx.cloud.callFunction({
name: 'worksheetsPublish',
const {
category,
previewImg: _oldPreview,
...metadata
} = publishMeta;
await apiRequest('/admin/worksheets/publish', {
method: 'POST',
data: {
...publishMeta,
previewImg: uploadRes.fileID,
...metadata,
categoryId: category,
previewAssetId: asset.id,
},
})) as {
result?: { success?: boolean; message?: string };
};
if (!cloudCall.result?.success) {
throw new Error(
cloudCall.result?.message || '云端写入失败',
);
}
});
this.setData({
debugPublishVisible: false,
debugPublishMeta: {
...publishMeta,
previewImg: uploadRes.fileID,
previewImg: asset.url,
},
});
@@ -555,8 +558,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
initPageInfo(
this: PageCanvasInstance,
functionIdOrOptions:
| string
| { title: string; desc?: string; functionId: string },
string | { title: string; desc?: string; functionId: string },
defaultTitle?: string,
) {
let functionId: string;
@@ -591,6 +593,13 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
});
wx.setNavigationBarTitle({ title });
tracker.reportPreviewView(this.route || '', {
worksheet_id: functionId,
worksheet_title: title,
...(this.data.currentMode
? { mode: this.data.currentMode }
: {}),
});
},
};
}
@@ -607,6 +616,88 @@ export function applyPageMixin(
config: PageCommonMethodsConfig = {},
) {
const commonMethods = getPageCommonMethods(config);
const trackedOptions = { ...pageOptions };
const generationStartKey = '__generationTrackingStartedAt';
const beginGenerationTracking = function (this: PageCanvasInstance) {
if ((this as any)[generationStartKey]) return;
const worksheetId =
typeof this.getWorksheetStatsId === 'function'
? this.getWorksheetStatsId()
: this.data.functionId;
(this as any)[generationStartKey] = Date.now();
tracker.reportGenerateStart(this.route || '', {
worksheet_id: worksheetId || '',
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
});
};
const originalOnRandom = pageOptions.onRandom;
if (typeof originalOnRandom === 'function') {
trackedOptions.onRandom = function (
this: PageCanvasInstance,
...args: any[]
) {
beginGenerationTracking.call(this);
return originalOnRandom.apply(this, args);
};
}
const originalOnPreviewRefresh = pageOptions.onPreviewRefresh;
if (typeof originalOnPreviewRefresh === 'function') {
trackedOptions.onPreviewRefresh = function (
this: PageCanvasInstance,
...args: any[]
) {
beginGenerationTracking.call(this);
return originalOnPreviewRefresh.apply(this, args);
};
}
const originalDrawCanvas = pageOptions.drawCanvas;
if (typeof originalDrawCanvas === 'function') {
trackedOptions.drawCanvas = async function (
this: PageCanvasInstance,
...args: any[]
) {
try {
const result = await originalDrawCanvas.apply(this, args);
const startedAt = (this as any)[generationStartKey] as
number | undefined;
if (startedAt) {
delete (this as any)[generationStartKey];
const worksheetId =
typeof this.getWorksheetStatsId === 'function'
? this.getWorksheetStatsId()
: this.data.functionId;
tracker.reportGenerateResult(
this.route || '',
this.data.hasContent !== false,
{
worksheet_id: worksheetId || '',
duration_ms: Date.now() - startedAt,
...(this.data.currentMode
? { mode: this.data.currentMode }
: {}),
},
);
}
return result;
} catch (error) {
const startedAt = (this as any)[generationStartKey] as
number | undefined;
if (startedAt) {
delete (this as any)[generationStartKey];
tracker.reportGenerateResult(this.route || '', false, {
worksheet_id: this.data.functionId || '',
duration_ms: Date.now() - startedAt,
fail_reason: 'draw_exception',
});
}
throw error;
}
};
}
// 提取 pageOptions 中的 data(如果有)
const pageData = pageOptions.data || {};
@@ -615,7 +706,7 @@ export function applyPageMixin(
// 注意:pageOptions 放在后面,这样页面可以覆盖公共方法
const mergedOptions: any = {
...commonMethods,
...pageOptions,
...trackedOptions,
// 处理 data 的合并(需要特殊处理,避免覆盖)
data: {
...pageData,
@@ -1,34 +1,9 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { PEN_CONTROL_MODE } from './generators/penControlGenerator';
import { PEN_CONTROL_WORKSHEET_DEFINITIONS } from '../../config/worksheets/penControl';
interface PenControlWorksheetDefinition {
id: typeof PEN_CONTROL_MODE;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const PEN_CONTROL_WORKSHEET_DEFINITIONS = [
{
id: PEN_CONTROL_MODE,
icon: 'edit',
title: '控笔组合练习',
subtitle: '3–6 种图形,每种占两行田字格',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['控笔', '运笔', '田字格', '学前'],
sortOrder: 40,
},
] as const satisfies ReadonlyArray<PenControlWorksheetDefinition>;
type PenControlWorksheetRow = (typeof PEN_CONTROL_WORKSHEET_DEFINITIONS)[number];
type PenControlWorksheetRow =
(typeof PEN_CONTROL_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
PEN_CONTROL_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
@@ -1,78 +1,44 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import type { TemplateType } from '../shared/draw/drawServiceFactory';
import type { TemplateType } from './draw/drawServiceFactory';
import { WORD_COLORING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/wordColoring';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */
interface WordColoringWorksheetDefinition {
id: string;
templateType: TemplateType;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const WORD_COLORING_WORKSHEET_DEFINITIONS = [
{
id: 'word-coloring-grid',
templateType: 'grid' as TemplateType,
icon: 'grid',
title: '网格涂色',
subtitle: '田字格涂色识字练习',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '田字格'],
sortOrder: 30,
},
{
id: 'word-coloring-find',
templateType: 'find' as TemplateType,
icon: 'search',
title: '找字涂色',
subtitle: '在字海中找到目标字并涂色',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '找字'],
sortOrder: 31,
},
] as const satisfies ReadonlyArray<WordColoringWorksheetDefinition>;
export { WORD_COLORING_WORKSHEET_DEFINITIONS };
type WordColoringWorksheetRow =
(typeof WORD_COLORING_WORKSHEET_DEFINITIONS)[number];
const WORD_COLORING_WORKSHEET_BY_ID = Object.fromEntries(
const WORKSHEET_BY_ID = Object.fromEntries(
WORD_COLORING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, WordColoringWorksheetRow>;
/** 页面渲染用:模式选择器列表 */
export const WORD_COLORING_MODE_OPTIONS = WORD_COLORING_WORKSHEET_DEFINITIONS;
const WORD_COLORING_TEMPLATE_TYPES: Record<string, TemplateType> = {
'word-coloring-grid': 'grid',
'word-coloring-find': 'find',
};
/** 页面 pageInfoLookup 用 */
export function getModeInfo(id: string) {
const m = WORD_COLORING_WORKSHEET_BY_ID[id];
const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
/** 判断 id 是否有效 */
export function isValidMode(id: string): boolean {
return id in WORD_COLORING_WORKSHEET_BY_ID;
return id in WORKSHEET_BY_ID;
}
/** 根据 id 获取 templateType */
export function getTemplateType(id: string): TemplateType {
const m = WORD_COLORING_WORKSHEET_BY_ID[id];
return m?.templateType || 'grid';
return WORD_COLORING_TEMPLATE_TYPES[id] || 'grid';
}
/** 发布用:从当前 mode 生成 DebugPublishMeta */
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORD_COLORING_WORKSHEET_BY_ID[id];
const m = WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
@@ -0,0 +1,76 @@
import { WORDS } from '../../../core/data/words';
export const WORD_TEST_GROUP_SIZE = 50;
export interface WordTestCategory {
id: string;
icon: string;
name: string;
parentName?: string;
words: string[];
}
export interface WordTestGroup {
index: number;
label: string;
words: string[];
}
const EXCLUDED_CATEGORY_IDS = new Set([4, 5]);
/** 默认分类:一年级上册 */
export const DEFAULT_WORD_TEST_CATEGORY_ID = '26-一年级上册';
export function buildWordTestCategories(): WordTestCategory[] {
const result: WordTestCategory[] = [];
for (const cat of WORDS) {
if (EXCLUDED_CATEGORY_IDS.has(cat.categoryId)) continue;
if (cat.sections?.length) {
for (const section of cat.sections) {
result.push({
id: `${cat.categoryId}-${section.sectionName}`,
icon: cat.icon,
name: section.sectionName,
parentName: cat.categoryName,
words: section.words,
});
}
continue;
}
if (cat.words?.length) {
result.push({
id: String(cat.categoryId),
icon: cat.icon,
name: cat.categoryName,
words: cat.words,
});
}
}
return result;
}
export function buildWordTestGroups(words: string[]): WordTestGroup[] {
if (!words.length) return [];
const groups: WordTestGroup[] = [];
for (let i = 0; i < words.length; i += WORD_TEST_GROUP_SIZE) {
const index = Math.floor(i / WORD_TEST_GROUP_SIZE) + 1;
groups.push({
index,
label: `第${index}组`,
words: words.slice(i, i + WORD_TEST_GROUP_SIZE),
});
}
return groups;
}
export function findWordTestCategory(
categories: WordTestCategory[],
id: string,
): WordTestCategory | undefined {
return categories.find((cat) => cat.id === id);
}
@@ -0,0 +1,227 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
const COLS = 5;
const ROWS = 10;
const LAYOUT = {
topGap: 10,
leftMargin: 36,
rightMargin: 36,
bottomMargin: 40,
instructionGap: 18,
gridTopGap: 12,
instructionFont: 'bold 16px "KaiTi", "STKaiti", "Microsoft Yahei", serif',
instructionBoxSize: 12,
instructionCheckSize: 14,
instructionSymbolGap: 3,
rowGap: 10,
charBoxGap: 12,
boxSize: 14,
} as const;
export interface WordTestSheetDrawData {
words: string[];
pageNumber: number;
}
export default class WordTestDrawService extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '测字表',
...options,
});
}
async draw(data: WordTestSheetDrawData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawInstruction();
this.drawWordGrid(data.words);
this.drawPageNumber(data.pageNumber);
this.drawPrintFooter();
}
private getContentRect() {
const contentTop = this.currentY;
const contentWidth =
this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
const contentHeight =
this.canvasHeight - contentTop - LAYOUT.bottomMargin;
return {
top: contentTop,
left: LAYOUT.leftMargin,
width: contentWidth,
height: contentHeight,
};
}
private drawInstruction() {
const { ctx, canvasWidth } = this;
const y = this.currentY + LAYOUT.instructionGap;
const {
instructionFont,
instructionBoxSize,
instructionCheckSize,
instructionSymbolGap,
} = LAYOUT;
const prefix = '认识的字';
const middle = '里打';
ctx.save();
ctx.font = instructionFont;
ctx.fillStyle = '#322E25';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const prefixW = ctx.measureText(prefix).width;
const middleW = ctx.measureText(middle).width;
const totalW =
prefixW +
instructionSymbolGap +
instructionBoxSize +
instructionSymbolGap +
middleW +
instructionSymbolGap +
instructionCheckSize;
let x = (canvasWidth - totalW) / 2;
ctx.fillText(prefix, x, y);
x += prefixW + instructionSymbolGap;
this.drawBox(
ctx,
x,
y - instructionBoxSize / 2,
instructionBoxSize,
instructionBoxSize,
);
x += instructionBoxSize + instructionSymbolGap;
ctx.fillText(middle, x, y);
x += middleW + instructionSymbolGap;
this.drawCheckMark(ctx, x, y, instructionCheckSize);
const metrics = ctx.measureText(prefix);
const instructionFontSize = 16;
const halfH = Math.max(
metrics.actualBoundingBoxAscent ?? instructionFontSize * 0.5,
metrics.actualBoundingBoxDescent ?? instructionFontSize * 0.5,
);
const contentStartY = y + halfH + LAYOUT.gridTopGap;
ctx.restore();
this.currentY = contentStartY;
}
/** 绘制对勾(替代文字 V) */
private drawCheckMark(
ctx: RenderingContext,
leftX: number,
centerY: number,
size: number,
) {
ctx.save();
ctx.strokeStyle = '#322E25';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(leftX + size * 0.12, centerY + size * 0.02);
ctx.lineTo(leftX + size * 0.38, centerY + size * 0.32);
ctx.lineTo(leftX + size * 0.88, centerY - size * 0.34);
ctx.stroke();
ctx.restore();
}
private getCharVerticalMetrics(
ctx: RenderingContext,
char: string,
fontSize: number,
) {
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(char);
const ascent = metrics.actualBoundingBoxAscent ?? fontSize * 0.82;
const descent = metrics.actualBoundingBoxDescent ?? fontSize * 0.18;
return { ascent, descent };
}
/** 以 centerY 为视觉中心,绘制汉字与右侧方框 */
private drawCharWithBox(
ctx: RenderingContext,
char: string,
startX: number,
centerY: number,
fontSize: number,
) {
const { charBoxGap, boxSize } = LAYOUT;
const { ascent, descent } = this.getCharVerticalMetrics(
ctx,
char,
fontSize,
);
ctx.textBaseline = 'alphabetic';
const textY = centerY + (ascent - descent) / 2;
ctx.fillText(char, startX, textY);
const boxX = startX + ctx.measureText(char).width + charBoxGap;
const boxY = centerY - boxSize / 2;
this.drawBox(ctx, boxX, boxY, boxSize, boxSize);
}
private drawWordGrid(words: string[]) {
const { ctx } = this;
const content = this.getContentRect();
const colWidth = content.width / COLS;
const rowHeight = content.height / ROWS;
const fontSize = 20;
ctx.save();
ctx.font = `${fontSize}px "KaiTi", "STKaiti", "Microsoft Yahei", serif`;
ctx.fillStyle = '#322E25';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const index = row * COLS + col;
const char = words[index];
if (!char) continue;
const cellLeft = content.left + col * colWidth;
const cellCenterY =
content.top + row * rowHeight + rowHeight / 2;
const charWidth = ctx.measureText(char).width;
const groupWidth =
charWidth + LAYOUT.charBoxGap + LAYOUT.boxSize;
const startX = cellLeft + (colWidth - groupWidth) / 2;
this.drawCharWithBox(ctx, char, startX, cellCenterY, fontSize);
}
}
ctx.restore();
}
private drawPageNumber(pageNumber: number) {
const { ctx, canvasWidth, canvasHeight } = this;
ctx.save();
ctx.font = '12px "Microsoft Yahei", sans-serif';
ctx.fillStyle = '#7C766A';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(pageNumber), canvasWidth / 2, canvasHeight - 28);
ctx.restore();
}
}
@@ -0,0 +1,43 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { WORD_TEST_WORKSHEET_DEFINITIONS } from '../../config/worksheets/wordTest';
type WordTestWorksheetRow = (typeof WORD_TEST_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
WORD_TEST_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, WordTestWorksheetRow>;
export const WORD_TEST_WORKSHEET_ID = WORD_TEST_WORKSHEET_DEFINITIONS[0].id;
export function getModeInfo(id: string) {
const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'chinese',
subcategory: 'word-test',
path: `/chinesePages/wordTestSheet/wordTestSheet?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: true,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,16 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "测字表",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"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"
}
}
@@ -0,0 +1,114 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.wt-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.wt-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.wt-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.wt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wt-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.wt-category-grid,
.wt-group-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.wt-category-card,
.wt-group-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s,
background 0.12s;
}
.wt-group-card {
padding: 24rpx 12rpx;
}
.wt-category-card--active,
.wt-group-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.wt-category-card--pressed,
.wt-group-card--pressed {
transform: scale(0.95);
}
.wt-category-card__icon {
font-size: 36rpx;
line-height: 1.2;
}
.wt-category-card__name,
.wt-group-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
text-align: center;
}
.wt-category-card__cat {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
}
.wt-category-card__check,
.wt-group-card__check {
position: absolute;
top: 50%;
left: 18rpx;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #9cd343;
}
@@ -0,0 +1,265 @@
import WordTestDrawService from './draw/wordTestDrawService';
import {
buildWordTestCategories,
buildWordTestGroups,
DEFAULT_WORD_TEST_CATEGORY_ID,
findWordTestCategory,
type WordTestCategory,
type WordTestGroup,
} from './data/wordTestCategories';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
WORD_TEST_WORKSHEET_ID,
} from './wordTestSheet.config';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
const pageInfoLookup = getModeInfo;
function buildSelectedMap(id: string | null): Record<string, boolean> {
if (!id) return {};
return { [id]: true };
}
type PageData = CanvasDataState & {
worksheetId: string;
categoryList: WordTestCategory[];
groupList: WordTestGroup[];
selectedCategoryId: string;
selectedGroupIndex: number;
selectedCategoryMap: Record<string, boolean>;
selectedGroupMap: Record<string, boolean>;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as WordTestDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '测字表',
functionId: WORD_TEST_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: WORD_TEST_WORKSHEET_ID,
categoryList: [] as WordTestCategory[],
groupList: [] as WordTestGroup[],
selectedCategoryId: DEFAULT_WORD_TEST_CATEGORY_ID,
selectedGroupIndex: 1,
selectedCategoryMap: buildSelectedMap(
DEFAULT_WORD_TEST_CATEGORY_ID,
),
selectedGroupMap: buildSelectedMap('1'),
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
this.syncDebugPublishEnv();
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: WORD_TEST_WORKSHEET_ID;
const categoryList = buildWordTestCategories();
const defaultCategory =
findWordTestCategory(
categoryList,
DEFAULT_WORD_TEST_CATEGORY_ID,
) || categoryList[0];
const selectedCategoryId = defaultCategory?.id || '';
const groupList = buildWordTestGroups(defaultCategory?.words || []);
this.setData({
worksheetId,
functionId: worksheetId,
categoryList,
groupList,
selectedCategoryId,
selectedGroupIndex: 1,
selectedCategoryMap: buildSelectedMap(selectedCategoryId),
selectedGroupMap: buildSelectedMap('1'),
});
this.initPageInfo(worksheetId, '测字表');
this.loadFavoritedMap();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
const category = this.getSelectedCategory();
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new WordTestDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.getSheetTitle(category),
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
getSelectedCategory(): WordTestCategory | undefined {
return findWordTestCategory(
this.data.categoryList,
this.data.selectedCategoryId,
);
},
getSelectedGroup(): WordTestGroup | undefined {
return (this.data.groupList as WordTestGroup[]).find(
(group: WordTestGroup) =>
group.index === this.data.selectedGroupIndex,
);
},
getSheetTitle(category?: WordTestCategory): string {
if (!category) return '测字表';
return `${category.name}(测字表)`;
},
async drawCanvas() {
if (!this.drawService) return;
const group = this.getSelectedGroup();
if (!group || group.words.length === 0) {
this.setData({ hasContent: false });
return;
}
const category = this.getSelectedCategory();
const title = this.getSheetTitle(category);
try {
(this.drawService as WordTestDrawService).options.title = title;
await (this.drawService as WordTestDrawService).draw({
words: group.words,
pageNumber: group.index,
});
this.setData({ hasContent: true });
} catch (e) {
console.error('wordTestSheet draw failed', e);
this.setData({ hasContent: false });
}
},
onSelectCategory(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.selectedCategoryId) return;
const category = findWordTestCategory(this.data.categoryList, id);
if (!category) return;
const groupList = buildWordTestGroups(category.words);
this.setData(
{
selectedCategoryId: id,
selectedCategoryMap: buildSelectedMap(id),
groupList,
selectedGroupIndex: 1,
selectedGroupMap: buildSelectedMap('1'),
},
() => this.drawCanvas(),
);
},
onSelectGroup(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index);
if (!index || index === this.data.selectedGroupIndex) return;
this.setData(
{
selectedGroupIndex: index,
selectedGroupMap: buildSelectedMap(String(index)),
},
() => this.drawCanvas(),
);
},
onPreviewRefresh() {
const groupList = this.data.groupList as WordTestGroup[];
if (!groupList.length) {
this.drawCanvas();
return;
}
const currentIdx = groupList.findIndex(
(group) => group.index === this.data.selectedGroupIndex,
);
const nextIdx =
currentIdx < 0 ? 0 : (currentIdx + 1) % groupList.length;
const nextGroup = groupList[nextIdx];
this.setData(
{
selectedGroupIndex: nextGroup.index,
selectedGroupMap: buildSelectedMap(String(nextGroup.index)),
},
() => this.drawCanvas(),
);
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = [WORD_TEST_WORKSHEET_ID];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,83 @@
<nav-bar title="测字表" />
<view class="wt-page">
<view class="wt-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view wx:if="{{groupList.length > 0}}" class="wt-section">
<view class="wt-section-header">
<text class="wt-section-title">选择组</text>
</view>
<view class="wt-group-grid">
<view
wx:for="{{groupList}}"
wx:key="index"
class="wt-group-card {{selectedGroupMap[item.index] ? 'wt-group-card--active' : ''}}"
data-index="{{item.index}}"
hover-class="wt-group-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectGroup">
<view
wx:if="{{selectedGroupMap[item.index]}}"
class="wt-group-card__check">
<toy-icon name="check" size="20rpx" color="#fff" />
</view>
<text class="wt-group-card__name">{{item.label}}</text>
</view>
</view>
</view>
<view class="wt-section">
<view class="wt-section-header">
<text class="wt-section-title">汉字分类</text>
</view>
<view class="wt-category-grid">
<view
wx:for="{{categoryList}}"
wx:key="id"
class="wt-category-card {{selectedCategoryMap[item.id] ? 'wt-category-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="wt-category-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectCategory">
<text class="wt-category-card__icon">{{item.icon}}</text>
<text class="wt-category-card__name">{{item.name}}</text>
<text
wx:if="{{item.parentName}}"
class="wt-category-card__cat"
>{{item.parentName}}</text
>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
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}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -41,6 +41,11 @@ Component({
type: Object,
value: {},
},
/** 页面可指定发布弹窗的默认裁剪模式,未指定时沿用仅裁头部。 */
defaultCropMode: {
type: String,
value: 'header-only',
},
},
data: {
@@ -83,7 +88,7 @@ Component({
formSubtitle: meta?.subtitle ?? '',
formTags: meta?.tags?.join(', ') ?? '',
formStatus: meta?.status ?? 'draft',
formCropMode: 'header-only', // 默认裁剪头部
formCropMode: this.properties.defaultCropMode,
formQuality: DEBUG_PUBLISH_DEFAULT_QUALITY,
formWidth: DEBUG_PUBLISH_DEFAULT_WIDTH,
});
@@ -152,7 +152,7 @@
bindchange="onWidthChange" />
</view>
<text class="debug-publish-tools__hint">
默认按文档规则裁掉页眉和页脚,并尽量压缩到 {{maxSizeKB}}KB 内。
按所选裁剪模式处理图片,并尽量压缩到 {{maxSizeKB}}KB 内。
</text>
</view>
+19 -1
View File
@@ -66,13 +66,21 @@
margin-top: 4rpx;
}
.nav-bar__title-group {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.nav-bar__title {
font-size: @fs-nav-title;
font-weight: @fw-nav-title;
color: @color-nav-title;
letter-spacing: @ls-nav-title;
line-height: 1;
flex: 1;
flex: 0 1 auto;
min-width: 0;
}
@@ -87,3 +95,13 @@
width: 100%;
flex-shrink: 0;
}
.nav-bar__environment {
margin-right: 16rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
background: @brand;
color: @text-title;
font-size: 20rpx;
line-height: 1.2;
flex-shrink: 0;
}
@@ -1,4 +1,5 @@
import { getNavInnerPx } from '../../utils/navMetrics';
import { getApiEnvironment } from '../../config/api';
const HOME_TAB_URL = '/pages/home/home';
const HOME_ROUTE = 'pages/home/home';
@@ -24,6 +25,7 @@ Component({
leftActionType: 'none' as 'none' | 'back' | 'home' | 'brand',
/** 仅首页(Tab 根页)在 logo 旁展示「涂鸦丫」;其余主 Tab 只展示 logo */
showBrandName: false,
apiEnvironment: '',
},
lifetimes: {
attached() {
@@ -44,6 +46,12 @@ Component({
},
methods: {
syncLeftAction() {
this.setData({
apiEnvironment:
wx.getAccountInfoSync().miniProgram.envVersion === 'develop'
? getApiEnvironment()
: '',
});
const pages = getCurrentPages();
const currentRoute = pages[pages.length - 1]?.route || '';
const isTabBarPage = TAB_BAR_ROUTES.has(currentRoute);
@@ -22,7 +22,12 @@
wx:elif="{{leftActionType === 'home'}}"
class="toy-icon toy-icon-home-o nav-bar__back-icon"></text>
</view>
<text class="nav-bar__title">{{title}}</text>
<view class="nav-bar__title-group">
<text class="nav-bar__title">{{title}}</text>
<text wx:if="{{apiEnvironment}}" class="nav-bar__environment"
>{{apiEnvironment}}</text
>
</view>
<view class="nav-bar__right">
<slot name="right"></slot>
</view>
+122
View File
@@ -0,0 +1,122 @@
export type ApiEnvironment = 'local' | 'staging' | 'production';
type MiniProgramVersion = 'develop' | 'trial' | 'release';
const LOCAL_BASE_URL = 'http://127.0.0.1:8800/api/v1';
const STORAGE_KEY = 'doodle_develop_api_environment_v1';
let selectedEnvironment: ApiEnvironment | undefined;
let revision = 0;
/** 暂时共用本地地址;部署后分别替换 staging/production,真机 local 使用电脑局域网 IP。 */
export const apiConfig = {
developmentEnvironment: 'local' as ApiEnvironment,
localBaseUrl: LOCAL_BASE_URL,
stagingBaseUrl: LOCAL_BASE_URL,
productionBaseUrl: LOCAL_BASE_URL,
timeout: 10000,
};
export const API_ENVIRONMENTS: ApiEnvironment[] = [
'local',
'staging',
'production',
];
export function isApiEnvironment(value: unknown): value is ApiEnvironment {
return API_ENVIRONMENTS.includes(value as ApiEnvironment);
}
/** 仅开发包读取本机选择;体验、正式及未知包始终连接 production。 */
export function getApiEnvironment(
version: string = wx.getAccountInfoSync().miniProgram.envVersion,
): ApiEnvironment {
if (version === 'develop' && selectedEnvironment === undefined) {
try {
const stored: unknown = wx.getStorageSync(STORAGE_KEY);
if (isApiEnvironment(stored)) selectedEnvironment = stored;
} catch {
/* 无缓存时使用开发默认值。 */
}
}
return version === 'develop'
? (selectedEnvironment ?? apiConfig.developmentEnvironment)
: 'production';
}
export function resolveApiConfig(version: MiniProgramVersion | string) {
return resolveEnvironment(getApiEnvironment(version), version);
}
function resolveEnvironment(environment: ApiEnvironment, version: string) {
const baseUrl = apiConfig[`${environment}BaseUrl`]
.trim()
.replace(/\/+$/, '');
if (!baseUrl) throw new Error(`请先配置 ${environment} 接口地址`);
// 用户指定的本机占位地址暂允许 HTTP;实际云端入口仍须使用 HTTPS。
if (
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
(environment !== 'local' &&
!baseUrl.startsWith('https://') &&
baseUrl !== LOCAL_BASE_URL)
)
throw new Error('接口地址配置不正确');
return {
environment,
baseUrl,
timeout: apiConfig.timeout,
revision,
version,
};
}
export function getApiConfig() {
const { appId = '', envVersion } = wx.getAccountInfoSync().miniProgram;
return { ...resolveApiConfig(envVersion), appId };
}
/** 即使三个地址相同也按环境隔离;develop 不与体验/正式包共享业务缓存。 */
export function apiContextKey(config = getApiConfig()): string {
const scope = config.version === 'develop' ? 'develop' : 'public';
return [config.appId, scope, config.environment, config.baseUrl]
.map(encodeURIComponent)
.join(':');
}
/** 版本号阻止 A→B→A 后旧 A 请求覆盖当前页面。 */
export function isCurrentApiConfig(config: ReturnType<typeof getApiConfig>) {
const current = getApiConfig();
return (
config.revision === current.revision &&
apiContextKey(config) === apiContextKey(current)
);
}
/** 写入成功后才提交内存选择;配置失败保留原环境,不自动回退。 */
export function selectApiEnvironment(environment: ApiEnvironment): boolean {
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
throw new Error('仅开发版可切换环境');
if (!isApiEnvironment(environment)) throw new Error('未知接口环境');
resolveEnvironment(environment, 'develop');
if (getApiEnvironment() === environment) return false;
wx.setStorageSync(STORAGE_KEY, environment);
selectedEnvironment = environment;
revision++;
return true;
}
export function getApiEnvironmentOptions() {
return API_ENVIRONMENTS.map((environment) => {
try {
return {
environment,
baseUrl: resolveEnvironment(environment, 'develop').baseUrl,
available: true,
};
} catch {
return {
environment,
baseUrl: '尚未配置有效地址',
available: false,
};
}
});
}
+65
View File
@@ -0,0 +1,65 @@
/** 与后端及 asset-sync 的公开资源配置保持一致;此处不包含上传凭据。 */
export const assetConfig = {
publicDomain: 'https://cdn.joeyone.cn',
keyPrefix: 'doodle-assets/',
};
/** 固定字体和页面预览使用相对路径;接口返回的资源 URL 直接使用。 */
export function publicAssetUrl(relativePath: string): string {
const parts = relativePath.split('/');
if (
relativePath.includes('\\') ||
[...relativePath].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
parts.some((part) => !part || part === '.' || part === '..')
)
throw new Error('资源相对路径不正确');
const domain = assetConfig.publicDomain.replace(/\/+$/, '');
const prefix = assetConfig.keyPrefix.replace(/\/+$/, '');
return `${domain}/${[...(prefix ? prefix.split('/') : []), ...parts].map(encodeURIComponent).join('/')}`;
}
const LEGACY_PREVIEW_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
const LEGACY_PREVIEW_HTTPS =
/^https?:\/\/636c-cloud1-9gifs7a2756e2c87-1351593184\.tcb\.qcloud\.la\/([^?#]+)(?:[?#].*)?$/;
/** 旧预拉取、缓存和云函数中的预览图指向已迁移的公开目录;其他资源不作替换。 */
export function resolvePreviewUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const raw = value.trim();
let path: string;
if (raw.startsWith(LEGACY_PREVIEW_PREFIX)) {
path = raw.slice(LEGACY_PREVIEW_PREFIX.length);
if (/[?#]/.test(path)) return '';
} else {
const match = raw.match(LEGACY_PREVIEW_HTTPS);
if (!match) return raw;
path = match[1];
}
try {
path = path.split('/').map(decodeURIComponent).join('/');
if (!path.startsWith('assets/previews/')) return raw;
return publicAssetUrl(path);
} catch {
// 畸形路径不继续请求旧云存储,也不生成越界的 CDN 地址。
return '';
}
}
/** 统一处理预拉取/缓存中的嵌套 previewImg;不修改原数据、头像和其他云文件引用。 */
export function normalizePreviewImages<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizePreviewImages(item)) as T;
}
if (!value || typeof value !== 'object') return value;
const result = { ...value } as Record<string, unknown>;
for (const key of Object.keys(result)) {
result[key] =
key === 'previewImg'
? resolvePreviewUrl(result[key])
: normalizePreviewImages(result[key]);
}
return result as T;
}
+5 -2
View File
@@ -6,6 +6,9 @@
// },
// };
/** 当前应用版本号;profile 页和首页公告共用 */
export const APP_VERSION = '3.6.0';
export const defaultPrintConfig: PrintConfig = {
// header: 'LogoImage',
header: 'wechat',
@@ -25,11 +28,11 @@ export const defaultShareConfig = {
* - 当日第 1 次满批:弹分享窗,须分享后才能继续下载
* - 当日第 2 次及以后满批:直接播放激励广告解锁
*/
export const downloadFreeBatchSize = 4;
export const downloadFreeBatchSize = 3;
/**
* develop 包下载限制开关
* - develop:为 true 时启用下载配额/分享/广告(便于联调);为 false 时不限制
* - 体验版 / 正式版:始终启用,不受此开关影响
*/
export const enableDownloadLimitInDevelop = false;
export const enableDownloadLimitInDevelop = true;
+29
View File
@@ -0,0 +1,29 @@
import type { WorksheetDefinition } from './types';
export const CLOCK_READING_WORKSHEET_DEFINITIONS = [
{
id: 'clock-reading',
icon: 'clock',
title: '认识时钟',
subtitle: '看钟读时间,填写数字时刻',
ageMin: 4,
ageMax: 7,
difficulty: 2,
tags: ['数学', '钟表', '时间'],
sortOrder: 127,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
export const CLOCK_CONNECT_WORKSHEET_DEFINITIONS = [
{
id: 'clock-connect',
icon: 'clock',
title: '时钟连线',
subtitle: '看钟表连对应时间,认识时刻',
ageMin: 4,
ageMax: 7,
difficulty: 2,
tags: ['数学', '钟表', '时间', '连线'],
sortOrder: 128,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+124
View File
@@ -0,0 +1,124 @@
import type { WorksheetDefinition } from './types';
export const FOCUS_WORKSHEET_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: 6,
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: 4,
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: 6,
ageMax: 8,
difficulty: 3,
tags: ['专注力', '格子仿画', '耐心'],
sortOrder: 212,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+13
View File
@@ -0,0 +1,13 @@
export type { WorksheetDefinition } from './types';
export { MATH_WORKSHEET_DEFINITIONS } from './math';
export {
CLOCK_READING_WORKSHEET_DEFINITIONS,
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
} from './clock';
export { FOCUS_WORKSHEET_DEFINITIONS } from './focus';
export { LETTER_TRACING_WORKSHEET_DEFINITIONS } from './letterTracing';
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin';
export { PEN_CONTROL_WORKSHEET_DEFINITIONS } from './penControl';
export { WORD_COLORING_WORKSHEET_DEFINITIONS } from './wordColoring';
export { WORD_TEST_WORKSHEET_DEFINITIONS } from './wordTest';
export { PAPER_SHEET_WORKSHEET_DEFINITIONS } from './papers';
@@ -0,0 +1,81 @@
import type { WorksheetDefinition } from './types';
export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
{
id: 'letter-tracing-single',
icon: 'start-a',
title: '字母默认字帖',
subtitle: '配图、例句与描红',
ageMin: 4,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '看图描红'],
sortOrder: 42,
},
{
id: 'letter-tracing-upper-lower',
icon: 'draw-o',
title: '字母基础描红',
subtitle: 'Uppercase / Lowercase 总览',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '字母总览'],
sortOrder: 44,
},
{
id: 'letter-tracing-case-pairing',
icon: 'font-size',
title: '大小写对照',
subtitle: '半组字母左大写右小写',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '大小写练习'],
sortOrder: 45,
},
{
id: 'letter-tracing-two-column',
icon: 'two-columns',
title: '两列描红',
subtitle: '左 A–M、右 N–Z 配对描红',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '两列练习'],
sortOrder: 43,
},
{
id: 'letter-tracing-half',
icon: 'square-half',
title: '13字母半表',
subtitle: '每行一个字母,13 字母半表',
ageMin: 5,
ageMax: 8,
difficulty: 2,
tags: ['字母', '描红', '半表练习'],
sortOrder: 47,
},
{
id: 'letter-tracing-three',
icon: 'ABC-list',
title: '三字母精练',
subtitle: '每页聚焦 3 个字母深度书写',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '三字母精练'],
sortOrder: 46,
},
{
id: 'letter-tracing-daily-checkin',
icon: 'task-o',
title: '字母每日打卡',
subtitle: '四宫格每日字母打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '每日打卡'],
sortOrder: 48,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+264
View File
@@ -0,0 +1,264 @@
import type { WorksheetDefinition } from './types';
export const MATH_WORKSHEET_DEFINITIONS = [
{
id: 'number-find',
title: '找数字,涂一涂',
subtitle: '找出目标数字并涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 101,
},
{
id: 'number-write',
title: '看数字,写一写',
subtitle: '按笔画顺序书写数字',
ageMin: 4,
ageMax: 6,
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: 4,
ageMax: 6,
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: 4,
ageMax: 6,
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: 5,
ageMax: 7,
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: 4,
ageMax: 6,
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: 5,
ageMax: 7,
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: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加法', '计算题'],
sortOrder: 123,
},
{
id: 'practice-subtraction',
title: '减法运算',
subtitle: '10/20/50/100以内减法',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '减法', '计算题'],
sortOrder: 124,
},
{
id: 'practice-mixed',
title: '混合运算',
subtitle: '10/20/50/100以内加减法混合',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加减法', '计算题'],
sortOrder: 125,
},
{
id: 'multiplication-table',
title: '九九乘法表',
subtitle: '学习九九乘法口诀',
ageMin: 7,
ageMax: 8,
difficulty: 3,
tags: ['数学', '乘法', '九九乘法表'],
sortOrder: 126,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+72
View File
@@ -0,0 +1,72 @@
import type { WorksheetDefinition } from './types';
/** 业务条目与绘制方式分离;颜色等用户参数不另建 worksheet。 */
export const PAPER_TYPES = [
{ id: 'tian', name: '田字格', desc: '汉字练习' },
{ id: 'mi', name: '米字格', desc: '书法练习' },
{ id: 'square', name: '方格', desc: '书法练字纸' },
{ id: 'four-line', name: '四线三格', desc: '拼音 / 字母' },
{ id: 'letter', name: '信纸', desc: '横线信笺' },
{ id: 'horizontal', name: '横线', desc: '横线本' },
{ id: 'vertical', name: '竖线', desc: '竖排书写' },
{ id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' },
{
id: 'dictation-pinyin',
name: '拼音/英语听写',
desc: '四线三格 + 订正栏',
},
{ id: 'homework-log', name: '作业登记表', desc: '分科登记' },
] as const;
export type PaperType = (typeof PAPER_TYPES)[number]['id'];
/** 原综合入口始终保留,不重分配历史收藏和统计。 */
export const PAPER_SHEET_WORKSHEET_ID = 'paper-sheet';
export interface PaperSheetDefinition extends WorksheetDefinition {
rendererKey: 'paper-sheet';
renderConfig: { paperType?: PaperType };
}
export const PAPER_SHEET_WORKSHEET_DEFINITIONS: ReadonlyArray<PaperSheetDefinition> =
[
{
id: PAPER_SHEET_WORKSHEET_ID,
rendererKey: 'paper-sheet',
renderConfig: {},
icon: 'draw-o',
title: '作业纸',
subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印',
ageMin: 3,
ageMax: 12,
difficulty: 1,
tags: [
'作业纸',
'田字格',
'米字格',
'方格',
'四线三格',
'信纸',
'横线',
'竖线',
'听写',
'作业登记表',
],
sortOrder: 50,
},
...PAPER_TYPES.map(
(paper, index): PaperSheetDefinition => ({
id: `paper-${paper.id}`,
rendererKey: 'paper-sheet',
renderConfig: { paperType: paper.id },
icon: 'draw-o',
title: paper.name,
subtitle: `${paper.desc},一键生成打印`,
ageMin: 3,
ageMax: 12,
difficulty: 1,
tags: ['作业纸', paper.name, paper.desc],
sortOrder: 51 + index,
}),
),
];
@@ -0,0 +1,15 @@
import type { WorksheetDefinition } from './types';
export const PEN_CONTROL_WORKSHEET_DEFINITIONS = [
{
id: 'pen-control-mix',
icon: 'edit',
title: '控笔练习',
subtitle: '多种控笔图形,运笔更轻松',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['控笔', '运笔', '田字格', '学前'],
sortOrder: 40,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+59
View File
@@ -0,0 +1,59 @@
import type { WorksheetDefinition } from './types';
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS = [
{
id: 'pinyin-tracing',
icon: 'draw-o',
title: '拼音描红练习',
subtitle: '跟着描红学拼音字母',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 50,
},
{
id: 'pinyin-dictation',
icon: 'start-a',
title: '拼音默写练习',
subtitle: '空白格子默写拼音字母',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 51,
},
{
id: 'pinyin-tracing-v2',
icon: 'pen-draw',
title: '拼音描红 8 列',
subtitle: '跟写描红,声韵分块',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 52,
},
{
id: 'pinyin-dictation-v2',
icon: 'ABC-underline',
title: '拼音默写 8 列',
subtitle: '空白默写,声韵分块',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 53,
},
{
id: 'pinyin-daily',
icon: 'task-o',
title: '拼音每日打卡',
subtitle: '四宫格每日打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['拼音', '每日练习', '打卡'],
sortOrder: 54,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+13
View File
@@ -0,0 +1,13 @@
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */
export interface WorksheetDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
/** 页面渲染时使用的图标,仅部分页面有 */
icon?: string;
}
@@ -0,0 +1,26 @@
import type { WorksheetDefinition } from './types';
export const WORD_COLORING_WORKSHEET_DEFINITIONS = [
{
id: 'word-coloring-grid',
icon: 'grid',
title: '网格涂色',
subtitle: '田字格涂色识字练习',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '田字格'],
sortOrder: 30,
},
{
id: 'word-coloring-find',
icon: 'search',
title: '找字涂色',
subtitle: '在字海中找到目标字并涂色',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '找字'],
sortOrder: 31,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+15
View File
@@ -0,0 +1,15 @@
import type { WorksheetDefinition } from './types';
export const WORD_TEST_WORKSHEET_DEFINITIONS = [
{
id: 'word-test-sheet',
icon: 'todo-list-o',
title: '测字表',
subtitle: '按分类测识字,勾选认识的字',
ageMin: 5,
ageMax: 8,
difficulty: 1,
tags: ['测字', '识字', '汉字', '一年级'],
sortOrder: 38,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+6
View File
@@ -38,6 +38,12 @@ export const CATEGORY_LIST: CategoryType[] = [
icon: '🔤',
path: '/pages/category/category?id=english',
},
{
id: 'papers',
name: '作业纸',
icon: '📄',
path: '/pages/category/category?id=papers',
},
// {
// id: 'craft',
// name: '创意手工',
+100 -36
View File
@@ -48,9 +48,7 @@ export const WORDS = [
'中', '个', '工', '王', '车', '儿', '女', '子', '门', '马', '牛', '羊', '米', '衣', '白',
'田', '石', '雨', '电', '云', '花', '草', '叶', '果', '鸟', '虫', '鱼', '头', '目', '耳',
'足', '心', '力', '立', '正',
// 扩充(常见基础字)
'刀', '又', '三', '四', '五', '六', '七', '八', '九', '十', '口', '门', '心', '耳', '目',
'牙', '鼻', '口', '眉', '田', '米', '木', '竹', '石', '土', '火', '水', '金'
'刀', '又', '三', '四', '五', '六', '七', '八', '九', '十', '牙', '鼻', '眉', '竹', '金',
],
},
{
@@ -62,10 +60,8 @@ export const WORDS = [
'灯', '家', '房', '床', '吃', '喝', '坐', '走', '跑', '看', '听', '说', '笑', '哭', '爱',
'好', '有', '来', '去', '开', '关', '里', '外', '多', '少', '红', '黄', '蓝', '绿', '白',
'黑', '是', '不', '我', '你',
// 扩充(贴近生活)
'他', '她', '它', '们', '在', '和', '把', '给', '用', '做', '玩', '买', '卖', '学', '读',
'写', '问', '答', '听', '看', '吃', '喝', '睡', '起', '穿', '洗', '擦', '开', '关', '拿',
'放'
'写', '问', '答', '睡', '起', '穿', '洗', '擦', '拿', '放',
],
},
{
@@ -77,17 +73,19 @@ export const WORDS = [
'南', '西', '北', '飞', '跳', '游', '唱', '画', '洗', '买', '卖', '问', '学', '读', '写',
'早', '晚', '明', '亮', '高', '长', '圆', '方', '快', '乐', '热', '冷', '轻', '重', '新',
'旧', '和', '同', '会', '要',
// 扩充(自然与常见动作)
'雨', '雷', '电', '云', '雾', '露', '霜', '星', '月', '阳', '阴', '晴', '风', '雪', '冰',
'草', '花', '叶', '果', '根', '看', '听', '说', '读', '写', '跑', '跳', '走', '爬', '抓',
'推', '拉', '抱', '笑', '哭'
'雨', '雷', '电', '云', '雾', '露', '霜', '月', '阳', '阴', '晴', '冰',
'草', '花', '叶', '果', '根', '跑', '走', '爬', '抓', '推', '拉', '抱', '笑', '哭',
],
},
{
categoryId: 3,
icon: '🎨',
categoryName: '颜色',
words: ['红', '蓝', '绿', '黄', '黑', '白', '紫', '橙', '粉', '棕', '灰'],
words: [
'红', '蓝', '绿', '黄', '黑', '白', '紫', '橙', '粉', '棕', '灰',
'青', '翠', '碧', '丹', '金', '银', '铜', '朱', '墨', '绛', '褐',
],
},
{
categoryId: 4,
@@ -123,61 +121,96 @@ export const WORDS = [
categoryId: 6,
icon: '☀️',
categoryName: '日字旁',
words: ['日', '明', '早', '时', '晴', '春', '星', '晨', '晚', '晒', '照'],
words: [
'日', '明', '早', '时', '晴', '春', '星', '晨', '晚', '晒', '照',
'旧', '昏', '旦', '旭', '暗', '显', '映', '晌', '昼', '晕', '昌', '易', '昂', '晶', '暄',
],
},
{
categoryId: 7,
icon: '💧',
categoryName: '三点水',
words: ['水', '江', '河', '流', '沙', '洗', '海', '汗', '汽', '澡', '泡', '湖', '泉'],
words: [
'水', '江', '河', '湖', '海', '泉', '汗', '汽', '沙', '洗', '波', '流', '游', '洪', '浴',
'浪', '池', '泡', '澡', '油', '洋', '溪', '泥', '深', '浅', '泪', '洁', '温', '漂', '混',
'涨', '滴', '港', '渔', '涛', '润', '消', '清', '渡', '洞',
],
},
{
categoryId: 8,
icon: '🐦',
categoryName: '鸟字边',
words: ['鸟', '鸡', '鸭', '鹅', '鸣', '鸽', '鸦', '鹊', '鹤'],
categoryName: '鸟字旁',
words: [
'鸟', '鸡', '鸭', '鹅', '鸣', '鸽', '鸦', '鹊', '鹤', '鸳', '鸯', '鹰', '鹏', '鹦',
],
},
{
categoryId: 9,
icon: '🐾',
categoryName: '反犬旁',
words: ['狗', '猫', '猴', '狮', '狼', '猪', '狠', '独', '犯'],
words: [
'狗', '猫', '猴', '狮', '狼', '猪', '狠', '独', '犯',
'猜', '猩', '猛', '猿', '猾', '猬', '猎', '狐', '狂', '猢',
],
},
{
categoryId: 10,
icon: '🪱',
categoryName: '虫字旁',
words: ['虫', '蚁', '蝶', '蜻', '蛙', '蛇', '蜘', '蜂', '蚊', '蛾'],
words: [
'虫', '蚁', '蚂', '蚊', '蚕', '蛹', '蛙', '蛾', '蛇', '蝶',
'蜻', '蜓', '蜘', '蛛', '蜂', '蝉', '螃', '蟹', '螺', '蝌',
'蝇', '螳', '蟋', '蟀',
],
},
{
categoryId: 11,
icon: '🌧️',
categoryName: '雨字头',
words: ['雨', '雪', '雷', '露', '雾', '雹', '霜', '雯', '霖'],
words: [
'雨', '雪', '雷', '露', '雾', '雹', '霜', '震', '零', '需', '霞', '霓', '霁',
],
},
{
categoryId: 12,
icon: '🌲',
categoryName: '木字旁',
words: ['木', '林', '树', '桃', '森', '松', '桥', '枝', '板', '柜', '杯', '校'],
words: [
'木', '林', '树', '桃', '森', '松', '桥', '枝', '板', '柜', '杯', '校',
'柳', '柏', '杨', '枫', '桐', '桂', '榕', '杉',
],
},
{
categoryId: 13,
icon: '👄',
categoryName: '口字旁',
words: ['口', '吃', '叫', '唱', '听', '吹', '叶', '和', '问', '品', '吐', '吸'],
words: [
'口', '吃', '叫', '唱', '听', '吹', '叶', '和', '问', '品', '吐', '吸', '嘴', '咽', '喉',
],
},
{
categoryId: 14,
icon: '🧍',
categoryName: '单人旁',
words: ['你', '他', '们', '作', '休', '住', '伙', '伴', '体', '位', '信', '化'],
words: [
'你', '他', '们', '作', '休', '住', '伙', '伴', '体', '位', '信', '化',
'保', '什', '但', '代', '使', '俩', '便', '仰', '传', '伤', '伯', '佳',
],
},
{
categoryId: 15,
icon: '❤️',
categoryName: '竖心旁',
words: ['心', '情', '快', '怕', '惊', '忙', '怀', '爱', '想', '念'],
words: [
'心', '情', '快', '怕', '惊', '忙', '怀', '爱', '想', '念',
'意', '愿', '态', '怪', '性', '悟', '惜', '懒', '慌',
],
},
// 新增适龄分类
@@ -186,7 +219,7 @@ export const WORDS = [
icon: '🧭',
categoryName: '方位方向',
words: [
'上', '下', '左', '右', '前', '后', '里', '外', '东', '南', '西', '北', '中', '近', '远'
'上', '下', '左', '右', '前', '后', '里', '外', '东', '南', '西', '北', '中', '近', '远',
],
},
{
@@ -195,7 +228,7 @@ export const WORDS = [
categoryName: '身体部位',
words: [
'头', '脸', '目', '眼', '眉', '鼻', '口', '牙', '舌', '耳', '手', '指', '掌', '臂',
'足', '腿', '心'
'足', '腿', '心', '发', '肩', '背', '肚', '腰', '皮', '骨', '血', '身',
],
},
{
@@ -204,14 +237,22 @@ export const WORDS = [
categoryName: '常见动物',
words: [
'狗', '猫', '马', '牛', '羊', '鸡', '鸭', '鹅', '鱼', '兔', '猪', '熊', '虎', '鹿', '猴',
'狼', '狮', '虎', '豹', '象',
'狼', '狮', '豹', '象', '鸟', '龟', '蛙', '蛇', '鼠', '狐', '狸', '蝶', '蜂', '蚁', '蚕',
'蝉', '蛛', '雀', '鸽', '鹰', '虾',
],
},
{
categoryId: 19,
icon: '🌼',
categoryName: '常见植物',
words: ['花', '草', '树', '叶', '果', '根', '竹', '松', '柳', '桃', '梅', '荷', '菊'],
words: [
'花', '草', '树', '叶', '果', '根', '竹', '松', '柳', '桃', '梅', '荷', '菊',
'兰', '桂', '杏', '槐', '柏', '杨', '榕', '杉', '枫', '桐', '瓜', '豆',
'蔬', '菜', '麦', '稻', '谷', '苹', '李', '梨', '橙', '柚', '柿', '葡',
'萄', '西', '荔', '枝', '椰', '蕉', '菠', '萝', '辣', '椒', '茄', '芹',
'卜', '葱', '姜', '蒜', '薯', '蘑', '菇', '笋', '苔', '蔓', '藕',
],
},
{
categoryId: 20,
@@ -219,40 +260,65 @@ export const WORDS = [
categoryName: '食物饮品',
words: [
'米', '饭', '面', '菜', '果', '肉', '蛋', '奶', '糖', '盐', '油', '水', '茶', '汤',
'粥', '饼'
'粥', '饼', '薯', '菇', '竹', '笋', '苔', '蔓', '荷', '藕',
],
},
{
categoryId: 21,
icon: '🚗',
categoryName: '交通出行',
words: ['车', '船', '飞', '机', '站', '路', '桥', '铁', '轨', '轮'],
words: [
'车', '船', '飞', '机', '站', '路', '桥', '铁', '轨', '轮',
'汽', '地', '高', '速', '动', '单', '双',
'驾', '乘', '骑', '停', '票', '码', '行', '驶', '程', '过',
'隧', '道', '转', '步', '街', '灯', '红', '绿', '黄',
],
},
{
categoryId: 22,
icon: '📚',
categoryName: '校园与文具',
words: ['书', '本', '笔', '尺', '刀', '纸', '包', '课', '桌', '椅', '图', '画', '作', '业'],
words: [
'书', '本', '笔', '尺', '刀', '纸', '包', '课', '桌', '椅', '图', '画', '作', '业',
'教', '室', '校', '园', '板', '黑', '白', '擦', '讲', '台', '钟', '铃',
'练', '习', '册', '橡', '皮', '胶', '垫', '盒', '袋', '卷',
'订', '夹', '筒', '水', '彩', '颜', '料', '文', '具', '铅',
'钢', '毛', '红', '蓝', '字', '帖', '典', '母', '材', '布', '贴', '考', '试', '印', '章', '机',
],
},
{
categoryId: 23,
icon: '⏰',
categoryName: '时间与季节',
words: [
'日', '月', '年', '时', '分', '秒', '早', '晚', '今', '明', '昨', '春', '夏', '秋', '冬'
'日', '月', '年', '岁', '时', '分', '秒', '晨', '午', '晚', '早', '暮', '夜', '昼',
'今', '明', '昨', '春', '夏', '秋', '冬', '季', '节', '阳', '阴',
'钟', '点', '刻', '旬', '期', '当', '初', '终', '始', '末', '再', '曾', '旧', '新',
],
},
{
categoryId: 24,
icon: '🟠',
categoryName: '形状与图形',
words: ['点', '线', '面', '圆', '方', '角', '弧', '长', '宽', '高'],
words: [
'点', '线', '面', '角', '边', '体', '圆', '方', '长', '宽', '高', '深', '矩', '形',
'弧', '棱', '柱', '锥', '球', '扇', '环', '扁', '厚', '薄',
],
},
{
categoryId: 25,
icon: '📏',
categoryName: '量词常用',
words: ['个', '只', '匹', '条', '朵', '棵', '片', '张', '本', '杯', '块', '双'],
categoryName: '常用量词',
words: [
'个', '只', '匹', '条', '朵', '棵', '片', '张', '本', '杯', '块', '双',
'头', '位', '节', '根', '座', '群', '瓶', '颗', '段', '辆', '架',
'支', '面', '粒', '把', '首', '封', '页', '箱', '队',
],
},
];
@@ -263,9 +329,7 @@ export function getCategoryTabIndex(categoryId: number): number {
}
/** 汇总分类下全部汉字(含一二年级 sections) */
export function collectCategoryWords(
cat: (typeof WORDS)[number],
): string[] {
export function collectCategoryWords(cat: (typeof WORDS)[number]): string[] {
if (cat.words?.length) {
return cat.words;
}
+85 -9
View File
@@ -16,6 +16,37 @@ import drawHeader from './drawHeader';
const LINE_COLOR = '#BCBAB2'; // 打印友好的深灰线色,避免渐变色
const LINE_WIDTH = 3;
/** 品牌水印模式:A 右下角醒目;B 页面中央大号旋转浅水印(默认) */
export type BrandWatermarkMode = 'A' | 'B';
export interface DrawPrintFooterOptions {
/** 默认 B */
mode?: BrandWatermarkMode;
/** 品牌文案,默认「涂鸦丫小程序」 */
text?: string;
opacity?: number;
fontSize?: number;
}
const BRAND_TEXT = '涂鸦丫小程序';
const BRAND_COLOR = '#7C766A';
/** 方案 A:右下角单行品牌水印 */
const FOOTER_MODE_A = {
fontSize: 20,
opacity: 0.12,
marginRight: 24,
marginBottom: 20,
} as const;
/** 方案 B:页面中央大号旋转浅水印,单行居中 */
const FOOTER_MODE_B = {
fontSize: 32,
opacity: 0.12,
rotationDeg: -25,
centerYRatio: 0.52,
} as const;
export class BaseDrawService {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
@@ -174,24 +205,69 @@ export class BaseDrawService {
}
/**
* 页脚:页面底部居中绘制品牌文案「涂鸦丫小程序」(粗体)。
* 品牌水印页脚(默认「涂鸦丫小程序」单行)。
* - A:右下角水印,适合有底部页码的页面
* - B:页面中央大号旋转浅水印(默认)
* 请在整页正文绘制完成后调用,避免被内容覆盖。
*/
async drawPrintFooter(): Promise<void> {
drawPrintFooter(options?: DrawPrintFooterOptions): void {
const mode = options?.mode ?? 'A';
const text = options?.text ?? BRAND_TEXT;
if (mode === 'A') {
this.drawPrintFooterModeA(text, options);
} else {
this.drawPrintFooterModeB(text, options);
}
}
/** 方案 A:右下角单行品牌水印 */
private drawPrintFooterModeA(
text: string,
options?: DrawPrintFooterOptions,
): void {
const { ctx, canvasWidth, canvasHeight } = this;
const text = '涂鸦丫小程序';
const bottomMargin = 12;
const fontSize = 13;
const fontSize = options?.fontSize ?? FOOTER_MODE_A.fontSize;
const opacity = options?.opacity ?? FOOTER_MODE_A.opacity;
const { marginRight, marginBottom } = FOOTER_MODE_A;
const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`;
const textColor = '#7C766A';
ctx.save();
ctx.font = font;
ctx.fillStyle = textColor;
ctx.fillStyle = BRAND_COLOR;
ctx.globalAlpha = opacity;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText(
text,
canvasWidth - marginRight,
canvasHeight - marginBottom,
);
ctx.restore();
}
/** 方案 B:页面中央大号旋转浅水印,单行居中 */
private drawPrintFooterModeB(
text: string,
options?: DrawPrintFooterOptions,
): void {
const { ctx, canvasWidth, canvasHeight } = this;
const fontSize = options?.fontSize ?? FOOTER_MODE_B.fontSize;
const opacity = options?.opacity ?? FOOTER_MODE_B.opacity;
const { rotationDeg, centerYRatio } = FOOTER_MODE_B;
const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`;
const centerX = canvasWidth / 2;
const centerY = canvasHeight * centerYRatio;
ctx.save();
ctx.font = font;
ctx.fillStyle = BRAND_COLOR;
ctx.globalAlpha = opacity;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const y = canvasHeight - bottomMargin - fontSize / 2;
ctx.fillText(text, canvasWidth / 2, y);
ctx.translate(centerX, centerY);
ctx.rotate((rotationDeg * Math.PI) / 180);
ctx.fillText(text, 0, 0);
ctx.restore();
}
+5 -15
View File
@@ -3,36 +3,26 @@
*
* 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线,
* 按 URL 去重,同一字体只加载一次。
* 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。
* 仅使用已配置的 HTTPS 字体地址。
*/
/** 字体加载所需的最小描述 */
export interface FontFace {
/** wx.loadFontFace 的 family 参数 */
name: string;
/** 字体文件 URL(HTTP/HTTPS)或 cloud:// 文件 ID */
/** 字体文件 HTTPS URL */
url: string;
}
const _loadedIds = new Set<string>();
/**
* 将 cloud:// 文件 ID 解析为临时 HTTPS URL。
* 非 cloud:// 的 URL 原样返回。
*/
async function resolveUrl(raw: string): Promise<string> {
if (!raw.startsWith('cloud://')) return raw;
const res = await wx.cloud.getTempFileURL({ fileList: [raw] });
const file = res.fileList?.[0];
if (file?.tempFileURL) return file.tempFileURL;
throw new Error(`cloud file resolve failed: ${raw}`);
}
/** 加载字体,同一 url 只加载一次 */
export async function loadFontFace(font: FontFace): Promise<void> {
if (_loadedIds.has(font.url)) return;
const resolved = await resolveUrl(font.url);
if (!font.url.startsWith('https://') || /["\r\n]/.test(font.url))
throw new Error('字体地址须为 HTTPS');
const resolved = font.url;
return new Promise((resolve, reject) => {
wx.loadFontFace({
+4 -8
View File
@@ -1,3 +1,5 @@
import { publicAssetUrl } from '../../config/assets';
/**
* 字体渲染配置(Font Profile)
*
@@ -12,10 +14,7 @@
/** 字母分类 */
export type LetterCategory =
| 'uppercase'
| 'tallLower'
| 'descender'
| 'lowercase';
'uppercase' | 'tallLower' | 'descender' | 'lowercase';
/** 单个分类的渲染参数 */
export interface CategoryMetrics {
@@ -48,9 +47,6 @@ export function fontFamilyOf(profile: FontProfile): string {
return `${profile.name}, Roboto, sans-serif`;
}
const CLOUD_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
// ── 字母分类集合 ──
const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
@@ -213,7 +209,7 @@ export const PRINT_CLEARLY_DASHED: FontProfile = {
/** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */
export const TONEOZ_PINYIN: FontProfile = {
name: 'ToneOZPinyin',
url: `${CLOUD_PREFIX}assets/fonts/ToneOZ-Pinyin-Regular.ttf`,
url: publicAssetUrl('assets/fonts/ToneOZ-Pinyin-Regular.ttf'),
categories: {
uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
+2 -1
View File
@@ -4,7 +4,8 @@ export type CategoryId =
| 'english'
| 'puzzle'
| 'craft'
| 'pinyin';
| 'pinyin'
| 'papers';
/** Tab / 首页用的分类展示模型 */
export interface CategoryType {
+1
View File
@@ -31,4 +31,5 @@ export interface WorksheetRecord extends WorksheetConfig {
_id: string;
createdAt: Date;
updatedAt: Date;
contentUpdatedAt?: Date;
}
@@ -1,99 +1,8 @@
import type { LetterTracingMode } from './generators/letter-tracing-generator';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { LETTER_TRACING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/letterTracing';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */
interface LetterTracingWorksheetDefinition {
id: LetterTracingMode;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
{
id: 'letter-tracing-single',
icon: 'start-a',
title: '字母默认字帖',
subtitle: '配图、例句与描红',
ageMin: 4,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '看图描红'],
sortOrder: 42,
},
{
id: 'letter-tracing-upper-lower',
icon: 'draw-o',
title: '字母基础描红',
subtitle: 'Uppercase / Lowercase 总览',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '字母总览'],
sortOrder: 44,
},
{
id: 'letter-tracing-case-pairing',
icon: 'font-size',
title: '大小写对照',
subtitle: '半组字母左大写右小写',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '大小写练习'],
sortOrder: 45,
},
{
id: 'letter-tracing-two-column',
icon: 'two-columns',
title: '两列描红',
subtitle: '左 A–M、右 N–Z 配对描红',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '两列练习'],
sortOrder: 43,
},
{
id: 'letter-tracing-half',
icon: 'square-half',
title: '13字母半表',
subtitle: '每行一个字母,13 字母半表',
ageMin: 5,
ageMax: 8,
difficulty: 2,
tags: ['字母', '描红', '半表练习'],
sortOrder: 47,
},
{
id: 'letter-tracing-three',
icon: 'ABC-list',
title: '三字母精练',
subtitle: '每页聚焦 3 个字母深度书写',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '三字母精练'],
sortOrder: 46,
},
{
id: 'letter-tracing-daily-checkin',
icon: 'task-o',
title: '字母每日打卡',
subtitle: '四宫格每日字母打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '每日打卡'],
sortOrder: 48,
},
] as const satisfies ReadonlyArray<LetterTracingWorksheetDefinition>;
export { LETTER_TRACING_WORKSHEET_DEFINITIONS };
type LetterTracingWorksheetRow =
(typeof LETTER_TRACING_WORKSHEET_DEFINITIONS)[number];
@@ -1,140 +1,8 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { FOCUS_WORKSHEET_DEFINITIONS } from '../../config/worksheets/focus';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */
interface FocusWorksheetDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const FOCUS_WORKSHEET_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: 6,
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: 4,
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: 6,
ageMax: 8,
difficulty: 3,
tags: ['专注力', '格子仿画', '耐心'],
sortOrder: 212,
},
] as const satisfies ReadonlyArray<FocusWorksheetDefinition>;
export { FOCUS_WORKSHEET_DEFINITIONS };
type FocusWorksheetDefinitionItem =
(typeof FOCUS_WORKSHEET_DEFINITIONS)[number];
@@ -55,6 +55,15 @@ createFocusPage({
onLoad(options: { id?: string; mode?: string }) {
const routeId = options.id || FOCUS_TYPE_CONFIGS[0].id;
// “识别形状”仍由独立页面承载。兼容已发布的旧路径和本地兜底
// /focusPages/focusDraw/focusDraw?id=shape-recognition,避免通用注册表
// 找不到该 id 时静默回退为第一个题型。
if (routeId === 'shape-recognition') {
wx.redirectTo({ url: '/focusPages/shape/shape' });
return;
}
const result = findTypeByRouteId(routeId);
if (!result) return;
+19
View File
@@ -10,6 +10,7 @@ import {
shouldShowShareGuide,
shouldUnlockWithAd,
} from '../../utils/shareGuide';
import tracker from '../../utils/tracker';
Page({
canvas: null as Canvas | null,
@@ -20,6 +21,8 @@ Page({
timer: null as any,
data: {
worksheetId: 'shape-recognition',
pageTitle: '识别图形,涂一涂',
shapeList: [] as ShapeCard[],
showSelectShapePopup: false,
selectedShapeIndex: -1,
@@ -31,6 +34,10 @@ Page({
},
onLoad() {
tracker.reportPreviewView('focusPages/shape/shape', {
worksheet_id: 'shape-recognition',
worksheet_title: '识别图形,涂一涂',
});
// 判断是否在PC端开发工具上运行
// const systemInfo = wx.getDeviceInfo();
// if (systemInfo.platform === 'devtools') {
@@ -213,6 +220,10 @@ Page({
* 随机生成6个图形
*/
refreshShapeCard() {
const startedAt = Date.now();
tracker.reportGenerateStart('focusPages/shape/shape', {
worksheet_id: 'shape-recognition',
});
// 随机取6个图形
const shapesCopy = [...SHAPES];
for (let i = shapesCopy.length - 1; i > 0; i--) {
@@ -244,6 +255,10 @@ Page({
() => {
// 绘制canvas
this.drawCanvas();
tracker.reportGenerateResult('focusPages/shape/shape', true, {
worksheet_id: 'shape-recognition',
duration_ms: Date.now() - startedAt,
});
},
);
},
@@ -312,6 +327,8 @@ Page({
await downloadPrint(this.canvas, {
errorToast: '请先生成图形',
trackerName: '图形涂色',
trackerPage: 'focusPages/shape/shape',
worksheetId: 'shape-recognition',
});
},
@@ -327,6 +344,8 @@ Page({
await downloadPrint(this.canvas, {
errorToast: '请先生成图形',
trackerName: '图形涂色',
trackerPage: 'focusPages/shape/shape',
worksheetId: 'shape-recognition',
});
}
},
@@ -0,0 +1,70 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import {
CLOCK_READING_WORKSHEET_DEFINITIONS,
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
} from '../../config/worksheets/clock';
export { CLOCK_READING_WORKSHEET_DEFINITIONS, CLOCK_CONNECT_WORKSHEET_DEFINITIONS };
/** 合并所有钟表类 worksheet 定义 */
const ALL_CLOCK_DEFINITIONS = [
...CLOCK_READING_WORKSHEET_DEFINITIONS,
...CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
];
type ClockWorksheetRow = (typeof ALL_CLOCK_DEFINITIONS)[number];
const CLOCK_WORKSHEET_BY_ID = Object.fromEntries(
ALL_CLOCK_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, ClockWorksheetRow>;
/** 时刻类型选项(顺序:随机、整点、半点、刻钟) */
export const CLOCK_TIME_MODE_OPTIONS = [
{ id: 'random' as const, label: '随机', subtitle: '0–59 分均可' },
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
];
export type ClockTimeMode = (typeof CLOCK_TIME_MODE_OPTIONS)[number]['id'];
/** 练习类型选项(顺序:认识时钟、时钟连线) */
export const CLOCK_EXERCISE_TYPE_OPTIONS = [
{ id: 'clock-reading' as const, label: '认识时钟', subtitle: '看钟写时间' },
{ id: 'clock-connect' as const, label: '时钟连线', subtitle: '钟表连对应时间' },
];
export type ClockExerciseType = (typeof CLOCK_EXERCISE_TYPE_OPTIONS)[number]['id'];
export function getModeInfo(id: string) {
const m = CLOCK_WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in CLOCK_WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = CLOCK_WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'math',
subcategory: 'clock',
path: `/mathPages/clockReading/clockReading?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: false,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,15 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "认识时钟",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -0,0 +1,96 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.cr-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.cr-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 64rpx;
}
.cr-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.cr-section-title {
font-size: 32rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.cr-chip-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24rpx;
}
.cr-chip {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 16rpx;
color: @text-secondary;
background: @bg-card;
border-radius: 32rpx;
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.cr-chip--pressed {
transform: scale(0.96);
background: @brand;
color: @text-selected-btn;
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.cr-chip--active {
background: @brand;
color: @text-selected-btn;
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.cr-chip__text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
width: 100%;
}
.cr-chip__label {
font-size: 28rpx;
font-weight: 700;
line-height: 36rpx;
}
.cr-chip__subtitle {
font-size: 20rpx;
font-weight: 500;
line-height: 28rpx;
opacity: 0.75;
text-align: center;
}
.cr-chip--active .cr-chip__subtitle,
.cr-chip--pressed .cr-chip__subtitle {
opacity: 0.85;
}
@@ -0,0 +1,231 @@
import ClockReadingDraw from './draw/clockReadingDraw';
import ClockConnectDraw from './draw/clockConnectDraw';
import {
buildClockReadingData,
type ClockReadingTimeMode,
} from './generators/clock-generator';
import { buildClockConnectData } from './generators/clock-connect-generator';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
CLOCK_TIME_MODE_OPTIONS,
CLOCK_EXERCISE_TYPE_OPTIONS,
type ClockTimeMode,
type ClockExerciseType,
} from './clockReading.config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
import type { BaseDrawService } from '../../core/draw/baseDraw';
const pageInfoLookup = getModeInfo;
const DEFAULT_WORKSHEET_ID = 'clock-reading';
type PageData = CanvasDataState & {
worksheetId: string;
timeMode: ClockTimeMode;
timeModeOptions: typeof CLOCK_TIME_MODE_OPTIONS;
exerciseType: ClockExerciseType;
exerciseTypeOptions: typeof CLOCK_EXERCISE_TYPE_OPTIONS;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as BaseDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '认识钟表',
functionId: DEFAULT_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: DEFAULT_WORKSHEET_ID,
timeMode: 'random' as ClockTimeMode,
timeModeOptions: CLOCK_TIME_MODE_OPTIONS,
exerciseType: 'clock-reading' as ClockExerciseType,
exerciseTypeOptions: CLOCK_EXERCISE_TYPE_OPTIONS,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: DEFAULT_WORKSHEET_ID;
this.syncDebugPublishEnv();
// 根据传入 id 判断练习类型
const exerciseType: ClockExerciseType =
worksheetId === 'clock-connect' ? 'clock-connect' : 'clock-reading';
this.setData({ exerciseType });
this.applyWorksheet(worksheetId);
this.loadFavoritedMap();
},
onSelectTimeMode(e: WechatMiniprogram.TouchEvent) {
const mode = e.currentTarget.dataset.mode as
| ClockTimeMode
| undefined;
if (!mode || mode === this.data.timeMode) return;
this.setData({ timeMode: mode }, () => this.drawCanvas());
},
onSelectExerciseType(e: WechatMiniprogram.TouchEvent) {
const type = e.currentTarget.dataset.type as
| ClockExerciseType
| undefined;
if (!type || type === this.data.exerciseType) return;
const worksheetId = type;
this.setData({ exerciseType: type });
this.applyWorksheet(worksheetId);
// 切换练习类型需要重新创建 drawService
this.recreateDrawService();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) => this.createDrawForType(canvas, ctx, options),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
createDrawForType(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
): BaseDrawService {
if (this.data.exerciseType === 'clock-connect') {
return new ClockConnectDraw(canvas, ctx, options);
}
return new ClockReadingDraw(canvas, ctx, options);
},
recreateDrawService() {
if (!this.canvas || !this.ctx) return;
const options = { title: this.data.pageTitle };
this.drawService = this.createDrawForType(
this.canvas,
this.ctx,
options,
);
this.drawCanvas();
},
async drawCanvas() {
if (!this.ctx || !this.drawService) return;
try {
if (this.data.exerciseType === 'clock-connect') {
const data = buildClockConnectData(
this.data.timeMode as ClockTimeMode,
);
await (this.drawService as ClockConnectDraw).draw(data);
} else {
const data = buildClockReadingData(
this.data.timeMode as ClockReadingTimeMode,
);
await (this.drawService as ClockReadingDraw).draw(data);
}
this.setData({ hasContent: true });
} catch (e) {
console.error('clock draw failed', e);
this.setData({ hasContent: false });
}
},
onPreviewRefresh() {
this.drawCanvas();
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id && this._favoritedMap) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = ['clock-reading', 'clock-connect'];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap?.[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
getWorksheetStatsId() {
return this.data.worksheetId;
},
applyWorksheet(worksheetId: string) {
if (!isValidMode(worksheetId)) return;
this.setData(
{
worksheetId,
functionId: worksheetId,
isPreviewFavorite: !!this._favoritedMap?.[worksheetId],
},
() => {
this.initPageInfo(worksheetId, '认识钟表');
if (this.drawService) {
this.drawService.options.title = this.data.pageTitle;
}
},
);
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,74 @@
<nav-bar title="认识钟表" />
<view class="cr-page">
<view class="cr-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="cr-section">
<text class="cr-section-title">时刻类型</text>
<view class="cr-chip-row">
<view
wx:for="{{timeModeOptions}}"
wx:key="id"
class="cr-chip {{timeMode === item.id ? 'cr-chip--active' : ''}}"
hover-class="cr-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-mode="{{item.id}}"
bind:tap="onSelectTimeMode">
<view class="cr-chip__text">
<text class="cr-chip__label">{{item.label}}</text>
<text class="cr-chip__subtitle">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
<view class="cr-section">
<text class="cr-section-title">练习类型</text>
<view class="cr-chip-row">
<view
wx:for="{{exerciseTypeOptions}}"
wx:key="id"
class="cr-chip {{exerciseType === item.id ? 'cr-chip--active' : ''}}"
hover-class="cr-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-type="{{item.id}}"
bind:tap="onSelectExerciseType">
<view class="cr-chip__text">
<text class="cr-chip__label">{{item.label}}</text>
<text class="cr-chip__subtitle">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
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}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -0,0 +1,152 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
ClockConnectData,
ClockConnectProblem,
} from '../generators/clock-connect-generator';
import { drawAnalogClock } from './drawAnalogClock';
import { NUMBER_COLORS } from '../../../constants/colors';
const ROWS = 6;
const MARGIN_X = 28;
const MARGIN_Y_TOP = 12;
const CLOCK_RADIUS = 38 * 1.2; // 放大 1.2 倍
const TIME_BOX_W = 70 * 3 * 0.8 * 0.75; // 放大3倍后缩80%,再缩短长度
const TIME_BOX_H = 28 * 3 * 0.8; // 放大3倍后缩80%
const TIME_BOX_RADIUS = 8 * 3 * 0.8; // 放大3倍后缩80%
const TIME_FONT_SIZE = 42 * 0.8; // 字体放大3倍后缩80%
const CONTENT_BOTTOM_PADDING = 28;
const DASH_PATTERN = [7, 5];
const BOX_COLOR = '#555555';
const LINE_POINT_RADIUS = 8;
const LINE_POINT_SPACING = 12;
const LINE_POINT_COLOR = '#93D333';
/** 从 NUMBER_COLORS 中随机取一个颜色 */
function getRandomColor(): string {
const keys = Object.keys(NUMBER_COLORS).map(Number);
const key = keys[Math.floor(Math.random() * keys.length)];
return NUMBER_COLORS[key];
}
/**
* 时钟连线绘制服务:
* 左侧 6 个钟表(右侧带连线圆点),右侧 6 个打乱顺序的时间(左侧带连线圆点)
* 用户需要将左侧的钟表和右侧对应的时间连线
*/
export default class ClockConnectDraw extends BaseDrawService {
async draw(data: ClockConnectData) {
if (!data?.clocks?.length) return;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawConnectGrid(data.clocks, data.shuffledTimes);
this.drawPrintFooter({ mode: 'A' });
}
private drawConnectGrid(
clocks: ClockConnectProblem[],
shuffledTimes: ClockConnectProblem[],
) {
const { ctx, canvasWidth, canvasHeight } = this;
const contentTop = this.currentY;
const availableH = canvasHeight - contentTop - CONTENT_BOTTOM_PADDING;
const clockBlockH = CLOCK_RADIUS * 2;
const totalGridH = ROWS * clockBlockH;
const rowGap = Math.max(
8,
(availableH - totalGridH - MARGIN_Y_TOP * 2) / (ROWS - 1),
);
const startY = contentTop + MARGIN_Y_TOP;
const rowH = clockBlockH + rowGap;
// 向中间靠拢
const leftCenterX = MARGIN_X + CLOCK_RADIUS + 40;
const rightCenterX = canvasWidth - MARGIN_X - TIME_BOX_W / 2 - 40;
for (let i = 0; i < ROWS && i < clocks.length; i++) {
const clock = clocks[i];
const cellTopY = startY + i * rowH;
const clockCy = cellTopY + CLOCK_RADIUS;
// 绘制左侧钟表
drawAnalogClock(ctx, {
cx: leftCenterX,
cy: clockCy,
radius: CLOCK_RADIUS,
hour: clock.hour,
minute: clock.minute,
});
// 绘制钟表右侧的连线圆点
const leftDotX =
leftCenterX + CLOCK_RADIUS + LINE_POINT_SPACING + LINE_POINT_RADIUS;
ctx.fillStyle = LINE_POINT_COLOR;
ctx.beginPath();
ctx.arc(leftDotX, clockCy, LINE_POINT_RADIUS, 0, Math.PI * 2);
ctx.fill();
// 绘制右侧时间框
const timeItem = shuffledTimes[i];
const boxX = rightCenterX - TIME_BOX_W / 2;
const boxY = clockCy - TIME_BOX_H / 2;
const color = getRandomColor();
this.drawTimeBox(boxX, boxY, timeItem.timeText, color);
// 绘制时间框左侧的连线圆点
const rightDotX = boxX - LINE_POINT_SPACING - LINE_POINT_RADIUS;
ctx.fillStyle = LINE_POINT_COLOR;
ctx.beginPath();
ctx.arc(rightDotX, clockCy, LINE_POINT_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
}
/** 绘制圆角虚线矩形 + 居中彩色时间文本 */
private drawTimeBox(x: number, y: number, text: string, color: string) {
const { ctx } = this;
ctx.save();
ctx.strokeStyle = BOX_COLOR;
ctx.lineWidth = 1.8;
ctx.setLineDash(DASH_PATTERN);
this.strokeRoundRect(x, y, TIME_BOX_W, TIME_BOX_H, TIME_BOX_RADIUS);
ctx.setLineDash([]);
ctx.fillStyle = color;
ctx.font = `bold ${TIME_FONT_SIZE}px "Microsoft Yahei", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + TIME_BOX_W / 2, y + TIME_BOX_H / 2);
ctx.restore();
}
private strokeRoundRect(
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const { ctx } = this;
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.arcTo(x + w, y, x + w, y + radius, radius);
ctx.lineTo(x + w, y + h - radius);
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
ctx.lineTo(x + radius, y + h);
ctx.arcTo(x, y + h, x, y + h - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
}
@@ -0,0 +1,63 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { ClockProblem, ClockReadingData } from '../generators/clock-generator';
import { drawAnalogClock, drawTimeInputBox } from './drawAnalogClock';
const ROWS = 4;
const COLS = 3;
const MARGIN_X = 28;
const CLOCK_RADIUS = 52; // 原 58 的 90%
const CLOCK_TO_BOX_GAP = 13; // 原 20 缩小三分之一
const INPUT_BOX_W = 88;
const INPUT_BOX_H = 30;
const ROW_GAP = 28; // 原 14 的两倍
const CONTENT_BOTTOM_PADDING = 28;
/**
* 认识钟表绘制服务:4×3 网格,每组钟表 + 填写框
*/
export default class ClockReadingDraw extends BaseDrawService {
async draw(data: ClockReadingData) {
if (!data?.problems?.length) return;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawGrid(data.problems);
this.drawPrintFooter({ mode: 'A' });
}
private drawGrid(problems: ClockProblem[]) {
const { ctx, canvasWidth, canvasHeight } = this;
const contentTop = this.currentY;
const availableH = canvasHeight - contentTop - CONTENT_BOTTOM_PADDING;
const clockBlockH =
CLOCK_RADIUS * 2 + CLOCK_TO_BOX_GAP + INPUT_BOX_H;
const totalGridH = ROWS * clockBlockH + (ROWS - 1) * ROW_GAP;
const startY = contentTop + Math.max(12, (availableH - totalGridH) / 2);
const availableW = canvasWidth - MARGIN_X * 2;
const colW = availableW / COLS;
const rowH = clockBlockH + ROW_GAP;
for (let i = 0; i < problems.length; i++) {
const row = Math.floor(i / COLS);
const col = i % COLS;
if (row >= ROWS) break;
const problem = problems[i];
const cellCenterX = MARGIN_X + col * colW + colW / 2;
const cellTopY = startY + row * rowH;
const clockCy = cellTopY + CLOCK_RADIUS;
drawAnalogClock(ctx, {
cx: cellCenterX,
cy: clockCy,
radius: CLOCK_RADIUS,
hour: problem.hour,
minute: problem.minute,
});
const boxX = cellCenterX - INPUT_BOX_W / 2;
const boxY = cellTopY + CLOCK_RADIUS * 2 + CLOCK_TO_BOX_GAP;
drawTimeInputBox(ctx, boxX, boxY, INPUT_BOX_W, INPUT_BOX_H);
}
}
}
@@ -0,0 +1,210 @@
const CLOCK_GREEN = '#3D9E47';
const CLOCK_BLACK = '#333333';
const HOUR_HAND_RED = '#E53935';
export interface DrawAnalogClockOptions {
cx: number;
cy: number;
radius: number;
hour: number;
minute: number;
}
function toCanvasAngle(degFromTwelve: number): number {
return ((degFromTwelve - 90) * Math.PI) / 180;
}
function handPoint(
cx: number,
cy: number,
angle: number,
perp: number,
along: number,
perpOffset: number,
) {
return {
x: cx + Math.cos(angle) * along + Math.cos(perp) * perpOffset,
y: cy + Math.sin(angle) * along + Math.sin(perp) * perpOffset,
};
}
/** 指针:近圆心 80% 为矩形,远端 20% 收尖为三角形 */
function drawCompositeHand(
ctx: RenderingContext,
cx: number,
cy: number,
angleDeg: number,
length: number,
width: number,
color: string,
) {
const angle = toCanvasAngle(angleDeg);
const perp = angle + Math.PI / 2;
const halfW = width / 2;
const rectEnd = length * 0.8;
const tip = handPoint(cx, cy, angle, perp, length, 0);
const rectEndR = handPoint(cx, cy, angle, perp, rectEnd, halfW);
const baseR = handPoint(cx, cy, angle, perp, 0, halfW);
const baseL = handPoint(cx, cy, angle, perp, 0, -halfW);
const rectEndL = handPoint(cx, cy, angle, perp, rectEnd, -halfW);
ctx.save();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(tip.x, tip.y);
ctx.lineTo(rectEndR.x, rectEndR.y);
ctx.lineTo(baseR.x, baseR.y);
ctx.lineTo(baseL.x, baseL.y);
ctx.lineTo(rectEndL.x, rectEndL.y);
ctx.closePath();
ctx.fill();
ctx.restore();
}
const HAND_WIDTH = 2.8;
/** 绘制模拟钟表:绿圈、刻度、数字、红时针、黑分针 */
export function drawAnalogClock(
ctx: RenderingContext,
options: DrawAnalogClockOptions,
): void {
const { cx, cy, radius, hour, minute } = options;
const faceRadius = radius - 4;
ctx.save();
// 外圈(线宽原 3.5 的 90%)
ctx.strokeStyle = CLOCK_GREEN;
ctx.lineWidth = 3.15;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
// 刻度(长度缩短为原来的 80%)
const tickOuter = faceRadius - 1;
const hourTickLen = 7 * 0.8;
const minuteTickLen = 3 * 0.8;
for (let i = 0; i < 60; i++) {
const angle = toCanvasAngle(i * 6);
const isHour = i % 5 === 0;
const tickLen = isHour ? hourTickLen : minuteTickLen;
const inner = tickOuter - tickLen;
ctx.strokeStyle = CLOCK_BLACK;
ctx.lineWidth = isHour ? 1.5 : 0.8;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
ctx.lineTo(cx + Math.cos(angle) * tickOuter, cy + Math.sin(angle) * tickOuter);
ctx.stroke();
}
// 数字 1–12(原 13px 的 70%)
ctx.fillStyle = CLOCK_BLACK;
ctx.font = 'bold 9px "Microsoft Yahei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const numberRadius = faceRadius - 14;
for (let n = 1; n <= 12; n++) {
const angle = toCanvasAngle(n * 30);
const nx = cx + Math.cos(angle) * numberRadius;
const ny = cy + Math.sin(angle) * numberRadius;
ctx.fillText(String(n), nx, ny);
}
const minuteAngle = minute * 6;
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// 指针:80% 矩形 + 20% 三角尖
const minuteHandLength = numberRadius - 3;
const hourHandLength = numberRadius * 0.67;
drawCompositeHand(
ctx,
cx,
cy,
minuteAngle,
minuteHandLength,
HAND_WIDTH,
CLOCK_BLACK,
);
drawCompositeHand(
ctx,
cx,
cy,
hourAngle,
hourHandLength,
HAND_WIDTH,
HOUR_HAND_RED,
);
// 中心点
ctx.fillStyle = CLOCK_BLACK;
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const INPUT_GREEN = '#3D9E47';
function strokeRoundRect(
ctx: RenderingContext,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.arcTo(x + w, y, x + w, y + radius, radius);
ctx.lineTo(x + w, y + h - radius);
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
ctx.lineTo(x + radius, y + h);
ctx.arcTo(x, y + h, x, y + h - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
/** 在矩形内水平、垂直居中绘制文字 */
function fillTextCentered(
ctx: RenderingContext,
text: string,
x: number,
y: number,
width: number,
height: number,
) {
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(text);
const ascent = metrics.actualBoundingBoxAscent ?? 10;
const descent = metrics.actualBoundingBoxDescent ?? 2;
const textH = ascent + descent;
const baselineY = y + (height - textH) / 2 + ascent;
ctx.fillText(text, x + width / 2, baselineY);
}
/** 绘制数字填写框(绿色圆角描边 + 居中冒号) */
export function drawTimeInputBox(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
): void {
ctx.save();
ctx.strokeStyle = INPUT_GREEN;
ctx.lineWidth = 1.5;
strokeRoundRect(ctx, x, y, width, height, 6);
ctx.fillStyle = CLOCK_BLACK;
ctx.font = 'bold 16px "Microsoft Yahei", sans-serif';
fillTextCentered(ctx, ':', x, y, width, height);
ctx.restore();
}
@@ -0,0 +1,94 @@
export type ClockConnectTimeMode =
| 'random'
| 'whole-hour'
| 'half-hour'
| 'quarter-hour';
export interface ClockConnectProblem {
hour: number;
minute: number;
/** 格式化后的时间文本,如 "6:00" */
timeText: string;
}
export interface ClockConnectData {
/** 左侧钟表列表(按原始顺序) */
clocks: ClockConnectProblem[];
/** 右侧时间文本列表(打乱顺序) */
shuffledTimes: ClockConnectProblem[];
timeMode: ClockConnectTimeMode;
}
export const CLOCK_CONNECT_PROBLEM_COUNT = 6;
const MINUTES_BY_MODE: Record<
Exclude<ClockConnectTimeMode, 'random'>,
readonly number[]
> = {
'whole-hour': [0],
'half-hour': [30],
'quarter-hour': [15, 45],
};
function pickMinute(timeMode: ClockConnectTimeMode): number {
if (timeMode === 'random') {
return Math.floor(Math.random() * 60);
}
const pool = MINUTES_BY_MODE[timeMode];
return pool[Math.floor(Math.random() * pool.length)] ?? 0;
}
function formatTime(hour: number, minute: number): string {
return `${hour}:${minute.toString().padStart(2, '0')}`;
}
/** Fisher-Yates shuffle */
function shuffle<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/** 生成一页 6 道时钟连线题,同一页内时刻不重复 */
export function generateClockConnectProblems(
timeMode: ClockConnectTimeMode,
): ClockConnectProblem[] {
const used = new Set<string>();
const problems: ClockConnectProblem[] = [];
let guard = 0;
while (problems.length < CLOCK_CONNECT_PROBLEM_COUNT && guard < 500) {
guard += 1;
const hour = Math.floor(Math.random() * 12) + 1;
const minute = pickMinute(timeMode);
const key = `${hour}:${minute}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ hour, minute, timeText: formatTime(hour, minute) });
}
return problems;
}
export function buildClockConnectData(
timeMode: ClockConnectTimeMode,
): ClockConnectData {
const clocks = generateClockConnectProblems(timeMode);
const shuffledTimes = shuffle(clocks);
return {
timeMode,
clocks,
shuffledTimes,
};
}
export const CLOCK_CONNECT_TIME_MODE_OPTIONS = [
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
{ id: 'random' as const, label: '随机', subtitle: '整点/半点/刻钟' },
];
@@ -0,0 +1,71 @@
export type ClockReadingTimeMode =
| 'random'
| 'whole-hour'
| 'half-hour'
| 'quarter-hour';
export interface ClockProblem {
hour: number;
minute: number;
}
export interface ClockReadingData {
problems: ClockProblem[];
timeMode: ClockReadingTimeMode;
}
export const CLOCK_PROBLEM_COUNT = 12;
const MINUTES_BY_MODE: Record<
Exclude<ClockReadingTimeMode, 'random'>,
readonly number[]
> = {
'whole-hour': [0],
'half-hour': [30],
'quarter-hour': [15, 45],
};
function pickMinute(timeMode: ClockReadingTimeMode): number {
if (timeMode === 'random') {
return Math.floor(Math.random() * 60);
}
const pool = MINUTES_BY_MODE[timeMode];
return pool[Math.floor(Math.random() * pool.length)] ?? 0;
}
/** 生成一页 12 道钟表题,同一页内时刻不重复 */
export function generateClockProblems(
timeMode: ClockReadingTimeMode,
): ClockProblem[] {
const used = new Set<string>();
const problems: ClockProblem[] = [];
let guard = 0;
while (problems.length < CLOCK_PROBLEM_COUNT && guard < 500) {
guard += 1;
const hour = Math.floor(Math.random() * 12) + 1;
const minute = pickMinute(timeMode);
const key = `${hour}:${minute}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ hour, minute });
}
return problems;
}
export function buildClockReadingData(
timeMode: ClockReadingTimeMode,
): ClockReadingData {
return {
timeMode,
problems: generateClockProblems(timeMode),
};
}
export const CLOCK_TIME_MODE_OPTIONS = [
{ id: 'random' as const, label: '随机', subtitle: '0–59 分均可' },
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
];
@@ -1,281 +1,8 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { MATH_WORKSHEET_DEFINITIONS } from '../../config/worksheets/math';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐,用于发布与批量同步脚本) */
interface MathWorksheetDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const MATH_WORKSHEET_DEFINITIONS = [
{
id: 'number-find',
title: '找数字,涂一涂',
subtitle: '找出目标数字并涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 101,
},
{
id: 'number-write',
title: '看数字,写一写',
subtitle: '按笔画顺序书写数字',
ageMin: 4,
ageMax: 6,
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: 4,
ageMax: 6,
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: 4,
ageMax: 6,
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: 5,
ageMax: 7,
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: 4,
ageMax: 6,
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: 5,
ageMax: 7,
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: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加法', '计算题'],
sortOrder: 123,
},
{
id: 'practice-subtraction',
title: '减法运算',
subtitle: '10/20/50/100以内减法',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '减法', '计算题'],
sortOrder: 124,
},
{
id: 'practice-mixed',
title: '混合运算',
subtitle: '10/20/50/100以内加减法混合',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加减法', '计算题'],
sortOrder: 125,
},
{
id: 'multiplication-table',
title: '九九乘法表',
subtitle: '学习九九乘法口诀',
ageMin: 7,
ageMax: 8,
difficulty: 3,
tags: ['数学', '乘法', '九九乘法表'],
sortOrder: 126,
},
] as const satisfies ReadonlyArray<MathWorksheetDefinition>;
export { MATH_WORKSHEET_DEFINITIONS };
type MathWorksheetDefinitionItem = (typeof MATH_WORKSHEET_DEFINITIONS)[number];
+9 -15
View File
@@ -1,3 +1,5 @@
import { fetchAgeRecommendations } from '../../services/site-api';
import { resolvePreviewUrl } from '../../config/assets';
import { appSharePageMethods } from '../../base/pageMixin';
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
import { parseMiniProgramUrl } from '../../utils/index';
@@ -91,24 +93,17 @@ async function fetchAgeRecommended(
ageKey: AgeBandKey,
): Promise<RecommendedItemView[]> {
try {
const res = await wx.cloud.callFunction({
name: 'ageRecommendedQuery',
data: { ageKey },
});
const payload = (res && (res.result as any)) || null;
if (!payload?.success) return [];
const items = (payload.data?.items || []) as any[];
const response = await fetchAgeRecommendations(ageKey);
const items = response.items;
return items
.map((it) => ({
title: String(it.title || ''),
image: String(it.previewImg || ''),
image: resolvePreviewUrl(it.previewImg),
subtitle: String(it.subtitle || ''),
ageBand: String(it.ageBand || it.ageRange || ''),
difficulty: toDifficultyLabel(
it.difficultyLabel || it.difficulty || '',
),
ageBand: `${it.ageMin}-${it.ageMax}岁`,
difficulty: toDifficultyLabel(it.difficulty || ''),
path: String(it.path || ''),
worksheetId: String(it.worksheetId || ''),
worksheetId: String(it.id || ''),
}))
.filter((it) => it.title);
} catch (e) {
@@ -174,8 +169,7 @@ Page({
onTapCard(e: WechatMiniprogram.CustomEvent) {
const detail = (e.detail || {}) as { title?: string; path?: string };
const path =
detail.path ||
(e.currentTarget.dataset.path as string | undefined);
detail.path || (e.currentTarget.dataset.path as string | undefined);
const title =
detail.title ||
(e.currentTarget.dataset.title as string | undefined);
+23 -1
View File
@@ -369,6 +369,16 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
path: '/mathPages/mathDraw/mathDraw?id=multiplication-table',
available: true,
}),
item({
id: 'clock-reading',
title: '认识钟表',
subtitle: '看钟读时间,填写数字时刻',
icon: '🕐',
ageBand: age(4, 7),
difficulty: 1,
path: '/mathPages/clockReading/clockReading?id=clock-reading',
available: true,
}),
],
pinyin: [
item({
@@ -433,7 +443,7 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
previewImg: '/assets/entrancePicture/focus/shape-recognition.png',
ageBand: age(3, 5),
difficulty: 1,
path: '/focusPages/focusDraw/focusDraw?id=shape-recognition',
path: '/focusPages/shape/shape',
available: true,
}),
item({
@@ -677,6 +687,18 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
available: true,
}),
],
papers: [
item({
id: 'paper-sheet',
title: '作业纸',
subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印',
icon: '📄',
ageBand: age(3, 12),
difficulty: 1,
path: '/papersPages/paperSheet/paperSheet?id=paper-sheet',
available: true,
}),
],
craft: [
item({
id: 'craft-coloring',
+20 -4
View File
@@ -288,10 +288,12 @@
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg,
transparent 25%,
rgba(255, 255, 255, 0.7) 50%,
transparent 75%);
background: linear-gradient(
135deg,
transparent 25%,
rgba(255, 255, 255, 0.7) 50%,
transparent 75%
);
animation: skeleton-shimmer 1.2s linear infinite;
z-index: 1;
}
@@ -317,3 +319,17 @@
height: 24rpx;
}
}
// 请求失败时允许显式重试,不用本地静态题型补回下架内容。
.cat-empty__retry {
margin: 8rpx 0 0;
padding: 0 32rpx;
border-radius: 999rpx;
background: #f3dba2;
color: @text-title;
font-size: 26rpx;
line-height: 72rpx;
&::after {
border: none;
}
}
@@ -0,0 +1,151 @@
import { resolvePreviewUrl } from '../../config/assets';
import type { ApiCategory, ApiWorksheet } from '../../services/content-api';
export interface CategoryTab {
id: string;
name: string;
icon: string;
}
export interface CategoryItem {
id: string;
title: string;
subtitle: string;
icon: string;
previewImg: string;
ageBand: string;
difficultyLabel: string;
path: string;
available: boolean;
likes: string;
downloads: string;
date: string;
updatedAt: string;
skeleton?: boolean;
}
export interface CategoryDataset {
sidebarTabs: CategoryTab[];
groups: Map<string, CategoryItem[]>;
}
export const BASE_TABS: CategoryTab[] = [
{ id: 'all', name: '全部', icon: '📋' },
{ id: 'hot', name: '热门推荐', icon: '🔥' },
{ id: 'top-downloads', name: '下载最多', icon: '📥' },
];
const DIFFICULTY_LABELS = ['入门', '基础', '进阶', '挑战'];
/** 比较任意长度的非负十进制计数;微信运行时无需 BigInt,也不丢失 64 位精度。 */
export function compareCountDescending(left: string, right: string): number {
const a = left.replace(/^0+(?=\d)/, '');
const b = right.replace(/^0+(?=\d)/, '');
return a.length !== b.length
? b.length - a.length
: a === b
? 0
: a < b
? 1
: -1;
}
function time(value: string): number {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** 保留原页面交互:最近更新的两项置顶,其余下载量降序,并列按稳定 ID。 */
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
const byId = (a: CategoryItem, b: CategoryItem) =>
a.id < b.id ? -1 : a.id === b.id ? 0 : 1;
const sorted = [...items].sort(
(a, b) => time(b.updatedAt) - time(a.updatedAt) || byId(a, b),
);
return [
...sorted.slice(0, 2),
...sorted
.slice(2)
.sort(
(a, b) =>
compareCountDescending(a.downloads, b.downloads) ||
byId(a, b),
),
];
}
/** 只展示本次接口返回的分类;并发下架导致目录缺失的题型直接丢弃。 */
export function buildCategoryDataset(
categories: ApiCategory[],
worksheets: ApiWorksheet[],
): CategoryDataset {
const reservedIds = new Set(BASE_TABS.map((tab) => tab.id));
const groups = new Map<string, CategoryItem[]>();
const icons = new Map<string, string>();
for (const category of categories) {
if (reservedIds.has(category.id) || groups.has(category.id))
throw new Error('分类数据异常,请稍后重试');
groups.set(category.id, []);
icons.set(category.id, category.icon || '📄');
}
for (const row of worksheets) {
const group = groups.get(row.category);
if (!group) continue;
const date = row.contentUpdatedAt || row.createdAt;
group.push({
id: row.id,
title: row.title,
subtitle: row.subtitle,
icon: icons.get(row.category) || '📄',
previewImg: resolvePreviewUrl(row.previewImg),
ageBand: `${row.ageMin}-${row.ageMax}岁`,
difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础',
path: row.path,
available: Boolean(row.path),
likes: row.likes,
downloads: row.downloads,
date: Number.isFinite(Date.parse(date))
? new Date(date).toISOString().slice(0, 10)
: '',
updatedAt: row.updatedAt,
});
}
return {
sidebarTabs: [
...BASE_TABS,
...categories.map(({ id, name, icon }) => ({ id, name, icon })),
],
groups,
};
}
/** 两个榜单沿用旧页面含义:热门推荐按收藏数,下载最多按下载数,各取前 12。 */
export function getCategoryItems(
dataset: CategoryDataset | null,
categoryId: string,
keyword = '',
): CategoryItem[] {
if (!dataset) return [];
let items: CategoryItem[];
if (
categoryId === 'all' ||
categoryId === 'hot' ||
categoryId === 'top-downloads'
) {
const all = sortWorksheets(Array.from(dataset.groups.values()).flat());
if (categoryId === 'all') items = all;
else {
const key = categoryId === 'hot' ? 'likes' : 'downloads';
items = all
.sort((a, b) => compareCountDescending(a[key], b[key]))
.slice(0, 12);
}
} else items = sortWorksheets(dataset.groups.get(categoryId) || []);
const kw = keyword.trim().toLowerCase();
return kw
? items.filter(
(item) =>
item.title.toLowerCase().includes(kw) ||
item.subtitle.toLowerCase().includes(kw),
)
: items;
}
+164 -257
View File
@@ -1,43 +1,19 @@
import { appSharePageMethods } from '../../base/pageMixin';
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { takePendingCategoryTabId } from '../../utils/index';
import { CATEGORY_DATA, type CategoryItem } from './category.data';
type CategoryTab = {
id: string;
name: string;
icon: string;
};
type CategoryDataset = {
searchPlaceholder: string;
categories: Array<{
id: string;
name: string;
icon: string;
items: CategoryItem[];
}>;
};
const DIFFICULTY_LABELS: Record<number, string> = {
1: '入门',
2: '基础',
3: '进阶',
4: '挑战',
};
const DYNAMIC_TABS: CategoryTab[] = [
{ id: 'hot', name: '热门推荐', icon: '🔥' },
{ id: 'top-downloads', name: '下载最多', icon: '📥' },
];
const SIDEBAR_TABS: CategoryTab[] = [
{ id: 'all', name: '全部', icon: '📋' },
...DYNAMIC_TABS,
...CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
({ id, name, icon }) => ({ id, name, icon }),
),
];
import tracker from '../../utils/tracker';
import { ApiRequestError } from '../../services/api-request';
import {
getWorksheet,
listAllWorksheets,
listCategories,
} from '../../services/content-api';
import {
BASE_TABS,
buildCategoryDataset,
getCategoryItems,
type CategoryDataset,
type CategoryItem,
} from './category.model';
const TAB_BAR_PATHS = new Set([
'/pages/home/home',
@@ -46,179 +22,45 @@ const TAB_BAR_PATHS = new Set([
'/pages/favorites/favorites',
'/pages/profile/profile',
]);
// Page-level mutable state (shared across methods)
let _categoryData: CategoryDataset | null = null;
// --- 以下仅为本地调试:拉取分类数据后查看各 worksheet 的 downloads / likes,非业务逻辑 ---
function debugLogWorksheetStatsAfterCategoryLoad(
source:
| { kind: 'cloud'; rows: Record<string, any>[] }
| { kind: 'static'; data: CategoryDataset },
) {
let map: Record<string, { downloads: number; likes: number }> = {};
if (source.kind === 'cloud') {
for (const raw of source.rows) {
const id = String(raw._id || '').trim();
if (!id) continue;
map[id] = {
downloads: Number(raw.downloads) || 0,
likes: Number(raw.likes) || 0,
};
}
} else {
for (const group of source.data.categories) {
for (const item of group.items) {
map[item.id] = {
downloads: Number(item.downloads) || 0,
likes: Number(item.likes) || 0,
};
}
}
}
console.log('worksheetStatsById', JSON.stringify(map, null, 2));
}
// --- 调试块结束 ---
/** 将云端 worksheet 原始数据转换为 CategoryItem */
function toDisplayItem(raw: Record<string, any>): CategoryItem {
const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4;
const ageMin = Number(raw.ageMin) || 0;
const ageMax = Number(raw.ageMax) || 0;
return {
id: String(raw._id || ''),
title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''),
previewImg: String(raw.previewImg || ''),
ageBand: `${ageMin}-${ageMax}岁`,
ageMin,
ageMax,
difficulty,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
path: String(raw.path || ''),
available: true,
likes: Number(raw.likes) || 0,
downloads: Number(raw.downloads) || 0,
date: raw.updatedAt
? new Date(raw.updatedAt).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
};
}
/** 排序:updatedAt 最新的 2 个置顶,其余按 downloads 降序 */
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
const byTime = [...items].sort(
(a, b) =>
new Date(b.updatedAt || '').getTime() -
new Date(a.updatedAt || '').getTime(),
);
const recent = byTime.slice(0, 2);
const recentIds = new Set(recent.map((w) => w.id));
const rest = byTime
.filter((w) => !recentIds.has(w.id))
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
return [...recent, ...rest];
}
/** 将云端 worksheet 数组按分类分组,构建 CategoryDataset */
function buildCategoryDataFromCloud(
rawList: Record<string, any>[],
): CategoryDataset {
const itemsByCategory: Record<string, CategoryItem[]> = {};
for (const raw of rawList) {
const item = toDisplayItem(raw);
const cat = String(raw.category || '');
if (!itemsByCategory[cat]) itemsByCategory[cat] = [];
itemsByCategory[cat].push(item);
}
const categories = CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
(cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
items: sortWorksheets(itemsByCategory[cat.id] || []),
}),
);
return { searchPlaceholder: '搜索练习纸...', categories };
}
function buildAllItems(): CategoryItem[] {
if (!_categoryData) return [];
const items: CategoryItem[] = [];
for (const group of _categoryData.categories) {
for (const item of group.items) {
items.push({ ...item });
}
}
return sortWorksheets(items);
}
const DYNAMIC_LIMIT = 12;
function buildHotItems(): CategoryItem[] {
const all = buildAllItems();
return [...all]
.sort((a, b) => (b.likes || 0) - (a.likes || 0))
.slice(0, DYNAMIC_LIMIT);
}
function buildTopDownloadsItems(): CategoryItem[] {
const all = buildAllItems();
return [...all]
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0))
.slice(0, DYNAMIC_LIMIT);
}
function getItemsByCategory(categoryId: string): CategoryItem[] {
if (!_categoryData) return [];
if (categoryId === 'all') return buildAllItems();
if (categoryId === 'hot') return buildHotItems();
if (categoryId === 'top-downloads') return buildTopDownloadsItems();
const group = _categoryData.categories.find((c) => c.id === categoryId);
if (!group) return [];
return group.items.map((item) => ({ ...item }));
}
function filterByKeyword(
items: CategoryItem[],
keyword: string,
): CategoryItem[] {
if (!keyword) return items;
const kw = keyword.toLowerCase();
return items.filter(
(item) =>
item.title.toLowerCase().includes(kw) ||
item.subtitle.toLowerCase().includes(kw),
);
}
// Skeleton placeholder items for loading state
const SKELETON_ITEMS = Array.from({ length: 6 }, (_, i) => ({
id: `skeleton-${i}`,
title: '\u00A0',
subtitle: '\u00A0',
skeleton: true,
}));
const SKELETON_ITEMS: CategoryItem[] = Array.from(
{ length: 6 },
(_, index) => ({
id: `skeleton-${index}`,
title: '\u00a0',
subtitle: '\u00a0',
icon: '',
previewImg: '',
ageBand: '',
difficultyLabel: '',
path: '',
available: false,
likes: '',
downloads: '',
date: '',
updatedAt: '',
skeleton: true,
}),
);
Page({
data: {
searchPlaceholder: '搜索练习纸...',
searchKeyword: '',
sidebarTabs: SIDEBAR_TABS,
sidebarTabs: BASE_TABS,
activeCategoryId: 'all',
displayItems: SKELETON_ITEMS as any[],
displayItems: SKELETON_ITEMS,
scrollIntoViewId: '',
loading: true,
errorMessage: '',
},
_defaultCategoryId: 'all',
_categoryData: null as CategoryDataset | null,
_loadVersion: 0,
_opening: false,
onLoad(options: Record<string, string>) {
tracker.reportPageView('pages/category/category');
if (options.id && options.id !== 'all') {
this._defaultCategoryId = options.id;
this.setData({ activeCategoryId: options.id });
@@ -226,99 +68,164 @@ Page({
},
onShow() {
_categoryData = null;
this.setData({ displayItems: SKELETON_ITEMS, loading: true });
void this.loadWorksheets(takePendingCategoryTabId() ?? undefined);
},
async loadWorksheets(overrideCategoryId?: string | null) {
try {
const res = await wx.cloud.callFunction({
name: 'worksheetsQuery',
data: { status: 'active' },
});
const result = (res?.result as any) || {};
if (!result.success) throw new Error(result.message || '查询失败');
onHide() {
this._loadVersion++;
},
const rows = result.data || [];
_categoryData = buildCategoryDataFromCloud(rows);
debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows });
} catch {
_categoryData = CATEGORY_DATA;
debugLogWorksheetStatsAfterCategoryLoad({
kind: 'static',
data: CATEGORY_DATA,
onUnload() {
this._loadVersion++;
},
/** 每次进入重新读取公开目录与完整分页;失败清空内容,不回退静态数据或旧云服务。 */
async loadWorksheets(overrideCategoryId?: string) {
const version = ++this._loadVersion;
this._categoryData = null;
this.setData({
displayItems: SKELETON_ITEMS,
sidebarTabs: BASE_TABS,
loading: true,
errorMessage: '',
});
try {
const [categories, worksheets] = await Promise.all([
listCategories(),
listAllWorksheets(),
]);
if (version !== this._loadVersion) return;
const dataset = buildCategoryDataset(categories, worksheets);
const desiredId =
overrideCategoryId ||
this.data.activeCategoryId ||
this._defaultCategoryId;
const activeId = dataset.sidebarTabs.some(
(tab) => tab.id === desiredId,
)
? desiredId
: 'all';
this._categoryData = dataset;
this.setData({
loading: false,
sidebarTabs: dataset.sidebarTabs,
activeCategoryId: activeId,
displayItems: getCategoryItems(
dataset,
activeId,
this.data.searchKeyword,
),
scrollIntoViewId: '',
errorMessage: '',
});
} catch (error) {
if (version !== this._loadVersion) return;
this._categoryData = null;
this.setData({
loading: false,
displayItems: [],
sidebarTabs: BASE_TABS,
errorMessage:
error instanceof Error
? error.message
: '加载失败,请稍后重试',
});
}
},
const activeId =
overrideCategoryId != null &&
String(overrideCategoryId).trim() !== ''
? String(overrideCategoryId).trim()
: this.data.activeCategoryId || this._defaultCategoryId;
const items = getItemsByCategory(activeId);
const filtered = filterByKeyword(items, this.data.searchKeyword);
this.setData({
loading: false,
searchPlaceholder: _categoryData.searchPlaceholder,
activeCategoryId: activeId,
displayItems: filtered,
});
onRetry() {
void this.loadWorksheets();
},
onTapCategory(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.activeCategoryId) return;
const items = getItemsByCategory(id);
const filtered = filterByKeyword(items, this.data.searchKeyword);
if (
!this._categoryData ||
this.data.loading ||
!id ||
id === this.data.activeCategoryId ||
!this._categoryData.sidebarTabs.some((tab) => tab.id === id)
)
return;
this.setData({
activeCategoryId: id,
displayItems: filtered,
displayItems: getCategoryItems(
this._categoryData,
id,
this.data.searchKeyword,
),
scrollIntoViewId: '',
});
tracker.report('category_select', 'pages/category/category', {
category_id: id,
});
},
onSearchInput(e: WechatMiniprogram.Input) {
const keyword = (e.detail.value ?? '').trim();
const items = getItemsByCategory(this.data.activeCategoryId);
const filtered = filterByKeyword(items, keyword);
this.setData({
searchKeyword: keyword,
displayItems: filtered,
displayItems: getCategoryItems(
this._categoryData,
this.data.activeCategoryId,
keyword,
),
});
},
onClearSearch() {
const items = getItemsByCategory(this.data.activeCategoryId);
this.setData({
searchKeyword: '',
displayItems: items,
displayItems: getCategoryItems(
this._categoryData,
this.data.activeCategoryId,
),
});
},
onTapItem(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string;
const title = e.currentTarget.dataset.title as string;
const available = e.currentTarget.dataset.available;
if (!available || !path) {
/** 点击时重新检查详情,已下架或网络不可用时不进入内置生成页。 */
async onTapItem(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (
this._opening ||
this.data.loading ||
!id ||
!this.data.displayItems.some(
(item) => item.id === id && item.available,
)
)
return;
this._opening = true;
const version = this._loadVersion;
try {
const row = await getWorksheet(id);
if (version !== this._loadVersion) return;
if (!row.path) throw new Error('内容暂不可用');
tracker.reportWorksheetClick('pages/category/category', {
worksheet_id: row.id,
worksheet_title: row.title,
category_id: this.data.activeCategoryId,
source: 'category_list',
});
const fail = () =>
wx.showToast({ title: '暂时无法打开该内容', icon: 'none' });
if (TAB_BAR_PATHS.has(row.path))
wx.switchTab({ url: row.path, fail });
else wx.navigateTo({ url: row.path, fail });
} catch (error) {
if (version !== this._loadVersion) return;
wx.showToast({
title: title ? `${title} 即将上线` : '即将上线',
title:
error instanceof Error
? error.message
: '暂时无法打开,请稍后重试',
icon: 'none',
});
return;
if (error instanceof ApiRequestError && error.statusCode === 404)
void this.loadWorksheets();
} finally {
this._opening = false;
}
if (TAB_BAR_PATHS.has(path)) {
wx.switchTab({ url: path });
return;
}
wx.navigateTo({ url: path });
},
...appSharePageMethods,
+12 -4
View File
@@ -55,9 +55,7 @@
wx:for="{{displayItems}}"
wx:key="id"
class="cat-card {{item.skeleton ? 'cat-card--skeleton' : ''}}"
data-path="{{item.path}}"
data-title="{{item.title}}"
data-available="{{item.available}}"
data-id="{{item.id}}"
bindtap="{{item.skeleton ? '' : 'onTapItem'}}">
<view class="cat-card__thumb">
<image
@@ -117,7 +115,17 @@
</view>
<view
wx:if="{{displayItems.length === 0 && !loading}}"
wx:if="{{errorMessage && !loading}}"
class="cat-empty">
<text class="cat-empty__icon">📡</text>
<text class="cat-empty__text">{{errorMessage}}</text>
<button class="cat-empty__retry" bindtap="onRetry">
重新加载
</button>
</view>
<view
wx:if="{{displayItems.length === 0 && !loading && !errorMessage}}"
class="cat-empty">
<text class="cat-empty__icon">🔍</text>
<text class="cat-empty__text">没有找到匹配的内容</text>
+6
View File
@@ -222,6 +222,9 @@ Page({
this.setData({
favoriteList: records.map((r) => mapFavoriteToItem(r)),
});
} catch {
this.setData({ favoriteList: [] });
wx.showToast({ title: '收藏加载失败,请重试', icon: 'none' });
} finally {
this.setData({ favLoading: false });
}
@@ -232,6 +235,9 @@ Page({
try {
const records = await getDownloadLogs(1, DOWNLOAD_HISTORY_MAX);
this.setData({ downloadSections: groupDownloadsByDate(records) });
} catch {
this.setData({ downloadSections: [] });
wx.showToast({ title: '下载记录加载失败,请重试', icon: 'none' });
} finally {
this.setData({ dlLoading: false });
}
+30 -6
View File
@@ -1,6 +1,8 @@
import { publicAssetUrl } from '../../config/assets';
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { AGE_BANDS } from '../../core/data/difficulty';
import { CategoryId } from '../../core/models/category';
import { APP_VERSION } from '../../config/config';
export type HomeDisplayItem = {
id: string;
@@ -59,6 +61,27 @@ const HOME_AGE_BANDS: HomeDisplayDataset['ageBands'] = AGE_BANDS.map(
}),
);
/** 首页跑马灯公告条(不随云端配置变化) */
export type HomeNotice = {
id: string;
text: string;
/** 可选跳转路径,为空则点击不跳转 */
path?: string;
};
export const HOME_NOTICES: HomeNotice[] = [
{
id: 'welcome',
text: '欢迎来到 涂鸦丫,趣味练习纸任你选',
path: '/mathPages/clockReading/clockReading?id=clock-connect',
},
{
id: `update-${APP_VERSION}`,
text: `${APP_VERSION} 上线:新增作业纸`,
path: '/mathPages/clockReading/clockReading?id=clock-connect',
},
];
/** 首页固定:每日打卡练习(不随云端配置变化) */
export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
{
@@ -69,8 +92,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '5-8岁',
difficulty: '基础',
icon: '📝',
previewImg:
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-daily-checkin.jpg',
previewImg: publicAssetUrl(
'assets/previews/chinese/hanzi-daily-checkin.jpg',
),
path: '/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-daily-checkin',
available: true,
},
@@ -82,8 +106,7 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '6-8岁',
difficulty: '基础',
icon: '🔤',
previewImg:
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg',
previewImg: publicAssetUrl('assets/previews/pinyin/pinyin-daily.jpg'),
path: '/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily',
available: true,
},
@@ -95,8 +118,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '6-8岁',
difficulty: '基础',
icon: '🔠',
previewImg:
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg',
previewImg: publicAssetUrl(
'assets/previews/english/letter-tracing-daily-checkin.jpg',
),
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin',
available: true,
},
+182 -84
View File
@@ -48,18 +48,103 @@
margin-top: 48rpx;
}
.home-hero-placeholder {
margin: @section-gap @page-padding-x 0;
height: 450rpx;
border-radius: 40rpx;
background: rgba(50, 46, 37, 0.04);
.home-notice-bar {
display: flex;
align-items: center;
margin: 24rpx @page-padding-x 0;
padding: 0 24rpx;
height: 64rpx;
border-radius: 999rpx;
background: fade(#ff8f1f, 10%);
overflow: hidden;
box-sizing: border-box;
}
/** 首页顶部「精选」swiper 轮播 */
.home-hero-swiper {
.home-notice-bar__label {
display: flex;
align-items: center;
flex-shrink: 0;
margin-right: 16rpx;
padding-right: 16rpx;
border-right: 2rpx solid fade(#b06b1a, 30%);
height: 36rpx;
}
.home-notice-bar__icon {
font-size: 26rpx;
line-height: 1;
}
.home-notice-bar__label-text {
margin-left: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: #b06b1a;
line-height: 1;
}
.home-notice-bar__viewport {
flex: 1;
min-width: 0;
overflow: hidden;
height: 100%;
display: flex;
align-items: center;
}
.home-notice-bar__track {
display: inline-flex;
flex-wrap: nowrap;
white-space: nowrap;
animation: home-notice-scroll 12s linear infinite;
will-change: transform;
}
.home-notice-bar__group {
display: inline-flex;
flex-shrink: 0;
}
.home-notice-bar__item {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.home-notice-bar__text {
font-size: 24rpx;
color: #7a4c12;
line-height: 1;
}
.home-notice-bar__sep {
margin: 0 24rpx;
font-size: 24rpx;
color: fade(#b06b1a, 50%);
line-height: 1;
}
@keyframes home-notice-scroll {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-50%, 0, 0);
}
}
/** 紧凑预览轮播:按发布时仅裁头部后的比例展示,标题悬浮于图内。 */
.home-hero-gallery {
margin-top: 16rpx;
background: radial-gradient(ellipse at center, #f3e8cc 0%, @bg-page 70%);
}
.home-hero-swiper {
width: 100%;
height: 450rpx;
// 单张宽 750 - 156 * 2 - 16 * 2 = 406rpx;裁头后预览约高 500rpx。
// 包含上下阴影留白,不再给副标题、年龄等信息预留高度。
height: 548rpx;
}
.home-hero-swiper-item {
@@ -67,115 +152,134 @@
}
.home-hero-card-wrap {
padding: 24rpx;
padding: 16rpx 16rpx 28rpx;
box-sizing: border-box;
transition: transform 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: scale(0.9);
opacity: 0.5;
transform-origin: center center;
transform: translateY(12rpx) scale(0.88);
opacity: 0.55;
transition:
transform 0.45s ease,
opacity 0.45s ease;
}
.home-hero-card-wrap--active {
transform: scale(1);
transform: translateY(0) scale(1);
opacity: 1;
}
.home-hero-card {
width: 100%;
border-radius: 32rpx;
overflow: visible;
position: relative;
border-radius: 16rpx;
overflow: hidden;
background: #ffffff;
box-shadow: 0 10rpx 28rpx rgba(50, 46, 37, 0.08);
box-shadow:
0 12rpx 30rpx rgba(96, 75, 33, 0.12),
0 2rpx 6rpx rgba(96, 75, 33, 0.06);
}
.home-hero-card__top {
height: 260rpx;
width: 100%;
height: 0;
// 与 debugPublish 的 header-only 一致:595 × (842 - 110)。
padding-top: 123.02521%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
/* 顶部背景用自身圆角裁切,替代 card 的 overflow: hidden */
border-top-left-radius: 32rpx;
border-top-right-radius: 32rpx;
overflow: hidden;
border-bottom: 1rpx solid rgba(50, 46, 37, 0.08);
background: #f5edd8;
background: #ffffff;
}
.home-hero-card__cover {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-fit: contain;
}
.home-hero-card__fallback {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24rpx;
background: #fffdf7;
}
.home-hero-card__icon {
font-size: 96rpx;
line-height: 1;
font-size: 100rpx;
line-height: 1.2;
}
.home-hero-card__badge {
.home-hero-card__title-overlay {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(50, 46, 37, 0.12);
font-size: 22rpx;
color: @text-title;
font-weight: 600;
}
.home-hero-card__body {
padding: 28rpx @page-padding-inner-x 30rpx;
}
.home-hero-card__title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16rpx;
/* 允许 flex 子项在文本过长时正确截断 */
min-width: 0;
left: 12rpx;
right: 12rpx;
bottom: 12rpx;
padding: 10rpx 16rpx;
border-radius: 12rpx;
background: rgba(255, 252, 244, 0.94);
box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.08);
pointer-events: none;
}
.home-hero-card__title {
font-size: 34rpx;
line-height: 1.35;
display: block;
color: @text-title;
font-size: 28rpx;
line-height: 40rpx;
font-weight: 700;
margin: 0;
flex: 1;
min-width: 0;
/* 标题与 meta 同行:超出后省略号(避免高度抖动) */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.home-hero-card__sub {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.4;
color: @text-secondary;
/* 第二行:超出后省略号 */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.home-hero-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
height: 32rpx;
}
.home-hero-pagination__dots {
display: flex;
align-items: center;
}
.home-hero-card__meta {
margin: 0;
font-size: 22rpx;
.home-hero-pagination__target {
min-width: 32rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.home-hero-pagination__dot {
width: 8rpx;
height: 8rpx;
border-radius: 999rpx;
background: #d5c9ad;
transition:
width 0.3s ease,
background 0.3s ease;
}
.home-hero-pagination__dot--active {
width: 28rpx;
background: @text-title;
}
.home-hero-pagination__count {
color: @text-gray;
/* meta 也做单行截断,防止极端长文本把布局挤爆 */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 20rpx;
line-height: 28rpx;
}
.home-section-panel {
@@ -391,7 +495,6 @@
display: block;
}
.home-page .category-tabs {
background: @bg-page;
}
@@ -426,11 +529,11 @@
pointer-events: none;
.home-hero-card__top {
background: #f3efe5;
animation: home-skeleton-pulse 1.5s ease-in-out infinite;
}
.home-hero-card__title,
.home-hero-card__sub {
.home-hero-card__title {
background: #e8e2d5;
border-radius: 8rpx;
color: transparent;
@@ -441,11 +544,6 @@
width: 50%;
height: 36rpx;
}
.home-hero-card__sub {
width: 70%;
height: 26rpx;
}
}
.home-hot-card--skeleton {
+63 -5
View File
@@ -1,11 +1,14 @@
import { resolvePreviewUrl } from '../../config/assets';
import { appSharePageMethods } from '../../base/pageMixin';
import { NAV_INNER_PX } from '../../utils/navMetrics';
import {
parseMiniProgramUrl,
stashPendingCategoryTabId,
} from '../../utils/index';
import tracker from '../../utils/tracker';
import {
HOME_DAILY_CHECKIN_ITEMS,
HOME_NOTICES,
createEmptyHomeDataset,
type HomeDisplayItem,
type HomeDisplaySection,
@@ -50,6 +53,7 @@ const TAB_BAR_PATHS = new Set([
function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
return {
...item,
previewImg: resolvePreviewUrl(item.previewImg),
};
}
@@ -123,6 +127,23 @@ function navigateByPath(path?: string, fallbackTitle?: string) {
});
}
function worksheetIdFromPath(path?: string): string {
if (!path) return '';
return String(parseMiniProgramUrl(path).query.id || '');
}
function trackWorksheetClick(
path: string | undefined,
title: string | undefined,
source: string,
) {
tracker.reportWorksheetClick('pages/home/home', {
worksheet_id: worksheetIdFromPath(path),
worksheet_title: title || '',
source,
});
}
// ── Skeleton data ──
function skeletonItem(id: string): HomeDisplayItemView {
@@ -190,18 +211,20 @@ Page({
hotRecommends: SKELETON_HOT,
dailyCheckinItems: HOME_DAILY_CHECKIN_ITEMS.map(toItemView),
sections: [] as HomeDisplaySectionView[],
notices: HOME_NOTICES,
loading: true,
},
onLoad() {
tracker.reportPageView('pages/home/home');
void this.initData();
},
async initData() {
wx.showLoading({ title: '加载中', mask: false });
async initData(override?: PageConfigData) {
if (!override) wx.showLoading({ title: '加载中', mask: false });
const app = getApp<IAppOption>();
const config = await app.getPageConfig();
const config = override || (await app.getPageConfig());
let homeData: HomeDisplayDataset;
@@ -228,13 +251,26 @@ Page({
searchPlaceholder: homeData.searchPlaceholder,
tabs,
ageBands,
activeAgeBand: homeData.ageBands[0]?.key || '3-4',
activeAgeBand:
override &&
homeData.ageBands.some(
(band) => band.key === this.data.activeAgeBand,
)
? this.data.activeAgeBand
: homeData.ageBands[0]?.key || '3-4',
activeTab: tabs.some((tab) => tab.id === this.data.activeTab)
? this.data.activeTab
: 'all',
heroSwiperCurrent: Math.min(
this.data.heroSwiperCurrent,
Math.max(0, homeData.featured.length - 1),
),
featuredItems: homeData.featured.map(toItemView),
hotRecommends: homeData.hot.slice(0, 3).map(toItemView),
sections,
});
wx.hideLoading();
if (!override) wx.hideLoading();
},
// ── Tab & Navigation ──
@@ -243,6 +279,19 @@ Page({
this.setData({ heroSwiperCurrent: e.detail.current });
},
/** 指示点与手势共用 swiper current,单张内容不自动轮播。 */
onTapHeroDot(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index);
if (
this.data.loading ||
!Number.isInteger(index) ||
index < 0 ||
index >= this.data.featuredItems.length
)
return;
this.setData({ heroSwiperCurrent: index });
},
onTabChange(e: WechatMiniprogram.CustomEvent) {
const id = e.detail.id as string | undefined;
if (!id) return;
@@ -272,12 +321,14 @@ Page({
onTapFeatured(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string | undefined;
const title = e.currentTarget.dataset.title as string | undefined;
trackWorksheetClick(path, title, 'home_featured');
navigateByPath(path, title);
},
onTapHotRecommend(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string | undefined;
const title = e.currentTarget.dataset.title as string | undefined;
trackWorksheetClick(path, title, 'home_hot');
navigateByPath(path, title);
},
@@ -295,6 +346,12 @@ Page({
navigateByPath(path, title);
},
onTapNotice(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string | undefined;
if (!path) return;
navigateByPath(path);
},
onTapCard(e: WechatMiniprogram.CustomEvent) {
const detail = (e.detail || {}) as { title?: string; path?: string };
const path =
@@ -302,6 +359,7 @@ Page({
const title =
detail.title ||
(e.currentTarget.dataset.title as string | undefined);
trackWorksheetClick(path, title, 'home_section');
navigateByPath(path, title);
},
+101 -51
View File
@@ -22,63 +22,113 @@
bind:change="onTabChange" />
</view>
<swiper
class="home-hero-swiper"
previous-margin="80rpx"
next-margin="80rpx"
circular="{{featuredItems.length > 1 && !loading}}"
autoplay="{{!loading}}"
interval="{{4000}}"
duration="{{600}}"
easing-function="easeInOutCubic"
bindchange="onHeroSwiperChange">
<swiper-item
wx:for="{{featuredItems}}"
wx:key="id"
class="home-hero-swiper-item">
<view
class="home-hero-card-wrap {{heroSwiperCurrent === index ? 'home-hero-card-wrap--active' : ''}}">
<view
class="home-hero-card {{item.skeleton ? 'home-hero-card--skeleton' : ''}}"
data-title="{{item.title}}"
data-path="{{item.path}}"
bindtap="{{item.skeleton ? '' : 'onTapFeatured'}}">
<view class="home-hero-card__top">
<image
wx:if="{{!item.skeleton && item.previewImg}}"
class="home-hero-card__cover"
src="{{item.previewImg}}"
mode="aspectFill" />
<text
wx:if="{{!item.skeleton && !item.previewImg}}"
class="home-hero-card__icon"
>{{item.icon}}</text
>
<text
wx:if="{{item.badge}}"
class="home-hero-card__badge"
>{{item.badge}}</text
<view wx:if="{{notices.length > 0}}" class="home-notice-bar">
<view class="home-notice-bar__label">
<text class="home-notice-bar__icon">📣</text>
<text class="home-notice-bar__label-text">公告</text>
</view>
<view class="home-notice-bar__viewport">
<view class="home-notice-bar__track">
<view class="home-notice-bar__group">
<view
wx:for="{{notices}}"
wx:key="id"
class="home-notice-bar__item"
data-path="{{item.path}}"
bindtap="onTapNotice">
<text class="home-notice-bar__text"
>{{item.text}}</text
>
<text class="home-notice-bar__sep">·</text>
</view>
<view class="home-hero-card__body">
<view class="home-hero-card__title-row">
<text class="home-hero-card__title"
>{{item.title}}</text
>
<text
wx:if="{{!item.skeleton}}"
class="home-hero-card__meta"
>{{item.ageBand}}·{{item.difficulty}}</text
>
</view>
<text class="home-hero-card__sub"
>{{item.subtitle}}</text
</view>
<view class="home-notice-bar__group" aria-hidden="true">
<view
wx:for="{{notices}}"
wx:key="id"
class="home-notice-bar__item"
data-path="{{item.path}}"
bindtap="onTapNotice">
<text class="home-notice-bar__text"
>{{item.text}}</text
>
<text class="home-notice-bar__sep">·</text>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</view>
<view
wx:if="{{loading || featuredItems.length > 0}}"
class="home-hero-gallery">
<!-- 按发布时仅裁页眉后的画幅展示,标题悬浮,不额外占用卡片高度。 -->
<swiper
class="home-hero-swiper"
previous-margin="156rpx"
next-margin="156rpx"
current="{{heroSwiperCurrent}}"
circular="{{featuredItems.length > 1 && !loading}}"
autoplay="{{featuredItems.length > 1 && !loading}}"
interval="{{5200}}"
duration="{{500}}"
easing-function="easeInOutCubic"
bindchange="onHeroSwiperChange">
<swiper-item
wx:for="{{featuredItems}}"
wx:key="id"
class="home-hero-swiper-item">
<view
class="home-hero-card-wrap {{heroSwiperCurrent === index ? 'home-hero-card-wrap--active' : ''}}">
<view
class="home-hero-card {{item.skeleton ? 'home-hero-card--skeleton' : ''}}"
data-title="{{item.title}}"
data-path="{{item.path}}"
bindtap="{{item.skeleton ? '' : 'onTapFeatured'}}">
<view class="home-hero-card__top">
<image
wx:if="{{!item.skeleton && item.previewImg}}"
class="home-hero-card__cover"
src="{{item.previewImg}}"
mode="aspectFit" />
<view
wx:if="{{!item.skeleton && !item.previewImg}}"
class="home-hero-card__fallback">
<text class="home-hero-card__icon"
>{{item.icon}}</text
>
</view>
</view>
<view class="home-hero-card__title-overlay">
<text class="home-hero-card__title"
>{{item.title}}</text
>
</view>
</view>
</view>
</swiper-item>
</swiper>
<view
wx:if="{{!loading && featuredItems.length > 1}}"
class="home-hero-pagination">
<view class="home-hero-pagination__dots">
<view
wx:for="{{featuredItems}}"
wx:key="id"
class="home-hero-pagination__target"
data-index="{{index}}"
bindtap="onTapHeroDot"
aria-role="button"
aria-label="{{'查看第' + (index + 1) + '张练习纸'}}">
<view
class="home-hero-pagination__dot {{heroSwiperCurrent === index ? 'home-hero-pagination__dot--active' : ''}}" />
</view>
</view>
<text class="home-hero-pagination__count"
>{{heroSwiperCurrent + 1}} / {{featuredItems.length}}</text
>
</view>
</view>
<view class="home-section-panel">
<view class="home-section-head">
+28 -5
View File
@@ -123,7 +123,7 @@ page {
box-sizing: border-box;
}
.profile-hero__stat+.profile-hero__stat {
.profile-hero__stat + .profile-hero__stat {
margin-left: 32rpx;
}
@@ -164,7 +164,7 @@ page {
box-sizing: border-box;
}
.profile-grid__cell+.profile-grid__cell {
.profile-grid__cell + .profile-grid__cell {
margin-left: 32rpx;
}
@@ -430,14 +430,17 @@ page {
}
.profile-editor__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24rpx;
display: flex;
padding-top: 20rpx;
}
.profile-editor__action {
flex: 1;
min-width: 0;
height: 92rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 24rpx;
display: flex;
align-items: center;
@@ -447,6 +450,14 @@ page {
font-weight: 700;
}
.profile-editor__action::after {
border: 0;
}
.profile-editor__action + .profile-editor__action {
margin-left: 24rpx;
}
.profile-editor__action--ghost {
background: @bg-header;
color: @text-title;
@@ -462,3 +473,15 @@ page {
.profile-editor__action--disabled {
opacity: 0.6;
}
/* 仅在账户恢复需要用户主动确认时展示,正常静默登录不出现。 */
.profile-account-restart {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 24rpx;
margin-bottom: @section-gap-xs;
border-radius: @radius-xl;
background: @bg-white;
font-size: 26rpx;
}
+59 -31
View File
@@ -1,6 +1,7 @@
import { uploadAsset, memberStats } from '../../services/member-api';
import { getUser as getServerUser, startAccount } from '../../utils/auth';
import { disablePageShareMenu } from '../../base/pageMixin';
import { getDownloadLogCount } from '../../utils/downloadLogs';
import { getFavoriteCount } from '../../utils/favorites';
import { APP_VERSION } from '../../config/config';
import {
getCachedUser,
getUser,
@@ -16,11 +17,13 @@ Page({
isDevEnv: false,
printCount: 0,
favoriteCount: 0,
version: '3.0.0',
version: APP_VERSION,
showProfileEditor: false,
draftNickName: '',
draftAvatarUrl: '/assets/imgs/doodle-head.png',
isSavingProfile: false,
serverAccountActionRequired: false,
isStartingAccount: false,
},
onLoad() {
@@ -34,17 +37,52 @@ Page({
},
onShow() {
void this.checkServerAccount();
this.syncUserInfoFromApp();
void this.refreshStats();
},
/** 正常登录始终静默;账户不存在时才展示用户主动重新开始的入口。 */
async checkServerAccount() {
try {
await getServerUser();
this.setData({ serverAccountActionRequired: false });
} catch (error) {
const reason = (error as { reason?: string }).reason;
this.setData({
serverAccountActionRequired:
reason === 'ACCOUNT_NOT_FOUND' ||
reason === 'ACCOUNT_RESTART_REQUIRED',
});
}
},
async onStartServerAccount() {
if (this.data.isStartingAccount) return;
this.setData({ isStartingAccount: true });
try {
await startAccount();
this.setData({ serverAccountActionRequired: false });
wx.showToast({ title: '账户已就绪', icon: 'success' });
} catch {
wx.showToast({ title: '账户暂不可用,请稍后重试', icon: 'none' });
} finally {
this.setData({ isStartingAccount: false });
}
},
async syncUserInfoFromApp() {
const app = getApp<IAppOption>();
const cachedUser = app.globalData.user || getCachedUser();
if (cachedUser) {
this.applyUserInfo(cachedUser);
}
if (cachedUser) this.applyUserInfo(cachedUser);
else
this.setData({
userName: '微信用户',
avatarUrl: this.DEFAULT_AVATAR_URL,
printCount: 0,
favoriteCount: 0,
});
try {
const latestUser = await getUser();
@@ -68,17 +106,20 @@ Page({
async refreshStats() {
try {
const [printCount, favoriteCount] = await Promise.all([
getDownloadLogCount(),
getFavoriteCount(),
]);
const stats = await memberStats();
const { downloadCount: printCount, favoriteCount } = stats;
this.setData({ printCount, favoriteCount });
} catch (e) {
console.error('refreshStats failed', e);
} catch {
this.setData({ printCount: 0, favoriteCount: 0 });
}
},
onOpenProfileEditor() {
console.log(
'onOpenProfileEditor',
this.data.userName,
this.data.avatarUrl,
);
this.setData({
showProfileEditor: true,
draftNickName:
@@ -120,12 +161,12 @@ Page({
this.setData({ isSavingProfile: true });
try {
const avatarUrl = await this.persistAvatarIfNeeded(
const avatarAssetId = await this.persistAvatarIfNeeded(
this.data.draftAvatarUrl,
);
const user = await updateUserProfile({
nickName,
avatarUrl,
avatarAssetId,
});
this.applyUserInfo(user);
@@ -139,24 +180,11 @@ Page({
}
},
async persistAvatarIfNeeded(avatarUrl: string): Promise<string> {
if (!avatarUrl) return avatarUrl;
if (
avatarUrl.startsWith('cloud://') ||
avatarUrl.startsWith('http://') ||
avatarUrl.startsWith('https://')
) {
return avatarUrl;
}
async persistAvatarIfNeeded(avatarUrl: string): Promise<string | null> {
const user = await getUser();
const cloudPath = `user-avatars/${user._id}-${Date.now()}.png`;
const uploadRes = await wx.cloud.uploadFile({
cloudPath,
filePath: avatarUrl,
});
return uploadRes.fileID;
if (!avatarUrl || avatarUrl === this.DEFAULT_AVATAR_URL) return null;
if (avatarUrl === user.avatarUrl) return user.avatarAssetId || null;
return (await uploadAsset(avatarUrl, 'avatar')).id;
},
onGoToDownloads() {
+20 -4
View File
@@ -38,6 +38,19 @@
</view>
</view>
<view
wx:if="{{serverAccountActionRequired}}"
class="profile-account-restart">
<text>账户需要重新开始使用</text>
<button
size="mini"
loading="{{isStartingAccount}}"
disabled="{{isStartingAccount}}"
bindtap="onStartServerAccount">
开始使用账户
</button>
</view>
<!-- 双格入口 -->
<view class="profile-grid">
<view class="profile-grid__cell" bindtap="onGoToFavorites">
@@ -212,16 +225,19 @@
</view>
<view class="profile-editor__actions">
<view
<button
class="profile-editor__action profile-editor__action--ghost"
disabled="{{isSavingProfile}}"
bindtap="onCloseProfileEditor">
取消
</view>
<view
</button>
<button
class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}"
loading="{{isSavingProfile}}"
disabled="{{isSavingProfile}}"
bindtap="onSubmitProfile">
{{isSavingProfile ? '保存中...' : '保存'}}
</view>
</button>
</view>
</view>
</van-popup>
@@ -0,0 +1,682 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { PaperType } from '../paperSheet.config';
/** 页面内容区边距(逻辑像素) */
const LAYOUT = {
topGap: 14,
leftMargin: 36,
rightMargin: 36,
bottomMargin: 36,
} as const;
interface ContentRect {
top: number;
left: number;
width: number;
height: number;
right: number;
bottom: number;
}
/** 将 #RRGGBB 转为带透明度的 rgba 字符串 */
function withAlpha(hex: string, alpha: number): string {
const normalized = hex.replace('#', '');
const full =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export default class PaperDrawService extends BaseDrawService {
/** 主线条颜色 */
private color: string = '#333333';
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '作业纸',
subTitle: '多种作业纸一键打印',
...options,
});
}
/**
* 绘制指定类型 + 颜色的作业纸
*/
async draw(paperType: PaperType, color: string) {
this.color = color;
this.options.title = this.optionTitleForType(paperType);
this.prepareDraw();
if (PaperDrawService.HEADER_TYPES.has(paperType)) {
// 听写、作业登记表:需要标注是谁的、哪天的,保留精简页眉
this.drawCompactHeader();
} else {
// 纯书写纸(田字格 / 米字格 / 方格 / 四线三格 / 信纸 / 横线 / 竖线)
// 不画页眉,最大化书写区,仅留顶部打印留白
this.currentY = 20;
}
const rect = this.getContentRect();
switch (paperType) {
case 'tian':
this.drawGridCells(rect, { diagonal: false });
break;
case 'mi':
this.drawGridCells(rect, { diagonal: true });
break;
case 'square':
this.drawSquareGrid(rect);
break;
case 'four-line':
this.drawFourLine(rect);
break;
case 'letter':
this.drawLetterPaper(rect);
break;
case 'horizontal':
this.drawHorizontalLines(rect);
break;
case 'vertical':
this.drawVerticalLines(rect);
break;
case 'dictation-hanzi':
this.drawDictation(rect, 'hanzi');
break;
case 'dictation-pinyin':
this.drawDictation(rect, 'pinyin');
break;
case 'homework-log':
this.drawHomeworkLog(rect);
break;
}
this.drawCenteredFooter();
}
/** 需要保留精简页眉(标题 + 姓名/日期)的纸张类型 */
private static readonly HEADER_TYPES = new Set<PaperType>([
'dictation-hanzi',
'dictation-pinyin',
'homework-log',
]);
/**
* 自定义精简页眉:无 Logo、无分割线,标题字体较小、整体高度更矮,
* 保留 姓名 / 日期 两个填写项(不含得分)。
* 绘制完成后设置 currentY 供正文使用。
*/
private drawCompactHeader() {
const { ctx, canvasWidth } = this;
const marginX = 24;
// 标题(较小字号,居中)
const titleY = 12;
ctx.fillStyle = '#322e25';
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(
String(this.options.title || '作业纸'),
canvasWidth / 2,
titleY,
);
// 姓名 / 日期(与标题拉开间距)
const metaY = 48;
const metaFontPx = 11;
ctx.font = `${metaFontPx}px "Microsoft Yahei"`;
ctx.fillStyle = '#7c766a';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const innerLeft = marginX + 6;
const innerRight = canvasWidth - marginX - 6;
const innerW = innerRight - innerLeft;
const colW = innerW / 2;
const drawMetaField = (label: string, x: number, right: number) => {
ctx.fillText(label, x, metaY);
const labelW = ctx.measureText(label).width;
const lineY = metaY + metaFontPx + 3;
ctx.strokeStyle = '#E5DCC9';
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x + labelW + 2, lineY);
ctx.lineTo(Math.max(x + labelW + 2, right), lineY);
ctx.stroke();
};
drawMetaField('姓名:', innerLeft, innerLeft + colW - 24);
drawMetaField('日期:', innerLeft + colW, innerRight);
// 正文起始 Y(不画分割线)
this.currentY = metaY + metaFontPx + 12;
}
/** 底部品牌水印:居中展示 */
private drawCenteredFooter() {
const { ctx, canvasWidth, canvasHeight } = this;
ctx.save();
ctx.font = 'bold 13px "Microsoft Yahei"';
ctx.fillStyle = '#7c766a';
ctx.globalAlpha = 0.35;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('涂鸦丫小程序', canvasWidth / 2, canvasHeight - 16);
ctx.restore();
}
private optionTitleForType(paperType: PaperType): string {
const map: Record<PaperType, string> = {
tian: '田字格作业纸',
mi: '米字格作业纸',
square: '方格作业纸',
'four-line': '四线三格作业纸',
letter: '信纸',
horizontal: '横线本',
vertical: '竖格作业纸',
'dictation-hanzi': '汉字听写',
'dictation-pinyin': '拼音 / 英语听写',
'homework-log': '作业登记表',
};
return map[paperType];
}
private getContentRect(): ContentRect {
const top = this.currentY + LAYOUT.topGap;
const left = LAYOUT.leftMargin;
const width = this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
const height = this.canvasHeight - top - LAYOUT.bottomMargin;
return {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
};
}
/** 主色 / 辅助色 */
private get borderColor(): string {
return this.color;
}
private get midColor(): string {
return withAlpha(this.color, 0.55);
}
private get diagColor(): string {
return withAlpha(this.color, 0.4);
}
// ─────────────────────────── 田字格 / 米字格 ───────────────────────────
private drawGridCells(rect: ContentRect, opts: { diagonal: boolean }) {
const { ctx } = this;
const targetCell = 46;
const gap = 8;
const cols = Math.max(
1,
Math.floor((rect.width + gap) / (targetCell + gap)),
);
const rows = Math.max(
1,
Math.floor((rect.height + gap) / (targetCell + gap)),
);
const cell = Math.min(
(rect.width - (cols - 1) * gap) / cols,
(rect.height - (rows - 1) * gap) / rows,
);
// 居中排布
const gridW = cols * cell + (cols - 1) * gap;
const startX = rect.left + (rect.width - gridW) / 2;
const startY = rect.top;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = startX + c * (cell + gap);
const y = startY + r * (cell + gap);
this.drawSingleGridCell(ctx, x, y, cell, opts.diagonal);
}
}
}
private drawSingleGridCell(
ctx: RenderingContext,
x: number,
y: number,
size: number,
diagonal: boolean,
) {
// 外框实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(x, y, size, size);
const cx = x + size / 2;
const cy = y + size / 2;
// 十字虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(cx, y);
ctx.lineTo(cx, y + size);
ctx.moveTo(x, cy);
ctx.lineTo(x + size, cy);
ctx.stroke();
// 对角虚线(米字格)
if (diagonal) {
ctx.strokeStyle = this.diagColor;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y);
ctx.lineTo(x, y + size);
ctx.stroke();
}
ctx.setLineDash([]);
}
// ─────────────────────────── 方格 ───────────────────────────
private drawSquareGrid(rect: ContentRect) {
const { ctx } = this;
const targetCell = 27;
const cols = Math.max(1, Math.round(rect.width / targetCell));
const cell = rect.width / cols;
const rows = Math.max(1, Math.floor(rect.height / cell));
const gridW = cols * cell;
const gridH = rows * cell;
const startX = rect.left;
const startY = rect.top;
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
for (let c = 0; c <= cols; c++) {
const x = startX + c * cell;
ctx.moveTo(x, startY);
ctx.lineTo(x, startY + gridH);
}
for (let r = 0; r <= rows; r++) {
const y = startY + r * cell;
ctx.moveTo(startX, y);
ctx.lineTo(startX + gridW, y);
}
ctx.stroke();
}
// ─────────────────────────── 四线三格 ───────────────────────────
private drawFourLine(rect: ContentRect) {
const { ctx } = this;
const groupHeight = 34; // 一组四线三格的总高
const groupGap = 24; // 组间距
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.floor((rect.height + groupGap) / unit));
for (let i = 0; i < count; i++) {
const topY = rect.top + i * unit;
const l1 = topY;
const l2 = topY + groupHeight / 3;
const l3 = topY + (groupHeight * 2) / 3;
const l4 = topY + groupHeight;
// 上下两条实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, l1);
this.strokeHLine(rect.left, rect.right, l4);
// 中间两条虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
this.strokeHLine(rect.left, rect.right, l2);
this.strokeHLine(rect.left, rect.right, l3);
ctx.setLineDash([]);
}
}
// ─────────────────────────── 信纸 ───────────────────────────
private drawLetterPaper(rect: ContentRect) {
const { ctx } = this;
const lineGap = 34;
const innerTop = rect.top + 10;
const innerBottom = rect.bottom - 24;
const lineCount = Math.floor((innerBottom - innerTop) / lineGap);
// 顶部双线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.6;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, rect.top);
this.strokeHLine(rect.left, rect.right, rect.top + 4);
// 中间横线
ctx.lineWidth = 1;
ctx.strokeStyle = withAlpha(this.color, 0.75);
for (let i = 1; i <= lineCount; i++) {
const y = innerTop + i * lineGap;
if (y >= innerBottom) break;
this.strokeHLine(rect.left, rect.right, y);
}
// 底部双线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.6;
this.strokeHLine(rect.left, rect.right, innerBottom);
this.strokeHLine(rect.left, rect.right, innerBottom + 4);
// 右下角「第 页」
ctx.fillStyle = this.borderColor;
ctx.font = '13px "Microsoft Yahei"';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText('第 页', rect.right, innerBottom + 10);
}
// ─────────────────────────── 横线 ───────────────────────────
private drawHorizontalLines(rect: ContentRect) {
const { ctx } = this;
const lineGap = 34;
const count = Math.floor(rect.height / lineGap);
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
for (let i = 0; i <= count; i++) {
const y = rect.top + i * lineGap;
if (y > rect.bottom) break;
this.strokeHLine(rect.left, rect.right, y);
}
}
// ─────────────────────────── 竖线 ───────────────────────────
private drawVerticalLines(rect: ContentRect) {
const { ctx } = this;
const targetGap = 34;
const count = Math.max(1, Math.round(rect.width / targetGap));
const gap = rect.width / count;
// 上下边界实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, rect.top);
this.strokeHLine(rect.left, rect.right, rect.bottom);
// 竖线
ctx.beginPath();
for (let c = 0; c <= count; c++) {
const x = rect.left + c * gap;
ctx.moveTo(x, rect.top);
ctx.lineTo(x, rect.bottom);
}
ctx.stroke();
}
// ─────────────────────────── 听写本(汉字 / 拼音英语) ───────────────────────────
/** 汉字听写:田字格行布局参数 */
private static readonly DICT_HANZI = { cell: 40, rowGap: 20 };
/** 拼音 / 英语听写:四线三格行布局参数 */
private static readonly DICT_PINYIN = { groupHeight: 34, groupGap: 22 };
private drawDictation(rect: ContentRect, variant: 'hanzi' | 'pinyin') {
// 先按行布局算出左侧书写区实际占用高度,使右侧订正栏与其等高
const usedHeight = this.dictationUsedHeight(rect.height, variant);
const alignedRect: ContentRect = {
...rect,
height: usedHeight,
bottom: rect.top + usedHeight,
};
// 右侧订正栏(与左侧等高),返回左侧书写区右边界
const leftAreaRight = this.drawCorrectionColumn(alignedRect);
const leftRect: ContentRect = {
...alignedRect,
width: leftAreaRight - rect.left,
right: leftAreaRight,
};
if (variant === 'hanzi') {
this.drawDictationHanzi(leftRect);
} else {
this.drawDictationPinyin(leftRect);
}
}
/** 计算听写本左侧行区实际占用高度(末行不含尾部间距) */
private dictationUsedHeight(
available: number,
variant: 'hanzi' | 'pinyin',
): number {
if (variant === 'hanzi') {
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
const unit = cell + rowGap;
const rows = Math.max(1, Math.floor((available + rowGap) / unit));
return rows * unit - rowGap;
}
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.floor((available + groupGap) / unit));
return count * unit - groupGap;
}
/** 绘制右侧订正栏,返回左侧书写区的右边界 x */
private drawCorrectionColumn(rect: ContentRect): number {
const { ctx } = this;
const correctW = 120;
const gapToCorrect = 16;
const correctX = rect.right - correctW;
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
ctx.setLineDash([]);
ctx.strokeRect(correctX, rect.top, correctW, rect.height);
ctx.fillStyle = this.borderColor;
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('订正栏', correctX + correctW / 2, rect.top + 14);
this.strokeHLine(correctX, correctX + correctW, rect.top + 40);
return correctX - gapToCorrect;
}
/** 汉字听写:整齐的田字格行(无上方提示线) */
private drawDictationHanzi(rect: ContentRect) {
const { ctx } = this;
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
const unit = cell + rowGap;
const rowCount = Math.max(1, Math.round((rect.height + rowGap) / unit));
const cols = Math.max(1, Math.floor(rect.width / cell));
for (let r = 0; r < rowCount; r++) {
const rowTop = rect.top + r * unit;
for (let c = 0; c < cols; c++) {
const x = rect.left + c * cell;
this.drawSingleGridCell(ctx, x, rowTop, cell, false);
}
}
}
/** 拼音 / 英语听写:四线三格行 */
private drawDictationPinyin(rect: ContentRect) {
const { ctx } = this;
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.round((rect.height + groupGap) / unit));
for (let i = 0; i < count; i++) {
const topY = rect.top + i * unit;
const l1 = topY;
const l2 = topY + groupHeight / 3;
const l3 = topY + (groupHeight * 2) / 3;
const l4 = topY + groupHeight;
// 上下实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, l1);
this.strokeHLine(rect.left, rect.right, l4);
// 中间两条虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
this.strokeHLine(rect.left, rect.right, l2);
this.strokeHLine(rect.left, rect.right, l3);
ctx.setLineDash([]);
}
}
// ─────────────────────────── 作业登记表 ───────────────────────────
private drawHomeworkLog(rect: ContentRect) {
const { ctx } = this;
const subjects = ['语文', '数学', '英语', '其他'];
const rowsPerSubject = 5;
const totalRows = subjects.length * rowsPerSubject;
const subjectColW = 60; // 左侧科目列
const numColW = 44; // 序号列
const checkColW = 56; // 右侧完成勾选列
const contentColX = rect.left + subjectColW + numColW;
const checkColX = rect.right - checkColW;
const rowHeight = rect.height / totalRows;
const tableTop = rect.top;
const tableBottom = rect.top + rowHeight * totalRows;
ctx.setLineDash([]);
// 外框
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
ctx.strokeRect(rect.left, tableTop, rect.width, tableBottom - tableTop);
// 竖分割线
ctx.lineWidth = 1;
this.strokeVLine(rect.left + subjectColW, tableTop, tableBottom);
this.strokeVLine(contentColX, tableTop, tableBottom);
this.strokeVLine(checkColX, tableTop, tableBottom);
ctx.font = '13px "Microsoft Yahei"';
for (let s = 0; s < subjects.length; s++) {
const blockTop = tableTop + s * rowsPerSubject * rowHeight;
const blockBottom = blockTop + rowsPerSubject * rowHeight;
// 科目分组分隔(实线)
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
this.strokeHLine(rect.left, rect.right, blockBottom);
// 科目名称(竖排居中)
ctx.fillStyle = this.borderColor;
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const subjectCx = rect.left + subjectColW / 2;
const subjectCy = (blockTop + blockBottom) / 2;
this.drawVerticalText(subjects[s], subjectCx, subjectCy, 18);
for (let r = 0; r < rowsPerSubject; r++) {
const rowTop = blockTop + r * rowHeight;
const rowCy = rowTop + rowHeight / 2;
// 序号
ctx.fillStyle = this.borderColor;
ctx.font = '13px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
String(r + 1),
rect.left + subjectColW + numColW / 2,
rowCy,
);
// 内容区书写虚线
ctx.strokeStyle = this.midColor;
ctx.lineWidth = 0.8;
ctx.setLineDash([3, 3]);
this.strokeHLine(
contentColX + 8,
checkColX - 8,
rowCy + rowHeight / 2 - 4,
);
ctx.setLineDash([]);
// 勾选方框
const boxSize = Math.min(18, rowHeight - 10);
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.strokeRect(
checkColX + (checkColW - boxSize) / 2,
rowCy - boxSize / 2,
boxSize,
boxSize,
);
}
}
}
// ─────────────────────────── 工具方法 ───────────────────────────
private strokeHLine(x1: number, x2: number, y: number) {
const { ctx } = this;
ctx.beginPath();
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.stroke();
}
private strokeVLine(x: number, y1: number, y2: number) {
const { ctx } = this;
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x, y2);
ctx.stroke();
}
/** 竖排文字(逐字向下) */
private drawVerticalText(
text: string,
cx: number,
cy: number,
lineHeight: number,
) {
const { ctx } = this;
const chars = text.split('');
const totalH = chars.length * lineHeight;
let y = cy - totalH / 2 + lineHeight / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const ch of chars) {
ctx.fillText(ch, cx, y);
y += lineHeight;
}
}
}
@@ -0,0 +1,96 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import {
PAPER_SHEET_WORKSHEET_DEFINITIONS,
PAPER_SHEET_WORKSHEET_ID,
PAPER_TYPES,
type PaperType,
} from '../../config/worksheets/papers';
export { PAPER_SHEET_WORKSHEET_ID, type PaperType };
export const PAPER_TYPE_OPTIONS = PAPER_TYPES;
const WORKSHEET_BY_ID = new Map(
PAPER_SHEET_WORKSHEET_DEFINITIONS.map((entry) => [entry.id, entry]),
);
/** 只接收已注册条目;旧综合入口继续支持 type,并兼容分享中的 mode。 */
export function resolvePaperEntry(options: {
id?: string;
type?: string;
mode?: string;
}) {
const id = options.id || PAPER_SHEET_WORKSHEET_ID;
const entry = WORKSHEET_BY_ID.get(id);
if (!entry) throw new Error('当前作业纸暂不可用,请更新小程序');
const legacyType = options.type || options.mode;
const paperType =
entry.renderConfig.paperType ||
(legacyType && isValidPaperType(legacyType)
? legacyType
: DEFAULT_PAPER_TYPE);
return { entry, paperType };
}
export function getWorksheetIdForPaperType(paperType: PaperType): string {
return PAPER_SHEET_WORKSHEET_DEFINITIONS.find(
(entry) => entry.renderConfig.paperType === paperType,
)!.id;
}
export interface PaperColorOption {
id: string;
name: string;
/** 主线条颜色 */
value: string;
}
/** 颜色选项:浅红、浅绿、黑色 */
export const PAPER_COLOR_OPTIONS: PaperColorOption[] = [
{ id: 'red', name: '浅红', value: '#E48A8A' },
{ id: 'green', name: '浅绿', value: '#7FB069' },
{ id: 'black', name: '黑色', value: '#333333' },
];
export const DEFAULT_PAPER_TYPE: PaperType = 'tian';
export const DEFAULT_PAPER_COLOR = PAPER_COLOR_OPTIONS[0].value;
export function getPaperTypeName(id: string): string {
return PAPER_TYPE_OPTIONS.find((p) => p.id === id)?.name || '作业纸';
}
export function isValidPaperType(id: string): id is PaperType {
return PAPER_TYPE_OPTIONS.some((p) => p.id === id);
}
export function getModeInfo(id: string) {
const m = WORKSHEET_BY_ID.get(id);
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return WORKSHEET_BY_ID.has(id);
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORKSHEET_BY_ID.get(id);
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'papers',
subcategory: 'paper-sheet',
path: `/papersPages/paperSheet/paperSheet?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: true,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,16 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "作业纸",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"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"
}
}
@@ -0,0 +1,133 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.ps-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.ps-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.ps-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.ps-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.ps-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
// ─────────── 纸张类型 ───────────
.ps-type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.ps-type-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition:
transform 0.12s,
box-shadow 0.12s,
border-color 0.12s,
background 0.12s;
}
.ps-type-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.ps-type-card--pressed {
transform: scale(0.95);
}
.ps-type-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
}
.ps-type-card__desc {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
}
// ─────────── 颜色 ───────────
.ps-color-list {
display: flex;
gap: 24rpx;
}
.ps-color-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
padding: 24rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition:
transform 0.12s,
border-color 0.12s,
background 0.12s;
}
.ps-color-item--active {
background: #ffffff;
border-color: @brand;
}
.ps-color-item--pressed {
transform: scale(0.95);
}
.ps-color-dot {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
box-shadow: inset 0 0 0 2rpx rgba(0, 0, 0, 0.08);
}
.ps-color-item__name {
font-size: 26rpx;
font-weight: 700;
color: @text-title;
}
@@ -0,0 +1,285 @@
import PaperDrawService from './draw/paperDrawService';
import {
PAPER_SHEET_WORKSHEET_ID,
PAPER_TYPE_OPTIONS,
PAPER_COLOR_OPTIONS,
DEFAULT_PAPER_TYPE,
DEFAULT_PAPER_COLOR,
getModeInfo,
getPublishMetaByMode,
isValidPaperType,
resolvePaperEntry,
getWorksheetIdForPaperType,
type PaperType,
} from './paperSheet.config';
import {
createPage,
getPageCommonMethods,
type CanvasDataState,
} from '../../base/pageMixin';
import { getWorksheet } from '../../services/content-api';
import { defaultShareConfig } from '../../config/config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
const pageInfoLookup = getModeInfo;
const commonMethods = getPageCommonMethods({
pageInfoLookup,
shareConfig: defaultShareConfig,
});
type PageData = CanvasDataState & {
worksheetId: string;
paperTypeOptions: typeof PAPER_TYPE_OPTIONS;
colorOptions: typeof PAPER_COLOR_OPTIONS;
selectedPaperType: PaperType;
selectedColor: string;
isPreviewFavorite: boolean;
isWorksheetReady: boolean;
favoriteLoading: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as PaperDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
_entryRequest: 0,
_favoriteVersions: {} as Record<string, number>,
data: {
pageTitle: '作业纸',
functionId: PAPER_SHEET_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: PAPER_SHEET_WORKSHEET_ID,
paperTypeOptions: PAPER_TYPE_OPTIONS,
colorOptions: PAPER_COLOR_OPTIONS,
selectedPaperType: DEFAULT_PAPER_TYPE,
selectedColor: DEFAULT_PAPER_COLOR,
isPreviewFavorite: false,
isWorksheetReady: false,
favoriteLoading: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
async onLoad(options: { id?: string; type?: string; mode?: string }) {
this.syncDebugPublishEnv();
await this.openEntry(options);
this.loadFavoritedMap();
},
onUnload() {
this._entryRequest++;
},
/** 开发版直接预览已注册配置以制作封面;体验/正式版切换前校验公开状态。 */
async openEntry(options: {
id?: string;
type?: string;
mode?: string;
}) {
const request = ++this._entryRequest;
this.setData({
isWorksheetReady: false,
hasContent: false,
// 换纸张即丢弃旧发布表单,防止旧条目元数据配上新封面。
debugPublishVisible: false,
debugPublishMeta: null,
});
try {
const { entry, paperType } = resolvePaperEntry(options);
// 草稿在公开详情中固定为 404;开发版使用本地注册配置,不通过失败请求判断预览权限。
if (!this.data.isDevEnv) {
const remote = await getWorksheet(entry.id);
if (
entry.id !== PAPER_SHEET_WORKSHEET_ID &&
(remote.rendering?.rendererKey !== entry.rendererKey ||
remote.rendering?.paperType !== paperType)
) {
throw new Error('作业纸配置暂不可用,请更新小程序');
}
}
if (request !== this._entryRequest) return;
this.setData({
worksheetId: entry.id,
selectedPaperType: paperType,
// 综合入口分享保留所选纸张;独立条目由 ID 唯一确定纸张类型。
currentMode:
entry.id === PAPER_SHEET_WORKSHEET_ID
? paperType
: entry.id,
isPreviewFavorite: this._favoritedMap[entry.id] === true,
isWorksheetReady: true,
});
this.initPageInfo(entry.id, entry.title);
await this.drawCanvas();
} catch (error) {
if (request !== this._entryRequest) return;
wx.showToast({
title:
error instanceof Error
? error.message
: '作业纸加载失败',
icon: 'none',
});
}
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new PaperDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
async drawCanvas() {
if (!this.drawService || !this.data.isWorksheetReady) return;
const request = this._entryRequest;
try {
await (this.drawService as PaperDrawService).draw(
this.data.selectedPaperType,
this.data.selectedColor,
);
if (
request === this._entryRequest &&
this.data.isWorksheetReady
)
this.setData({ hasContent: true });
} catch (err) {
console.error('paperSheet draw failed', err);
if (request === this._entryRequest)
this.setData({ hasContent: false });
}
},
onSelectPaperType(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string | undefined;
if (!id || !isValidPaperType(id)) return;
const worksheetId = getWorksheetIdForPaperType(id);
if (
worksheetId === this.data.worksheetId &&
this.data.isWorksheetReady
)
return;
return this.openEntry({ id: worksheetId });
},
onSelectColor(e: WechatMiniprogram.TouchEvent) {
const value = e.currentTarget.dataset.value as string | undefined;
if (!value || value === this.data.selectedColor) return;
this.setData({ selectedColor: value }, () => this.drawCanvas());
},
onPreviewRefresh() {
this.drawCanvas();
},
onShare() {},
async exportToPrint() {
if (!this.data.isWorksheetReady || !this.data.hasContent) return;
return commonMethods.exportToPrint.call(this);
},
async onShareSuccess() {
if (!this.data.isWorksheetReady || !this.data.hasContent) {
this.setData({ showShareDialog: false });
return;
}
return commonMethods.onShareSuccess.call(this);
},
/** 请求失败不显示收藏成功,且响应只更新操作时的条目。 */
async onPreviewFavorite() {
if (!this.data.isWorksheetReady || this.data.favoriteLoading)
return;
const id = this.data.worksheetId;
const next = !this.data.isPreviewFavorite;
this._favoriteVersions[id] = (this._favoriteVersions[id] || 0) + 1;
this.setData({ favoriteLoading: true });
try {
const ok = await (next ? addFavorite(id) : removeFavorite(id));
if (ok) {
this._favoritedMap[id] = next;
if (this.data.worksheetId === id)
this.setData({ isPreviewFavorite: next });
}
wx.showToast({
title: ok
? next
? '收藏成功'
: '已取消收藏'
: '收藏操作失败,请重试',
icon: 'none',
});
} finally {
this.setData({ favoriteLoading: false });
}
},
async loadFavoritedMap() {
const ids = [
PAPER_SHEET_WORKSHEET_ID,
...PAPER_TYPE_OPTIONS.map((paper) =>
getWorksheetIdForPaperType(paper.id),
),
];
const versions = { ...this._favoriteVersions };
try {
const map = await batchCheckFavorited(ids);
// 慢速初次查询不能覆盖查询期间完成的收藏操作。
for (const id of ids) {
if (versions[id] === this._favoriteVersions[id])
this._favoritedMap[id] = map[id] === true;
}
this.setData({
isPreviewFavorite:
this._favoritedMap[this.data.worksheetId] === true,
});
} catch {
// 收藏加载失败不阻止预览;后续操作仍以服务端写入结果为准。
}
},
getWorksheetStatsId(): string {
return this.data.worksheetId;
},
getPublishMeta(): DebugPublishMeta {
if (!this.data.isWorksheetReady) throw new Error('请先加载作业纸');
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,77 @@
<nav-bar title="{{pageTitle}}" />
<view class="ps-page">
<view class="ps-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{isWorksheetReady && !favoriteLoading}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="ps-section">
<view class="ps-section-header">
<text class="ps-section-title">纸张类型</text>
</view>
<view class="ps-type-grid">
<view
wx:for="{{paperTypeOptions}}"
wx:key="id"
class="ps-type-card {{selectedPaperType === item.id ? 'ps-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="ps-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectPaperType">
<text class="ps-type-card__name">{{item.name}}</text>
<text class="ps-type-card__desc">{{item.desc}}</text>
</view>
</view>
</view>
<view class="ps-section">
<view class="ps-section-header">
<text class="ps-section-title">颜色</text>
</view>
<view class="ps-color-list">
<view
wx:for="{{colorOptions}}"
wx:key="id"
class="ps-color-item {{selectedColor === item.value ? 'ps-color-item--active' : ''}}"
data-value="{{item.value}}"
hover-class="ps-color-item--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectColor">
<view
class="ps-color-dot"
style="background:{{item.value}}" />
<text class="ps-color-item__name">{{item.name}}</text>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
defaultCropMode="none"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -1,5 +1,8 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from '../../config/worksheets/pinyin';
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS };
export type PinyinDictationMode =
| 'pinyin-tracing'
@@ -8,77 +11,6 @@ export type PinyinDictationMode =
| 'pinyin-dictation-v2'
| 'pinyin-daily';
interface PinyinDictationWorksheetDefinition {
id: string; // 使用 string 以兼容动态生成的 ID
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS: PinyinDictationWorksheetDefinition[] =
[
{
id: 'pinyin-tracing',
icon: 'draw-o',
title: '拼音描红练习',
subtitle: '跟着描红学拼音字母',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 50,
},
{
id: 'pinyin-dictation',
icon: 'start-a',
title: '拼音默写练习',
subtitle: '空白格子默写拼音字母',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 51,
},
{
id: 'pinyin-tracing-v2',
icon: 'pen-draw',
title: '拼音描红 8 列',
subtitle: '跟写描红,声韵分块',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 52,
},
{
id: 'pinyin-dictation-v2',
icon: 'ABC-underline',
title: '拼音默写 8 列',
subtitle: '空白默写,声韵分块',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 53,
},
{
id: 'pinyin-daily',
icon: 'task-o',
title: '拼音每日打卡',
subtitle: '四宫格每日打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['拼音', '每日练习', '打卡'],
sortOrder: 54,
},
];
type PinyinDictationWorksheetRow =
(typeof PINYIN_DICTATION_WORKSHEET_DEFINITIONS)[number];
-70
View File
@@ -1,70 +0,0 @@
/**
* 云服务平台适配器
* 封装微信云开发 API
*/
export interface CloudResult {
code?: string | number;
message?: string;
info?: string;
data?: any;
[key: string]: any;
}
/**
* 调用云函数
*/
export async function callCloud(
action: string,
options?: Record<string, any>,
): Promise<CloudResult> {
try {
const name = 'doodle';
const { errMsg, result } = await wx.cloud.callFunction({
name,
data: {
action,
...options,
},
});
if (!/ok/.test(errMsg)) {
wx.showToast({
title: '服务器错误',
icon: 'none',
duration: 2000,
});
return {
code: '500',
info: '服务器繁忙',
message: errMsg,
};
}
return result as CloudResult;
} catch (e: any) {
return { code: '500', info: e.message || '服务器繁忙' };
}
}
/**
* 查询云数据库集合
*/
export async function queryCollection(
collection: string,
where?: Record<string, any>,
limit: number = 20,
orderBy?: { field: string; order: 'asc' | 'desc' },
): Promise<any[]> {
try {
const db = wx.cloud.database();
let query = db.collection(collection).where(where || {}).limit(limit);
if (orderBy) {
query = query.orderBy(orderBy.field, orderBy.order);
}
const { data } = await query.get();
return data;
} catch (e) {
console.error('queryCollection failed:', collection, e);
return [];
}
}
+170
View File
@@ -0,0 +1,170 @@
import { apiRequest } from './api-request';
import type { ApiWorksheet } from './content-api';
export type AdminAction =
| 'categories.list'
| 'categories.create'
| 'categories.update'
| 'worksheets.list'
| 'worksheets.status'
| 'worksheets.batch'
| 'site.config'
| 'site.rebuild'
| 'site.timer';
interface AdminWorksheet extends ApiWorksheet {
status: 'draft' | 'active' | 'hidden';
likesDisplay: string;
downloadsDisplay: string;
likesSeed: string;
downloadsSeed: string;
}
interface AdminPage {
items: AdminWorksheet[];
total: number;
hasNext: boolean;
page: number;
pageSize: number;
}
/** 管理页迁移适配:只调用具名 REST 接口,旧页面展示字段在客户端映射,不传旧用户身份。 */
export async function managementRequest<T>(
action: AdminAction,
payload: Record<string, unknown> = {},
): Promise<{
success: boolean;
data?: T;
stats?: { total: number; active: number; draft: number; hidden: number };
message?: string;
}> {
let data: unknown;
let stats;
switch (action) {
case 'categories.list': {
const rows =
await apiRequest<Array<{ id: string }>>('/admin/categories');
data = rows.map((row) => ({ ...row, _id: row.id }));
break;
}
case 'categories.create':
data = await apiRequest('/admin/categories', {
method: 'POST',
data: payload,
});
break;
case 'categories.update': {
const { id, ...fields } = payload;
data = await apiRequest(
`/admin/categories/${encodeURIComponent(String(id))}`,
{ method: 'PUT', data: fields },
);
break;
}
case 'worksheets.list': {
const rows: AdminWorksheet[] = [];
let expected: number | undefined;
for (let page = 1; ; page++) {
const response = await apiRequest<AdminPage>(
'/admin/catalog/worksheets',
{
data: {
page,
pageSize: 100,
...(payload.category
? { categoryId: payload.category }
: {}),
...(payload.status
? { status: payload.status }
: {}),
},
},
);
if (
response.page !== page ||
response.pageSize !== 100 ||
!Array.isArray(response.items) ||
!Number.isSafeInteger(response.total) ||
(expected !== undefined && response.total !== expected) ||
response.hasNext !== page * 100 < response.total
)
throw new Error('管理目录发生变化,请重试');
expected = response.total;
rows.push(...response.items);
if (!response.hasNext) break;
}
if (
rows.length !== expected ||
new Set(rows.map((row) => row.id)).size !== rows.length
)
throw new Error('管理目录发生变化,请重试');
data = rows.map((row) => ({
...row,
_id: row.id,
likes: payload.rawStats ? row.likes : row.likesDisplay,
downloads: payload.rawStats
? row.downloads
: row.downloadsDisplay,
}));
stats = {
total: rows.length,
active: rows.filter((row) => row.status === 'active').length,
draft: rows.filter((row) => row.status === 'draft').length,
hidden: rows.filter((row) => row.status === 'hidden').length,
};
break;
}
case 'worksheets.status':
data = await apiRequest(
`/admin/worksheets/${encodeURIComponent(String(payload.id))}/status`,
{ method: 'PUT', data: { status: payload.status } },
);
break;
case 'worksheets.batch': {
const patches = (
payload.patches as Array<Record<string, unknown>>
).map(({ likes_seed, downloads_seed, ...row }) => ({
...row,
...(likes_seed === undefined ? {} : { likesSeed: likes_seed }),
...(downloads_seed === undefined
? {}
: { downloadsSeed: downloads_seed }),
}));
data = await apiRequest('/admin/worksheets/batch', {
method: 'POST',
timeout: 60000,
data: { patches, dryRun: payload.dryRun === true },
});
break;
}
case 'site.config':
data = await apiRequest('/admin/site/config');
break;
case 'site.rebuild':
data = await apiRequest('/admin/site/rebuild', {
method: 'POST',
timeout: 60000,
data: payload,
});
break;
case 'site.timer':
data = await apiRequest('/admin/site/timer', {
method: 'PUT',
data: { enabled: payload.enabled },
});
break;
default:
throw new Error('不支持的管理操作');
}
if (
[
'categories.create',
'categories.update',
'worksheets.status',
'worksheets.batch',
'site.rebuild',
].includes(action)
) {
const app = getApp<IAppOption>();
if (typeof app.refreshPageConfig === 'function')
await app.refreshPageConfig();
}
return { success: true, data: data as T, ...(stats ? { stats } : {}) };
}
+13
View File
@@ -0,0 +1,13 @@
let pendingWrites = 0;
/** 覆盖登录等待、上传及重试全过程,防止写操作进行中切换服务端。 */
export function beginApiWrite() {
pendingWrites++;
return () => {
pendingWrites--;
};
}
export function hasPendingApiWrites() {
return pendingWrites > 0;
}
+118
View File
@@ -0,0 +1,118 @@
import { getApiConfig } from '../config/api';
import { beginApiWrite } from './api-activity';
import {
ApiRequestError,
requestApi,
type RequestOptions,
} from './api-transport';
import {
cachedServerUser,
clearSession,
ensureSession,
getAuthContext,
recoverSession,
rejectSession,
} from './auth-session';
export { ApiRequestError } from './api-transport';
const recoverable = new Set([
'AUTH_REQUIRED',
'AUTH_INVALID',
'AUTH_EXPIRED',
'AUTH_REVOKED',
]);
const blocked = new Set([
'ACCOUNT_NOT_FOUND',
'ACCOUNT_DISABLED',
'WECHAT_LOGIN_BLOCKED',
]);
/** 公开接口显式 auth=none;其余接口合并登录,最多恢复并重发一次,不跨账户补发。 */
export async function apiRequest<T>(
path: string,
options: RequestOptions & { auth?: 'required' | 'none' } = {},
): Promise<T> {
const finish =
options.upload || (options.method && options.method !== 'GET')
? beginApiWrite()
: undefined;
try {
return await performApiRequest<T>(path, options);
} finally {
finish?.();
}
}
async function performApiRequest<T>(
path: string,
options: RequestOptions & { auth?: 'required' | 'none' } = {},
): Promise<T> {
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
const header = Object.fromEntries(
Object.entries(options.header || {}).filter(
([key]) => key.toLowerCase() !== 'authorization',
),
);
if (options.auth === 'none')
return requestApi(path, { ...options, header }, getApiConfig());
const context = getAuthContext();
const previousUserId = cachedServerUser()?.id;
const initial = await ensureSession(context);
if (previousUserId && initial.user.id !== previousUserId) {
clearSession(false, context);
throw new ApiRequestError(
'账户已变化,请重新操作',
401,
'ACCOUNT_CHANGED',
);
}
const send = async (accessToken: string) => {
if (getAuthContext().key !== context.key)
throw new ApiRequestError('接口环境已变化,请重新操作');
const result = await requestApi<T>(
path,
{
...options,
header: { ...header, Authorization: `Bearer ${accessToken}` },
},
context,
);
if (
getAuthContext().key !== context.key ||
cachedServerUser()?.id !== initial.user.id
)
throw new ApiRequestError('账户状态已变化,请重新操作');
return result;
};
try {
return await send(initial.accessToken);
} catch (error) {
if (!(error instanceof ApiRequestError) || error.statusCode !== 401)
throw error;
if (!recoverable.has(error.reason)) {
if (blocked.has(error.reason))
rejectSession(context, initial.accessToken, true);
throw error;
}
const renewed = await recoverSession(context, initial);
if (renewed.user.id !== initial.user.id) {
clearSession(false, context);
throw new ApiRequestError(
'账户已变化,请重新操作',
401,
'ACCOUNT_CHANGED',
);
}
try {
return await send(renewed.accessToken);
} catch (second) {
if (second instanceof ApiRequestError && second.statusCode === 401)
rejectSession(
context,
renewed.accessToken,
blocked.has(second.reason),
);
throw second;
}
}
}

Some files were not shown because too many files have changed in this diff Show More