diff --git a/cloudfunctions/doodle/README.md b/cloudfunctions/doodle/README.md deleted file mode 100644 index bef4ab7..0000000 --- a/cloudfunctions/doodle/README.md +++ /dev/null @@ -1,4 +0,0 @@ -## code 说明 - -code = 0 请求成功 -code = 1 请求失败 diff --git a/cloudfunctions/doodle/common/responseMiddleware.js b/cloudfunctions/doodle/common/responseMiddleware.js deleted file mode 100644 index 75a7ec2..0000000 --- a/cloudfunctions/doodle/common/responseMiddleware.js +++ /dev/null @@ -1,29 +0,0 @@ -function responseMiddleware(fn) { - return async (event, context) => { - try { - const result = await fn(event, context); - console.log('responseMiddleware result:', result); - if (result && result.code === undefined) { - return sendResponse(result); - } else { - return sendResponse( - result.data, - result.code || 0, - result.message || '', - ); - } - } catch (err) { - return sendResponse({}, 1, err.message || '请求失败'); - } - }; -} - -function sendResponse(data = null, code = 0, message = '请求成功') { - return { - code, - message, - data, - }; -} - -module.exports = responseMiddleware; diff --git a/cloudfunctions/doodle/common/sendResponse.js b/cloudfunctions/doodle/common/sendResponse.js deleted file mode 100644 index fc13ff3..0000000 --- a/cloudfunctions/doodle/common/sendResponse.js +++ /dev/null @@ -1,9 +0,0 @@ -function sendResponse(data = {}, code = 0, message = '请求成功') { - return { - code, - message, - data, - }; -} - -module.exports = sendResponse; diff --git a/cloudfunctions/doodle/config.json b/cloudfunctions/doodle/config.json deleted file mode 100644 index 41a485c..0000000 --- a/cloudfunctions/doodle/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "openapi": [ - "wxacode.get" - ] - } -} \ No newline at end of file diff --git a/cloudfunctions/doodle/excels/math_exam.pdf b/cloudfunctions/doodle/excels/math_exam.pdf deleted file mode 100644 index e69de29..0000000 diff --git a/cloudfunctions/doodle/fonts/SimHei.ttf b/cloudfunctions/doodle/fonts/SimHei.ttf deleted file mode 100644 index 5bd4687..0000000 Binary files a/cloudfunctions/doodle/fonts/SimHei.ttf and /dev/null differ diff --git a/cloudfunctions/doodle/fonts/msyh.ttc b/cloudfunctions/doodle/fonts/msyh.ttc deleted file mode 100644 index ddc87b9..0000000 Binary files a/cloudfunctions/doodle/fonts/msyh.ttc and /dev/null differ diff --git a/cloudfunctions/doodle/fonts/msyhbd.ttc b/cloudfunctions/doodle/fonts/msyhbd.ttc deleted file mode 100644 index 351b10f..0000000 Binary files a/cloudfunctions/doodle/fonts/msyhbd.ttc and /dev/null differ diff --git a/cloudfunctions/doodle/fonts/msyhl.ttc b/cloudfunctions/doodle/fonts/msyhl.ttc deleted file mode 100644 index c185092..0000000 Binary files a/cloudfunctions/doodle/fonts/msyhl.ttc and /dev/null differ diff --git a/cloudfunctions/doodle/index.js b/cloudfunctions/doodle/index.js deleted file mode 100644 index 03e6ee0..0000000 --- a/cloudfunctions/doodle/index.js +++ /dev/null @@ -1,30 +0,0 @@ -const cloud = require('wx-server-sdk'); -const getOpenId = require('./src/user/getOpenId'); -const { createPDF } = require('./src/test/createPDF'); - -cloud.init({ - env: cloud.DYNAMIC_CURRENT_ENV, -}); - -exports.main = async (event, context) => { - try { - switch (event.action) { - case 'getOpenId': { - const res = await getOpenId(event, context); - return res; - } - case 'createPDF': { - return await createPDF(); - } - default: - return { - code: 1, - message: '请检查云函数名是否正确!', - data: {}, - }; - } - } catch (error) { - console.error('Error in cloud function:', error); - return { code: 1, message: '内部服务器错误', data: {} }; - } -}; diff --git a/cloudfunctions/doodle/output/math.pdf b/cloudfunctions/doodle/output/math.pdf deleted file mode 100644 index 7f1d79c..0000000 Binary files a/cloudfunctions/doodle/output/math.pdf and /dev/null differ diff --git a/cloudfunctions/doodle/package.json b/cloudfunctions/doodle/package.json deleted file mode 100644 index e37b3e2..0000000 --- a/cloudfunctions/doodle/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "user", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "", - "license": "ISC", - "dependencies": { - "pdfkit": "^0.16.0", - "wx-server-sdk": "~2.4.0" - } -} diff --git a/cloudfunctions/doodle/src/test/createPDF.js b/cloudfunctions/doodle/src/test/createPDF.js deleted file mode 100644 index 73ceb5e..0000000 --- a/cloudfunctions/doodle/src/test/createPDF.js +++ /dev/null @@ -1,95 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const cloud = require('wx-server-sdk'); -const PDFDocument = require('pdfkit'); -const responseMiddleware = require('../../common/responseMiddleware'); - -const createPDF = responseMiddleware(async () => { - const doc = new PDFDocument(); - // 创建一个临时文件来存储PDF数据 - const filePath = 'output/math.pdf'; - // 将 PDF 文档写入到文件流中 - const fileStream = fs.createWriteStream(filePath); - - // 将PDF文档内容写入文件流 - doc.pipe(fileStream); - // 注册中文字体 - const fontPath = path.join(__dirname, '../../fonts', 'SimHei.ttf'); // path.join(__dirname, 'fonts', 'SimHei.ttf'); - - console.log('fontPath---:', fontPath); - // doc.registerFont(fontPath, { family: 'SimHei' }); - doc.registerFont('SimHei', fontPath); - console.log(100); - // 使用中文字体 - doc.font('SimHei').fontSize(24).text('你好,世界!'); - console.log(111); - // 添加标题 - doc.fontSize(24).text('数学题试卷', { align: 'center' }); - - // // 添加计算题 - doc.fontSize(18).text('1. 计算:2 + 2 = ?', { align: 'left' }); - // doc.font('SimHei') - // .fontSize(18) - // .text('2. 计算:5 * 3 = ?', { align: 'left', y: doc.y + 20 }); - - // 绘制正方形 - doc.rect(200, 100, 50, 50).stroke(); - doc.text('正方形', 100, 160); - - doc.addPage({ - margins: { - top: 50, - bottom: 50, - left: 72, - right: 72, - }, - }); - - doc.font('SimHei').fontSize(24).text('新世界'); - - // // 绘制长方形 - // doc.rect(200, 100, 100, 50).stroke(); - // doc.font('SimHei').text('长方形', 200, 160); - - // 结束PDF文档写入 - doc.end(); - // doc.pipe(writeStream); - - // // 添加一些内容到 PDF 文档 - // doc.fontSize(25).text('Hello, World!', 100, 100); - - // // 结束 PDF 文档 - // doc.end(); - - // // 监听文件流事件 - // writeStream.on('finish', () => { - // console.log('PDF 文件已成功创建!'); - // }); - - // writeStream.on('error', (err) => { - // console.error('创建 PDF 文件时出错:', err); - // }); - // // 等待文件写入完成 - // await new Promise((resolve, reject) => { - // fileStream.on('finish', resolve); - // fileStream.on('error', reject); - // }); - // const uploadResult = await cloud.uploadFile({ - // cloudPath: 'math.pdf', // 云存储中的文件路径 - // filePath: filePath, // 本地临时文件路径 - // }); - // console.log('uploadResult---:', JSON.stringify(uploadResult)); - - // // 删除本地临时文件 - // // fs.unlinkSync(filePath); - // fs.rmSync(filePath); - - // // 返回文件ID - // return { - // fileID: uploadResult.fileID, - // }; -}); -// 使用中间件包装异步函数 -module.exports = { - createPDF, -}; diff --git a/cloudfunctions/doodle/src/user/getOpenId.js b/cloudfunctions/doodle/src/user/getOpenId.js deleted file mode 100644 index 33fa11b..0000000 --- a/cloudfunctions/doodle/src/user/getOpenId.js +++ /dev/null @@ -1,13 +0,0 @@ -const cloud = require('wx-server-sdk'); -const responseMiddleware = require('../../common/responseMiddleware'); - -// 使用中间件包装异步函数 -module.exports = responseMiddleware(async (event, context) => { - // 获取基础信息 - const wxContext = cloud.getWXContext(); - return { - openid: wxContext.OPENID, - appid: wxContext.APPID, - unionid: wxContext.UNIONID, - }; -}); diff --git a/docs/PC-Web端技术预案.md b/docs/PC-Web端技术预案.md index f39630f..8537dae 100644 --- a/docs/PC-Web端技术预案.md +++ b/docs/PC-Web端技术预案.md @@ -21,7 +21,7 @@ - 小程序 DAU 稳定 > 1000 - 用户反馈中"想在电脑上用"的需求频繁出现 - 内容积累到 100+ 种题型 -- 模板引擎已稳定运行,core/ 层与 wx.* 完全解耦 +- 模板引擎已稳定运行,core/ 层与 wx.\* 完全解耦 --- @@ -56,12 +56,12 @@ PC Web 技术栈: PC Web 需要一个 API 层来访问小程序云开发的数据。三种方案对比: -| 方案 | 说明 | 成本 | 推荐度 | -|------|------|------|--------| -| **A: 云开发 HTTP API** | 微信云开发提供的 HTTP 触发器,Web 直接调用 | 零额外成本 | ⭐⭐⭐⭐⭐ | -| **B: Cloudflare Workers** | 轻量 Serverless 代理层,转发请求到云开发 | 极低(免费额度充裕) | ⭐⭐⭐⭐ | -| **C: Vercel Serverless** | Next.js API Routes 做代理 | 低(Vercel 免费额度) | ⭐⭐⭐ | -| **D: 独立 Node.js 服务** | 自建 Express/Fastify 服务,独立数据库 | 较高(¥50-100/月+) | ⭐⭐ | +| 方案 | 说明 | 成本 | 推荐度 | +| ------------------------- | ------------------------------------------ | --------------------- | ---------- | +| **A: 云开发 HTTP API** | 微信云开发提供的 HTTP 触发器,Web 直接调用 | 零额外成本 | ⭐⭐⭐⭐⭐ | +| **B: Cloudflare Workers** | 轻量 Serverless 代理层,转发请求到云开发 | 极低(免费额度充裕) | ⭐⭐⭐⭐ | +| **C: Vercel Serverless** | Next.js API Routes 做代理 | 低(Vercel 免费额度) | ⭐⭐⭐ | +| **D: 独立 Node.js 服务** | 自建 Express/Fastify 服务,独立数据库 | 较高(¥50-100/月+) | ⭐⭐ | **推荐路径**:先用方案 A(零成本),若遇到限制再升级到方案 B。 @@ -104,44 +104,179 @@ doodle/ 当前不需要搭建 Monorepo,只需在小程序项目中做好分层即可: 1. 将 `core/` 作为独立目录组织代码 -2. 确保 `core/` 内**不 import 任何 wx.* API** +2. 确保 `core/` 内**不 import 任何 wx.\* API** 3. 模板引擎、生成器、数据模型全部放在 `core/` 中 4. 未来迁移时,将 `core/` 提取为独立 npm 包即可 ### 4.3 平台适配层映射 -| 适配器 | 小程序实现 | Web 实现 | -|--------|-----------|---------| -| CanvasAdapter | `wx.createSelectorQuery` + Canvas 2D | `document.getElementById` + Canvas 2D | -| ImageAdapter | `canvas.createImage()` | `new Image()` | -| StorageAdapter | `wx.setStorageSync` / `wx.getStorageSync` | `localStorage` | -| CloudAdapter | `wx.cloud.callFunction` | HTTP fetch 到云开发 API | -| ShareAdapter | `onShareAppMessage` / `onShareTimeline` | Web Share API / 复制链接 | -| ExportAdapter | `canvasToTempFilePath` → `saveImageToPhotosAlbum` | `canvas.toBlob()` → 下载 / jsPDF | +| 适配器 | 小程序实现 | Web 实现 | +| -------------- | ------------------------------------------------- | ------------------------------------- | +| CanvasAdapter | `wx.createSelectorQuery` + Canvas 2D | `document.getElementById` + Canvas 2D | +| ImageAdapter | `canvas.createImage()` | `new Image()` | +| StorageAdapter | `wx.setStorageSync` / `wx.getStorageSync` | `localStorage` | +| CloudAdapter | `wx.cloud.callFunction` | HTTP fetch 到云开发 API | +| ShareAdapter | `onShareAppMessage` / `onShareTimeline` | Web Share API / 复制链接 | +| ExportAdapter | `canvasToTempFilePath` → `saveImageToPhotosAlbum` | `canvas.toBlob()` → 下载 / jsPDF | --- ## 五、Web 端特有功能 -| 功能 | 说明 | -|------|------| -| **直接下载 PDF** | jsPDF 前端生成,无需保存到相册 | -| **批量下载** | 一次生成多张 → 打包 ZIP 下载 | -| **打印预览** | 浏览器原生 `window.print()` + CSS @media print | -| **SEO** | SSR/SSG 生成题型介绍页,搜索引擎可抓取 | -| **用户登录** | 微信扫码登录 / 手机号 + 验证码 | -| **分享** | 生成题型页面 URL,可直接分享链接 | +| 功能 | 说明 | +| ---------------- | ---------------------------------------------- | +| **直接下载 PDF** | jsPDF 前端生成,无需保存到相册 | +| **批量下载** | 一次生成多张 → 打包 ZIP 下载 | +| **打印预览** | 浏览器原生 `window.print()` + CSS @media print | +| **SEO** | SSR/SSG 生成题型介绍页,搜索引擎可抓取 | +| **用户登录** | 微信扫码登录 / 手机号 + 验证码 | +| **分享** | 生成题型页面 URL,可直接分享链接 | --- ## 六、开发估算 -| 阶段 | 内容 | 预估人时 | -|------|------|---------| -| 环境搭建 | Monorepo + Next.js 项目 + core 包提取 | ~16h | -| 平台适配层 | 5 个适配器的 Web 实现 | ~16h | -| UI 开发 | 首页 + 分类页 + 生成页 + 我的 | ~40h | -| 用户登录 | 微信扫码 + 手机号登录 | ~16h | -| SEO 优化 | SSG 题型页 + sitemap + meta | ~8h | -| 测试与上线 | 联调 + 部署 Vercel | ~8h | -| **总计** | | **~104h(约 3-4 周全职)** | +| 阶段 | 内容 | 预估人时 | +| ---------- | ------------------------------------- | -------------------------- | +| 环境搭建 | Monorepo + Next.js 项目 + core 包提取 | ~16h | +| 平台适配层 | 5 个适配器的 Web 实现 | ~16h | +| UI 开发 | 首页 + 分类页 + 生成页 + 我的 | ~40h | +| 用户登录 | 微信扫码 + 手机号登录 | ~16h | +| SEO 优化 | SSG 题型页 + sitemap + meta | ~8h | +| 测试与上线 | 联调 + 部署 Vercel | ~8h | +| **总计** | | **~104h(约 3-4 周全职)** | + +--- + +## 八、PC Web 端技术预案(远期) + +> PC Web 端属于远期规划,详细方案见独立文档:[PC-Web端技术预案](./PC-Web端技术预案.md) +> +> **当前阶段的准备**:只需确保 `core/` 层内不 import 任何 `wx.*` API,未来迁移时提取为独立 npm 包即可。 + +--- + +## 九、前端重构路线图 + +### Phase 1:基础重构(第 1-2 周) + +``` +优先级:🔴 关键 + +目标:不改变现有功能,优化代码结构 + +1. 修复技术债务 + ├── 统一云函数名称(callCloud 中 robotaxi → doodle) + ├── 清理 cloudfunctions/doodle/index.js 中无效的模块引用 + ├── 移除 debug 页中指向不存在页面的死链 + └── 清理未使用的代码和资源 + +2. 建立 core/ 目录 + ├── 将 constants/ 迁移到 core/data/ + ├── 将纯工具函数迁移到 core/utils/ + ├── 将 DrawService 基类提取到 core/draw/ + └── 定义统一的 WorksheetType 数据模型 + +3. 建立 platform/ 目录 + ├── 抽取 canvas-adapter(封装 wx Canvas API) + ├── 抽取 storage-adapter(封装 wx.Storage) + └── 抽取 image-adapter(封装图片加载) + +4. 统一 WXML 模板 + └── 合并 mathPages 和 focusPages 的重复 canvas-page-template +``` + +### Phase 2:模板引擎 + 首页重构 + 配置化(第 3-5 周) + +``` +优先级:🟡 重要(模板引擎是后续扩展的基础) + +1. 模板引擎核心开发 + ├── 实现 BaseTemplateRenderer 基类 + ├── 实现 TemplateEngine 入口(配置解析 + 渲染器/生成器调度) + ├── 实现 RendererRegistry / GeneratorRegistry + ├── 开发通用 worksheet 页面(pages/worksheet/worksheet) + │ └── 根据 JSON 配置动态渲染参数面板 + Canvas 预览 + ├── 优先实现 3 种高复用模板渲染器: + │ ├── grid-exercise(网格计算型,覆盖 ~15 种现有数学题) + │ ├── full-page-asset(全幅素材型,覆盖涂色卡/折纸等新内容) + │ └── tracing-writing(描红书写型,覆盖练字/字母/拼音) + └── 将 2-3 种现有题型试点迁移到模板引擎(验证可行性) + +2. 数据模型升级 + ├── WorksheetConfig 增加 template/generator/layoutConfig 等字段 + ├── 定义 Category 模型 + ├── 重构 MATH_FUNCTION_TYPES / FOCUS_FUNCTION_TYPES 为统一 JSON 格式 + └── 编写存量题型的 JSON 配置映射 + +3. 首页重构 + ├── 新建 pages/home/ 替代原四个 Tab 入口页 + ├── 实现分类标签栏 + 搜索 + 推荐区 + ├── 实现年龄筛选、难度筛选 + └── 使用新的 worksheet-card 组件(带预览图) + +4. TabBar 重构 + └── 发现 | 分龄 | 收藏 | 我的 + +5. 云数据库初始化(现阶段) + ├── 建表:worksheets(含模板引擎字段)、categories + ├── 编写数据初始化脚本(存量题型 JSON 导入) + └── 实现前端数据加载(缓存优先 + 云端更新,见 小程序云开发方案.md) +``` + +### Phase 3:模板扩展 + 新内容接入(第 6-8 周) + +``` +优先级:🟡 重要 + +1. 补全剩余模板渲染器 + ├── match-connect(配对连线型) + ├── grid-coloring(网格涂色型) + ├── card-layout(卡片排列型) + ├── sequence-pattern(序列/排序型) + └── special-graphic(时钟/特殊图形型) + +2. 新增题型(通过 JSON 配置 + 素材上传,大部分无需写新代码) + ├── 数学:时钟练习(需 special-graphic 渲染器) + ├── 语文:拼音练习(复用 tracing-writing) + ├── 英语:字母描红(复用 tracing-writing + letter-tracing 生成器) + ├── 英语:字母闪卡(复用 card-layout + static-asset) + ├── 益智:控笔练习(复用 tracing-writing) + └── 创意:涂色卡(复用 full-page-asset,仅需上传素材) + +3. 存量题型批量迁移 + ├── 批次 1:grid-exercise 类 (~15 种) + ├── 批次 2:match-connect 类 (~6 种) + └── 批次 3:grid-coloring 类 (~8 种) + +4. 完善新分包 + ├── english/ 分包 + ├── puzzle/ 分包 + └── craft/ 分包 +``` + +### Phase 4:体验与功能升级(第 9-10 周) + +``` +优先级:🟢 增强 + +1. 用户体系 + ├── 微信登录 + ├── 收藏功能(本地 + 云端同步) + └── 下载历史 + +2. 打印体验 + ├── 打印指南页面 + ├── 批量生成图片(多张保存到相册) + └── 客户端渲染性能优化 + +3. 运营能力 + ├── 数据埋点完善 + ├── 下载统计展示(热门排行) + └── 用户反馈入口 + +4. 远期增值功能 + └── PDF 导出(会员专属,云函数合并 PNG 为 PDF;迁移后可为 NestJS) +``` + +--- diff --git a/miniprogram/docs/svg-path-analysis.md b/docs/svg-path-analysis.md similarity index 100% rename from miniprogram/docs/svg-path-analysis.md rename to docs/svg-path-analysis.md diff --git a/docs/后端部署方案.md b/docs/后端部署方案.md new file mode 100644 index 0000000..d1c44c7 --- /dev/null +++ b/docs/后端部署方案.md @@ -0,0 +1,902 @@ +# Doodle Mini — 后端部署方案(NestJS + 自有云服务器) + +> 版本:v1.0 +> 最后更新:2026-03-27 +> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。 +> **现阶段**:小程序后端以 [小程序云开发方案](./小程序云开发方案.md) 为准,详见 [技术架构设计文档](./技术架构设计文档.md) 中的阶段说明(当前阶段有效期至 **2026-09-15**)。 +> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [产品设计文档](./产品设计文档.md) | [PC-Web端技术预案](./PC-Web端技术预案.md) + +--- + +## 一、方案决策 + +### 1.1 为什么选择自建后端(何时采用) + +| 考量 | 说明 | +| -------------- | --------------------------------------------------------------------------------- | +| **零增量成本** | 已有云服务器,不新增费用;微信云开发免费额度有限,超出需持续付费 | +| **灵活性强** | NestJS 是完整的 Node.js 框架,不受云开发 SDK 限制,可自由选择数据库、缓存、队列等 | +| **多端友好** | RESTful API 天然支持小程序、PC Web、App 等多端接入,无需桥接层 | +| **数据自主** | 数据存储在自有服务器,不锁定平台,迁移自由 | +| **技术栈统一** | 前后端均使用 TypeScript,共享类型定义和数据模型,开发效率高 | + +### 1.2 技术选型 + +| 层级 | 技术 | 说明 | +| -------- | ----------------------- | ------------------------------------------------------------ | +| 运行时 | Node.js 20 LTS | 长期支持版本,稳定可靠 | +| 框架 | NestJS 10+ | 企业级 Node.js 框架,模块化架构,内置 DI/AOP/中间件等 | +| 数据库 | MySQL 8.0 | 成熟稳定的关系型数据库,适合结构化的题型/用户数据 | +| ORM | Prisma | 类型安全的 ORM,自动生成 TS 类型,迁移管理方便 | +| 缓存 | Redis(可选) | 热门题型列表、配置数据缓存,降低数据库压力;初期可不引入 | +| 文件存储 | 本地磁盘 + Nginx 静态 | 素材文件存服务器本地,Nginx 直接提供静态文件服务,零成本 CDN | +| 进程管理 | PM2 | 守护进程、自动重启、日志管理 | +| 反向代理 | Nginx | HTTPS 终止、静态文件、反向代理、Gzip 压缩 | +| 容器化 | Docker + docker-compose | 可选,方便环境一致性和部署自动化 | + +--- + +## 二、系统架构 + +``` +┌───────────────────────────────────────────────────────────────┐ +│ 客户端 │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 微信小程序 │ │ PC Web (远期) │ │ +│ │ wx.request() │ │ axios/fetch │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +└───────────┼───────────────────────────┼───────────────────────┘ + │ HTTPS │ + ▼ ▼ +┌───────────────────────────────────────────────────────────────┐ +│ 云服务器 │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Nginx │ │ +│ │ • HTTPS 终止(Let's Encrypt 证书) │ │ +│ │ • /api/* → 反向代理到 NestJS (localhost:3000) │ │ +│ │ • /static/* → 直接提供静态素材文件 │ │ +│ │ • Gzip 压缩、请求限流 │ │ +│ └───────────┬────────────────────────┬────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────┐ ┌──────────────────────┐ │ +│ │ NestJS 应用 │ │ 静态文件目录 │ │ +│ │ (PM2 守护) │ │ │ │ +│ │ │ │ /data/doodle/ │ │ +│ │ ┌──────────┐ │ │ ├── previews/ │ │ +│ │ │ Auth │ │ │ ├── coloring/ │ │ +│ │ │ Module │ │ │ ├── origami/ │ │ +│ │ ├──────────┤ │ │ ├── fonts/ │ │ +│ │ │ Worksheet│ │ │ └── share/ │ │ +│ │ │ Module │ │ └──────────────────────┘ │ +│ │ ├──────────┤ │ │ +│ │ │ User │ │ │ +│ │ │ Module │ │ │ +│ │ ├──────────┤ │ │ +│ │ │ Favorite │ │ │ +│ │ │ Module │ │ │ +│ │ ├──────────┤ │ │ +│ │ │ Stats │ │ │ +│ │ │ Module │ │ │ +│ │ ├──────────┤ │ │ +│ │ │ Upload │ │ │ +│ │ │ Module │ │ │ +│ │ └──────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌──────────┐ │ │ +│ │ │ MySQL │ │ │ +│ │ │ (Prisma) │ │ │ +│ │ └──────────┘ │ │ +│ └────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、项目结构 + +``` +doodle-server/ +├── src/ +│ ├── main.ts ← 应用入口 +│ ├── app.module.ts ← 根模块 +│ │ +│ ├── common/ ← 通用模块 +│ │ ├── guards/ +│ │ │ └── wx-auth.guard.ts ← 微信登录鉴权守卫 +│ │ ├── interceptors/ +│ │ │ └── response.interceptor.ts ← 统一响应格式 +│ │ ├── filters/ +│ │ │ └── http-exception.filter.ts← 统一异常处理 +│ │ ├── decorators/ +│ │ │ └── current-user.decorator.ts ← 获取当前用户 +│ │ └── dto/ +│ │ └── pagination.dto.ts ← 分页参数 +│ │ +│ ├── auth/ ← 鉴权模块 +│ │ ├── auth.module.ts +│ │ ├── auth.controller.ts ← POST /auth/wx-login +│ │ └── auth.service.ts ← 微信 code2session + JWT 签发 +│ │ +│ ├── worksheet/ ← 题型配置模块 +│ │ ├── worksheet.module.ts +│ │ ├── worksheet.controller.ts ← GET /worksheets, GET /worksheets/:id +│ │ ├── worksheet.service.ts +│ │ └── dto/ +│ │ ├── query-worksheet.dto.ts ← 查询筛选参数 +│ │ └── worksheet-response.dto.ts +│ │ +│ ├── category/ ← 分类模块 +│ │ ├── category.module.ts +│ │ ├── category.controller.ts ← GET /categories +│ │ └── category.service.ts +│ │ +│ ├── user/ ← 用户模块 +│ │ ├── user.module.ts +│ │ ├── user.controller.ts ← GET /user/profile, PATCH /user/profile +│ │ └── user.service.ts +│ │ +│ ├── favorite/ ← 收藏模块 +│ │ ├── favorite.module.ts +│ │ ├── favorite.controller.ts ← GET/POST/DELETE /favorites +│ │ └── favorite.service.ts +│ │ +│ ├── history/ ← 下载历史模块 +│ │ ├── history.module.ts +│ │ ├── history.controller.ts ← GET/POST /history +│ │ └── history.service.ts +│ │ +│ ├── stats/ ← 统计模块 +│ │ ├── stats.module.ts +│ │ ├── stats.controller.ts ← POST /stats/download, GET /stats/popular +│ │ └── stats.service.ts +│ │ +│ ├── feedback/ ← 反馈模块 +│ │ ├── feedback.module.ts +│ │ ├── feedback.controller.ts ← POST /feedback +│ │ └── feedback.service.ts +│ │ +│ └── upload/ ← 素材上传模块(管理后台用) +│ ├── upload.module.ts +│ ├── upload.controller.ts ← POST /upload/asset +│ └── upload.service.ts +│ +├── prisma/ +│ ├── schema.prisma ← 数据库 Schema +│ └── seed.ts ← 初始数据填充脚本 +│ +├── nginx/ +│ └── doodle-api.conf ← Nginx 配置模板 +│ +├── .env.example ← 环境变量模板 +├── docker-compose.yml ← 可选容器化部署 +├── ecosystem.config.js ← PM2 配置 +├── package.json +└── tsconfig.json +``` + +--- + +## 四、数据库设计(Prisma Schema) + +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +// ─── 题型配置表 ─── + +model Worksheet { + id String @id @default(cuid()) + title String @db.VarChar(100) + desc String @db.VarChar(500) + category String @db.VarChar(20) // math | chinese | english | puzzle | craft + subcategory String @db.VarChar(50) + ageMin Int @db.SmallInt // 适龄最小值 + ageMax Int @db.SmallInt // 适龄最大值 + difficulty Int @db.SmallInt // 1-4 + previewImage String @db.VarChar(500) + tags Json // string[] + isNew Boolean @default(false) + isHot Boolean @default(false) + sortOrder Int @default(0) + downloadCount Int @default(0) + status String @default("active") @db.VarChar(10) // active | draft | hidden + + // 模板引擎字段 + template String @db.VarChar(30) // TemplateType + generator String @db.VarChar(30) // GeneratorType + generatorConfig Json // 生成器参数 + layoutConfig Json // 排版参数 + userConfigurable Json? // 用户可调整参数定义 + legacyPage String? @db.VarChar(200) // 旧页面路径(迁移过渡) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + favorites Favorite[] + downloadLogs DownloadLog[] + + @@index([category, status, sortOrder]) + @@index([status, sortOrder]) + @@map("worksheets") +} + +// ─── 分类表 ─── + +model Category { + id String @id @default(cuid()) + name String @db.VarChar(50) + icon String @db.VarChar(200) + color String @db.VarChar(10) + sortOrder Int @default(0) + parentId String? @db.VarChar(30) + + @@index([parentId, sortOrder]) + @@map("categories") +} + +// ─── 用户表 ─── + +model User { + id String @id @default(cuid()) + openid String @unique @db.VarChar(100) + unionid String? @unique @db.VarChar(100) + nickName String? @db.VarChar(50) + avatarUrl String? @db.VarChar(500) + totalDownloads Int @default(0) + createdAt DateTime @default(now()) + lastActiveAt DateTime @default(now()) + + favorites Favorite[] + downloadLogs DownloadLog[] + feedbacks Feedback[] + + @@map("users") +} + +// ─── 收藏表 ─── + +model Favorite { + id String @id @default(cuid()) + userId String + worksheetId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + worksheet Worksheet @relation(fields: [worksheetId], references: [id]) + + @@unique([userId, worksheetId]) + @@index([userId, createdAt]) + @@map("favorites") +} + +// ─── 下载日志表 ─── + +model DownloadLog { + id String @id @default(cuid()) + userId String + worksheetId String + params Json? // 生成参数快照 + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + worksheet Worksheet @relation(fields: [worksheetId], references: [id]) + + @@index([userId, createdAt]) + @@index([worksheetId, createdAt]) + @@map("download_logs") +} + +// ─── 反馈表 ─── + +model Feedback { + id String @id @default(cuid()) + userId String + content String @db.Text + contact String? @db.VarChar(100) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@map("feedback") +} + +// ─── 学习路线表 ─── + +model LearningPlan { + id String @id @default(cuid()) + ageMin Int @db.SmallInt + ageMax Int @db.SmallInt + ageLabel String @db.VarChar(20) + milestones Json // string[],能力目标描述 + weeks Json // WeekPlan[],4 周学习路线 + + @@unique([ageMin, ageMax]) + @@map("learning_plans") +} +``` + +--- + +## 五、API 接口设计 + +### 5.1 鉴权 + +微信小程序通过 `wx.login()` 获取临时 `code`,发送到后端换取 `openid`,后端签发 JWT。 + +``` +POST /api/auth/wx-login + Body: { code: string } + Response: { token: string, user: UserInfo } + +流程: + 小程序 wx.login() → code + → POST /api/auth/wx-login { code } + → 后端调用微信 code2Session API 获取 openid + → 查找或创建用户记录 + → 签发 JWT (payload: { userId, openid }) + → 返回 { token, user } + 小程序将 token 存入 wx.Storage + 后续请求 Header: Authorization: Bearer +``` + +### 5.2 题型配置 + +``` +GET /api/worksheets + Query: category, subcategory, ageMin, ageMax, difficulty, status, page, pageSize + Response: { items: Worksheet[], total: number } + +GET /api/worksheets/:id + Response: Worksheet + +GET /api/worksheets/popular + Query: limit (default 10) + Response: Worksheet[] +``` + +### 5.3 分类 + +``` +GET /api/categories + Response: Category[] +``` + +### 5.4 用户 + +``` +GET /api/user/profile ← 需登录 + Response: UserInfo + +PATCH /api/user/profile ← 需登录 + Body: { nickName?, avatarUrl? } + Response: UserInfo +``` + +### 5.5 收藏 + +``` +GET /api/favorites ← 需登录 + Query: page, pageSize + Response: { items: FavoriteWithWorksheet[], total: number } + +POST /api/favorites ← 需登录 + Body: { worksheetId: string } + Response: { id: string } + +DELETE /api/favorites/:worksheetId ← 需登录 + Response: { success: true } +``` + +### 5.6 下载历史 / 统计 + +``` +GET /api/history ← 需登录 + Query: page, pageSize + Response: { items: DownloadLogWithWorksheet[], total: number } + +POST /api/stats/download + Body: { worksheetId: string, params?: object } + Response: { success: true } +``` + +### 5.7 反馈 + +``` +POST /api/feedback ← 需登录 + Body: { content: string, contact?: string } + Response: { id: string } +``` + +### 5.8 学习路线 + +``` +GET /api/learning-plans + Response: LearningPlan[] + +GET /api/learning-plans/:ageRange ← 如 "5-6" + Response: LearningPlan +``` + +### 5.9 统一响应格式 + +```typescript +// 成功 +{ + code: 0, + data: { ... }, + message: "success" +} + +// 失败 +{ + code: 40001, // 业务错误码 + data: null, + message: "具体错误信息" +} + +// 分页数据 +{ + code: 0, + data: { + items: [...], + total: 128, + page: 1, + pageSize: 20 + } +} +``` + +--- + +## 六、小程序端适配层 + +原方案中 `platform/cloud-adapter.ts` 需要从微信云开发调用改为标准 HTTP 请求。 + +```typescript +// platform/cloud-adapter.ts — 改为 HTTP API 调用 + +const BASE_URL = 'https://api.your-domain.com/api'; + +class HttpCloudAdapter implements ICloudAdapter { + private token: string | null = null; + + async login(): Promise { + const { code } = await wx.login(); + const res = await this.request('POST', '/auth/wx-login', { code }); + this.token = res.token; + wx.setStorageSync('token', res.token); + return res.user; + } + + async request(method: string, path: string, data?: any): Promise { + const token = this.token || wx.getStorageSync('token'); + const res = await new Promise((resolve, reject) => { + wx.request({ + url: `${BASE_URL}${path}`, + method: method as any, + data, + header: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + success: (res) => resolve(res.data), + fail: reject, + }); + }); + + if (res.code !== 0) { + throw new Error(res.message || '请求失败'); + } + return res.data; + } + + // ─── 业务方法(与云开发版保持同一接口)─── + + async getWorksheetList( + query: WorksheetQuery, + ): Promise> { + return this.request('GET', '/worksheets', query); + } + + async getWorksheetDetail(id: string): Promise { + return this.request('GET', `/worksheets/${id}`); + } + + async addFavorite(worksheetId: string): Promise { + await this.request('POST', '/favorites', { worksheetId }); + } + + async removeFavorite(worksheetId: string): Promise { + await this.request('DELETE', `/favorites/${worksheetId}`); + } + + async getFavorites( + page: number, + pageSize: number, + ): Promise> { + return this.request('GET', '/favorites', { page, pageSize }); + } + + async reportDownload(worksheetId: string, params?: object): Promise { + await this.request('POST', '/stats/download', { worksheetId, params }); + } + + async submitFeedback(content: string, contact?: string): Promise { + await this.request('POST', '/feedback', { content, contact }); + } +} +``` + +数据加载策略与原方案一致(缓存优先 + 后台更新),只是数据源从云数据库变为 HTTP API: + +``` +┌────────────────────────────────────────────────────────────┐ +│ 数据加载策略 │ +│ │ +│ 题型配置数据 (worksheets/categories) │ +│ ┌────────────────────────────────────────┐ │ +│ │ 优先级 1: 本地缓存(wx.Storage) │ │ +│ │ 优先级 2: HTTP API 查询 │ │ +│ │ 优先级 3: 前端内置兜底数据 │ ← 保证离线可用 │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 缓存策略: │ +│ • 首次启动:API 拉取 → 写入本地缓存 │ +│ • 后续启动:先用缓存渲染 → 后台静默更新 │ +│ • 缓存有效期:24 小时 │ +│ • 无网络:使用本地缓存或内置兜底 │ +└────────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、静态资源 / 素材存储 + +不再使用微信云存储,改为服务器本地磁盘 + Nginx 直接提供静态文件服务。 + +``` +服务器文件目录: +/data/doodle/ +├── assets/ +│ ├── previews/ ← 题型效果预览图 +│ │ ├── math/ +│ │ ├── chinese/ +│ │ ├── english/ +│ │ ├── puzzle/ +│ │ └── craft/ +│ ├── coloring/ ← 涂色卡线稿(SVG/PNG) +│ │ ├── animals/ +│ │ ├── vehicles/ +│ │ ├── holidays/ +│ │ └── ... +│ ├── origami/ ← 折纸展开图 +│ ├── stickers/ ← 贴纸素材 +│ ├── maze-templates/ ← 迷宫模板数据(JSON) +│ └── craft-templates/ ← 手工模板 +├── fonts/ ← 字体文件 +│ ├── SimHei.ttf +│ └── handwriting.ttf +└── share/ ← 分享图 + └── default-share.png +``` + +Nginx 配置中将 `/static/` 路径映射到此目录: + +```nginx +server { + listen 443 ssl http2; + server_name api.your-domain.com; + + ssl_certificate /etc/letsencrypt/live/api.your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.your-domain.com/privkey.pem; + + # 静态素材,长缓存 + Gzip + location /static/ { + alias /data/doodle/; + expires 30d; + add_header Cache-Control "public, immutable"; + gzip on; + gzip_types image/svg+xml application/json; + } + + # API 反向代理 + location /api/ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 请求限流(防刷) + limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + location /api/ { + limit_req zone=api burst=50 nodelay; + # ... proxy_pass 同上 + } +} +``` + +小程序中引用素材的 URL 从 `cloud://doodle-xxx/assets/...` 变为 `https://api.your-domain.com/static/assets/...`。 + +--- + +## 八、微信登录对接 + +自建后端需要自行对接微信小程序登录,但整体流程非常简单: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 微信登录流程 │ +│ │ +│ 小程序 后端 │ +│ ────── ──── │ +│ wx.login() → code │ │ +│ │ │ │ +│ └──── POST /auth/wx-login { code } ────→ │ +│ │ │ +│ ├── 调用微信 API ──→ │ +│ │ POST https://api.weixin. │ +│ │ qq.com/sns/jscode2session │ +│ │ { appid, secret, code, │ +│ │ grant_type } │ +│ │ │ +│ │ ←── { openid, session_key, │ +│ │ unionid? } │ +│ │ │ +│ ├── 查找/创建 User 记录 │ +│ ├── 签发 JWT │ +│ │ │ +│ ←── { token, user } ─────┘ │ +│ │ +│ wx.setStorageSync('token', token) │ +│ 后续请求携带 Authorization: Bearer │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +NestJS 实现要点: + +```typescript +// auth/auth.service.ts 核心逻辑 +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly httpService: HttpService, + ) {} + + async wxLogin(code: string) { + // 1. 调用微信 code2Session + const wxRes = await this.httpService.axiosRef.get( + 'https://api.weixin.qq.com/sns/jscode2session', + { + params: { + appid: process.env.WX_APPID, + secret: process.env.WX_SECRET, + js_code: code, + grant_type: 'authorization_code', + }, + }, + ); + + const { openid, unionid } = wxRes.data; + if (!openid) throw new UnauthorizedException('微信登录失败'); + + // 2. 查找或创建用户 + const user = await this.prisma.user.upsert({ + where: { openid }, + update: { lastActiveAt: new Date() }, + create: { openid, unionid }, + }); + + // 3. 签发 JWT + const token = this.jwtService.sign({ + sub: user.id, + openid: user.openid, + }); + + return { token, user }; + } +} +``` + +需要在微信公众平台配置**合法域名**:`api.your-domain.com`(在「开发管理 → 开发设置 → 服务器域名 → request 合法域名」中添加)。 + +--- + +## 九、部署方案 + +### 9.1 PM2 部署(推荐,简单直接) + +```javascript +// ecosystem.config.js +module.exports = { + apps: [ + { + name: 'doodle-api', + script: 'dist/main.js', + instances: 1, // 单实例即可,轻量应用 + exec_mode: 'fork', + env: { + NODE_ENV: 'production', + PORT: 3000, + }, + error_file: '/var/log/doodle/error.log', + out_file: '/var/log/doodle/out.log', + merge_logs: true, + max_memory_restart: '300M', + }, + ], +}; +``` + +部署步骤: + +```bash +# 1. 克隆代码 +git clone /opt/doodle-server +cd /opt/doodle-server + +# 2. 安装依赖 +npm ci --production + +# 3. 构建 +npm run build + +# 4. 初始化数据库 +npx prisma migrate deploy +npx prisma db seed + +# 5. 启动 +pm2 start ecosystem.config.js + +# 6. 设置开机自启 +pm2 save +pm2 startup +``` + +### 9.2 Docker 部署(可选) + +```yaml +# docker-compose.yml +version: '3.8' + +services: + api: + build: . + ports: + - '3000:3000' + environment: + - DATABASE_URL=mysql://doodle:password@mysql:3306/doodle + - JWT_SECRET=${JWT_SECRET} + - WX_APPID=${WX_APPID} + - WX_SECRET=${WX_SECRET} + depends_on: + - mysql + volumes: + - ./data/assets:/data/doodle + restart: unless-stopped + + mysql: + image: mysql:8.0 + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=doodle + - MYSQL_USER=doodle + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + +volumes: + mysql_data: +``` + +### 9.3 环境变量 + +```env +# .env.example +NODE_ENV=production +PORT=3000 + +# 数据库 +DATABASE_URL="mysql://doodle:your_password@localhost:3306/doodle" + +# JWT +JWT_SECRET="your-jwt-secret-key" +JWT_EXPIRES_IN="7d" + +# 微信小程序 +WX_APPID="your-wx-appid" +WX_SECRET="your-wx-secret" + +# 静态资源 +STATIC_BASE_URL="https://api.your-domain.com/static" +ASSET_DIR="/data/doodle" +``` + +--- + +## 十、开发路线 + +``` +Phase 1(与前端 Phase 1-2 同步,第 1-3 周) +├── NestJS 项目初始化(脚手架 + 基础配置) +├── Prisma Schema 定义 + 数据库迁移 +├── 微信登录鉴权模块(wx-login + JWT) +├── 题型配置 CRUD API(worksheets / categories) +├── 数据初始化脚本(存量题型 JSON 导入) +├── Nginx 配置 + HTTPS 证书 +└── PM2 部署上线 + +Phase 2(与前端 Phase 2-3 同步,第 4-6 周) +├── 收藏 / 下载历史 API +├── 统计模块(下载计数、热门排行) +├── 素材上传接口(管理后台用) +├── 学习路线数据 API +└── 前端 cloud-adapter 适配层完成 + +Phase 3(与前端 Phase 4 同步,第 7-8 周) +├── 反馈模块 +├── 用户信息完善 +├── API 性能优化(缓存策略) +└── 日志 / 监控完善 +``` + +--- + +## 十一、成本对比 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 成本对比 │ +│ │ +│ 微信云开发方案: │ +│ ┌────────────────────────────────────────┐ │ +│ │ 免费额度内:¥0 │ │ +│ │ 超出后(DAU 3000+):¥19.9-99 元/月 │ │ +│ │ 随用量增长持续增加 │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 自有云服务器方案(NestJS): │ +│ ┌────────────────────────────────────────┐ │ +│ │ 服务器:¥0(已有,无增量成本) │ │ +│ │ 域名 + SSL:¥0(Let's Encrypt 免费证书)│ │ +│ │ MySQL:¥0(服务器上安装,资源占用很小) │ │ +│ │ 总计新增成本:¥0 │ │ +│ │ │ │ +│ │ 唯一成本是初始开发时间(约 3-5 天搭建) │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 结论:自建方案利用现有资源,长期零增量成本 ✅ │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 十二、技术风险与应对 + +| 风险 | 影响 | 应对方案 | +| ---------------- | ---------- | ------------------------------------------------------------------------------------ | +| 服务器宕机 | 后端不可用 | PM2 自动重启 + 前端兜底数据保证基本可用 | +| 数据库故障 | 数据丢失 | 定期备份(mysqldump cron)+ 前端本地缓存保证体验不中断 | +| 微信域名校验 | 请求被拦截 | 提前在公众平台配置合法域名,开发阶段可勾选「不校验」 | +| 服务器带宽不足 | 素材加载慢 | 图片压缩 + Nginx Gzip + 合理设置 Cache-Control + 远期可接入免费 CDN(如 Cloudflare) | +| JWT 安全性 | token 泄露 | 设置合理过期时间 + refresh token 机制 + HTTPS 传输 | +| 并发压力(远期) | 响应变慢 | 引入 Redis 缓存热点数据 + 数据库索引优化 + 按需水平扩展 | diff --git a/docs/小程序云开发方案.md b/docs/小程序云开发方案.md new file mode 100644 index 0000000..9edb532 --- /dev/null +++ b/docs/小程序云开发方案.md @@ -0,0 +1,348 @@ +# Doodle Mini — 小程序云开发方案 + +> 版本:v1.1 +> 最后更新:2026-03-27 +> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。 +> **阶段截止**:与总架构文档一致,**有效期至 2026 年 9 月 15 日**(到期前需复盘用量与成本,并决定是否迁移至 [后端部署方案](./后端部署方案.md) 所述自建服务)。 + +--- + +## 一、方案定位 + +微信云开发提供免运维的 Serverless 后端能力(云数据库、云存储、云函数),与小程序 `wx.cloud` 原生集成,**接入与鉴权成本低**,适合在阶段内快速落地题型配置下发、用户数据与素材存储。 + +**与总架构文档的关系**: + +- [技术架构设计文档](./技术架构设计文档.md):描述前端分层、模板引擎、系统总览及**后端分阶段策略**(现阶段云开发 + 到期后可选 NestJS)。 +- **本文档**:仅展开**云开发侧**的库表、云函数、存储与调用策略等技术细节。 + +**到期后选项(不在本文档展开)**:若免费额度不足或需更强多端能力,可迁移至 [后端部署方案](./后端部署方案.md)(NestJS + 自有服务器);迁移时需做数据导出与 `cloud-adapter` → `http-adapter` 的适配,详见技术架构文档中的「可迁移」原则。 + +--- + +## 二、与自建后端对比(决策参考) + +| 维度 | 微信云开发 | 自建云服务器(NestJS) | 现阶段采用 | +| ----------- | ----------------------------- | ---------------------- | -------------------- | +| 接入成本 | ⭐ 极低(原生集成) | ⭐⭐ 需搭建部署 | **云开发** | +| 运维成本 | ⭐ 免运维 | ⭐⭐ 需简单维护 | **云开发** | +| 费用 | 免费额度内 ¥0;超出按套餐计费 | 已有服务器可零增量 | **云开发(阶段内)** | +| 小程序集成 | ⭐ 鉴权与 OpenID 一体化 | 需自建登录与 JWT | **云开发** | +| PC Web 支持 | 需 HTTP 云函数或桥接 | RESTful 天然支持 | 远期看 NestJS | +| 灵活性 | 受云开发 SDK 与配额约束 | 完全自主 | 远期看 NestJS | +| 数据迁移 | 可导出,迁移需工作量 | 标准 MySQL,自主备份 | 远期看 NestJS | + +--- + +## 三、云数据库集合设计 + +本节与 [后端部署方案](./后端部署方案.md) 中的 **Prisma Schema(MySQL)** 字段语义对齐,便于阶段结束后迁移;云数据库为 **MongoDB 兼容模型**(文档 = BSON),下列类型按 MongoDB 惯例书写。 + +### 3.1 设计约定 + +| 约定 | 说明 | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `_id` | 每条文档主键。可由云开发自动生成,也可在写入时指定为字符串(如业务 ID、cuid)。**不要求**与微信 `openid` 相同。 | +| `Date` | 对应 BSON `Date`,云函数/控制台写入时使用 `serverDate()` 或 `new Date()`。 | +| `openid` / `unionid` | 显式业务字段;与下节「用户标识」一致。 | +| `_openid` | 仅当文档由**小程序端直连数据库**写入且开启用户校验时,云开发可自动注入当前用户 `openid`;若数据经**云函数**写入,通常自行维护 `openid`/`userId` 字段,不依赖 `_openid`。 | +| 命名 | 集合名与 Prisma `@@map` 一致:`worksheets`、`categories`、`users`、`favorites`、`download_logs`、`feedback`、`learning_plans`。 | + +### 3.2 用户标识:`openid`、`unionid` 与 `_id` + +**用户的 `_id` 只能是 `openid` 吗?——不是。** + +- 云数据库文档的 `_id` 默认为云开发生成的唯一 ID;也可以在 `add` 时**自定义**为字符串(例如与 [后端部署方案](./后端部署方案.md) 一致的 `cuid()`,或直接等于 `openid`)。 +- **推荐**:`users` 集合使用 **独立 `openid` 字段(唯一)** + **`unionid` 字段(可选、唯一)**,`_id` 可用自动生成或自定义;与 Prisma 中 `User.id`(cuid)+ `openid` / `unionid` 的建模方式一致,迁移时映射清晰。 +- **`unionid`**:同一微信开放平台下多应用用户唯一标识;需在小程序后台绑定开放平台,且用户已授权;未绑定时可能为空,字段应为可选并建**稀疏唯一索引**(仅非空值唯一)。 + +**`users` 集合完整字段与索引**:见 **§3.5**(与 Prisma `User` 一一对应)。 + +子表(收藏、下载日志、反馈)中的 `userId` 建议与 `users._id` 保持一致(若 `_id` 采用 cuid,则存 cuid;若 `_id` 即 `openid`,则存 `openid`),与 Prisma 外键语义一致。 + +**`unionid` 的写入**:在云函数中可通过 `cloud.getWXContext()` 读取 `UNIONID`(用户已绑定开放平台且当前会话可返回时才有值);首次登录 upsert `users` 时写入或更新 `unionid` 字段。若仅能在服务端用 `code` 换 `session`,则使用微信 `code2Session` 返回的 `unionid` 字段(同样可能为空)。**不要**在小程序端把 `unionid` 当可信主键直接展示给第三方,存储与鉴权仍以服务端为准。 + +--- + +### 3.3 集合:`worksheets`(题型配置) + +| 字段 | BSON 类型 | 必填 | 说明 | +| ------------------ | --------------- | ---- | --------------------------------------------------------------- | +| `_id` | string | 是 | 主键;建议与后端一致用 cuid 或可读业务 ID。 | +| `title` | string | 是 | 标题,建议 ≤100 字符。 | +| `desc` | string | 是 | 描述,建议 ≤500 字符。 | +| `category` | string | 是 | 大类:`math` \| `chinese` \| `english` \| `puzzle` \| `craft`。 | +| `subcategory` | string | 是 | 子类,建议 ≤50 字符。 | +| `ageMin` | int | 是 | 适龄最小值(与 Prisma 一致;替代原 `ageRange` 数组)。 | +| `ageMax` | int | 是 | 适龄最大值。 | +| `difficulty` | int | 是 | 难度 1–4。 | +| `previewImage` | string | 是 | 预览图 URL/云存储 fileID,建议 ≤500 字符。 | +| `tags` | array\ | 是 | 标签列表。 | +| `isNew` | bool | 是 | 是否新品,默认 `false`。 | +| `isHot` | bool | 是 | 是否热门,默认 `false`。 | +| `sortOrder` | int | 是 | 排序权重,默认 `0`。 | +| `downloadCount` | int | 是 | 下载次数,默认 `0`。 | +| `status` | string | 是 | `active` \| `draft` \| `hidden`,默认 `active`。 | +| `template` | string | 是 | 渲染模板类型 `TemplateType`,建议 ≤30 字符。 | +| `generator` | string | 是 | 生成器类型 `GeneratorType`,建议 ≤30 字符。 | +| `generatorConfig` | object | 是 | 生成器参数(JSON 对象)。 | +| `layoutConfig` | object | 是 | 排版参数(JSON 对象)。 | +| `userConfigurable` | object \| null | 否 | 用户可调整参数定义(JSON)。 | +| `legacyPage` | string \| null | 否 | 旧页面路径,迁移过渡用,建议 ≤200 字符。 | +| `createdAt` | date | 是 | 创建时间。 | +| `updatedAt` | date | 是 | 更新时间。 | + +**索引建议** + +| 索引键 | 类型 | 说明 | +| ------------------------------------------ | ---- | ---------------- | +| `{ category: 1, status: 1, sortOrder: 1 }` | 复合 | 列表筛选与排序。 | +| `{ status: 1, sortOrder: 1 }` | 复合 | 全量上架列表。 | + +--- + +### 3.4 集合:`categories`(分类) + +对应 Prisma `Category`。 + +| 字段 | BSON 类型 | 必填 | 说明 | +| ----------- | -------------- | ---- | --------------------------------- | +| `_id` | string | 是 | 主键。 | +| `name` | string | 是 | 名称,建议 ≤50 字符。 | +| `icon` | string | 是 | 图标 URL/fileID,建议 ≤200 字符。 | +| `color` | string | 是 | 色值,建议 ≤10 字符。 | +| `sortOrder` | int | 是 | 排序,默认 `0`。 | +| `parentId` | string \| null | 否 | 父分类 ID。 | + +**索引建议**:`{ parentId: 1, sortOrder: 1 }`。 + +--- + +### 3.5 集合:`users`(用户) + +对应 Prisma `User`(`@@map("users")`)。**`openid` / `unionid` 与 `_id` 的取舍、`unionid` 写入方式**见 §3.2。 + +| 字段 | BSON 类型 | 必填 | 说明 | +| ---------------- | -------------- | ---- | -------------------------------------------------------------------- | +| `_id` | string | 是 | 主键;可与 Prisma `User.id` 一样使用 cuid,也可自定义为其它字符串,团队内与用户档案查询方式统一即可。 | +| `openid` | string | 是 | 当前小程序下微信用户标识;与 Prisma 一致建议 ≤100 字符;**业务唯一**,索引见下表。 | +| `unionid` | string \| null | 否 | 开放平台下跨应用用户标识;建议 ≤100 字符;未绑开放平台或未返回时为空。 | +| `nickName` | string \| null | 否 | 用户昵称;建议 ≤50 字符。 | +| `avatarUrl` | string \| null | 否 | 头像 URL 或云存储 fileID;建议 ≤500 字符。 | +| `totalDownloads` | int | 是 | 累计下载次数,默认 `0`。 | +| `createdAt` | date | 是 | 首次创建时间。 | +| `lastActiveAt` | date | 是 | 最近一次活跃时间(登录、打开小程序、写库等策略由实现约定)。 | + +**索引建议** + +| 索引键 | 类型 | 说明 | +| --------- | ------------ | ---------------------------------------------------- | +| `openid` | 唯一 | 登录与 upsert 主查。 | +| `unionid` | 唯一(稀疏) | 仅当存在开放平台绑定且需跨端关联时使用;空值不互斥。 | + +--- + +### 3.6 集合:`favorites`(收藏) + +对应 Prisma `Favorite`。 + +| 字段 | BSON 类型 | 必填 | 说明 | +| ------------- | --------- | ---- | ----------------------- | +| `_id` | string | 是 | 主键。 | +| `userId` | string | 是 | 关联 `users._id`。 | +| `worksheetId` | string | 是 | 关联 `worksheets._id`。 | +| `createdAt` | date | 是 | 收藏时间。 | + +**索引建议** + +| 索引键 | 类型 | 说明 | +| ------------------------------- | ---- | ---------------------------- | +| `{ userId: 1, worksheetId: 1 }` | 唯一 | 同一用户同一题型仅一条收藏。 | +| `{ userId: 1, createdAt: -1 }` | 复合 | 「我的收藏」按时间倒序。 | + +> 若小程序端直连写库且使用安全规则,可额外保留 `_openid` 与 `userId` 二选一做一致性校验;以云函数为主时以 `userId` + 云函数鉴权为准。 + +--- + +### 3.7 集合:`download_logs`(下载日志) + +对应 Prisma `DownloadLog`。 + +| 字段 | BSON 类型 | 必填 | 说明 | +| ------------- | -------------- | ---- | ----------------------- | +| `_id` | string | 是 | 主键。 | +| `userId` | string | 是 | 关联 `users._id`。 | +| `worksheetId` | string | 是 | 关联 `worksheets._id`。 | +| `params` | object \| null | 否 | 生成参数快照(JSON)。 | +| `createdAt` | date | 是 | 下载时间。 | + +**索引建议**:`{ userId: 1, createdAt: -1 }`;`{ worksheetId: 1, createdAt: -1 }`(统计/运营)。 + +--- + +### 3.8 集合:`feedback`(用户反馈) + +对应 Prisma `Feedback`。 + +| 字段 | BSON 类型 | 必填 | 说明 | +| ----------- | -------------- | ---- | -------------------------- | +| `_id` | string | 是 | 主键。 | +| `userId` | string | 是 | 关联 `users._id`。 | +| `content` | string | 是 | 反馈正文。 | +| `contact` | string \| null | 否 | 联系方式,建议 ≤100 字符。 | +| `createdAt` | date | 是 | 提交时间。 | + +**索引建议**:`{ userId: 1 }`。 + +--- + +### 3.9 集合:`learning_plans`(学习路线) + +对应 Prisma `LearningPlan`(总架构中的适龄学习路线配置)。 + +| 字段 | BSON 类型 | 必填 | 说明 | +| ------------ | --------------- | ---- | ------------------------------------------------------ | +| `_id` | string | 是 | 主键。 | +| `ageMin` | int | 是 | 适龄下限。 | +| `ageMax` | int | 是 | 适龄上限。 | +| `ageLabel` | string | 是 | 展示用年龄段文案,建议 ≤20 字符。 | +| `milestones` | array\ | 是 | 能力目标描述(JSON 数组,与 Prisma `Json` 一致)。 | +| `weeks` | array | 是 | 周计划结构 `WeekPlan[]`(JSON 数组,与 Prisma 一致)。 | + +**索引建议**:`{ ageMin: 1, ageMax: 1 }` **唯一**,与 Prisma `@@unique([ageMin, ageMax])` 一致。 + +--- + +### 3.10 ER 关系(逻辑) + +``` +users (1) ──< favorites >── (N) worksheets +users (1) ──< download_logs >── (N) worksheets +users (1) ──< feedback +categories ──(可选业务关联)── worksheets.category / subcategory(worksheet 内嵌字符串,非 DB 外键) +learning_plans:独立配置表,按 ageMin/ageMax 查询 +``` + +--- + +## 四、云函数设计 + +``` +cloudfunctions/ +└── doodle/ + ├── index.js ← 统一入口(action 路由) + ├── package.json + ├── src/ + │ ├── user/ + │ │ ├── getOpenId.js ← ✅ 已有,保留 + │ │ └── updateProfile.js ← 🆕 更新用户信息 + │ ├── worksheet/ + │ │ ├── getList.js ← 🆕 获取题型列表(支持分类/筛选) + │ │ ├── getDetail.js ← 🆕 获取题型详情 + │ │ └── incrementDownload.js← 🆕 下载计数+1 + │ ├── favorite/ + │ │ ├── add.js ← 🆕 添加收藏 + │ │ ├── remove.js ← 🆕 取消收藏 + │ │ └── list.js ← 🆕 我的收藏列表 + │ ├── history/ + │ │ └── list.js ← 🆕 下载历史 + │ ├── feedback/ + │ │ └── submit.js ← 🆕 提交反馈 + │ └── pdf/ + │ └── generate.js ← 🆕 PDF 生成(远期增值功能,会员专属) + └── common/ + ├── responseMiddleware.js ← ✅ 已有,保留 + └── auth.js ← 🆕 鉴权中间件 +``` + +--- + +## 五、云函数调用与数据加载策略 + +``` +┌────────────────────────────────────────────────────────────┐ +│ 数据加载策略 │ +│ │ +│ 题型配置数据 (worksheets/categories) │ +│ ┌────────────────────────────────────────┐ │ +│ │ 优先级 1: 本地缓存(wx.Storage) │ │ +│ │ 优先级 2: 云数据库查询 │ │ +│ │ 优先级 3: 前端内置兜底数据 │ ← 保证离线可用 │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 缓存策略: │ +│ • 首次启动:云端拉取 → 写入本地缓存 │ +│ • 后续启动:先用缓存渲染 → 后台静默更新 │ +│ • 缓存有效期:24 小时 │ +│ • 无网络:使用本地缓存或内置兜底 │ +│ │ +│ 用户数据 (favorites/history) │ +│ ┌────────────────────────────────────────┐ │ +│ │ 本地优先写入 → 后台同步云端 │ │ +│ │ 冲突策略:以云端为准(云端时间戳更新) │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 统计数据 (download count) │ +│ ┌────────────────────────────────────────┐ │ +│ │ 批量上报:本地累计 → 退出时/定时上报 │ │ +│ │ 非关键路径,允许丢失 │ │ +│ └────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +--- + +## 六、云存储规划 + +``` +云存储目录结构: +cloud://doodle-xxx.xxxx/ +├── assets/ +│ ├── previews/ ← 题型效果预览图 +│ │ ├── math/ +│ │ ├── chinese/ +│ │ ├── english/ +│ │ ├── puzzle/ +│ │ └── craft/ +│ ├── coloring/ ← 涂色卡线稿(SVG/PNG) +│ │ ├── animals/ +│ │ ├── vehicles/ +│ │ ├── holidays/ +│ │ └── ... +│ ├── origami/ ← 折纸展开图 +│ ├── stickers/ ← 贴纸素材 +│ ├── maze-templates/ ← 迷宫模板数据(JSON) +│ └── craft-templates/ ← 手工模板 +├── fonts/ ← 字体文件 +│ ├── SimHei.ttf +│ └── handwriting.ttf +└── share/ ← 分享图 + └── default-share.png +``` + +--- + +## 七、费用与额度(阶段内监控) + +``` +免费额度(基础版 1): +┌────────────────────────────────────────┐ +│ 云数据库:2 GB 存储 / 50 万次读写/天 │ +│ 云存储:5 GB / 2 GB 下载/天 │ +│ 云函数:10 万次调用/月 / 1000 GBs/月 │ +│ CDN:5 GB/月 │ +└────────────────────────────────────────┘ + +预估用量(DAU 1000): +┌────────────────────────────────────────┐ +│ 云数据库:~100 MB(足够) │ +│ 云存储:~2 GB(素材渐增) │ +│ 云函数:~3 万次/月 │ +│ CDN:~3 GB/月 │ +│ │ +│ 结论:免费额度可覆盖到 DAU 3000 左右 │ +│ 超出后需升级套餐(如 19.9 元/月起) │ +└────────────────────────────────────────┘ +``` + +**建议在 2026-09-15 前**:结合控制台用量与业务目标,决定续用云开发、升级套餐或启动迁移至 [后端部署方案](./后端部署方案.md)。 diff --git a/docs/技术架构设计文档.md b/docs/技术架构设计文档.md index 45c5dcc..f362009 100644 --- a/docs/技术架构设计文档.md +++ b/docs/技术架构设计文档.md @@ -1,8 +1,10 @@ # Doodle Mini - 技术架构设计文档 -> 版本:v3.0 -> 最后更新:2026-03-24 -> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [PC-Web端技术预案](./PC-Web端技术预案.md) +> 版本:v3.2 +> 最后更新:2026-03-27 +> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [后端部署方案](./后端部署方案.md) | [PC-Web端技术预案](./PC-Web端技术预案.md) + +**后端阶段说明(重要)**:**现阶段**后端能力采用**微信小程序云开发**(云数据库、云存储、云函数),与 [小程序云开发方案](./小程序云开发方案.md) 一致。**当前阶段有效期至 2026 年 9 月 15 日**(到期前需结合用量、成本与产品路线,决定续用云开发、升级套餐或迁移至 [后端部署方案](./后端部署方案.md) 所述 NestJS + 自有服务器方案)。前端仍通过 `platform/cloud-adapter` 对接 `wx.cloud`,迁移时再抽象为 HTTP 适配层。 --- @@ -12,7 +14,7 @@ | 原则 | 说明 | | ------------ | ------------------------------------------------------------------------------------------------- | -| **成本优先** | 优先使用微信云开发,避免自建服务器的运维和费用 | +| **成本优先** | 现阶段用云开发免费额度控制成本;**2026-09-15** 前复盘是否续用或迁移自建;核心逻辑仍保持前端执行 | | **渐进增强** | 先小程序跑通,后续再扩展 PC Web,不为未来过度设计 | | **前端为主** | Canvas 渲染、随机生成、PNG 导出等核心逻辑保持前端执行,零服务器成本;后端仅做必要的数据存储和服务 | | **可迁移** | 核心绘制逻辑与平台 API 解耦,为将来迁移 Web 做准备 | @@ -38,9 +40,10 @@ │ 需要后端(轻量级) │ ├─────────────────────────────────────────────────────────┤ │ 📦 内容配置远程下发(热更新题型/分类,无需发版) │ -│ 📦 静态素材存储与 CDN 分发(涂色卡线稿、折纸模板等) │ +│ 📦 静态素材存储与分发(涂色卡线稿、折纸模板等) │ │ 📦 用户数据同步(收藏、历史记录跨设备) │ │ 📦 下载统计 / 热度排名 │ +│ 📦 用户标识 / 鉴权(云开发侧 OpenID 等) │ │ 📦 PDF 多页合并导出(远期增值功能,非核心路径) │ │ 📦 未来 PC Web 端的 API 服务 │ └─────────────────────────────────────────────────────────┘ @@ -48,24 +51,29 @@ #### 方案对比与选择 -| 维度 | 微信云开发 | 自建云服务器 | 决策 | -| ----------- | -------------------------------------------------------- | -------------------- | --------- | -| 接入成本 | ⭐ 极低(已有基础) | ⭐⭐⭐ 需搭建部署 | 云开发 ✅ | -| 运维成本 | ⭐ 免运维 | ⭐⭐⭐ 需监控/维护 | 云开发 ✅ | -| 费用 | 免费额度足够早期(数据库 2GB/存储 5GB/云函数 10万次/月) | 最低约 ¥50-100/月 | 云开发 ✅ | -| 小程序集成 | ⭐ 原生集成,鉴权零成本 | ⭐⭐ 需对接登录/鉴权 | 云开发 ✅ | -| PC Web 支持 | ⭐⭐ 需通过 HTTP API 桥接 | ⭐ 天然支持 | 自建 ✅ | -| 灵活性 | ⭐⭐ 受限于云开发 SDK | ⭐ 完全自由 | 自建 ✅ | -| 数据迁移 | ⭐⭐ 可导出但不方便 | ⭐ 标准数据库 | 自建 ✅ | +| 维度 | 微信云开发 | 自有云服务器(NestJS) | 现阶段决策(至 2026-09-15) | +| ----------- | ----------------------------- | --------------------------- | --------------------------- | +| 接入成本 | ⭐ 极低(原生集成) | ⭐⭐ 需搭建部署(~3-5天) | **云开发 ✅** | +| 运维成本 | ⭐ 免运维 | ⭐⭐ 需简单维护(PM2 守护) | **云开发 ✅** | +| 费用 | 免费额度内 ¥0;超出按套餐计费 | 已有服务器可零增量 | **云开发(阶段内)✅** | +| 小程序集成 | ⭐ 鉴权零成本 | ⭐⭐ 需对接微信登录+JWT | **云开发 ✅** | +| PC Web 支持 | ⭐⭐ 需通过 HTTP API 桥接 | ⭐ RESTful API 天然支持 | 远期再评估 NestJS | +| 灵活性 | ⭐⭐ 受限于云开发 SDK | ⭐ 完全自由 | 到期后按需评估 | +| 数据迁移 | ⭐⭐ 可导出 | ⭐ 标准 MySQL | 到期后按需评估 | -**最终决策:近期使用微信云开发,远期按需引入轻量后端** +**最终决策(分阶段)** ``` -Phase 1-3(当前~8周):100% 微信云开发 - └── 云数据库 + 云存储 + 云函数,零服务器成本 +现阶段(至 2026-09-15):微信小程序云开发 + └── 云数据库 + 云存储 + 云函数;详情见 → 小程序云开发方案.md -远期(需要 PC Web 时):按需引入轻量后端 - └── 详见 PC-Web端技术预案.md +阶段到期后:在 2026-09-15 前复盘,可选路径包括 + ① 续用云开发(含升级套餐) + ② 迁移至 NestJS + 自有服务器 → 后端部署方案.md + ③ 混合方案(按模块拆分) + +平台适配层保持抽象:当前 cloud-adapter(wx.cloud),迁移时可替换为 http-adapter, + 业务服务层接口尽量不变(见 platform/ 设计)。 ``` --- @@ -100,13 +108,13 @@ Phase 1-3(当前~8周):100% 微信云开发 │ │ 🔑 此层可直接在未来 PC Web 项目中 import 复用 │ │ │ └──────────────────────────────────────────────────────┘ │ └───────────────────────────┬────────────────────────────────────┘ - │ + │ wx.cloud / callFunction ▼ ┌────────────────────────────────────────────────────────────────┐ -│ 后端服务层 │ +│ 后端服务层(现阶段:微信云开发) │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ -│ │ 微信云开发(零服务器成本) │ │ +│ │ 微信云开发 │ │ │ │ │ │ │ │ ┌────────────┐ ┌──────────┐ ┌─────────────┐ │ │ │ │ │ 云数据库 │ │ 云存储 │ │ 云函数 │ │ │ @@ -121,11 +129,13 @@ Phase 1-3(当前~8周):100% 微信云开发 │ └──────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ -│ │ CDN (腾讯云) │ │ +│ │ CDN(云开发资源) │ │ │ │ 素材图片 / 字体文件 / 预览图 / 分享图 │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ -│ PC Web 远期方案见 → PC-Web端技术预案.md │ +│ 云开发详细设计 → 小程序云开发方案.md │ +│ 阶段到期后可选迁移 → 后端部署方案.md │ +│ PC Web 远期方案 → PC-Web端技术预案.md │ └────────────────────────────────────────────────────────────────┘ ``` @@ -165,22 +175,16 @@ miniprogram/ ``` miniprogram/ -├── core/ ← 🆕 共享核心层(平台无关) +├── core/ ← 🆕 共享核心层(平台无关,可被主包/分包复用) │ ├── models/ ← 数据模型定义 │ │ ├── worksheet.ts ← WorksheetType 统一模型 │ │ ├── category.ts ← 分类模型 │ │ ├── user.ts ← 用户数据模型 │ │ └── print-config.ts ← 打印配置模型 -│ ├── generators/ ← 题目生成算法(纯逻辑) -│ │ ├── math/ ← 数学题目生成器 -│ │ │ ├── addition.ts -│ │ │ ├── subtraction.ts -│ │ │ ├── number-sequence.ts -│ │ │ └── ... -│ │ ├── focus/ ← 专注力题目生成器 -│ │ ├── chinese/ ← 语文题目生成器 -│ │ └── english/ ← 英语题目生成器 -│ ├── draw/ ← 绘制服务(Canvas 2D API,平台无关) +│ ├── generators/ ← 题目生成算法(仅保留跨分包通用部分) +│ │ ├── common/ ← 公共算法(如随机、序列、通用算术) +│ │ └── registry.ts ← 生成器注册与装配 +│ ├── draw/ ← 绘制服务(仅保留跨分包通用部分) │ │ ├── base-draw.ts ← 基础绘制(纸面、页眉、网格等) │ │ ├── template-engine.ts ← 🆕 模板引擎入口 │ │ ├── renderer-registry.ts ← 🆕 渲染器注册表 @@ -194,10 +198,7 @@ miniprogram/ │ │ │ ├── full-page-asset.ts │ │ │ ├── sequence-pattern.ts │ │ │ └── special-graphic.ts -│ │ ├── legacy/ ← 存量专用绘制服务(渐进迁移后移除) -│ │ │ ├── math-draw/ -│ │ │ ├── focus-draw/ -│ │ │ └── chinese-draw/ +│ │ └── legacy/ ← 存量中仍可复用的通用兼容层(逐步收敛) │ ├── data/ ← 内容配置数据 │ │ ├── worksheets.ts ← 所有题型定义(本地兜底) │ │ ├── categories.ts ← 分类定义 @@ -211,7 +212,7 @@ miniprogram/ ├── platform/ ← 🆕 平台适配层 │ ├── canvas-adapter.ts ← Canvas API 适配(wx ↔ Web) │ ├── storage-adapter.ts ← 存储适配(wxStorage ↔ localStorage) -│ ├── cloud-adapter.ts ← 云服务适配(wx.cloud ↔ HTTP) +│ ├── cloud-adapter.ts ← 云服务适配(wx.cloud;迁移 NestJS 后可换 http-adapter) │ ├── image-adapter.ts ← 图片加载适配 │ └── share-adapter.ts ← 分享能力适配 │ @@ -228,22 +229,27 @@ miniprogram/ │ ├── app-store.ts ← 全局状态(用户/配置/主题) │ └── page-store.ts ← 页面级状态工具 │ -├── pages/ ← 重构后的主包页面 -│ ├── home/ ← 发现首页(合并原 4 个 Tab 入口) -│ ├── category/ ← 分类详情页 -│ ├── worksheet/ ← 统一的题型生成/预览页 +├── pages/ ← 主包(受 2MB 限制,只放高频入口与通用页) +│ ├── home/ ← 发现首页 +│ ├── category/ ← 分类详情页(仅列表,不承载重绘制逻辑) │ ├── profile/ ← 我的 -│ ├── guide/ ← 打印指南 -│ ├── favorites/ ← 收藏列表 -│ ├── history/ ← 下载历史 -│ └── settings/ ← 设置 +│ └── guide/ ← 打印指南 │ -├── subpackages/ ← 🆕 按品类分包 -│ ├── math/ ← 数学题型页面 -│ ├── chinese/ ← 语文题型页面 -│ ├── english/ ← 英语题型页面 -│ ├── puzzle/ ← 益智游戏页面 -│ └── craft/ ← 创意手工页面 +├── mathPages/ ← 数学分包 +│ ├── ... ← 数学题型页面 +│ └── shared/ ← 仅数学分包依赖(service/templates/assets/constants/draw/generators) +├── chinesPages/ ← 语文分包(命名可按实现统一为 chinesePages) +│ ├── ... ← 识字/练字及后续语文题型页面 +│ └── shared/ ← 仅语文分包依赖(含语文专用 draw/generators) +├── englishPages/ ← 英语分包 +│ ├── ... ← 字母/拼读等页面 +│ └── shared/ ← 仅英语分包依赖(含英语专用 draw/generators) +├── puzzlePages/ ← 益智分包 +│ ├── ... ← 迷宫/找不同/连线等页面 +│ └── shared/ ← 仅益智分包依赖(含益智专用 draw/generators) +├── craftPages/ ← 创意手工分包 +│ ├── ... ← 涂色卡/折纸/手工模板页面 +│ └── shared/ ← 仅创意手工分包依赖(含手工专用 draw/generators) │ ├── components/ ← UI 组件 │ ├── shared/ ← 通用组件 @@ -265,6 +271,14 @@ miniprogram/ └── style/ ← 全局样式 ``` +> 分包约束(新增):为控制主包体积与首屏加载,**题型页面、重绘制逻辑、题型私有素材、题型私有工具、题型私有 draw 与 generators** 必须下沉到对应分包;仅该分包使用的依赖统一放到该分包的 `shared/` 目录中(延续当前 `mathPages/shared`、`focusPages/shared` 的组织方式)。 +> +> 归属原则(draw / generators): +> +> 1. **跨分包复用**(2 个及以上分包共用)→ 放 `core/draw`、`core/generators`; +> 2. **仅单一分包使用** → 放对应 `Pages/shared/draw`、`Pages/shared/generators`; +> 3. 如后续从“单分包”演进为“跨分包”,再从分包 `shared` 上提到 `core`,避免过早抽象导致主包膨胀。 + ### 3.3 分层职责 ``` @@ -534,71 +548,71 @@ interface WorksheetConfig { status: 'active' | 'draft' | 'hidden'; // ─── 模板引擎配置(渲染用)─── - template: TemplateType; // 使用哪个排版模板 - generator: GeneratorType; // 使用哪个数据生成器 - generatorConfig: Record; // 生成器参数(不同生成器不同) - layoutConfig: LayoutConfig; // 排版参数 - userConfigurable?: UserConfigField[]; // 用户可调整的参数定义 + template: TemplateType; // 使用哪个排版模板 + generator: GeneratorType; // 使用哪个数据生成器 + generatorConfig: Record; // 生成器参数(不同生成器不同) + layoutConfig: LayoutConfig; // 排版参数 + userConfigurable?: UserConfigField[]; // 用户可调整的参数定义 } /** * 排版模板枚举(前端代码实现,新增需发版) */ type TemplateType = - | 'grid-exercise' // 模式 A:网格计算型 - | 'match-connect' // 模式 B:配对连线型 - | 'grid-coloring' // 模式 C:网格涂色型 - | 'card-layout' // 模式 D:卡片排列型 - | 'tracing-writing' // 模式 E:描红书写型 - | 'full-page-asset' // 模式 F:全幅素材型 - | 'sequence-pattern' // 模式 G:序列/排序型 - | 'special-graphic'; // 模式 H:时钟/特殊图形型 + | 'grid-exercise' // 模式 A:网格计算型 + | 'match-connect' // 模式 B:配对连线型 + | 'grid-coloring' // 模式 C:网格涂色型 + | 'card-layout' // 模式 D:卡片排列型 + | 'tracing-writing' // 模式 E:描红书写型 + | 'full-page-asset' // 模式 F:全幅素材型 + | 'sequence-pattern' // 模式 G:序列/排序型 + | 'special-graphic'; // 模式 H:时钟/特殊图形型 /** * 数据生成器枚举(前端代码实现,新增需发版) */ type GeneratorType = - | 'arithmetic' // 加减乘除运算题 - | 'number-sequence' // 数列/排序 - | 'number-decompose' // 数的分与合 - | 'counting' // 数数类 - | 'comparison' // 比大小 - | 'shape-grid' // 图形网格 - | 'color-pattern' // 颜色规律 - | 'character-tracing' // 汉字描红(需 SVG 笔画数据) - | 'letter-tracing' // 字母描红 - | 'pinyin-tracing' // 拼音描红 - | 'static-asset' // 静态素材(不生成,直接用素材) - | 'maze' // 迷宫算法 - | 'dot-connect' // 点连线 - | 'clock' // 时钟 - | 'custom'; // 自定义(需指定 customGeneratorId) + | 'arithmetic' // 加减乘除运算题 + | 'number-sequence' // 数列/排序 + | 'number-decompose' // 数的分与合 + | 'counting' // 数数类 + | 'comparison' // 比大小 + | 'shape-grid' // 图形网格 + | 'color-pattern' // 颜色规律 + | 'character-tracing' // 汉字描红(需 SVG 笔画数据) + | 'letter-tracing' // 字母描红 + | 'pinyin-tracing' // 拼音描红 + | 'static-asset' // 静态素材(不生成,直接用素材) + | 'maze' // 迷宫算法 + | 'dot-connect' // 点连线 + | 'clock' // 时钟 + | 'custom'; // 自定义(需指定 customGeneratorId) /** * 排版配置 */ interface LayoutConfig { - columns?: number; // 列数 - rows?: number; // 行数 - fontSize?: number; // 字号 - showBorder?: boolean; // 是否显示边框 - showTitle?: boolean; // 是否显示标题 - padding?: number; // 内边距 - itemSpacing?: number; // 元素间距 - showInstruction?: boolean; // 是否显示题目说明文字 - instructionText?: string; // 说明文字内容 + columns?: number; // 列数 + rows?: number; // 行数 + fontSize?: number; // 字号 + showBorder?: boolean; // 是否显示边框 + showTitle?: boolean; // 是否显示标题 + padding?: number; // 内边距 + itemSpacing?: number; // 元素间距 + showInstruction?: boolean; // 是否显示题目说明文字 + instructionText?: string; // 说明文字内容 } /** * 用户可配置的参数定义(渲染到参数设置面板) */ interface UserConfigField { - key: string; // 对应 generatorConfig 中的 key - label: string; // 显示名称 + key: string; // 对应 generatorConfig 中的 key + label: string; // 显示名称 type: 'select' | 'slider' | 'switch'; - options?: { label: string; value: any }[]; // select 类型的选项 - min?: number; // slider 最小值 - max?: number; // slider 最大值 + options?: { label: string; value: any }[]; // select 类型的选项 + min?: number; // slider 最小值 + max?: number; // slider 最大值 defaultValue: any; } ``` @@ -737,7 +751,11 @@ abstract class BaseTemplateRenderer { this.paper = paper; } - async render(data: any, layout: LayoutConfig, printConfig: PrintConfig): Promise { + async render( + data: any, + layout: LayoutConfig, + printConfig: PrintConfig, + ): Promise { this.drawBackground(); this.drawHeader(printConfig); if (layout.showInstruction) { @@ -747,7 +765,10 @@ abstract class BaseTemplateRenderer { this.drawFooter(printConfig); } - protected abstract drawContent(data: any, layout: LayoutConfig): Promise; + protected abstract drawContent( + data: any, + layout: LayoutConfig, + ): Promise; } /** @@ -804,12 +825,15 @@ class TemplateEngine { async render( worksheetConfig: WorksheetConfig, - userParams: Record, // 用户在 UI 上调整的参数 + userParams: Record, // 用户在 UI 上调整的参数 ctx: CanvasRenderingContext2D, printConfig: PrintConfig, ): Promise { // 1. 合并用户参数到 generatorConfig - const mergedConfig = { ...worksheetConfig.generatorConfig, ...userParams }; + const mergedConfig = { + ...worksheetConfig.generatorConfig, + ...userParams, + }; // 2. 选择生成器,生成题目数据 const generator = this.generatorRegistry.get(worksheetConfig.generator); @@ -839,7 +863,7 @@ createPage({ const config = WorksheetService.getConfig(worksheetId); this.setData({ title: config.title, - userFields: config.userConfigurable, // 动态渲染参数面板 + userFields: config.userConfigurable, // 动态渲染参数面板 userParams: getDefaultParams(config), }); }, @@ -927,124 +951,34 @@ createPage({ --- -## 六、后端(云开发)详细设计 +## 六、后端概要设计 -### 6.1 云数据库集合设计 +> **现阶段(至 2026-09-15)**:云数据库、云存储、云函数及调用策略的**完整设计**见 → [小程序云开发方案](./小程序云开发方案.md)。 +> **阶段到期后可选迁移**:NestJS 项目结构、Prisma、REST API、部署等见 → [后端部署方案](./后端部署方案.md)。 -``` -云数据库 Collections: -┌─────────────────────────────────────────────────┐ -│ │ -│ worksheets (题型配置表) │ -│ ┌─────────────────────────────────────┐ │ -│ │ _id: string │ │ -│ │ category: string │ │ -│ │ subcategory: string │ │ -│ │ title: string │ │ -│ │ desc: string │ │ -│ │ ageRange: [number, number] │ │ -│ │ difficulty: 1|2|3|4 │ │ -│ │ previewImage: string │ │ -│ │ tags: string[] │ │ -│ │ isNew: boolean │ │ -│ │ isHot: boolean │ │ -│ │ sortOrder: number │ │ -│ │ downloadCount: number │ │ -│ │ status: 'active'|'draft'|'hidden' │ │ -│ │ │ │ -│ │ ── 模板引擎字段 ── │ │ -│ │ template: TemplateType │ ← 渲染模板│ -│ │ generator: GeneratorType │ ← 生成器 │ -│ │ generatorConfig: object │ ← 生成参数│ -│ │ layoutConfig: object │ ← 排版参数│ -│ │ userConfigurable: array │ ← 用户可调│ -│ │ legacyPage: string | null │ ← 旧页面 │ -│ │ │ │ -│ │ createdAt: Date │ │ -│ │ updatedAt: Date │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ categories (分类表) │ -│ ┌─────────────────────────────────────┐ │ -│ │ _id: string │ │ -│ │ name: string │ │ -│ │ icon: string │ │ -│ │ color: string │ │ -│ │ sortOrder: number │ │ -│ │ parentId: string | null │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ users (用户表) │ -│ ┌─────────────────────────────────────┐ │ -│ │ _id: string (openid) │ │ -│ │ uuid: string │ │ -│ │ nickName: string │ │ -│ │ avatarUrl: string │ │ -│ │ totalDownloads: number │ │ -│ │ createdAt: Date │ │ -│ │ lastActiveAt: Date │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ favorites (收藏表) │ -│ ┌─────────────────────────────────────┐ │ -│ │ _id: string │ │ -│ │ _openid: string │ │ -│ │ worksheetId: string │ │ -│ │ createdAt: Date │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ download_logs (下载日志表) │ -│ ┌─────────────────────────────────────┐ │ -│ │ _id: string │ │ -│ │ _openid: string │ │ -│ │ worksheetId: string │ │ -│ │ params: object (生成参数快照) │ │ -│ │ createdAt: Date │ │ -│ └─────────────────────────────────────┘ │ -│ │ -│ feedback (用户反馈表) │ -│ ┌─────────────────────────────────────┐ │ -│ │ _id: string │ │ -│ │ _openid: string │ │ -│ │ content: string │ │ -│ │ contact: string │ │ -│ │ createdAt: Date │ │ -│ └─────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────┘ -``` +### 6.1 现阶段:微信云开发(摘要) -### 6.2 云函数设计 +| 组件 | 技术 | 说明 | +| ---------- | ---------------------------- | -------------------------------------- | +| 数据 | 云数据库(文档型) | worksheets / categories / users 等集合 | +| 文件 | 云存储(COS) | 预览图、涂色线稿、字体等 | +| 逻辑 | 云函数(Node.js) | 统一入口路由、业务 action | +| 小程序接入 | `wx.cloud` + `cloud-adapter` | 与业务服务层解耦,便于日后替换为 HTTP | -``` -cloudfunctions/ -└── doodle/ - ├── index.js ← 统一入口(action 路由) - ├── package.json - ├── src/ - │ ├── user/ - │ │ ├── getOpenId.js ← ✅ 已有,保留 - │ │ └── updateProfile.js ← 🆕 更新用户信息 - │ ├── worksheet/ - │ │ ├── getList.js ← 🆕 获取题型列表(支持分类/筛选) - │ │ ├── getDetail.js ← 🆕 获取题型详情 - │ │ └── incrementDownload.js← 🆕 下载计数+1 - │ ├── favorite/ - │ │ ├── add.js ← 🆕 添加收藏 - │ │ ├── remove.js ← 🆕 取消收藏 - │ │ └── list.js ← 🆕 我的收藏列表 - │ ├── history/ - │ │ └── list.js ← 🆕 下载历史 - │ ├── feedback/ - │ │ └── submit.js ← 🆕 提交反馈 - │ └── pdf/ - │ └── generate.js ← 🆕 PDF 生成(远期增值功能,会员专属) - └── common/ - ├── responseMiddleware.js ← ✅ 已有,保留 - └── auth.js ← 🆕 鉴权中间件 -``` +### 6.2 远期可选:自建 NestJS(摘要) -### 6.3 云函数调用策略 +| 组件 | 技术 | 说明 | +| ---- | ----------------------- | --------------------------- | +| 应用 | NestJS + TypeScript | RESTful API,多端统一 | +| 数据 | MySQL 8.0 + Prisma | 关系型存储与迁移 | +| 文件 | 本地磁盘 + Nginx | 静态素材 URL 由自有域名提供 | +| 鉴权 | 微信 code2Session + JWT | 小程序与 Web 可共用逻辑 | + +(接口路径、表结构、部署步骤等以 [后端部署方案](./后端部署方案.md) 为准,此处不重复。) + +### 6.3 数据加载策略(两阶段一致的产品策略) + +无论后端是云开发还是 HTTP API,**客户端侧优先级**保持一致: ``` ┌────────────────────────────────────────────────────────────┐ @@ -1053,7 +987,7 @@ cloudfunctions/ │ 题型配置数据 (worksheets/categories) │ │ ┌────────────────────────────────────────┐ │ │ │ 优先级 1: 本地缓存(wx.Storage) │ │ -│ │ 优先级 2: 云数据库查询 │ │ +│ │ 优先级 2: 云端拉取(云 DB 或 HTTP API) │ │ │ │ 优先级 3: 前端内置兜底数据 │ ← 保证离线可用 │ │ └────────────────────────────────────────┘ │ │ │ @@ -1062,49 +996,19 @@ cloudfunctions/ │ • 后续启动:先用缓存渲染 → 后台静默更新 │ │ • 缓存有效期:24 小时 │ │ • 无网络:使用本地缓存或内置兜底 │ -│ │ -│ 用户数据 (favorites/history) │ -│ ┌────────────────────────────────────────┐ │ -│ │ 本地优先写入 → 后台同步云端 │ │ -│ │ 冲突策略:以云端为准(云端时间戳更新) │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ 统计数据 (download count) │ -│ ┌────────────────────────────────────────┐ │ -│ │ 批量上报:本地累计 → 退出时/定时上报 │ │ -│ │ 非关键路径,允许丢失 │ │ -│ └────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────┘ ``` -### 6.4 云存储规划 +### 6.4 静态素材 URL(现阶段) + +素材使用 **云存储 fileID / 临时 URL**,例如: ``` -云存储目录结构: -cloud://doodle-xxx.xxxx/ -├── assets/ -│ ├── previews/ ← 题型效果预览图 -│ │ ├── math/ -│ │ ├── chinese/ -│ │ ├── english/ -│ │ ├── puzzle/ -│ │ └── craft/ -│ ├── coloring/ ← 涂色卡线稿(SVG/PNG) -│ │ ├── animals/ -│ │ ├── vehicles/ -│ │ ├── holidays/ -│ │ └── ... -│ ├── origami/ ← 折纸展开图 -│ ├── stickers/ ← 贴纸素材 -│ ├── maze-templates/ ← 迷宫模板数据(JSON) -│ └── craft-templates/ ← 手工模板 -├── fonts/ ← 字体文件 -│ ├── SimHei.ttf -│ └── handwriting.ttf -└── share/ ← 分享图 - └── default-share.png +cloud://doodle-xxx.xxxx/assets/coloring/animals/dinosaur-01.svg ``` +(目录规划见 [小程序云开发方案](./小程序云开发方案.md) 第六节。迁移自建后改为 `https://你的域名/static/...`。) + --- ## 七、关键技术方案 @@ -1177,7 +1081,7 @@ class WxImageAdapter implements IImageAdapter { │ 微信聊天直接发图片,接收方零门槛查看 │ │ │ │ 4. 零服务器成本 │ -│ Canvas 本地渲染,不消耗云函数算力和存储 │ +│ Canvas 本地渲染,不消耗云函数算力与云存储(主路径) │ │ 用户量增长不会带来额外成本 │ │ │ │ 5. 技术架构成熟 │ @@ -1189,7 +1093,7 @@ class WxImageAdapter implements IImageAdapter { → 用户打印时多选图片即可 PDF 导出方案(远期增值功能,会员专属): - 小程序:客户端渲染多张 PNG → 上传云存储 → 云函数用 pdfkit 合并为 PDF + 小程序:客户端渲染多张 PNG → 上传云存储 → 云函数 pdfkit 合并为 PDF(迁移自建后同逻辑可落在 NestJS) 定位:会员订阅的差异化权益,非核心路径 ``` @@ -1233,167 +1137,28 @@ PDF 导出方案(远期增值功能,会员专属): └──────────────────────────────────────────────────────┘ ``` ---- - -## 八、PC Web 端技术预案(远期) - -> PC Web 端属于远期规划,详细方案见独立文档:[PC-Web端技术预案](./PC-Web端技术预案.md) -> -> **当前阶段的准备**:只需确保 `core/` 层内不 import 任何 `wx.*` API,未来迁移时提取为独立 npm 包即可。 - ---- - -## 九、前端重构路线图 - -### Phase 1:基础重构(第 1-2 周) - -``` -优先级:🔴 关键 - -目标:不改变现有功能,优化代码结构 - -1. 修复技术债务 - ├── 统一云函数名称(callCloud 中 robotaxi → doodle) - ├── 清理 cloudfunctions/doodle/index.js 中无效的模块引用 - ├── 移除 debug 页中指向不存在页面的死链 - └── 清理未使用的代码和资源 - -2. 建立 core/ 目录 - ├── 将 constants/ 迁移到 core/data/ - ├── 将纯工具函数迁移到 core/utils/ - ├── 将 DrawService 基类提取到 core/draw/ - └── 定义统一的 WorksheetType 数据模型 - -3. 建立 platform/ 目录 - ├── 抽取 canvas-adapter(封装 wx Canvas API) - ├── 抽取 storage-adapter(封装 wx.Storage) - └── 抽取 image-adapter(封装图片加载) - -4. 统一 WXML 模板 - └── 合并 mathPages 和 focusPages 的重复 canvas-page-template -``` - -### Phase 2:模板引擎 + 首页重构 + 配置化(第 3-5 周) - -``` -优先级:🟡 重要(模板引擎是后续扩展的基础) - -1. 模板引擎核心开发 - ├── 实现 BaseTemplateRenderer 基类 - ├── 实现 TemplateEngine 入口(配置解析 + 渲染器/生成器调度) - ├── 实现 RendererRegistry / GeneratorRegistry - ├── 开发通用 worksheet 页面(pages/worksheet/worksheet) - │ └── 根据 JSON 配置动态渲染参数面板 + Canvas 预览 - ├── 优先实现 3 种高复用模板渲染器: - │ ├── grid-exercise(网格计算型,覆盖 ~15 种现有数学题) - │ ├── full-page-asset(全幅素材型,覆盖涂色卡/折纸等新内容) - │ └── tracing-writing(描红书写型,覆盖练字/字母/拼音) - └── 将 2-3 种现有题型试点迁移到模板引擎(验证可行性) - -2. 数据模型升级 - ├── WorksheetConfig 增加 template/generator/layoutConfig 等字段 - ├── 定义 Category 模型 - ├── 重构 MATH_FUNCTION_TYPES / FOCUS_FUNCTION_TYPES 为统一 JSON 格式 - └── 编写存量题型的 JSON 配置映射 - -3. 首页重构 - ├── 新建 pages/home/ 替代原四个 Tab 入口页 - ├── 实现分类标签栏 + 搜索 + 推荐区 - ├── 实现年龄筛选、难度筛选 - └── 使用新的 worksheet-card 组件(带预览图) - -4. TabBar 重构 - └── 发现 | 分龄 | 收藏 | 我的 - -5. 云数据库初始化 - ├── 建表:worksheets(含模板引擎字段)、categories - ├── 编写数据初始化脚本(存量题型 JSON 导入) - └── 实现前端数据加载(缓存优先 + 云端更新) -``` - -### Phase 3:模板扩展 + 新内容接入(第 6-8 周) - -``` -优先级:🟡 重要 - -1. 补全剩余模板渲染器 - ├── match-connect(配对连线型) - ├── grid-coloring(网格涂色型) - ├── card-layout(卡片排列型) - ├── sequence-pattern(序列/排序型) - └── special-graphic(时钟/特殊图形型) - -2. 新增题型(通过 JSON 配置 + 素材上传,大部分无需写新代码) - ├── 数学:时钟练习(需 special-graphic 渲染器) - ├── 语文:拼音练习(复用 tracing-writing) - ├── 英语:字母描红(复用 tracing-writing + letter-tracing 生成器) - ├── 英语:字母闪卡(复用 card-layout + static-asset) - ├── 益智:控笔练习(复用 tracing-writing) - └── 创意:涂色卡(复用 full-page-asset,仅需上传素材) - -3. 存量题型批量迁移 - ├── 批次 1:grid-exercise 类 (~15 种) - ├── 批次 2:match-connect 类 (~6 种) - └── 批次 3:grid-coloring 类 (~8 种) - -4. 完善新分包 - ├── english/ 分包 - ├── puzzle/ 分包 - └── craft/ 分包 -``` - -### Phase 4:体验与功能升级(第 9-10 周) - -``` -优先级:🟢 增强 - -1. 用户体系 - ├── 微信登录 - ├── 收藏功能(本地 + 云端同步) - └── 下载历史 - -2. 打印体验 - ├── 打印指南页面 - ├── 批量生成图片(多张保存到相册) - └── 客户端渲染性能优化 - -3. 运营能力 - ├── 数据埋点完善 - ├── 下载统计展示(热门排行) - └── 用户反馈入口 - -4. 远期增值功能 - └── PDF 导出(会员专属,云函数合并 PNG 为 PDF) -``` - ---- - ## 十、成本预估 -### 9.1 微信云开发费用 +### 10.1 后端费用 ``` -免费额度(基础版 1): +现阶段(微信云开发,至 2026-09-15 复盘): ┌────────────────────────────────────────┐ -│ 云数据库:2 GB 存储 / 50 万次读写/天 │ -│ 云存储:5 GB / 2 GB 下载/天 │ -│ 云函数:10 万次调用/月 / 1000 GBs/月 │ -│ CDN:5 GB/月 │ -└────────────────────────────────────────┘ - -预估用量(DAU 1000): -┌────────────────────────────────────────┐ -│ 云数据库:~100 MB(足够) │ -│ 云存储:~2 GB(素材渐增) │ -│ 云函数:~3 万次/月 │ -│ CDN:~3 GB/月 │ +│ 免费额度内:云数据库 / 云存储 / 云函数 │ +│ 预估早期 DAU 下可落在免费额度内 │ +│ 超出后按套餐计费(如 19.9 元/月起) │ │ │ -│ 结论:免费额度可覆盖到 DAU 3000 左右 │ -│ 超出后升级到 19.9 元/月即可 │ +│ 详见 → 小程序云开发方案.md 第七节 │ +└────────────────────────────────────────┘ + +到期后若迁移自建(参考 后端部署方案.md): +┌────────────────────────────────────────┐ +│ 已有云服务器可做到增量 ¥0(仅运维) │ +│ 需在 2026-09-15 前对比两路径总成本 │ └────────────────────────────────────────┘ ``` -### 9.2 开发资源 +### 10.2 开发资源 ``` 开发投入估算: @@ -1415,44 +1180,46 @@ PDF 导出方案(远期增值功能,会员专属): ## 十一、技术风险与应对 -| 风险 | 影响 | 应对方案 | -| -------------------- | ------------------ | ----------------------------------------------------------------- | -| 云开发免费额度不够 | 功能受限 | ① 优化查询减少调用 ② 本地缓存减少读取 ③ 升级付费版(19.9元/月起) | -| Canvas 兼容性 | 低端机渲染异常 | ① 使用 Canvas 2D(已采用)② 控制单次绘制复杂度 ③ 降级方案 | -| 小程序包体积 | 超 2MB 限制 | ① 素材用 CDN / 云存储 ② 合理分包 ③ 图片压缩 | -| 素材制作瓶颈 | 涂色卡等需设计资源 | ① 使用开源 SVG 素材 ② AI 生成线稿 ③ 社区投稿 | -| 跨平台迁移成本 | Web 端重复开发 | ① core/ 层保持平台无关 ② 适配器模式隔离差异(详见 [PC-Web端技术预案](./PC-Web端技术预案.md)) | +| 风险 | 影响 | 应对方案 | +| -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------- | +| 云开发免费额度不足 | 功能受限或需付费 | ① 优化查询与缓存 ② 控制云函数调用 ③ **2026-09-15 前**复盘是否升级套餐或迁移 [后端部署方案](./后端部署方案.md) | +| 服务器宕机(迁移自建后) | 后端不可用 | ① PM2 自动重启 ② 前端兜底数据 ③ 定期备份数据库 | +| Canvas 兼容性 | 低端机渲染异常 | ① 使用 Canvas 2D(已采用)② 控制单次绘制复杂度 ③ 降级方案 | +| 小程序包体积 | 超 2MB 限制 | ① 素材用云存储 / CDN ② 合理分包 ③ 图片压缩 | +| 素材制作瓶颈 | 涂色卡等需设计资源 | ① 使用开源 SVG 素材 ② AI 生成线稿 ③ 社区投稿 | +| 跨平台迁移成本 | Web 端重复开发 | ① core/ 层保持平台无关 ② 适配器隔离差异(详见 [PC-Web端技术预案](./PC-Web端技术预案.md)) | +| 带宽与加载(迁移自建远期) | 素材加载慢 | ① 图片压缩 ② 长缓存 ③ 可接入 CDN | --- ## 附录 A:现有代码与目标架构映射 -| 现有文件 | 目标位置 | 迁移动作 | -| -------------------------------- | -------------------------------------- | --------------------- | -| `constants/mathFunctions.ts` | `core/data/worksheets.ts` | 合并,增加字段 | -| `constants/focusFunctions.ts` | `core/data/worksheets.ts` | 合并,增加字段 | -| `constants/words.ts` | `core/data/words.ts` | 移动 | -| `constants/colors.ts` | `core/data/colors.ts` | 移动 | -| `constants/shapes.ts` | `core/data/shapes.ts` | 移动 | -| `service/baseDraw.ts` | `core/draw/base-draw.ts` | 重构,去除 wx.\* 依赖 | -| `service/wordDrawService.ts` | `core/draw/chinese-draw/word-draw.ts` | 移动,去除 wx.\* | -| `service/drawServiceFactory.ts` | `core/draw/draw-factory.ts` | 扩展 | -| `mathPages/shared/service/*.ts` | `core/draw/legacy/math-draw/*.ts` | 移入 legacy,去除 wx.\*,逐步迁移到模板引擎 | -| `focusPages/shared/service/*.ts` | `core/draw/legacy/focus-draw/*.ts` | 移入 legacy,去除 wx.\*,逐步迁移到模板引擎 | -| `base/pageMixin.ts` | `services/print-service.ts` + 页面代码 | 拆分职责 | -| `base/callCloud.ts` | `platform/cloud-adapter.ts` | 重构 | -| `utils/saveImage.ts` | `platform/canvas-adapter.ts` | 合并到适配器 | -| `utils/downloadPrint.ts` | `services/print-service.ts` | 整合 | -| `utils/tracker.ts` | `services/stats-service.ts` | 整合 | -| `config/config.ts` | `core/models/print-config.ts` | 类型化 | +| 现有文件 | 目标位置 | 迁移动作 | +| -------------------------------- | -------------------------------------- | ------------------------------------------------ | +| `constants/mathFunctions.ts` | `core/data/worksheets.ts` | 合并,增加字段 | +| `constants/focusFunctions.ts` | `core/data/worksheets.ts` | 合并,增加字段 | +| `constants/words.ts` | `core/data/words.ts` | 移动 | +| `constants/colors.ts` | `core/data/colors.ts` | 移动 | +| `constants/shapes.ts` | `core/data/shapes.ts` | 移动 | +| `service/baseDraw.ts` | `core/draw/base-draw.ts` | 重构,去除 wx.\* 依赖 | +| `service/wordDrawService.ts` | `core/draw/chinese-draw/word-draw.ts` | 移动,去除 wx.\* | +| `service/drawServiceFactory.ts` | `core/draw/draw-factory.ts` | 扩展 | +| `mathPages/shared/service/*.ts` | `core/draw/legacy/math-draw/*.ts` | 移入 legacy,去除 wx.\*,逐步迁移到模板引擎 | +| `focusPages/shared/service/*.ts` | `core/draw/legacy/focus-draw/*.ts` | 移入 legacy,去除 wx.\*,逐步迁移到模板引擎 | +| `base/pageMixin.ts` | `services/print-service.ts` + 页面代码 | 拆分职责 | +| `base/callCloud.ts` | `platform/cloud-adapter.ts` | 封装 wx.cloud;迁移 NestJS 后再考虑 http-adapter | +| `utils/saveImage.ts` | `platform/canvas-adapter.ts` | 合并到适配器 | +| `utils/downloadPrint.ts` | `services/print-service.ts` | 整合 | +| `utils/tracker.ts` | `services/stats-service.ts` | 整合 | +| `config/config.ts` | `core/models/print-config.ts` | 类型化 | ## 附录 B:技术选型决策记录 -| 决策点 | 选项 | 决策 | 理由 | -| -------------- | ---------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| 跨端框架 | Taro / uni-app / 原生 | **原生** | 项目已用原生开发,迁移成本高;Taro 等框架对 Canvas 2D 支持有限;core/ 层抽取足以实现代码复用 | -| 状态管理 | MobX / 自定义 / 原生 setData | **轻量自定义** | 小程序场景简单,不需要 Redux 级方案;页面级 setData + 全局 Store 够用 | -| UI 组件库 | Vant / 自研 | **Vant + 自定义组件** | 已用 Vant,保持;业务组件自研 | -| 云开发 vs 自建 | 云开发 / 云服务器 | **云开发** | 零运维、免费额度充足、原生集成鉴权 | -| 导出方案 | PNG 保存相册 / PDF | **PNG 为主,PDF 为增值** | PNG 保存到相册是手机端最短路径(用户教育成本低、零服务器开销);PDF 作为远期会员增值功能,小程序用云端 pdfkit 合并,Web 用 jsPDF | -| Monorepo | pnpm workspace / Turborepo | **暂不采用** | 当前只有小程序,过早引入增加复杂度;core/ 作为目录组织,未来再拆包 | +| 决策点 | 选项 | 决策 | 理由 | +| --------- | ---------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| 跨端框架 | Taro / uni-app / 原生 | **原生** | 项目已用原生开发,迁移成本高;Taro 等框架对 Canvas 2D 支持有限;core/ 层抽取足以实现代码复用 | +| 状态管理 | MobX / 自定义 / 原生 setData | **轻量自定义** | 小程序场景简单,不需要 Redux 级方案;页面级 setData + 全局 Store 够用 | +| UI 组件库 | Vant / 自研 | **Vant + 自定义组件** | 已用 Vant,保持;业务组件自研 | +| 后端方案 | 云开发 / 自有云服务器 | **现阶段云开发(至 2026-09-15)** | 详见 [小程序云开发方案](./小程序云开发方案.md);到期后可选迁移 [后端部署方案](./后端部署方案.md)(NestJS) | +| 导出方案 | PNG 保存相册 / PDF | **PNG 为主,PDF 为增值** | PNG 保存到相册是手机端最短路径(用户教育成本低、零服务器开销);PDF 作为远期会员增值功能,服务端 pdfkit 合并,Web 用 jsPDF | +| Monorepo | pnpm workspace / Turborepo | **暂不采用** | 当前只有小程序,过早引入增加复杂度;core/ 作为目录组织,未来再拆包 | diff --git a/miniprogram/app.json b/miniprogram/app.json index dc0cce9..12f1834 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -86,8 +86,8 @@ }, "tabBar": { "color": "#999999", - "selectedColor": "#333333", - "backgroundColor": "#ffffff", + "selectedColor": "#FFD709", + "backgroundColor": "#F8F0E0", "borderStyle": "white", "list": [ { diff --git a/miniprogram/components/shared/category-tabs/category-tabs.json b/miniprogram/components/shared/category-tabs/category-tabs.json index 59a1db6..278aeab 100644 --- a/miniprogram/components/shared/category-tabs/category-tabs.json +++ b/miniprogram/components/shared/category-tabs/category-tabs.json @@ -1,4 +1,5 @@ { "component": true, + "styleIsolation": "apply-shared", "usingComponents": {} } diff --git a/miniprogram/components/shared/category-tabs/category-tabs.less b/miniprogram/components/shared/category-tabs/category-tabs.less index 151c82d..9e47e2f 100644 --- a/miniprogram/components/shared/category-tabs/category-tabs.less +++ b/miniprogram/components/shared/category-tabs/category-tabs.less @@ -13,7 +13,7 @@ flex-direction: row; align-items: center; gap: 16rpx; - padding: 16rpx 24rpx; + padding: 30rpx 24rpx; box-sizing: border-box; } @@ -33,4 +33,4 @@ background: #f7ee47; font-weight: 600; color: rgba(0, 0, 0, 0.88); -} +} \ No newline at end of file diff --git a/miniprogram/components/shared/worksheet-card/worksheet-card.json b/miniprogram/components/shared/worksheet-card/worksheet-card.json index 59a1db6..278aeab 100644 --- a/miniprogram/components/shared/worksheet-card/worksheet-card.json +++ b/miniprogram/components/shared/worksheet-card/worksheet-card.json @@ -1,4 +1,5 @@ { "component": true, + "styleIsolation": "apply-shared", "usingComponents": {} } diff --git a/miniprogram/core/data/categories.ts b/miniprogram/core/data/categories.ts index 6eb62c7..093ff18 100644 --- a/miniprogram/core/data/categories.ts +++ b/miniprogram/core/data/categories.ts @@ -1,16 +1,16 @@ -import { CategoryType } from '../models/worksheet'; +import type { CategoryType } from '../models/category'; export const CATEGORIES: CategoryType[] = [ { id: 'math', - name: '数学启蒙', + name: '数感启蒙', icon: '📐', color: '#e3f2fd', gradient: ['#fff9c4', '#fff176'], }, { id: 'chinese', - name: '语文启蒙', + name: '趣味识字', icon: '✏️', color: '#e8f5e9', gradient: ['#c8e6c9', '#a5d6a7'], @@ -22,13 +22,13 @@ export const CATEGORIES: CategoryType[] = [ color: '#e0f7fa', gradient: ['#b2ebf2', '#80deea'], }, - { - id: 'focus', - name: '专注力', - icon: '🧩', - color: '#f3e5f5', - gradient: ['#e1bee7', '#ce93d8'], - }, + // { + // id: 'focus', + // name: '专注力', + // icon: '🧩', + // color: '#f3e5f5', + // gradient: ['#e1bee7', '#ce93d8'], + // }, { id: 'puzzle', name: '益智游戏', diff --git a/miniprogram/core/data/difficulty.ts b/miniprogram/core/data/difficulty.ts new file mode 100644 index 0000000..679d73f --- /dev/null +++ b/miniprogram/core/data/difficulty.ts @@ -0,0 +1,39 @@ +import type { DifficultyLevel } from '../models/worksheet'; + +/** 产品文档:难度 入门 / 基础 / 进阶 / 挑战 → 云库 difficulty 1–4 */ +export const DIFFICULTY_LEVEL_TO_STARS: Record< + DifficultyLevel, + 1 | 2 | 3 | 4 +> = { + beginner: 1, + basic: 2, + intermediate: 3, + advanced: 4, +}; + +export const STARS_TO_DIFFICULTY_LEVEL: Record< + 1 | 2 | 3 | 4, + DifficultyLevel +> = { + 1: 'beginner', + 2: 'basic', + 3: 'intermediate', + 4: 'advanced', +}; + +export function difficultyToStars( + level?: DifficultyLevel, +): 1 | 2 | 3 | 4 { + return level ? DIFFICULTY_LEVEL_TO_STARS[level] : 2; +} + +/** 分龄 Tab / 筛选(现有功能清单 + 产品设计 3-8 岁) */ +export const AGE_BANDS = [ + { key: '3-4', minAge: 3, maxAge: 4, label: '3-4 岁' }, + { key: '4-5', minAge: 4, maxAge: 5, label: '4-5 岁' }, + { key: '5-6', minAge: 5, maxAge: 6, label: '5-6 岁' }, + { key: '6-7', minAge: 6, maxAge: 7, label: '6-7 岁' }, + { key: '7-8', minAge: 7, maxAge: 8, label: '7-8 岁' }, +] as const; + +export type AgeBandKey = (typeof AGE_BANDS)[number]['key']; diff --git a/miniprogram/core/data/words.ts b/miniprogram/core/data/words.ts new file mode 100644 index 0000000..f59f76c --- /dev/null +++ b/miniprogram/core/data/words.ts @@ -0,0 +1,62 @@ +/** + * 识字 Tab 词库分类(现有功能清单 §四):供生成识字卡、与 future worksheet-service 对齐 + */ + +export interface WordCategory { + id: string; + name: string; + /** 示例与常用字,可渐进扩充 */ + words: string[]; +} + +// prettier-ignore +export const WORD_BANKS: WordCategory[] = [ + { + id: 'basic-stroke', + name: '基础独体字', + words: ['人', '口', '手', '上', '下', '日', '月', '水', '火', '山', '大', '小', '木', '门', '心', '子', '女', '马', '牛', '羊'], + }, + { + id: 'daily-highfreq', + name: '生活高频字', + words: ['爸', '妈', '书', '包', '吃', '喝', '走', '跑', '睡', '醒', '玩', '学', '校', '衣', '裤', '鞋', '床', '灯', '车', '门'], + }, + { + id: 'nature-action', + name: '自然与动作', + words: ['风', '雪', '星', '光', '春', '夏', '秋', '冬', '雨', '云', '花', '草', '树', '叶', '看', '听', '说', '想', '开', '关'], + }, + { + id: 'measure', + name: '常用量词', + words: ['个', '只', '条', '本', '张', '块', '头', '匹', '位', '双', '串', '瓶', '盒', '杯', '碗', '把', '朵', '颗', '辆', '件'], + }, + { + id: 'color', + name: '颜色', + words: ['红', '黄', '蓝', '绿', '白', '黑', '紫', '粉', '灰', '橙', '金', '银', '青', '棕', '米', '肉', '雪', '墨', '桃', '奶'], + }, + { + id: 'animal', + name: '动物', + words: ['猫', '狗', '鸟', '鱼', '兔', '鸡', '鸭', '猪', '猴', '象', '虎', '熊', '狮', '鹿', '鼠', '蛇', '龟', '虾', '贝', '虫'], + }, + { + id: 'food', + name: '食物', + words: ['米', '饭', '面', '汤', '菜', '肉', '蛋', '奶', '果', '瓜', '糖', '饼', '饺', '粥', '茶', '豆', '茄', '笋', '枣', '梨'], + }, + { + id: 'body-health', + name: '身体与健康', + words: ['头', '眼', '耳', '鼻', '嘴', '牙', '臂', '腿', '脚', '汗', '痛', '病', '药', '医', '笑', '哭', '渴', '饿', '饱', '累'], + }, +]; + +export function getWordCategoryById(id: string): WordCategory | undefined { + return WORD_BANKS.find((c) => c.id === id); +} + +export function getAllWordCategories(): WordCategory[] { + return WORD_BANKS; +} diff --git a/miniprogram/core/data/worksheets.ts b/miniprogram/core/data/worksheets.ts index 8cab68b..cd4df26 100644 --- a/miniprogram/core/data/worksheets.ts +++ b/miniprogram/core/data/worksheets.ts @@ -1,7 +1,14 @@ -import { WorksheetType } from '../models/worksheet'; +import type { + GeneratorType, + LayoutConfig, + TemplateType, + WorksheetDefinition, + WorksheetType, +} from '../models/worksheet'; +import { difficultyToStars } from './difficulty'; /** - * MathFunctionType - 保持向后兼容 + * MathFunctionType - 保持向后兼容(mathPageMixin / mathIndex) */ export interface MathFunctionType { id: string; @@ -13,7 +20,7 @@ export interface MathFunctionType { } /** - * FocusFunctionType - 保持向后兼容 + * FocusFunctionType - 保持向后兼容(focusPageMixin / focusIndex) */ export interface FocusFunctionType { id: string; @@ -25,128 +32,270 @@ export interface FocusFunctionType { img?: string; } +interface SheetEngine { + subcategory: string; + template: TemplateType; + generator: GeneratorType; + generatorConfig?: Record; + layoutConfig?: LayoutConfig; + tags?: string[]; + sortOrder: number; +} + +function legacyPath(ui: WorksheetType): string | null { + if (!ui.page) return null; + if (ui.subpackage) { + return `/${ui.subpackage}/${ui.page}/${ui.page}`; + } + return `/pages/${ui.page}/${ui.page}`; +} + +function sheet(ui: WorksheetType, engine: SheetEngine): WorksheetDefinition { + const ageMin = ui.ageRange?.[0] ?? 3; + const ageMax = ui.ageRange?.[1] ?? 8; + return { + ...ui, + subcategory: engine.subcategory, + ageMin, + ageMax, + difficultyLevel: difficultyToStars(ui.difficulty), + previewImage: ui.img ?? '', + tags: engine.tags?.length ? engine.tags : [engine.subcategory], + isNew: false, + isHot: false, + sortOrder: engine.sortOrder, + downloadCount: 0, + status: 'active', + template: engine.template, + generator: engine.generator, + generatorConfig: engine.generatorConfig ?? {}, + layoutConfig: engine.layoutConfig ?? { showBorder: true }, + userConfigurable: null, + legacyPage: legacyPath(ui), + }; +} + +const L = { + gridEx: { showBorder: true, fontSize: 18 } satisfies LayoutConfig, + card: { showBorder: true, columns: 2, fontSize: 16 } satisfies LayoutConfig, + trace: { + showBorder: true, + showInstruction: true, + fontSize: 20, + } satisfies LayoutConfig, +}; + +// 现有功能清单 §二:数感启蒙 26 种 + §三 13 种 + §四 §五 语文 2 // prettier-ignore -export const MATH_FUNCTION_TYPES: MathFunctionType[] = [ - { id: 'number-sort', page: 'numberSort', title: '数字排序', desc: '数字排序,写出正确顺序', icon: '🔢', img: '/assets/mathEntrance/number-sort.png' }, - { id: 'one-digit-addition', page: 'oneDigitAddition', title: '一位数加法', desc: '通过圆点学习一位数加法运算', icon: '➕', img: '/assets/mathEntrance/one-digit-addition.png' }, - { id: 'make-ten', page: 'makeTen', title: '凑十法练习', desc: '通过凑十法学习20以内进位加法', icon: '➕', img: '/assets/mathEntrance/make-ten.png' }, - { id: 'break-ten', page: 'breakTen', title: '破十法练习', desc: '通过破十法学习20以内退位减法', icon: '➖', img: '/assets/mathEntrance/break-ten.png' }, - { id: 'flat-ten', page: 'flatTen', title: '平十法练习', desc: '通过平十法学习20以内退位减法', icon: '➖', img: '/assets/mathEntrance/flat-ten.png' }, - { id: 'borrow-ten', page: 'borrowTen', title: '借十法练习', desc: '通过借十法学习20以上退位减法', icon: '➖', img: '/assets/mathEntrance/borrow-ten.png' }, - { id: 'number-decompose-20', page: 'numberDecompose', title: '20以内数的分与合', desc: '把数字分一分,合一合', icon: '🔢', img: '/assets/mathEntrance/number-decompose-20.png' }, - { id: 'number-decompose', page: 'numberDecompose', title: '10以内数的分与合', desc: '把数字分一分,合一合', icon: '🔢', img: '/assets/mathEntrance/number-decompose.png' }, - { id: 'number-find', page: 'numberFind', title: '找数字,涂一涂', desc: '找一找下面相同的数字,涂上颜色', img: '/assets/mathEntrance/number-find.png' }, - { id: 'number-write', page: 'numberFind', title: '看数字,写一写', desc: '看数字,按笔画顺序写一写', img: '/assets/mathEntrance/number-write.png' }, - { id: 'number-coloring', page: 'countMatch', title: '按数字,涂颜色', desc: '按数字给相应的圆圈涂上颜色', icon: '🎨', img: '/assets/mathEntrance/number-coloring.png' }, - { id: 'counting-matching', page: 'countMatch', title: '数一数,连一连', desc: '通过连线配对数字和对应的数量图形', icon: '🔗', img: '/assets/mathEntrance/count-match.png' }, - { id: 'number-object-match', page: 'numberObjectMatch', title: '数物连线', desc: '连线相同数量的物品和数字', icon: '🔗', img: '/assets/mathEntrance/number-object-match.png' }, - { id: 'number-object-fill', page: 'numberObjectMatch', title: '数物填写', desc: '数一数物品数量,填写对应的数字', icon: '✏️', img: '/assets/mathEntrance/number-object-fill.png' }, - { id: 'counting-select', page: 'countingSelect', title: '数一数,选一选', desc: '数出物品数量,圈出正确答案', icon: '✓', img: '/assets/mathEntrance/counting-select.png' }, - { id: 'counting-fill', page: 'countingSelect', title: '数一数,填一填', desc: '数出物品数量,填写对应的数字', icon: '✏️', img: '/assets/mathEntrance/counting-fill.png' }, - { id: 'compare', page: 'compare', title: '数一数,比大小', desc: '比较数量大小,在 ⭕️ 中填入>、<、=', icon: '⚖️', img: '/assets/mathEntrance/compare.png' }, - { id: 'missing-number', page: 'missingNumber', title: '填上缺少的数字', desc: '在数字序列中找出并填写缺失的数字', icon: '❓', img: '/assets/mathEntrance/missing-number.png' }, - { id: 'addition-5', page: 'addition', title: '5以内加法', desc: '练习5以内的加法运算,图形化展示', icon: '➕', img: '/assets/mathEntrance/addition-5.png' }, - { id: 'addition-10', page: 'addition', title: '10以内加法', desc: '练习10以内的加法运算,图形化展示', icon: '➕', img: '/assets/mathEntrance/addition-10.png' }, - { id: 'subtraction-10', page: 'addition', title: '10以内减法', desc: '练习10以内的减法运算,图形化展示', icon: '➖', img: '/assets/mathEntrance/subtraction-10.png' }, - { id: 'addition-subtraction-10', page: 'addition', title: '10以内加减法', desc: '练习10以内的加法和减法混合运算', icon: '±', img: '/assets/mathEntrance/addition-subtraction-10.png' }, - { id: 'practice-addition', page: 'calculationPractice', title: '加法运算', desc: '练习10/20/50/100以内加法运算', icon: '➕', img: '/assets/mathEntrance/practice-addition.png' }, - { id: 'practice-subtraction', page: 'calculationPractice', title: '减法运算', desc: '练习10/20/50/100以内减法运算', icon: '➖', img: '/assets/mathEntrance/practice-subtraction.png' }, - { id: 'practice-mixed', page: 'calculationPractice', title: '混合运算', desc: '练习10/20/50/100以内加减法混合运算', icon: '±', img: '/assets/mathEntrance/practice-mixed.png' }, - { id: 'multiplication-table', page: 'multiplicationTable', title: '九九乘法表', desc: '学习九九乘法口诀', icon: '✖️', img: '/assets/mathEntrance/multiplication-table.png' }, +export const ALL_WORKSHEETS: WorksheetDefinition[] = [ + sheet( + { id: 'number-find', title: '找数字,涂一涂', desc: '在数字方阵中找出目标数字并涂色', category: 'math', page: 'numberFind', subpackage: 'mathPages', img: '/assets/mathEntrance/number-find.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fff3e0', previewText: '🔍 1 2 3' }, + { subcategory: 'number-sense', template: 'grid-coloring', generator: 'counting', sortOrder: 1, layoutConfig: { ...L.gridEx }, generatorConfig: { functionId: 'number-find' } }, + ), + sheet( + { id: 'number-write', title: '看数字,写一写', desc: '按笔画顺序练习书写数字', category: 'math', page: 'numberFind', subpackage: 'mathPages', img: '/assets/mathEntrance/number-write.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fff3e0', previewText: '1 2 3' }, + { subcategory: 'number-sense', template: 'tracing-writing', generator: 'counting', sortOrder: 2, layoutConfig: { ...L.trace }, generatorConfig: { functionId: 'number-write' } }, + ), + sheet( + { id: 'number-coloring', title: '按数字,涂颜色', desc: '按指定数字给对应圆圈涂色', category: 'math', page: 'countMatch', subpackage: 'mathPages', icon: '🎨', img: '/assets/mathEntrance/number-coloring.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '🎨' }, + { subcategory: 'number-sense', template: 'grid-coloring', generator: 'counting', sortOrder: 3, layoutConfig: { ...L.gridEx }, generatorConfig: { functionId: 'number-coloring' } }, + ), + sheet( + { id: 'counting-matching', title: '数一数,连一连', desc: '连线配对数字和对应数量图形', category: 'math', page: 'countMatch', subpackage: 'mathPages', icon: '🔗', img: '/assets/mathEntrance/count-match.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '3↔🍎🍎🍎' }, + { subcategory: 'counting', template: 'match-connect', generator: 'counting', sortOrder: 4, layoutConfig: { ...L.gridEx }, generatorConfig: { functionId: 'counting-matching' } }, + ), + sheet( + { id: 'number-object-match', title: '数物连线', desc: '连线相同数量的物品和数字', category: 'math', page: 'numberObjectMatch', subpackage: 'mathPages', icon: '🔗', img: '/assets/mathEntrance/number-object-match.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '🔗' }, + { subcategory: 'counting', template: 'match-connect', generator: 'counting', sortOrder: 5, generatorConfig: { functionId: 'number-object-match' } }, + ), + sheet( + { id: 'number-object-fill', title: '数物填写', desc: '数物品数量,填写对应数字', category: 'math', page: 'numberObjectMatch', subpackage: 'mathPages', icon: '✏️', img: '/assets/mathEntrance/number-object-fill.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '✏️' }, + { subcategory: 'counting', template: 'grid-exercise', generator: 'counting', sortOrder: 6, generatorConfig: { functionId: 'number-object-fill' } }, + ), + sheet( + { id: 'counting-select', title: '数一数,选一选', desc: '数出物品数量,圈出正确答案', category: 'math', page: 'countingSelect', subpackage: 'mathPages', icon: '✓', img: '/assets/mathEntrance/counting-select.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '✓' }, + { subcategory: 'counting', template: 'grid-exercise', generator: 'counting', sortOrder: 7, generatorConfig: { functionId: 'counting-select' } }, + ), + sheet( + { id: 'counting-fill', title: '数一数,填一填', desc: '数出物品数量,填写数字', category: 'math', page: 'countingSelect', subpackage: 'mathPages', icon: '✏️', img: '/assets/mathEntrance/counting-fill.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '✏️' }, + { subcategory: 'counting', template: 'grid-exercise', generator: 'counting', sortOrder: 8, generatorConfig: { functionId: 'counting-fill' } }, + ), + sheet( + { id: 'compare', title: '数一数,比大小', desc: '比较数量,填入 ><=', category: 'math', page: 'compare', subpackage: 'mathPages', icon: '⚖️', img: '/assets/mathEntrance/compare.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '3 ○ 5' }, + { subcategory: 'counting', template: 'grid-exercise', generator: 'comparison', sortOrder: 9, generatorConfig: { functionId: 'compare' } }, + ), + sheet( + { id: 'number-sort', title: '数字排序', desc: '写出正确的数字顺序', category: 'math', page: 'numberSort', subpackage: 'mathPages', icon: '🔢', img: '/assets/mathEntrance/number-sort.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fff3e0', previewText: '1→2→3' }, + { subcategory: 'sequence', template: 'sequence-pattern', generator: 'number-sequence', sortOrder: 10, generatorConfig: { functionId: 'number-sort' } }, + ), + sheet( + { id: 'missing-number', title: '填上缺少的数字', desc: '在数列中找出并填写缺失数字', category: 'math', page: 'missingNumber', subpackage: 'mathPages', icon: '❓', img: '/assets/mathEntrance/missing-number.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fff3e0', previewText: '1_3_5' }, + { subcategory: 'sequence', template: 'sequence-pattern', generator: 'number-sequence', sortOrder: 11, generatorConfig: { functionId: 'missing-number' } }, + ), + sheet( + { id: 'number-decompose', title: '10以内数的分与合', desc: '把数字分一分,合一合', category: 'math', page: 'numberDecompose', subpackage: 'mathPages', icon: '🔢', img: '/assets/mathEntrance/number-decompose.png', ageRange: [4, 6], difficulty: 'beginner', previewBg: '#fff3e0', previewText: '8=?+?' }, + { subcategory: 'decompose', template: 'grid-exercise', generator: 'number-decompose', sortOrder: 12, generatorConfig: { max: 10 } }, + ), + sheet( + { id: 'number-decompose-20', title: '20以内数的分与合', desc: '把数字分一分,合一合', category: 'math', page: 'numberDecompose', subpackage: 'mathPages', icon: '🔢', img: '/assets/mathEntrance/number-decompose-20.png', ageRange: [5, 7], difficulty: 'basic', previewBg: '#fff3e0', previewText: '15=?+?' }, + { subcategory: 'decompose', template: 'grid-exercise', generator: 'number-decompose', sortOrder: 13, generatorConfig: { max: 20 } }, + ), + sheet( + { id: 'one-digit-addition', title: '一位数加法', desc: '通过圆点学习一位数加法运算', category: 'math', page: 'oneDigitAddition', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/one-digit-addition.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '2+3=?' }, + { subcategory: 'arithmetic-visual', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 14, generatorConfig: { operators: ['+'], maxNumber: 9, showDots: true } }, + ), + sheet( + { id: 'addition-5', title: '5以内加法', desc: '图形化展示 5 以内加法', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/addition-5.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e3f2fd', previewText: '2+1=?' }, + { subcategory: 'arithmetic-visual', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 15, generatorConfig: { operators: ['+'], maxNumber: 5, showDots: true } }, + ), + sheet( + { id: 'addition-10', title: '10以内加法', desc: '图形化展示 10 以内加法', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/addition-10.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '3+5=?' }, + { subcategory: 'arithmetic-visual', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 16, generatorConfig: { operators: ['+'], maxNumber: 10, showDots: true } }, + ), + sheet( + { id: 'subtraction-10', title: '10以内减法', desc: '图形化展示 10 以内减法', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/subtraction-10.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '8-3=?' }, + { subcategory: 'arithmetic-visual', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 17, generatorConfig: { operators: ['-'], maxNumber: 10, showDots: true } }, + ), + sheet( + { id: 'addition-subtraction-10', title: '10以内加减法', desc: '加减法混合运算', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '±', img: '/assets/mathEntrance/addition-subtraction-10.png', ageRange: [5, 7], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '±' }, + { subcategory: 'arithmetic-visual', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 18, generatorConfig: { operators: ['+', '-'], maxNumber: 10, showDots: true } }, + ), + sheet( + { id: 'make-ten', title: '凑十法练习', desc: '20 以内进位加法', category: 'math', page: 'makeTen', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/make-ten.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '7+?=10' }, + { subcategory: 'methods-ten', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 19, generatorConfig: { method: 'make-ten' } }, + ), + sheet( + { id: 'break-ten', title: '破十法练习', desc: '20 以内退位减法', category: 'math', page: 'breakTen', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/break-ten.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '15-8=?' }, + { subcategory: 'methods-ten', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 20, generatorConfig: { method: 'break-ten' } }, + ), + sheet( + { id: 'flat-ten', title: '平十法练习', desc: '20 以内退位减法', category: 'math', page: 'flatTen', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/flat-ten.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '14-6=?' }, + { subcategory: 'methods-ten', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 21, generatorConfig: { method: 'flat-ten' } }, + ), + sheet( + { id: 'borrow-ten', title: '借十法练习', desc: '20 以上退位减法', category: 'math', page: 'borrowTen', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/borrow-ten.png', ageRange: [6, 8], difficulty: 'advanced', previewBg: '#e3f2fd', previewText: '32-8=?' }, + { subcategory: 'methods-ten', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 22, generatorConfig: { method: 'borrow-ten' } }, + ), + sheet( + { id: 'practice-addition', title: '加法运算', desc: '10/20/50/100 以内加法', category: 'math', page: 'calculationPractice', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/practice-addition.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '25+18=?' }, + { subcategory: 'practice', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 23, generatorConfig: { operators: ['+'], preset: 'vertical' } }, + ), + sheet( + { id: 'practice-subtraction', title: '减法运算', desc: '10/20/50/100 以内减法', category: 'math', page: 'calculationPractice', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/practice-subtraction.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '43-17=?' }, + { subcategory: 'practice', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 24, generatorConfig: { operators: ['-'], preset: 'vertical' } }, + ), + sheet( + { id: 'practice-mixed', title: '混合运算', desc: '10/20/50/100 以内加减法混合', category: 'math', page: 'calculationPractice', subpackage: 'mathPages', icon: '±', img: '/assets/mathEntrance/practice-mixed.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '±' }, + { subcategory: 'practice', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 25, generatorConfig: { operators: ['+', '-'], preset: 'vertical' } }, + ), + sheet( + { id: 'multiplication-table', title: '九九乘法表', desc: '学习九九乘法口诀', category: 'math', page: 'multiplicationTable', subpackage: 'mathPages', icon: '✖️', img: '/assets/mathEntrance/multiplication-table.png', ageRange: [6, 8], difficulty: 'intermediate', previewBg: '#f3e5f5', previewText: '3×4=12' }, + { subcategory: 'practice', template: 'grid-exercise', generator: 'arithmetic', sortOrder: 26, generatorConfig: { operators: ['×'], mode: 'multiplication-table' } }, + ), + + sheet( + { id: 'color-shape-match', title: '根据颜色画图形', desc: '根据颜色画出对应图形', category: 'focus', page: 'colorShapeMatch', subpackage: 'focusPages', icon: '🎯', img: '/assets/focusEntrance/color-shape-match.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#f3e5f5', previewText: '🎯' }, + { subcategory: 'focus-visual', template: 'grid-exercise', generator: 'shape-grid', sortOrder: 27, generatorConfig: { functionId: 'color-shape-match' } }, + ), + sheet( + { id: 'shape-symbol', title: '图形符号配对', desc: '根据图形画对应符号', category: 'focus', page: 'shapeSymbol', subpackage: 'focusPages', icon: '🔗', img: '/assets/focusEntrance/shape-symbol.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e8eaf6', previewText: '△→♠' }, + { subcategory: 'focus-visual', template: 'match-connect', generator: 'shape-grid', sortOrder: 28, generatorConfig: { functionId: 'shape-symbol' } }, + ), + sheet( + { id: 'shape-recognition', title: '识别形状', desc: '识别形状,涂一涂', category: 'focus', page: 'shape', subpackage: 'focusPages', icon: '🔍', img: '/assets/focusEntrance/shape-recognition.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '△ ○ □' }, + { subcategory: 'focus-visual', template: 'grid-coloring', generator: 'shape-grid', sortOrder: 29, generatorConfig: { functionId: 'shape-recognition' } }, + ), + sheet( + { id: 'position-coloring', title: '方位涂涂乐', desc: '观察位置,在方格中涂色', category: 'focus', page: 'positionColoring', subpackage: 'focusPages', icon: '📍', img: '/assets/focusEntrance/position-coloring.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e8f5e9', previewText: '📍' }, + { subcategory: 'focus-grid', template: 'grid-coloring', generator: 'shape-grid', sortOrder: 30, generatorConfig: { functionId: 'position-coloring' } }, + ), + sheet( + { id: 'color-pattern', title: '颜色找规律', desc: '观察颜色规律,在空白图形中涂色', category: 'focus', page: 'colorPattern', subpackage: 'focusPages', icon: '🎨', img: '/assets/focusEntrance/color-pattern.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fff8e1', previewText: '🎨' }, + { subcategory: 'focus-grid', template: 'grid-coloring', generator: 'color-pattern', sortOrder: 31, generatorConfig: { functionId: 'color-pattern' } }, + ), + sheet( + { id: 'match-connect', title: '连连看', desc: '根据物品连一连', category: 'focus', page: 'matchConnect', subpackage: 'focusPages', icon: '🔗', img: '/assets/focusEntrance/match-connect.png', ageRange: [3, 6], difficulty: 'beginner', previewBg: '#e0f7fa', previewText: '🔗' }, + { subcategory: 'focus-connect', template: 'match-connect', generator: 'shape-grid', sortOrder: 32, generatorConfig: { functionId: 'match-connect' } }, + ), + sheet( + { id: 'line-recognition', title: '线条识别', desc: '认识不同线条,画出颜色对应的线条', category: 'focus', page: 'lineRecognition', subpackage: 'focusPages', icon: '📏', img: '/assets/focusEntrance/line-recognition.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#f1f8e9', previewText: '〰️' }, + { subcategory: 'focus-visual', template: 'tracing-writing', generator: 'shape-grid', sortOrder: 33, generatorConfig: { functionId: 'line-recognition' } }, + ), + sheet( + { id: 'grid-reasoning', title: '方格推理', desc: '推理出合并方格并连线', category: 'focus', page: 'gridReasoning', subpackage: 'focusPages', icon: '🧩', img: '/assets/focusEntrance/grid-reasoning.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e8eaf6', previewText: '🧩' }, + { subcategory: 'focus-logic', template: 'special-graphic', generator: 'shape-grid', sortOrder: 34, generatorConfig: { functionId: 'grid-reasoning' } }, + ), + sheet( + { id: 'code-connect', title: '译码连线', desc: '按数字顺序将数字对应颜色连线', category: 'focus', page: 'codeConnect', subpackage: 'focusPages', icon: '🔢', img: '/assets/focusEntrance/code-connect.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#fff3e0', previewText: '🔢' }, + { subcategory: 'focus-connect', template: 'match-connect', generator: 'dot-connect', sortOrder: 35, generatorConfig: { functionId: 'code-connect' } }, + ), + sheet( + { id: 'dot-connect', title: '数字点连线', desc: '按数字顺序连点成图', category: 'focus', page: 'dotConnect', subpackage: 'focusPages', icon: '🔗', img: '/assets/focusEntrance/dot-connect.png', ageRange: [3, 6], difficulty: 'beginner', previewBg: '#e3f2fd', previewText: '1→2→3' }, + { subcategory: 'focus-connect', template: 'match-connect', generator: 'dot-connect', sortOrder: 36, generatorConfig: { functionId: 'dot-connect' } }, + ), + sheet( + { id: 'grid-drawing-3x3', title: '格子仿画 3×3', desc: '简单有趣,培养专注力', category: 'focus', page: 'gridDrawing', subpackage: 'focusPages', icon: '🎨', mode: '3x3', img: '/assets/focusEntrance/grid-drawing-3x3.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '▦' }, + { subcategory: 'grid-copy', template: 'grid-exercise', generator: 'shape-grid', sortOrder: 37, generatorConfig: { grid: '3x3' } }, + ), + sheet( + { id: 'grid-drawing-5x5', title: '格子仿画 5×5', desc: '创意挑战,提升观察力', category: 'focus', page: 'gridDrawing', subpackage: 'focusPages', icon: '🎨', mode: '5x5', img: '/assets/focusEntrance/grid-drawing-5x5.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fce4ec', previewText: '▦' }, + { subcategory: 'grid-copy', template: 'grid-exercise', generator: 'shape-grid', sortOrder: 38, generatorConfig: { grid: '5x5' } }, + ), + sheet( + { id: 'grid-drawing-7x7', title: '格子仿画 7×7', desc: '大师挑战,锻炼耐心', category: 'focus', page: 'gridDrawing', subpackage: 'focusPages', icon: '🎨', mode: '7x7', img: '/assets/focusEntrance/grid-drawing-7x7.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#fce4ec', previewText: '▦' }, + { subcategory: 'grid-copy', template: 'grid-exercise', generator: 'shape-grid', sortOrder: 39, generatorConfig: { grid: '7x7' } }, + ), + + sheet( + { id: 'word-recognition', title: '识字卡', desc: '输入或选字生成涂色识字卡(grid/find 模板)', category: 'chinese', page: 'index', subpackage: '', icon: '📖', img: '', ageRange: [3, 6], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '大 小' }, + { subcategory: 'literacy', template: 'card-layout', generator: 'custom', sortOrder: 40, generatorConfig: { functionId: 'word-recognition' }, layoutConfig: { ...L.card } }, + ), + sheet( + { id: 'copybook', title: '练字帖', desc: '选字生成田字格笔顺练字帖', category: 'chinese', page: 'copyBook', subpackage: '', icon: '✏️', img: '', ageRange: [4, 7], difficulty: 'basic', previewBg: '#fff8e1', previewText: '横竖撇' }, + { subcategory: 'copybook', template: 'tracing-writing', generator: 'character-tracing', sortOrder: 41, generatorConfig: { functionId: 'copybook' }, layoutConfig: { ...L.trace } }, + ), ]; -// prettier-ignore -export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = [ - { id: 'color-shape-match', page: 'colorShapeMatch', title: '根据颜色画图形', desc: '根据颜色画出对应的图形', icon: '🎯', img: '/assets/focusEntrance/color-shape-match.png' }, - { id: 'shape-symbol', page: 'shapeSymbol', title: '图形符号配对', desc: '根据图形画对应的符号', icon: '🔗', img: '/assets/focusEntrance/shape-symbol.png' }, - { id: 'shape-recognition', page: 'shape', title: '识别形状', desc: '识别形状,涂一涂', icon: '🔍', img: '/assets/focusEntrance/shape-recognition.png' }, - { id: 'position-coloring', page: 'positionColoring', title: '方位涂涂乐', desc: '观察卡片位置,在对应的方格中涂上颜色', icon: '📍', img: '/assets/focusEntrance/position-coloring.png' }, - { id: 'color-pattern', page: 'colorPattern', title: '颜色找规律', desc: '观察颜色规律,在空白图形中涂上颜色', icon: '🎨', img: '/assets/focusEntrance/color-pattern.png' }, - { id: 'match-connect', page: 'matchConnect', title: '连连看', desc: '快来根据物品连一连吧!', icon: '🔗', img: '/assets/focusEntrance/match-connect.png' }, - { id: 'line-recognition', page: 'lineRecognition', title: '线条识别', desc: '认识不同线条,画出颜色对应的线条', icon: '📏', img: '/assets/focusEntrance/line-recognition.png' }, - { id: 'grid-reasoning', page: 'gridReasoning', title: '方格推理', desc: '仔细观察,推理出合并方格并连线', icon: '🧩', img: '/assets/focusEntrance/grid-reasoning.png' }, - { id: 'code-connect', page: 'codeConnect', title: '译码连线', desc: '按照数字顺序,将数字对应的颜色连线', icon: '🔢', img: '/assets/focusEntrance/code-connect.png' }, - { id: 'dot-connect', page: 'dotConnect', title: '数字点连线', desc: '按数字顺序连点', icon: '🔗', img: '/assets/focusEntrance/dot-connect.png' }, - { id: 'grid-drawing-3x3', page: 'gridDrawing', title: '格子仿画 3x3', desc: '🎯 简单有趣,培养专注力', icon: '🎨', mode: '3x3', img: '/assets/focusEntrance/grid-drawing-3x3.png' }, - { id: 'grid-drawing-5x5', page: 'gridDrawing', title: '格子仿画 5x5', desc: '✨ 创意挑战,提升观察力', icon: '🎨', mode: '5x5', img: '/assets/focusEntrance/grid-drawing-5x5.png' }, - { id: 'grid-drawing-7x7', page: 'gridDrawing', title: '格子仿画 7x7', desc: '🌟 大师挑战,锻炼耐心', icon: '🎨', mode: '7x7', img: '/assets/focusEntrance/grid-drawing-7x7.png' }, -]; +export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS + .filter((w) => w.category === 'math') + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((w) => ({ + id: w.id, + page: w.page, + title: w.title, + desc: w.desc, + icon: w.icon, + img: w.img, + })); -/** - * 统一的 WorksheetType 列表 - 合并所有分类 - * 用于新版首页和分类页展示 - */ -// prettier-ignore -export const ALL_WORKSHEETS: WorksheetType[] = [ - // === 数学 === - { id: 'number-sort', title: '数字排序', desc: '数字排序,写出正确顺序', category: 'math', page: 'numberSort', subpackage: 'mathPages', icon: '🔢', img: '/assets/mathEntrance/number-sort.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fff3e0', previewText: '1→2→3' }, - { id: 'one-digit-addition', title: '一位数加法', desc: '通过圆点学习一位数加法运算', category: 'math', page: 'oneDigitAddition', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/one-digit-addition.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '2+3=?' }, - { id: 'make-ten', title: '凑十法练习', desc: '通过凑十法学习20以内进位加法', category: 'math', page: 'makeTen', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/make-ten.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '7+?=10' }, - { id: 'break-ten', title: '破十法练习', desc: '通过破十法学习20以内退位减法', category: 'math', page: 'breakTen', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/break-ten.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '15-8=?' }, - { id: 'flat-ten', title: '平十法练习', desc: '通过平十法学习20以内退位减法', category: 'math', page: 'flatTen', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/flat-ten.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '14-6=?' }, - { id: 'borrow-ten', title: '借十法练习', desc: '通过借十法学习20以上退位减法', category: 'math', page: 'borrowTen', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/borrow-ten.png', ageRange: [6, 8], difficulty: 'advanced', previewBg: '#e3f2fd', previewText: '32-8=?' }, - { id: 'number-decompose-20', title: '20以内分与合', desc: '把数字分一分,合一合', category: 'math', page: 'numberDecompose', subpackage: 'mathPages', icon: '🔢', img: '/assets/mathEntrance/number-decompose-20.png', ageRange: [5, 7], difficulty: 'basic', previewBg: '#fff3e0', previewText: '15=?+?' }, - { id: 'number-decompose', title: '10以内分与合', desc: '把数字分一分,合一合', category: 'math', page: 'numberDecompose', subpackage: 'mathPages', icon: '🔢', img: '/assets/mathEntrance/number-decompose.png', ageRange: [4, 6], difficulty: 'beginner', previewBg: '#fff3e0', previewText: '8=?+?' }, - { id: 'number-find', title: '找数字涂一涂', desc: '找一找下面相同的数字,涂上颜色', category: 'math', page: 'numberFind', subpackage: 'mathPages', img: '/assets/mathEntrance/number-find.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fff3e0', previewText: '🔍 1 2 3' }, - { id: 'number-write', title: '看数字写一写', desc: '看数字,按笔画顺序写一写', category: 'math', page: 'numberFind', subpackage: 'mathPages', img: '/assets/mathEntrance/number-write.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fff3e0', previewText: '1 2 3' }, - { id: 'number-coloring', title: '按数字涂颜色', desc: '按数字给相应的圆圈涂上颜色', category: 'math', page: 'countMatch', subpackage: 'mathPages', icon: '🎨', img: '/assets/mathEntrance/number-coloring.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '🎨' }, - { id: 'counting-matching', title: '数一数连一连', desc: '通过连线配对数字和对应的数量图形', category: 'math', page: 'countMatch', subpackage: 'mathPages', icon: '🔗', img: '/assets/mathEntrance/count-match.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '3↔🍎🍎🍎' }, - { id: 'number-object-match', title: '数物连线', desc: '连线相同数量的物品和数字', category: 'math', page: 'numberObjectMatch', subpackage: 'mathPages', icon: '🔗', img: '/assets/mathEntrance/number-object-match.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '🔗' }, - { id: 'number-object-fill', title: '数物填写', desc: '数一数物品数量,填写对应的数字', category: 'math', page: 'numberObjectMatch', subpackage: 'mathPages', icon: '✏️', img: '/assets/mathEntrance/number-object-fill.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '✏️' }, - { id: 'counting-select', title: '数一数选一选', desc: '数出物品数量,圈出正确答案', category: 'math', page: 'countingSelect', subpackage: 'mathPages', icon: '✓', img: '/assets/mathEntrance/counting-select.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '✓' }, - { id: 'counting-fill', title: '数一数填一填', desc: '数出物品数量,填写对应的数字', category: 'math', page: 'countingSelect', subpackage: 'mathPages', icon: '✏️', img: '/assets/mathEntrance/counting-fill.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e8f5e9', previewText: '✏️' }, - { id: 'compare', title: '比大小', desc: '比较数量大小,在 ⭕️ 中填入>、<、=', category: 'math', page: 'compare', subpackage: 'mathPages', icon: '⚖️', img: '/assets/mathEntrance/compare.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '3 ○ 5' }, - { id: 'missing-number', title: '填缺少的数字', desc: '在数字序列中找出并填写缺失的数字', category: 'math', page: 'missingNumber', subpackage: 'mathPages', icon: '❓', img: '/assets/mathEntrance/missing-number.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fff3e0', previewText: '1_3_5' }, - { id: 'addition-5', title: '5以内加法', desc: '练习5以内的加法运算', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/addition-5.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#e3f2fd', previewText: '2+1=?' }, - { id: 'addition-10', title: '10以内加法', desc: '练习10以内的加法运算', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/addition-10.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '3+5=?' }, - { id: 'subtraction-10', title: '10以内减法', desc: '练习10以内的减法运算', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/subtraction-10.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '8-3=?' }, - { id: 'addition-subtraction-10', title: '10以内加减法', desc: '练习加法和减法混合运算', category: 'math', page: 'addition', subpackage: 'mathPages', icon: '±', img: '/assets/mathEntrance/addition-subtraction-10.png', ageRange: [5, 7], difficulty: 'basic', previewBg: '#e3f2fd', previewText: '±' }, - { id: 'practice-addition', title: '加法运算', desc: '练习10/20/50/100以内加法运算', category: 'math', page: 'calculationPractice', subpackage: 'mathPages', icon: '➕', img: '/assets/mathEntrance/practice-addition.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '25+18=?' }, - { id: 'practice-subtraction', title: '减法运算', desc: '练习10/20/50/100以内减法运算', category: 'math', page: 'calculationPractice', subpackage: 'mathPages', icon: '➖', img: '/assets/mathEntrance/practice-subtraction.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '43-17=?' }, - { id: 'practice-mixed', title: '混合运算', desc: '练习10/20/50/100以内加减法混合运算', category: 'math', page: 'calculationPractice', subpackage: 'mathPages', icon: '±', img: '/assets/mathEntrance/practice-mixed.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#e3f2fd', previewText: '±' }, - { id: 'multiplication-table', title: '九九乘法表', desc: '学习九九乘法口诀', category: 'math', page: 'multiplicationTable', subpackage: 'mathPages', icon: '✖️', img: '/assets/mathEntrance/multiplication-table.png', ageRange: [6, 8], difficulty: 'intermediate', previewBg: '#f3e5f5', previewText: '3×4=12' }, +export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS + .filter((w) => w.category === 'focus') + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((w) => ({ + id: w.id, + page: w.page, + title: w.title, + desc: w.desc, + icon: w.icon ?? '・', + mode: w.mode, + img: w.img, + })); - // === 专注力 === - { id: 'color-shape-match', title: '颜色画图形', desc: '根据颜色画出对应的图形', category: 'focus', page: 'colorShapeMatch', subpackage: 'focusPages', icon: '🎯', img: '/assets/focusEntrance/color-shape-match.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#f3e5f5', previewText: '🎯' }, - { id: 'shape-symbol', title: '图形符号配对', desc: '根据图形画对应的符号', category: 'focus', page: 'shapeSymbol', subpackage: 'focusPages', icon: '🔗', img: '/assets/focusEntrance/shape-symbol.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e8eaf6', previewText: '△→♠' }, - { id: 'shape-recognition', title: '识别形状', desc: '识别形状,涂一涂', category: 'focus', page: 'shape', subpackage: 'focusPages', icon: '🔍', img: '/assets/focusEntrance/shape-recognition.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '△ ○ □' }, - { id: 'position-coloring', title: '方位涂涂乐', desc: '观察卡片位置,在对应的方格中涂上颜色', category: 'focus', page: 'positionColoring', subpackage: 'focusPages', icon: '📍', img: '/assets/focusEntrance/position-coloring.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#e8f5e9', previewText: '📍' }, - { id: 'color-pattern', title: '颜色找规律', desc: '观察颜色规律,在空白图形中涂上颜色', category: 'focus', page: 'colorPattern', subpackage: 'focusPages', icon: '🎨', img: '/assets/focusEntrance/color-pattern.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fff8e1', previewText: '🎨' }, - { id: 'match-connect', title: '连连看', desc: '快来根据物品连一连吧!', category: 'focus', page: 'matchConnect', subpackage: 'focusPages', icon: '🔗', img: '/assets/focusEntrance/match-connect.png', ageRange: [3, 6], difficulty: 'beginner', previewBg: '#e0f7fa', previewText: '🔗' }, - { id: 'line-recognition', title: '线条识别', desc: '认识不同线条,画出颜色对应的线条', category: 'focus', page: 'lineRecognition', subpackage: 'focusPages', icon: '📏', img: '/assets/focusEntrance/line-recognition.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#f1f8e9', previewText: '〰️' }, - { id: 'grid-reasoning', title: '方格推理', desc: '仔细观察,推理出合并方格并连线', category: 'focus', page: 'gridReasoning', subpackage: 'focusPages', icon: '🧩', img: '/assets/focusEntrance/grid-reasoning.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#e8eaf6', previewText: '🧩' }, - { id: 'code-connect', title: '译码连线', desc: '按照数字顺序,将数字对应的颜色连线', category: 'focus', page: 'codeConnect', subpackage: 'focusPages', icon: '🔢', img: '/assets/focusEntrance/code-connect.png', ageRange: [5, 7], difficulty: 'intermediate', previewBg: '#fff3e0', previewText: '🔢' }, - { id: 'dot-connect', title: '数字点连线', desc: '按数字顺序连点', category: 'focus', page: 'dotConnect', subpackage: 'focusPages', icon: '🔗', img: '/assets/focusEntrance/dot-connect.png', ageRange: [3, 6], difficulty: 'beginner', previewBg: '#e3f2fd', previewText: '1→2→3' }, - { id: 'grid-drawing-3x3', title: '格子仿画 3x3', desc: '简单有趣,培养专注力', category: 'focus', page: 'gridDrawing', subpackage: 'focusPages', icon: '🎨', mode: '3x3', img: '/assets/focusEntrance/grid-drawing-3x3.png', ageRange: [3, 5], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '▦' }, - { id: 'grid-drawing-5x5', title: '格子仿画 5x5', desc: '创意挑战,提升观察力', category: 'focus', page: 'gridDrawing', subpackage: 'focusPages', icon: '🎨', mode: '5x5', img: '/assets/focusEntrance/grid-drawing-5x5.png', ageRange: [4, 6], difficulty: 'basic', previewBg: '#fce4ec', previewText: '▦' }, - { id: 'grid-drawing-7x7', title: '格子仿画 7x7', desc: '大师挑战,锻炼耐心', category: 'focus', page: 'gridDrawing', subpackage: 'focusPages', icon: '🎨', mode: '7x7', img: '/assets/focusEntrance/grid-drawing-7x7.png', ageRange: [5, 8], difficulty: 'intermediate', previewBg: '#fce4ec', previewText: '▦' }, - - // === 语文 === - { id: 'word-recognition', title: '识字卡', desc: '认识汉字,选字生成识字卡', category: 'chinese', page: 'index', subpackage: '', icon: '📖', ageRange: [3, 6], difficulty: 'beginner', previewBg: '#fce4ec', previewText: '大 小' }, - { id: 'copybook', title: '练字帖', desc: '选字生成田字格练字帖', category: 'chinese', page: 'copyBook', subpackage: '', icon: '✏️', ageRange: [4, 7], difficulty: 'basic', previewBg: '#fff8e1', previewText: '横竖撇' }, -]; - -/** - * 按分类获取题型列表 - */ -export function getWorksheetsByCategory(category: string): WorksheetType[] { +export function getWorksheetsByCategory(category: string): WorksheetDefinition[] { if (category === 'all') return ALL_WORKSHEETS; return ALL_WORKSHEETS.filter((w) => w.category === category); } -/** - * 按年龄范围筛选 - */ -export function getWorksheetsByAge(worksheets: WorksheetType[], age: number): WorksheetType[] { +export function getWorksheetsByAge( + worksheets: readonly T[], + age: number, +): T[] { return worksheets.filter((w) => { if (!w.ageRange) return true; return age >= w.ageRange[0] && age <= w.ageRange[1]; }); } -/** - * 获取题型的导航路径 - */ export function getWorksheetPath(worksheet: WorksheetType): string { if (!worksheet.page) return ''; if (worksheet.subpackage) { @@ -154,3 +303,7 @@ export function getWorksheetPath(worksheet: WorksheetType): string { } return `/pages/${worksheet.page}/${worksheet.page}`; } + +export function getWorksheetById(id: string): WorksheetDefinition | undefined { + return ALL_WORKSHEETS.find((w) => w.id === id); +} diff --git a/miniprogram/core/models/category.ts b/miniprogram/core/models/category.ts new file mode 100644 index 0000000..3afea08 --- /dev/null +++ b/miniprogram/core/models/category.ts @@ -0,0 +1,21 @@ +import type { WorksheetCategory } from './worksheet'; + +/** Tab / 首页用的分类展示模型 */ +export interface CategoryType { + id: WorksheetCategory; + name: string; + icon: string; + color: string; + /** 分类用于背景或装饰的渐变色,数组中为渐变起止的两个颜色值 */ + gradient: [string, string]; +} + +/** 云集合 `categories` 文档(与小程序云开发方案 §3.4 对齐) */ +export interface CategoryRecord { + _id: string; + name: string; + icon: string; + color: string; + sortOrder: number; + parentId?: string | null; +} diff --git a/miniprogram/core/models/download-log.ts b/miniprogram/core/models/download-log.ts new file mode 100644 index 0000000..d860f5a --- /dev/null +++ b/miniprogram/core/models/download-log.ts @@ -0,0 +1,8 @@ +/** 云集合 `download_logs` 文档(§3.7) */ +export interface DownloadLogRecord { + _id: string; + userId: string; + worksheetId: string; + params?: Record | null; + createdAt: Date; +} diff --git a/miniprogram/core/models/favorite.ts b/miniprogram/core/models/favorite.ts new file mode 100644 index 0000000..700c990 --- /dev/null +++ b/miniprogram/core/models/favorite.ts @@ -0,0 +1,7 @@ +/** 云集合 `favorites` 文档(§3.6) */ +export interface FavoriteRecord { + _id: string; + userId: string; + worksheetId: string; + createdAt: Date; +} diff --git a/miniprogram/core/models/feedback.ts b/miniprogram/core/models/feedback.ts new file mode 100644 index 0000000..ad5e063 --- /dev/null +++ b/miniprogram/core/models/feedback.ts @@ -0,0 +1,8 @@ +/** 云集合 `feedback` 文档(§3.8) */ +export interface FeedbackRecord { + _id: string; + userId: string; + content: string; + contact?: string | null; + createdAt: Date; +} diff --git a/miniprogram/core/models/learning-plan.ts b/miniprogram/core/models/learning-plan.ts new file mode 100644 index 0000000..43324aa --- /dev/null +++ b/miniprogram/core/models/learning-plan.ts @@ -0,0 +1,9 @@ +/** 云集合 `learning_plans` 文档(§3.9);weeks 结构与后端 WeekPlan[] 一致,此处用 unknown[] 保持 core 零依赖 */ +export interface LearningPlanRecord { + _id: string; + ageMin: number; + ageMax: number; + ageLabel: string; + milestones: string[]; + weeks: unknown[]; +} diff --git a/miniprogram/core/models/user.ts b/miniprogram/core/models/user.ts new file mode 100644 index 0000000..52f6772 --- /dev/null +++ b/miniprogram/core/models/user.ts @@ -0,0 +1,11 @@ +/** 云集合 `users` 文档(与小程序云开发方案 §3.5 对齐) */ +export interface UserRecord { + _id: string; + openid: string; + unionid?: string | null; + nickName?: string | null; + avatarUrl?: string | null; + totalDownloads: number; + createdAt: Date; + lastActiveAt: Date; +} diff --git a/miniprogram/core/models/worksheet.ts b/miniprogram/core/models/worksheet.ts index 957685c..129bbf0 100644 --- a/miniprogram/core/models/worksheet.ts +++ b/miniprogram/core/models/worksheet.ts @@ -1,6 +1,116 @@ -export type WorksheetCategory = 'math' | 'focus' | 'chinese' | 'english' | 'puzzle' | 'craft'; -export type DifficultyLevel = 'beginner' | 'basic' | 'intermediate' | 'advanced'; +/** + * 题型与模板引擎相关模型:对齐技术架构 §5.4 与云开发 worksheets 集合 §3.3 + */ +/** 小程序现状:专注力单独成类;云端 category 枚举无 focus 时可映射为 puzzle + subcategory */ +export type WorksheetCategory = + | 'math' + | 'focus' + | 'chinese' + | 'english' + | 'puzzle' + | 'craft'; + +/** 云 worksheets.category 严格枚举(§3.3) */ +export type WorksheetCloudCategory = + | 'math' + | 'chinese' + | 'english' + | 'puzzle' + | 'craft'; + +export type DifficultyLevel = + | 'beginner' + | 'basic' + | 'intermediate' + | 'advanced'; + +export type WorksheetStatus = 'active' | 'draft' | 'hidden'; + +/** 排版模板(技术架构 §5.4) */ +export type TemplateType = + | 'grid-exercise' + | 'match-connect' + | 'grid-coloring' + | 'card-layout' + | 'tracing-writing' + | 'full-page-asset' + | 'sequence-pattern' + | 'special-graphic'; + +/** 数据生成器(技术架构 §5.4) */ +export type GeneratorType = + | 'arithmetic' + | 'number-sequence' + | 'number-decompose' + | 'counting' + | 'comparison' + | 'shape-grid' + | 'color-pattern' + | 'character-tracing' + | 'letter-tracing' + | 'pinyin-tracing' + | 'static-asset' + | 'maze' + | 'dot-connect' + | 'clock' + | 'custom'; + +export interface LayoutConfig { + columns?: number; + rows?: number; + fontSize?: number; + showBorder?: boolean; + showTitle?: boolean; + padding?: number; + itemSpacing?: number; + showInstruction?: boolean; + instructionText?: string; +} + +export interface UserConfigField { + key: string; + label: string; + type: 'select' | 'slider' | 'switch'; + options?: { label: string; value: unknown }[]; + min?: number; + max?: number; + defaultValue: unknown; +} + +/** 与架构文档 WorksheetConfig 一致,供云端下发 / 本地内置 */ +export interface WorksheetConfig { + id: string; + title: string; + desc: string; + category: WorksheetCloudCategory; + subcategory: string; + ageMin: number; + ageMax: number; + difficulty: 1 | 2 | 3 | 4; + previewImage: string; + tags: string[]; + isNew: boolean; + isHot: boolean; + sortOrder: number; + status: WorksheetStatus; + template: TemplateType; + generator: GeneratorType; + generatorConfig: Record; + layoutConfig: LayoutConfig; + userConfigurable?: UserConfigField[] | null; + legacyPage?: string | null; + downloadCount: number; +} + +/** 云集合 `worksheets` 文档(§3.3,含数据库字段) */ +export interface WorksheetRecord extends WorksheetConfig { + _id: string; + createdAt: Date; + updatedAt: Date; +} + +/** 列表卡片 / 导航用(兼容存量页面) */ export interface WorksheetType { id: string; title: string; @@ -17,10 +127,61 @@ export interface WorksheetType { previewText?: string; } -export interface CategoryType { - id: WorksheetCategory; - name: string; - icon: string; - color: string; - gradient: [string, string]; +/** 内置兜底 + 模板引擎字段(本地 core/data/worksheets) */ +export interface WorksheetDefinition extends WorksheetType { + subcategory: string; + ageMin: number; + ageMax: number; + /** 1–4,对应云 worksheets.difficulty */ + difficultyLevel: 1 | 2 | 3 | 4; + previewImage: string; + tags: string[]; + isNew: boolean; + isHot: boolean; + sortOrder: number; + downloadCount: number; + status: WorksheetStatus; + template: TemplateType; + generator: GeneratorType; + generatorConfig: Record; + layoutConfig: LayoutConfig; + userConfigurable?: UserConfigField[] | null; + legacyPage?: string | null; +} + +/** 写入云库时的 category:focus 归为 puzzle,子类区分 */ +export function toCloudCategory( + category: WorksheetCategory, +): WorksheetCloudCategory { + if (category === 'focus') return 'puzzle'; + return category; +} + +/** 由内置定义生成云侧 worksheets 文档形状(不含 _id/时间,供同步层补全) */ +export function worksheetDefinitionToConfig( + def: WorksheetDefinition, +): Omit { + return { + id: def.id, + title: def.title, + desc: def.desc, + category: toCloudCategory(def.category), + subcategory: def.subcategory, + ageMin: def.ageMin, + ageMax: def.ageMax, + difficulty: def.difficultyLevel, + previewImage: def.previewImage, + tags: def.tags, + isNew: def.isNew, + isHot: def.isHot, + sortOrder: def.sortOrder, + downloadCount: def.downloadCount, + status: def.status, + template: def.template, + generator: def.generator, + generatorConfig: def.generatorConfig, + layoutConfig: def.layoutConfig, + userConfigurable: def.userConfigurable ?? null, + legacyPage: def.legacyPage ?? null, + }; } diff --git a/miniprogram/core/utils/format.ts b/miniprogram/core/utils/format.ts new file mode 100644 index 0000000..0f61a5a --- /dev/null +++ b/miniprogram/core/utils/format.ts @@ -0,0 +1,48 @@ +/** + * 展示层格式化(无 i18n 框架依赖,供 UI / Canvas 文案使用) + */ + +/** 适龄文案,如 3-5岁 */ +export function formatAgeRangeLabel(minAge: number, maxAge: number): string { + return `${minAge}-${maxAge}岁`; +} + +/** 比较符号(全角,与现有比大小题型一致) */ +export function formatCompareSymbol( + relation: 'gt' | 'lt' | 'eq', +): '>' | '<' | '=' { + switch (relation) { + case 'gt': + return '>'; + case 'lt': + return '<'; + default: + return '='; + } +} + +/** 数字左侧补零,如 padLeadingZero(3, 2) => "03" */ +export function padLeadingZero(num: number, width: number): string { + const s = String(Math.trunc(Math.abs(num))); + if (s.length >= width) return num < 0 ? `-${s}` : s; + const pad = '0'.repeat(width - s.length); + return num < 0 ? `-${pad}${s}` : `${pad}${s}`; +} + +/** 截断过长字符串 */ +export function truncateText( + text: string, + maxChars: number, + ellipsis = '…', +): string { + if (text.length <= maxChars) return text; + const cut = Math.max(0, maxChars - ellipsis.length); + return text.slice(0, cut) + ellipsis; +} + +/** 非负整数千分位(中文场景常用逗号或空格,此处用半角逗号) */ +export function formatThousands(n: number): string { + const i = Math.trunc(n); + if (i < 0) return `-${formatThousands(-i)}`; + return String(i).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} diff --git a/miniprogram/core/utils/math-utils.ts b/miniprogram/core/utils/math-utils.ts new file mode 100644 index 0000000..b36aee0 --- /dev/null +++ b/miniprogram/core/utils/math-utils.ts @@ -0,0 +1,79 @@ +/** + * 纯数学工具(无随机、无平台依赖),供 generators / 模板引擎使用 + */ + +/** 将数值限制在 [min, max] */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** 最大公约数(非负整数) */ +export function gcd(a: number, b: number): number { + let x = Math.abs(Math.trunc(a)); + let y = Math.abs(Math.trunc(b)); + while (y !== 0) { + const t = y; + y = x % y; + x = t; + } + return x || 1; +} + +/** 最小公倍数 */ +export function lcm(a: number, b: number): number { + const g = gcd(a, b); + return Math.abs(Math.trunc(a) * Math.trunc(b)) / g; +} + +/** 闭区间 [min, max] 上的整数列表 */ +export function integerRangeInclusive(min: number, max: number): number[] { + const lo = Math.min(min, max); + const hi = Math.max(min, max); + const out: number[] = []; + for (let i = lo; i <= hi; i += 1) { + out.push(i); + } + return out; +} + +/** 数组求和 */ +export function sum(numbers: readonly number[]): number { + let s = 0; + for (let i = 0; i < numbers.length; i += 1) { + s += numbers[i]; + } + return s; +} + +/** 算术平均(空数组为 0) */ +export function mean(numbers: readonly number[]): number { + if (numbers.length === 0) return 0; + return sum(numbers) / numbers.length; +} + +/** 各位数字之和(十进制,忽略负号) */ +export function digitSum(n: number): number { + let v = Math.abs(Math.trunc(n)); + let s = 0; + while (v > 0) { + s += v % 10; + v = Math.floor(v / 10); + } + return s; +} + +export function isEven(n: number): boolean { + return n % 2 === 0; +} + +export function isOdd(n: number): boolean { + return !isEven(n); +} + +/** + * 将浮点数舍入到指定小数位(避免 0.1+0.2 展示问题) + */ +export function roundToDecimals(value: number, decimals: number): number { + const p = 10 ** decimals; + return Math.round(value * p) / p; +} diff --git a/miniprogram/core/utils/random.ts b/miniprogram/core/utils/random.ts index 0afe743..b2ae485 100644 --- a/miniprogram/core/utils/random.ts +++ b/miniprogram/core/utils/random.ts @@ -13,11 +13,20 @@ export function randomPick(arr: T[]): T { } /** - * 从数组中随机选取 n 个不重复元素 + * 从数组中随机选取 n 个不重复元素(Fisher-Yates,无 sort 偏置) */ -export function randomPickN(arr: T[], n: number): T[] { - const shuffled = [...arr].sort(() => Math.random() - 0.5); - return shuffled.slice(0, Math.min(n, arr.length)); +export function randomPickN(arr: readonly T[], n: number): T[] { + return sampleWithoutReplacement(arr, n); +} + +/** + * 无放回抽样,不改变入参数组 + */ +export function sampleWithoutReplacement( + arr: readonly T[], + n: number, +): T[] { + return shuffle([...arr]).slice(0, Math.min(n, arr.length)); } /** diff --git a/miniprogram/pages/category/category.ts b/miniprogram/pages/category/category.ts index 99e0e1d..a604814 100644 --- a/miniprogram/pages/category/category.ts +++ b/miniprogram/pages/category/category.ts @@ -1,4 +1,7 @@ -import type { WorksheetType } from '../../core/models/worksheet'; +import type { + WorksheetDefinition, + WorksheetType, +} from '../../core/models/worksheet'; import { getWorksheetPath, getWorksheetsByAge, @@ -63,7 +66,7 @@ function toDisplayList(list: WorksheetType[]): DisplayItem[] { Page({ data: { title: '分类', - sourceItems: [] as WorksheetType[], + sourceItems: [] as WorksheetDefinition[], items: [] as DisplayItem[], ageFilters: [ { key: 'all', label: '全部' }, @@ -95,13 +98,11 @@ Page({ const minAge = options.minAge != null ? Number(options.minAge) : NaN; const maxAge = options.maxAge != null ? Number(options.maxAge) : NaN; if (!Number.isNaN(minAge) && !Number.isNaN(maxAge)) { - const map = new Map(); + const map = new Map(); const lo = Math.min(minAge, maxAge); const hi = Math.max(minAge, maxAge); for (let age = lo; age <= hi; age += 1) { - getWorksheetsByAge(base, age).forEach((w) => - map.set(w.id, w), - ); + getWorksheetsByAge(base, age).forEach((w) => map.set(w.id, w)); } base = Array.from(map.values()); } diff --git a/miniprogram/pages/home/home.json b/miniprogram/pages/home/home.json index 7dbf850..4f4597e 100644 --- a/miniprogram/pages/home/home.json +++ b/miniprogram/pages/home/home.json @@ -1,11 +1,12 @@ { + "navigationStyle": "custom", "navigationBarTitleText": "涂鸦丫", "navigationBarTextStyle": "black", "navigationBarBackgroundColor": "#FEF6E7", "backgroundColor": "#FEF6E7", "enablePullDownRefresh": false, "usingComponents": { - "worksheet-card": "/components/shared/worksheet-card/worksheet-card", - "category-tabs": "/components/shared/category-tabs/category-tabs" + "category-tabs": "/components/shared/category-tabs/category-tabs", + "van-icon": "@vant/weapp/icon/index" } } diff --git a/miniprogram/pages/home/home.less b/miniprogram/pages/home/home.less index 91bbbbe..3b1b75e 100644 --- a/miniprogram/pages/home/home.less +++ b/miniprogram/pages/home/home.less @@ -1,172 +1,257 @@ -/* 首页体验配色:主题 #FFD709 / 正文 #605B50 / 底 #FEF6E7 / 按钮字 #322E25 / 标签 #91F78E */ +@import '../../style/theme3.less'; + +@home-padding: 28rpx; .home-page { min-height: 100vh; - background: #fef6e7; + background: @bg-page; padding-bottom: 48rpx; box-sizing: border-box; - color: #605b50; + color: @text-secondary; + // padding: 0 24rpx 48rpx; } -.home-header { - background: #fef6e7; - padding: 24rpx 24rpx 20rpx; +.home-nav { + background: @bg-header; + box-sizing: content-box; } -.home-brand { +.home-nav__inner { + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + padding: 0 16px; + gap: 10px; +} + +.home-nav__logo { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 16px; +} + +.home-nav__name { + font-size: 17px; + font-weight: 700; + color: @text-title; + line-height: 1; +} + +.home-search-wrap { + margin-top: 28rpx; + padding: 0 @home-padding; + box-sizing: border-box; +} + +.home-search { display: flex; flex-direction: row; align-items: center; gap: 16rpx; - margin-bottom: 24rpx; -} - -.home-brand__duck { - font-size: 48rpx; - line-height: 1; -} - -.home-brand__name { - font-size: 36rpx; - font-weight: 700; - color: #322e25; -} - -.home-search { - background: rgba(255, 255, 255, 0.72); - border-radius: 40rpx; + background: #e5dcc9; + border-radius: 999rpx; padding: 22rpx 32rpx; - border: 1rpx solid rgba(255, 215, 9, 0.35); + box-sizing: border-box; +} + +.home-search__icon { + flex-shrink: 0; + line-height: 1; } .home-search__placeholder { font-size: 28rpx; - color: rgba(96, 91, 80, 0.55); + color: rgba(50, 46, 37, 0.45); + flex: 1; } -.home-body { - padding: 24rpx 24rpx 0; +.home-tabs-wrap { + // margin-top: 28rpx; } -.home-section { - margin-bottom: 40rpx; +.home-hero-placeholder { + // margin: 8rpx auto 0; + margin: 0 24rpx; + // width: 684rpx; + height: 480rpx; + border-radius: 40rpx; + background: rgba(50, 46, 37, 0.04); } -.home-section--hot { - margin-bottom: 32rpx; +.home-section-panel { + margin-top: 48rpx; + padding: 0 @home-padding; + box-sizing: border-box; } -.home-section__title-row { - margin-bottom: 20rpx; +.home-section-panel--hot { + margin-top: 32rpx; } -.home-section__title { - font-size: 32rpx; - font-weight: 700; - color: #605b50; -} - -.home-section__head { +.home-section-head { display: flex; - flex-direction: row; align-items: center; justify-content: space-between; - margin-bottom: 20rpx; } -.home-section__head-left { - display: flex; - flex-direction: row; - align-items: center; - gap: 12rpx; -} - -.home-section__icon { - font-size: 36rpx; - line-height: 1; -} - -.home-section__name { +.home-section-head__title { font-size: 32rpx; font-weight: 700; - color: #605b50; + color: @text-title; } -.home-section__more { - font-size: 26rpx; - color: #322e25; - font-weight: 500; +.home-section-head__more { + font-size: 24rpx; + color: @text-selected-btn; } -.home-hot__scroll { +.home-age-scroll { + margin-top: 32rpx; width: 100%; white-space: nowrap; } -.home-hot__row { +.home-age-row { display: inline-flex; flex-direction: row; - gap: 24rpx; - padding-bottom: 8rpx; + gap: 20rpx; + padding-right: @home-padding; } -.home-hot__item { - display: inline-block; - width: 240rpx; - vertical-align: top; +.home-age-item { + width: calc((750rpx - 48rpx - 40rpx) / 3.2); + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; } -.home-hot__card-wrap { - width: 240rpx; - padding: 4rpx; - border-radius: 28rpx; - box-sizing: border-box; - background: linear-gradient(145deg, rgba(255, 215, 9, 0.85), rgba(254, 246, 231, 0.95)); +.home-age-item__icon-wrap { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + background: rgba(248, 240, 224, 0.85); + display: flex; + align-items: center; + justify-content: center; +} + +.home-age-item__icon-wrap--active { + background: rgba(255, 215, 9, 0.28); +} + +.home-age-item__icon { + font-size: 44rpx; + line-height: 1; +} + +.home-age-item__label { + margin-top: 12rpx; + font-size: 26rpx; + color: @text-title; + font-weight: 600; + white-space: nowrap; +} + +.home-hot-list { + margin-top: 36rpx; + display: flex; + flex-direction: column; + gap: 28rpx; +} + +.home-hot-card { + background: #ffffff; + border-radius: 24rpx; + padding: 24rpx; + display: flex; + align-items: center; + gap: 20rpx; + box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04); +} + +.home-hot-card__thumb { + width: 144rpx; + height: 144rpx; + border-radius: 24rpx; + background: linear-gradient(140deg, #ad060f 0%, #d93b0d 40%, #a20606 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.home-hot-card__thumb-emoji { + font-size: 56rpx; +} + +.home-hot-card__content { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.home-hot-card__title { + font-size: 30rpx; + color: #2f2f2f; + font-weight: 600; +} + +.home-hot-card__desc { + margin-top: 8rpx; + font-size: 24rpx; + color: #616161; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-hot-card__meta { + margin-top: 16rpx; + display: flex; + align-items: center; + gap: 16rpx; +} + +.home-hot-card__tag { + font-size: 22rpx; + color: @text-selected-btn; + background: #ffd709; + border-radius: 999rpx; + padding: 4rpx 16rpx; +} + +.home-hot-card__score { + font-size: 24rpx; + color: #6b6b6b; +} + +.home-hot-card__action { + width: 56rpx; + height: 56rpx; + border-radius: 50%; + background: #ffd709; + color: #2b2b2b; + font-size: 36rpx; + line-height: 56rpx; + text-align: center; + flex-shrink: 0; } -/* category-tabs:仅首页通过 apply-shared + .home-page 生效 */ .home-page .category-tabs { - background: #fef6e7; + background: @bg-page; } .home-page .category-tabs__pill { background: rgba(96, 91, 80, 0.08); - color: #605b50; + color: @text-secondary; } .home-page .category-tabs__pill--active { background: #ffd709; - color: #322e25; + color: @text-selected-btn; font-weight: 600; -} - -/* worksheet-card:仅首页 */ -.home-page .worksheet-card { - box-shadow: 0 8rpx 28rpx rgba(50, 46, 37, 0.08); -} - -.home-page .worksheet-card__title { - color: #605b50; -} - -.home-page .worksheet-card__preview-text { - color: #605b50; -} - -.home-page .worksheet-card__tag--age, -.home-page .worksheet-card__tag--diff { - background: #91f78e; - color: #322e25; -} - -.home-grid { - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 20rpx; -} - -.home-grid__cell { - width: calc((100% - 40rpx) / 3); - box-sizing: border-box; -} +} \ No newline at end of file diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index 07e2a39..3d8f572 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -1,81 +1,77 @@ -import { ALL_WORKSHEETS, getWorksheetsByCategory, getWorksheetPath } from '../../core/data/worksheets'; import { CATEGORIES } from '../../core/data/categories'; -import { WorksheetType } from '../../core/models/worksheet'; +import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty'; const CATEGORY_TABS = [ { id: 'all', name: '全部' }, ...CATEGORIES.map((c) => ({ id: c.id, name: c.name })), ]; +const AGE_BAND_ICONS = ['🌱', '🚀', '🎓', '🧠', '🏆'] as const; + +const MOCK_HOT_RECOMMENDS = [ + { + id: 'hot-1', + title: '100以内的加减法', + desc: '让算术变得像游戏一样简单', + tag: '数学', + score: '4.8', + emoji: '🍊', + }, + { + id: 'hot-2', + title: '创意涂鸦大作战', + desc: '释放孩子的艺术想象力', + tag: '艺术', + score: '4.9', + emoji: '🎨', + }, +] as const; + +const { statusBarHeight } = wx.getWindowInfo(); + Page({ data: { + statusBarHeight, tabs: CATEGORY_TABS, activeTab: 'all', - sections: [] as Array<{ title: string; icon: string; category: string; items: WorksheetType[] }>, - hotItems: [] as WorksheetType[], - filteredItems: null as WorksheetType[] | null, - }, - - onLoad() { - const sections = CATEGORIES.filter((cat) => { - const items = getWorksheetsByCategory(cat.id); - return items.length > 0; - }).map((cat) => ({ - title: cat.name, - icon: cat.icon, - category: cat.id, - items: getWorksheetsByCategory(cat.id).slice(0, 3), - })); - - const hotItems = ALL_WORKSHEETS.filter((w) => w.page).slice(0, 4); - - this.setData({ sections, hotItems }); + ageBands: AGE_BANDS.map((item, idx) => ({ + ...item, + icon: AGE_BAND_ICONS[idx] || '🌟', + })), + activeAgeBand: AGE_BANDS[0].key as AgeBandKey, + hotRecommends: [...MOCK_HOT_RECOMMENDS], }, onTabChange(e: WechatMiniprogram.CustomEvent) { - const activeTab = e.detail.id; - if (activeTab === 'all') { - this.setData({ activeTab, filteredItems: null }); - } else { - const filteredItems = getWorksheetsByCategory(activeTab); - this.setData({ activeTab, filteredItems }); - } + this.setData({ activeTab: e.detail.id }); }, - onCardTap(e: WechatMiniprogram.TouchEvent) { - const id = e.currentTarget.dataset.id as string | undefined; - if (!id) return; - const item = ALL_WORKSHEETS.find((w) => w.id === id); - if (!item) return; - const path = getWorksheetPath(item); - if (path) { - wx.navigateTo({ url: path }); - } + onTapAgeBand(e: WechatMiniprogram.TouchEvent) { + const key = e.currentTarget.dataset.key as AgeBandKey | undefined; + if (!key) return; + this.setData({ activeAgeBand: key }); }, - onHotCardTap(e: WechatMiniprogram.TouchEvent) { - const id = e.currentTarget.dataset.id as string | undefined; - if (!id) return; - const item = ALL_WORKSHEETS.find((w) => w.id === id); - if (!item) return; - const path = getWorksheetPath(item); - if (path) { - wx.navigateTo({ url: path }); - } + onTapHotRecommend(e: WechatMiniprogram.TouchEvent) { + const title = e.currentTarget.dataset.title as string | undefined; + if (!title) return; + wx.showToast({ title: `已选择:${title}`, icon: 'none' }); }, - onSeeMore(e: WechatMiniprogram.TouchEvent) { - const { category } = e.currentTarget.dataset; - wx.navigateTo({ - url: `/pages/category/category?category=${category}`, - }); + onTapAgeMore() { + wx.showToast({ title: '分龄全部 即将上线', icon: 'none' }); + }, + + onTapHotMore() { + wx.showToast({ title: '热门推荐 即将上线', icon: 'none' }); }, onShareAppMessage() { return { title: '涂鸦丫 - 快来生成宝宝的专属学习卡', path: '/pages/home/home', - imageUrl: 'https://cdn.joeyone.cn/doodle/share-img/index-share-v2.png', + imageUrl: + 'https://cdn.joeyone.cn/doodle/share-img/index-share-v2.png', }; }, diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml index a43d4ce..767452f 100644 --- a/miniprogram/pages/home/home.wxml +++ b/miniprogram/pages/home/home.wxml @@ -1,105 +1,93 @@ - -function ageLabel(arr) { - if (!arr || arr.length < 2) { - return ''; - } - return arr[0] + '-' + arr[1] + '岁'; -} -module.exports.ageLabel = ageLabel; - - - - - 🦆 - 涂鸦丫 - - - 搜索练习单(即将上线) + + + + 涂鸦丫 - + + + + 搜索你喜欢的练习册... + + - - - + + + + + + + + + 按年龄挑选 + 查看全部 + + + - - - - - - - - - 🔥 热门下载 - - - - - - - - - - - - - - - - {{item.icon}} - {{item.title}} - - 更多 - - + wx:for="{{ageBands}}" + wx:key="key" + class="home-age-item" + data-key="{{item.key}}" + bindtap="onTapAgeBand"> - + class="home-age-item__icon-wrap {{activeAgeBand === item.key ? 'home-age-item__icon-wrap--active' : ''}}"> + {{item.icon}} + {{item.label}} - + + + + + + 热门推荐 + 查看全部 + + + + + {{item.emoji}} + + + {{item.title}} + {{item.desc}} + + {{item.tag}} + {{item.score}} ★ + + + + + + diff --git a/miniprogram/platform/canvas-adapter.ts b/miniprogram/platform/canvas-adapter.ts index 27c749b..fcde6c5 100644 --- a/miniprogram/platform/canvas-adapter.ts +++ b/miniprogram/platform/canvas-adapter.ts @@ -3,11 +3,57 @@ * 封装微信小程序 Canvas API,为未来 Web 平台迁移做准备 */ +type QueryHost = + | WechatMiniprogram.Page.TrivialInstance + | WechatMiniprogram.Component.TrivialInstance; + +type Canvas2DContextLike = WechatMiniprogram.RenderingContext & { + scale: (x: number, y: number) => void; +}; + export interface CanvasInitResult { canvas: WechatMiniprogram.Canvas; - ctx: CanvasRenderingContext2D; + ctx: Canvas2DContextLike; width: number; height: number; + dpr: number; +} + +/** + * 与架构文档 ICanvasAdapter 对齐的小程序实现 + */ +export interface ICanvasAdapter { + getCanvas(): WechatMiniprogram.Canvas; + getContext(): Canvas2DContextLike; + getSize(): { width: number; height: number; dpr: number }; + setSize(width: number, height: number): void; + loadImage(src: string): Promise; + toDataURL(type?: 'png' | 'jpg', quality?: number): Promise; + toBlob(): Promise; + toTempFilePath(options?: { + quality?: number; + fileType?: 'png' | 'jpg'; + }): Promise; +} + +function createQuery(host?: QueryHost): WechatMiniprogram.SelectorQuery { + if (host && typeof host.createSelectorQuery === 'function') { + return host.createSelectorQuery(); + } + return wx.createSelectorQuery(); +} + +function getDpr(): number { + try { + return Math.max(1, wx.getSystemInfoSync().pixelRatio || 1); + } catch { + return 1; + } +} + +function ensureAspectRatio(aspectRatio: number): number { + if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return 1; + return aspectRatio; } /** @@ -15,43 +61,53 @@ export interface CanvasInitResult { * @param selector Canvas 选择器 (如 '#canvasContent') * @param wrapperSelector 容器选择器 (如 '#canvasWrapper') * @param aspectRatio 宽高比 (width / height) + * @param host 可选:页面/组件实例,组件内调用建议传入 this */ export function initCanvas( selector: string, wrapperSelector: string, aspectRatio: number, + host?: QueryHost, ): Promise { return new Promise((resolve, reject) => { - const query = wx.createSelectorQuery(); - query + const ratio = ensureAspectRatio(aspectRatio); + createQuery(host) .select(wrapperSelector) .boundingClientRect((rect) => { - if (!rect) { + if (!rect || !rect.width) { reject(new Error('Canvas wrapper not found')); return; } const boxWidth = rect.width; - const boxHeight = boxWidth / aspectRatio; + const boxHeight = boxWidth / ratio; - wx.createSelectorQuery() + createQuery(host) .select(selector) .fields({ node: true, size: true }) .exec((res) => { - if (!res[0]) { + if (!res || !res[0] || !res[0].node) { reject(new Error('Canvas node not found')); return; } - const canvas = res[0].node; - const ctx = canvas.getContext('2d'); - const dpr = wx.getSystemInfoSync().pixelRatio; + const canvas = res[0].node as WechatMiniprogram.Canvas; + const ctx = canvas.getContext( + '2d', + ) as Canvas2DContextLike; + const dpr = getDpr(); canvas.width = boxWidth * dpr; canvas.height = boxHeight * dpr; ctx.scale(dpr, dpr); - resolve({ canvas, ctx, width: boxWidth, height: boxHeight }); + resolve({ + canvas, + ctx, + width: boxWidth, + height: boxHeight, + dpr, + }); }); }) .exec(); @@ -69,9 +125,9 @@ export function canvasToTempFilePath( wx.canvasToTempFilePath({ canvas, fileType: options?.fileType || 'png', - quality: options?.quality || 1, + quality: options?.quality ?? 1, success: (res) => resolve(res.tempFilePath), - fail: reject, + fail: (err) => reject(err), }); }); } @@ -86,7 +142,64 @@ export function loadImage( return new Promise((resolve, reject) => { const img = canvas.createImage(); img.onload = () => resolve(img); - img.onerror = reject; + img.onerror = (err) => reject(err); img.src = src; }); } + +/** + * 通过初始化结果创建微信 Canvas 适配器实例 + */ +export function createWxCanvasAdapter(init: CanvasInitResult): ICanvasAdapter { + let width = init.width; + let height = init.height; + let dpr = init.dpr; + let scaled = true; + + const applyScale = () => { + if (!scaled) { + init.ctx.scale(dpr, dpr); + scaled = true; + } + }; + + return { + getCanvas() { + return init.canvas; + }, + getContext() { + return init.ctx; + }, + getSize() { + return { width, height, dpr }; + }, + setSize(nextWidth: number, nextHeight: number) { + width = Math.max(1, nextWidth); + height = Math.max(1, nextHeight); + dpr = getDpr(); + init.canvas.width = width * dpr; + init.canvas.height = height * dpr; + scaled = false; + applyScale(); + }, + loadImage(src: string) { + return loadImage(init.canvas, src); + }, + async toDataURL(type = 'png', quality = 1) { + const maybeCanvas = init.canvas as unknown as { + toDataURL?: (mimeType?: string, q?: number) => string; + }; + if (typeof maybeCanvas.toDataURL === 'function') { + const mimeType = type === 'jpg' ? 'image/jpeg' : 'image/png'; + return maybeCanvas.toDataURL(mimeType, quality); + } + throw new Error('toDataURL is not supported in current runtime'); + }, + async toBlob() { + throw new Error('toBlob is not supported in WeChat Mini Program'); + }, + toTempFilePath(options) { + return canvasToTempFilePath(init.canvas, options); + }, + }; +} diff --git a/miniprogram/style/theme3.less b/miniprogram/style/theme3.less new file mode 100644 index 0000000..a55c84b --- /dev/null +++ b/miniprogram/style/theme3.less @@ -0,0 +1,53 @@ +// v3.0 主题色体系(theme3) +// 主题色 #FFD709 · 页背景 #FEF6E7 · Header #F8F0E0 · 主标题字 #322E25 · 选中按钮字 #6C5A00 + +// 品牌 / 主题色 +@brand: #ffd709; +@brand-dark: #e5c200; +@brand-light: #fff4c4; + +// 与 theme.less 对齐的别名 +@primary-color: @brand; +@select-color: @brand; + +// 页面结构背景 +@bg-page: #fef6e7; +@bg-header: #f8f0e0; +@bg-default: @bg-page; +@bg-white: #ffffff; +@bg-gray: #f5edd8; + +// 文本:标题 / title +@text-title: #322e25; +@text-primary: @text-title; +@text-default: @text-title; + +// 选中态按钮上的文字 +@text-selected-btn: #6c5a00; + +// 辅助文本(正文可读色,可与标题区分) +@text-secondary: #605b50; +@text-light: rgba(50, 46, 37, 0.45); +@text-gray: #8a8478; +@text-white: #ffffff; +@text-disable: rgba(50, 46, 37, 0.35); + +// 链接与外链(沿用可读蓝绿,可按需覆盖) +@link-color: #2794f4; +@wechat-color: #1fe000; + +// 边框(适配暖色底) +@border: 1rpx solid rgba(50, 46, 37, 0.08); +@border-color: rgba(255, 215, 9, 0.35); + +// 圆角 +@radius-sm: 16rpx; +@radius: 24rpx; +@radius-lg: 32rpx; + +// 阴影 +@shadow: 0 4rpx 16rpx rgba(50, 46, 37, 0.06); +@shadow-lg: 0 8rpx 32rpx rgba(50, 46, 37, 0.1); + +// icon +@font-size-icon: 40rpx; \ No newline at end of file