feat:更新core 和首页代码
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
## code 说明
|
||||
|
||||
code = 0 请求成功
|
||||
code = 1 请求失败
|
||||
@@ -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;
|
||||
@@ -1,9 +0,0 @@
|
||||
function sendResponse(data = {}, code = 0, message = '请求成功') {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = sendResponse;
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"openapi": [
|
||||
"wxacode.get"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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: {} };
|
||||
}
|
||||
};
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
+168
-33
@@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+902
@@ -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 <token>
|
||||
```
|
||||
|
||||
### 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<UserInfo> {
|
||||
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<T>(method: string, path: string, data?: any): Promise<T> {
|
||||
const token = this.token || wx.getStorageSync('token');
|
||||
const res = await new Promise<any>((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<PaginatedResult<Worksheet>> {
|
||||
return this.request('GET', '/worksheets', query);
|
||||
}
|
||||
|
||||
async getWorksheetDetail(id: string): Promise<Worksheet> {
|
||||
return this.request('GET', `/worksheets/${id}`);
|
||||
}
|
||||
|
||||
async addFavorite(worksheetId: string): Promise<void> {
|
||||
await this.request('POST', '/favorites', { worksheetId });
|
||||
}
|
||||
|
||||
async removeFavorite(worksheetId: string): Promise<void> {
|
||||
await this.request('DELETE', `/favorites/${worksheetId}`);
|
||||
}
|
||||
|
||||
async getFavorites(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<PaginatedResult<Favorite>> {
|
||||
return this.request('GET', '/favorites', { page, pageSize });
|
||||
}
|
||||
|
||||
async reportDownload(worksheetId: string, params?: object): Promise<void> {
|
||||
await this.request('POST', '/stats/download', { worksheetId, params });
|
||||
}
|
||||
|
||||
async submitFeedback(content: string, contact?: string): Promise<void> {
|
||||
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 <token> │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
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 <repo-url> /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 缓存热点数据 + 数据库索引优化 + 按需水平扩展 |
|
||||
@@ -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\<string\> | 是 | 标签列表。 |
|
||||
| `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\<string\> | 是 | 能力目标描述(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)。
|
||||
+205
-438
@@ -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. **仅单一分包使用** → 放对应 `<xxx>Pages/shared/draw`、`<xxx>Pages/shared/generators`;
|
||||
> 3. 如后续从“单分包”演进为“跨分包”,再从分包 `shared` 上提到 `core`,避免过早抽象导致主包膨胀。
|
||||
|
||||
### 3.3 分层职责
|
||||
|
||||
```
|
||||
@@ -534,71 +548,71 @@ interface WorksheetConfig {
|
||||
status: 'active' | 'draft' | 'hidden';
|
||||
|
||||
// ─── 模板引擎配置(渲染用)───
|
||||
template: TemplateType; // 使用哪个排版模板
|
||||
generator: GeneratorType; // 使用哪个数据生成器
|
||||
generatorConfig: Record<string, any>; // 生成器参数(不同生成器不同)
|
||||
layoutConfig: LayoutConfig; // 排版参数
|
||||
userConfigurable?: UserConfigField[]; // 用户可调整的参数定义
|
||||
template: TemplateType; // 使用哪个排版模板
|
||||
generator: GeneratorType; // 使用哪个数据生成器
|
||||
generatorConfig: Record<string, any>; // 生成器参数(不同生成器不同)
|
||||
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<void> {
|
||||
async render(
|
||||
data: any,
|
||||
layout: LayoutConfig,
|
||||
printConfig: PrintConfig,
|
||||
): Promise<void> {
|
||||
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<void>;
|
||||
protected abstract drawContent(
|
||||
data: any,
|
||||
layout: LayoutConfig,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -804,12 +825,15 @@ class TemplateEngine {
|
||||
|
||||
async render(
|
||||
worksheetConfig: WorksheetConfig,
|
||||
userParams: Record<string, any>, // 用户在 UI 上调整的参数
|
||||
userParams: Record<string, any>, // 用户在 UI 上调整的参数
|
||||
ctx: CanvasRenderingContext2D,
|
||||
printConfig: PrintConfig,
|
||||
): Promise<void> {
|
||||
// 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/ 作为目录组织,未来再拆包 |
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#333333",
|
||||
"backgroundColor": "#ffffff",
|
||||
"selectedColor": "#FFD709",
|
||||
"backgroundColor": "#F8F0E0",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared",
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared",
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -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: '益智游戏',
|
||||
|
||||
@@ -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'];
|
||||
@@ -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;
|
||||
}
|
||||
+263
-110
@@ -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<string, unknown>;
|
||||
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<T extends WorksheetType>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 云集合 `download_logs` 文档(§3.7) */
|
||||
export interface DownloadLogRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
params?: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 云集合 `favorites` 文档(§3.6) */
|
||||
export interface FavoriteRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
worksheetId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 云集合 `feedback` 文档(§3.8) */
|
||||
export interface FeedbackRecord {
|
||||
_id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
contact?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<WorksheetRecord, '_id' | 'createdAt' | 'updatedAt'> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, ',');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,11 +13,20 @@ export function randomPick<T>(arr: T[]): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数组中随机选取 n 个不重复元素
|
||||
* 从数组中随机选取 n 个不重复元素(Fisher-Yates,无 sort 偏置)
|
||||
*/
|
||||
export function randomPickN<T>(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<T>(arr: readonly T[], n: number): T[] {
|
||||
return sampleWithoutReplacement(arr, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无放回抽样,不改变入参数组
|
||||
*/
|
||||
export function sampleWithoutReplacement<T>(
|
||||
arr: readonly T[],
|
||||
n: number,
|
||||
): T[] {
|
||||
return shuffle([...arr]).slice(0, Math.min(n, arr.length));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, WorksheetType>();
|
||||
const map = new Map<string, WorksheetDefinition>();
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+196
-111
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,105 +1,93 @@
|
||||
<wxs module="fmt">
|
||||
function ageLabel(arr) {
|
||||
if (!arr || arr.length < 2) {
|
||||
return '';
|
||||
}
|
||||
return arr[0] + '-' + arr[1] + '岁';
|
||||
}
|
||||
module.exports.ageLabel = ageLabel;
|
||||
</wxs>
|
||||
|
||||
<view class="home-page">
|
||||
<view class="home-header">
|
||||
<view class="home-brand">
|
||||
<text class="home-brand__duck">🦆</text>
|
||||
<text class="home-brand__name">涂鸦丫</text>
|
||||
</view>
|
||||
<view class="home-search">
|
||||
<text class="home-search__placeholder">搜索练习单(即将上线)</text>
|
||||
<view class="home-nav" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="home-nav__inner">
|
||||
<image
|
||||
src="/assets/imgs/doodle-logo.png"
|
||||
mode="aspectFill"
|
||||
class="home-nav__logo" />
|
||||
<text class="home-nav__name">涂鸦丫</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<category-tabs tabs="{{tabs}}" active-tab="{{activeTab}}" bind:change="onTabChange" />
|
||||
<view class="home-search-wrap">
|
||||
<view class="home-search">
|
||||
<van-icon
|
||||
name="search"
|
||||
size="36rpx"
|
||||
color="rgba(50, 46, 37, 0.45)"
|
||||
class="home-search__icon" />
|
||||
<text class="home-search__placeholder">搜索你喜欢的练习册...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="home-body">
|
||||
<block wx:if="{{activeTab != 'all'}}">
|
||||
<view class="home-grid">
|
||||
<view class="home-tabs-wrap">
|
||||
<category-tabs
|
||||
tabs="{{tabs}}"
|
||||
active-tab="{{activeTab}}"
|
||||
bind:change="onTabChange" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="home-hero-placeholder"
|
||||
aria-role="img"
|
||||
aria-label="推荐位占位" />
|
||||
|
||||
<view class="home-section-panel">
|
||||
<view class="home-section-head">
|
||||
<text class="home-section-head__title">按年龄挑选</text>
|
||||
<text class="home-section-head__more" bindtap="onTapAgeMore"
|
||||
>查看全部</text
|
||||
>
|
||||
</view>
|
||||
<scroll-view class="home-age-scroll" scroll-x enable-flex>
|
||||
<view class="home-age-row">
|
||||
<view
|
||||
wx:for="{{filteredItems}}"
|
||||
wx:key="id"
|
||||
class="home-grid__cell"
|
||||
bindtap="onCardTap"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<worksheet-card
|
||||
title="{{item.title}}"
|
||||
preview-bg="{{item.previewBg}}"
|
||||
preview-text="{{item.previewText}}"
|
||||
img="{{item.img}}"
|
||||
age-range="{{fmt.ageLabel(item.ageRange)}}"
|
||||
difficulty="{{item.difficulty}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<view class="home-section home-section--hot">
|
||||
<view class="home-section__title-row">
|
||||
<text class="home-section__title">🔥 热门下载</text>
|
||||
</view>
|
||||
<scroll-view class="home-hot__scroll" scroll-x enable-flex>
|
||||
<view class="home-hot__row">
|
||||
<view
|
||||
wx:for="{{hotItems}}"
|
||||
wx:key="id"
|
||||
class="home-hot__item"
|
||||
bindtap="onHotCardTap"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<view class="home-hot__card-wrap">
|
||||
<worksheet-card
|
||||
title="{{item.title}}"
|
||||
preview-bg="{{item.previewBg}}"
|
||||
preview-text="{{item.previewText}}"
|
||||
img="{{item.img}}"
|
||||
age-range="{{fmt.ageLabel(item.ageRange)}}"
|
||||
difficulty="{{item.difficulty}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view wx:for="{{sections}}" wx:key="category" class="home-section">
|
||||
<view class="home-section__head">
|
||||
<view class="home-section__head-left">
|
||||
<text class="home-section__icon">{{item.icon}}</text>
|
||||
<text class="home-section__name">{{item.title}}</text>
|
||||
</view>
|
||||
<text class="home-section__more" bindtap="onSeeMore" data-category="{{item.category}}">更多</text>
|
||||
</view>
|
||||
<view class="home-grid">
|
||||
wx:for="{{ageBands}}"
|
||||
wx:key="key"
|
||||
class="home-age-item"
|
||||
data-key="{{item.key}}"
|
||||
bindtap="onTapAgeBand">
|
||||
<view
|
||||
wx:for="{{item.items}}"
|
||||
wx:for-item="w"
|
||||
wx:key="id"
|
||||
class="home-grid__cell"
|
||||
bindtap="onCardTap"
|
||||
data-id="{{w.id}}"
|
||||
>
|
||||
<worksheet-card
|
||||
title="{{w.title}}"
|
||||
preview-bg="{{w.previewBg}}"
|
||||
preview-text="{{w.previewText}}"
|
||||
img="{{w.img}}"
|
||||
age-range="{{fmt.ageLabel(w.ageRange)}}"
|
||||
difficulty="{{w.difficulty}}"
|
||||
/>
|
||||
class="home-age-item__icon-wrap {{activeAgeBand === item.key ? 'home-age-item__icon-wrap--active' : ''}}">
|
||||
<text class="home-age-item__icon">{{item.icon}}</text>
|
||||
</view>
|
||||
<text class="home-age-item__label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="home-section-panel home-section-panel--hot">
|
||||
<view class="home-section-head">
|
||||
<text class="home-section-head__title">热门推荐</text>
|
||||
<text class="home-section-head__more" bindtap="onTapHotMore"
|
||||
>查看全部</text
|
||||
>
|
||||
</view>
|
||||
<view class="home-hot-list">
|
||||
<view
|
||||
wx:for="{{hotRecommends}}"
|
||||
wx:key="id"
|
||||
class="home-hot-card"
|
||||
data-title="{{item.title}}"
|
||||
bindtap="onTapHotRecommend">
|
||||
<view class="home-hot-card__thumb">
|
||||
<text class="home-hot-card__thumb-emoji"
|
||||
>{{item.emoji}}</text
|
||||
>
|
||||
</view>
|
||||
<view class="home-hot-card__content">
|
||||
<text class="home-hot-card__title">{{item.title}}</text>
|
||||
<text class="home-hot-card__desc">{{item.desc}}</text>
|
||||
<view class="home-hot-card__meta">
|
||||
<text class="home-hot-card__tag">{{item.tag}}</text>
|
||||
<text class="home-hot-card__score"
|
||||
>{{item.score}} ★</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<view class="home-hot-card__action">+</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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<WechatMiniprogram.Image>;
|
||||
toDataURL(type?: 'png' | 'jpg', quality?: number): Promise<string>;
|
||||
toBlob(): Promise<unknown>;
|
||||
toTempFilePath(options?: {
|
||||
quality?: number;
|
||||
fileType?: 'png' | 'jpg';
|
||||
}): Promise<string>;
|
||||
}
|
||||
|
||||
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<CanvasInitResult> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user