Compare commits

...

10 Commits

Author SHA1 Message Date
R524809 f82ac4d89d feat: 开发练习纸 2026-08-25 15:40:38 +08:00
R524809 c0b8666257 feat: 时钟连线和认识时钟合并 2026-07-29 17:15:25 +08:00
R524809 2ffd0b302d feat: 开发时钟连线 2026-07-29 16:41:29 +08:00
R524809 37586b7bf2 feat: 修改版本号和更新内容 2026-06-25 12:57:58 +08:00
R524809 783ebfb936 feat: 新增认识时钟绘制 2026-06-25 12:46:43 +08:00
R524809 b802709517 fix: worksheet 展示时间修改 2026-06-15 16:40:20 +08:00
R524809 0bcf846857 fix: 解决公告点击跳转路径不对的问题 2026-06-15 15:43:34 +08:00
R524809 11469f7dca feat: 优化分类页内容管理 2026-06-15 15:39:46 +08:00
R524809 25a37fc5e2 feat: 新增幼儿测字表 2026-06-11 14:39:06 +08:00
R524809 a6e8f4df02 feat: 新增控笔练习,增加首页公告 2026-05-29 10:21:39 +08:00
71 changed files with 4990 additions and 741 deletions
+8 -3
View File
@@ -15,6 +15,13 @@ function ageBand(min, max) {
return `${min}-${max}`; return `${min}-${max}`;
} }
function toContentDate(ws) {
const ts = ws.contentUpdatedAt || ws.createdAt;
return ts
? new Date(ts).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
}
function toDisplayItem(ws) { function toDisplayItem(ws) {
return { return {
id: ws._id, id: ws._id,
@@ -30,9 +37,7 @@ function toDisplayItem(ws) {
available: true, available: true,
likes: (ws.likes || 0) + (ws.likes_seed || 0), likes: (ws.likes || 0) + (ws.likes_seed || 0),
downloads: (ws.downloads || 0) + (ws.downloads_seed || 0), downloads: (ws.downloads || 0) + (ws.downloads_seed || 0),
date: ws.updatedAt date: toContentDate(ws),
? new Date(ws.updatedAt).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
}; };
} }
@@ -105,6 +105,7 @@ exports.main = async (event) => {
likes: payload.likes, likes: payload.likes,
status: payload.status, status: payload.status,
updatedAt: db.serverDate(), updatedAt: db.serverDate(),
contentUpdatedAt: db.serverDate(),
}; };
try { try {
@@ -127,6 +128,7 @@ exports.main = async (event) => {
data: { data: {
...record, ...record,
createdAt: db.serverDate(), createdAt: db.serverDate(),
contentUpdatedAt: db.serverDate(),
}, },
}); });
+3
View File
@@ -19,6 +19,7 @@ exports.main = async (event) => {
const category = String(event.category || '').trim(); const category = String(event.category || '').trim();
const status = String(event.status || '').trim(); const status = String(event.status || '').trim();
const rawStats = event.rawStats === true;
// Build query condition // Build query condition
const where = {}; const where = {};
@@ -61,7 +62,9 @@ exports.main = async (event) => {
else if (item.status === 'hidden') stats.hidden++; else if (item.status === 'hidden') stats.hidden++;
} }
if (!rawStats) {
mergeSeedStats(data); mergeSeedStats(data);
}
return { return {
success: true, success: true,
+171
View File
@@ -0,0 +1,171 @@
# AI 生成题目方案(对话式出题)
> 文档状态:方案讨论 / 待评审
> 创建日期:2026-06-16
> 背景:当前小程序与同类应用差异不明显,用户画像不够精准。探索引入 AI 能力——用户用自然语言描述需求,AI 将其翻译为对预置「技能(绘制方法)」的调用,并完成出题与绘制。
---
## 一、战略层面:先想清楚「为什么要做」
本方案在技术上完全可行,但要避免 AI 沦为「为差异化而加的功能」,而非「解决真实痛点的功能」。立项前需先回答:
- **用户的真实痛点是什么?** 是「找不到合适的题目」,还是「孩子不爱练」「不知道该练什么」?如果痛点不在出题,AI 出题器再酷也救不了留存。
- **家长真的会打字描述需求吗?** 「我想要培养数感的题目」这类话,家长未必说得出(多数人不知道「数感」是什么)。更真实的诉求往往是「我家娃 5 岁,给我今天该练的」。这意味着 AI 的价值可能不在「自然语言理解」,而在 **「帮不懂教育的家长做决策」**。
- **不用 AI 能否满足 80%?** 如果几个下拉框 + 模板就能满足,AI 的边际价值仅是「输入方式更自然」,通常撑不起差异化。
**结论 / 定位建议:** 把 AI 定位成 **「懂教育的助教」**(帮家长判断该练什么、循序渐进地推荐),而不是 **「自然语言转绘制指令的翻译器」**。前者是真差异,后者只是花哨的表单。
---
## 二、技术层面:经典的 Function Calling / Tool Use 架构
「把需求变成可调用的技能」在工程上的成熟范式叫 **工具调用(tool use / function calling**:已有的「绘制方法」即工具,LLM 负责把人话翻译成「调用哪个工具 + 什么参数」。
### 2.1 整体数据流(小程序云开发)
```
用户输入(自然语言)
→ 小程序前端 (聊天式 UI)
→ 云函数 ai-orchestrator
→ 调用 LLM(带 tools 定义)
├─ 缺参数 → 返回追问("孩子几岁?") ← 多轮
└─ 参数齐全 → 返回结构化调用意图
→ 确认环节(用户点"确认生成")
→ 云函数 / 前端调用内置绘制方法
→ 生成题目数据 + 渲染(canvas/图片)
→ 返回前端展示 / 保存 / 下载
```
### 2.2 关键模块
**1)技能注册表(最核心)**
每个绘制方法描述成一个 tool schema,让 LLM 知道有哪些能力、各需要什么参数:
```json
{
"name": "generate_number_sense",
"description": "生成培养数感的练习题(比大小、数的分解、数数等)",
"parameters": {
"type": "object",
"properties": {
"age": { "type": "integer", "description": "孩子年龄 3-8" },
"sub_type": { "type": "string", "enum": ["比大小", "数的分解", "按数取物"] },
"count": { "type": "integer", "description": "题目数量" },
"number_range": { "type": "string", "enum": ["1-10", "1-20", "1-100"] }
},
"required": ["age", "sub_type", "count"]
}
}
```
**2LLM 选型(国内合规很重要)**
云函数可发 HTTP 请求,能接任意 LLM。国内备案合规、且支持 OpenAI 兼容 function calling 的可选:**通义千问、豆包(火山方舟)、DeepSeek、文心、混元**。微信云开发本身也有「AI 能力 / 微信对话开放平台」。建议挑一个支持 `tools` 参数的,编排逻辑几乎无需自写。
**3)多轮对话 + 槽位填充(slot filling**
「缺年龄就追问年龄」的逻辑交给 LLM 判断,比手写 if-else 更强。需要做的只是:
- 每轮带上对话历史(存云数据库或前端回传);
- 在 system prompt 里要求:「参数不全时先友好追问,不要瞎猜」。
**4)「决策」与「生成」必须分离 ⚠️(最重要的工程原则)**
让 LLM 决定「生成什么」(结构化参数),但题目实际内容由确定性代码生成:
- ❌ 不要让 LLM 直接吐出 20 道算术题——会算错、会重复、不可控。
- ✅ 让 LLM 输出 `{age:5, sub_type:"比大小", count:20, range:"1-10"}`,再由绘制方法按规则生成 + 渲染。
兼得 AI 的「听得懂人话」与传统代码的「100% 正确可控」。
**5)确认环节**
真正调用绘制方法前,把解析出的参数回显给用户确认(「将生成 20 道 1-10 比大小,确认?」)。既防误解,也是好体验。
### 2.3 小程序云开发的几个坑
| 坑 | 说明 / 对策 |
| -------------- | ------------------------------------------------------------------------------------- |
| **云函数超时** | 默认 20s,LLM 调用可能慢。先做**非流式**(一次返回);想要打字机效果,后期再用 WebSocket 或 HTTP 触发器 + SSE,复杂度高,别一开始就做。 |
| **内容安全(必须)** | 微信强制要求。用户输入和 AI 输出都要过 `security.msgSecCheck`,否则可能被封。合规硬要求,非可选。 |
| **API Key 保护** | LLM 的 key 只能放云函数环境变量,**绝不能进前端**。 |
| **成本与频率** | 每次对话烧 token。加缓存(相同需求复用)、限频,防刷。 |
| **冷启动延迟** | 云函数冷启动 + LLM 延迟叠加,首次可能 3-5sUI 要有 loading 反馈。 |
---
## 三、要不要把绘制方法搬到服务端?
**短答:不需要把「绘制」搬到服务端。但要先把现有方法拆成两层——「出题数据」和「画图」——只有前者可能值得上服务端,后者留在小程序里。**
### 3.1 把「绘制方法」拆成三层
```
① 意图理解层 用户人话 → 结构化参数 ← 必须在服务端(云函数 + LLM)
② 出题数据层 参数 → 题目数据(JSON) ← 可服务端、可客户端
③ 渲染绘制层 题目数据 → canvas 画出来 ← 留在小程序前端
```
现有「绘制方法」大概率是 ②③ 混在一起(一个函数既算题目又调 `wx.canvas` 画图)。做 AI **真正要做的不是「搬到服务端」,而是「把 ② 和 ③ 解耦」**
### 3.2 为什么渲染层(③)不该搬服务端
- 小程序 canvas 是**客户端 API**,云函数(Node 环境)里没有 DOM、没有原生 canvas;要画图得引 `node-canvas` 之类,又重又易踩坑,得不偿失。
- 渲染留前端:性能好、可交互(手写、橡皮擦等)、省服务器成本。
→ ③ **保持现状,一行都不用动**
### 3.3 ② 出题数据层:搬不搬都行
**方案 A — 什么都不搬(最快上线,推荐起步)**
云函数只做 ①,返回结构化参数给前端:
```js
// 云函数返回
{ skill: "number_sense", age: 5, sub_type: "比大小", count: 20, range: "1-10" }
```
前端拿到参数,调已有的出题 + 绘制方法,照常跑。AI 是「加在前面的一层翻译」,老代码完全复用。
**方案 B — 把出题逻辑(②)搬服务端**
云函数直接算好题目数据返回:
```js
{ problems: [ {left:7, right:3, op:">"}, {left:2, right:8, op:"<"}, ... ] }
```
前端只负责画。
什么时候才值得选 B
- 出题逻辑要**保密/防作弊**(题库、难度算法不想暴露前端);
- **多端复用**(以后有 H5、APP,出题逻辑只维护一份);
- 出题需要**服务端资源**(查数据库题库、调别的接口)。
以上都没有,**先选 A**。
### 3.4 关键动作
唯一的关键动作:**确保出题逻辑是个「纯数据函数」(输入参数、输出 JSON、不碰 canvas)**。做到这点,搬不搬服务端都是后话,随时可切。
---
## 四、最小可行起步(MVP
别一上来做全套对话系统,先做最小闭环验证:
1. **单个技能**:只挑「数感题目」一个绘制方法接进来。
2. **单轮或两轮对话**:用户说需求 → AI 解析参数(缺了追问一次)→ 确认 → 生成。
3. **跑通链路**:「人话 → 结构化参数 → 已有绘制方法」。
跑通后回答关键问题:**家长真的会用自然语言输入吗?还是更想点几下就出题?** 用真实数据决定是否继续往「对话式」投入,而非凭感觉。
---
## 五、待办 / 下一步
- [ ] 盘点现有「绘制方法」清单及调用方式,判断 ②③ 缠绕程度。
- [ ] 确定 LLM 供应商(合规 + 支持 function calling)。
- [ ] 抽离一个出题逻辑为纯数据函数(以「数感题目」为试点)。
- [ ] 搭建 `ai-orchestrator` 云函数骨架 + 技能注册表。
- [ ] 接入内容安全 `msgSecCheck`
- [ ] MVP 灰度,观察家长真实输入行为与转化数据。
@@ -0,0 +1,183 @@
# 认识钟表 — 预览、出题与绘制设计
> 配套文档:[产品设计文档](../产品设计文档.md) | [技术架构设计文档](../技术架构设计文档.md)
> 版本:v1.0
> 最后更新:2026-06-25
---
## 一、目标与范围
为幼儿园中班至小学一年级(约 4–7 岁)提供 **「看模拟钟表,填写数字时间」** 的可打印练习纸。
- 每页 **4 行 × 3 列**,共 12 题
- 每组:上方模拟钟表 + 下方带冒号的数字填写框
- 小程序内预览、换题、下载打印
- 排版归类为技术架构文档 **模式 H:时钟/特殊图形型**
**实现路径**:独立绘制页 `mathPages/clockReading/`,不并入 `mathDraw` 聚合页(时钟绘制逻辑特殊,独立维护更清晰)。
---
## 二、页面结构
```
mathPages/clockReading/
├── clockReading.ts
├── clockReading.wxml
├── clockReading.less
├── clockReading.json
├── clockReading.config.ts
├── generators/
│ └── clock-generator.ts # 纯数据出题
└── draw/
├── clockReadingDraw.ts # 整页编排
└── drawAnalogClock.ts # 模拟钟表(可复用)
```
**交互区(预览卡片下方)**
| 区域 | 说明 |
|------|------|
| 预览卡片 | Canvas 实时预览 A4 效果,支持换一换 |
| 时刻类型 chips | 随机(默认)/ 整点 / 半点 / 刻钟 |
| 底部操作 | 分享、下载打印(复用 `pageMixin` |
---
## 三、练习纸版面
### 3.1 网格布局
```
┌──────────────────────────────────────────┐
│ [统一页眉] 认识钟表 │
│ 姓名:___ 日期:___ 得分:___ │
├──────────────────────────────────────────┤
│ [钟1] [钟2] [钟3] │
│ [__:__] [__:__] [__:__] │
│ [钟4] [钟5] [钟6] │
│ ... │
│ (4 行 × 3 列 = 12 题) │
└──────────────────────────────────────────┘
```
### 3.2 单题单元
1. **模拟钟表**:绿色外圈、1–12 数字、红色时针、黑色分针
2. **填写框**:绿色圆角描边,中间固定冒号 `:`,左右留白供手写
### 3.3 A4 尺寸参数(逻辑像素 595×842)
| 参数 | 值 |
|------|-----|
| 内容区起始 Y | 页眉后 ~110px |
| 左右边距 | 28px |
| 行数 / 列数 | 4 / 3 |
| 钟表半径 | ~58px |
| 钟表与填写框间距 | 10px |
| 填写框 | 宽 88px,高 30px,圆角 6px |
| 行高 | ~168px |
---
## 四、钟表视觉规范
| 元素 | 颜色 | 色值 |
|------|------|------|
| 外圈 | 绿色 | `#3D9E47` |
| 刻度 / 数字 | 黑色 | `#333333` |
| 时针 | 红色 | `#E53935` |
| 分针 / 中心点 | 黑色 | `#333333` |
| 填写框描边 | 绿色 | `#3D9E47` |
| 冒号 | 黑色 | `#333333` |
**指针角度**12 点方向为 0°,顺时针):
```ts
const minuteAngle = minute * 6;
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// Canvas 绘制时减去 90° 偏移(0° 在 3 点方向)
```
---
## 五、时刻类型
| 模式 ID | 显示名称 | 分针位置 | 示例 | 难度 |
|---------|----------|----------|------|------|
| `random` | 随机 | 059 分均可 | 如 4:07、11:23 | ★★ |
| `whole-hour` | 整点 | 12 点(0 分) | 3:00, 9:00 | ★ |
| `half-hour` | 半点 | 6 点(30 分) | 6:30, 10:30 | ★★ |
| `quarter-hour` | 刻钟 | 3 或 9 点(15/45 分) | 2:15, 7:45 | ★★★ |
> **命名说明**:「刻钟」对应传统「一刻」「三刻」,比「一刻时」更符合小学钟表教学用语,且与「整点」「半点」形成清晰三元组。
**随机模式**:分钟在 0–59 之间均匀随机,不限制为整点/半点/刻钟。
---
## 六、出题规则(`clock-generator.ts`
```ts
interface ClockProblem {
hour: number; // 112
minute: number; // 随机模式 0–59;其他模式见时刻类型表
}
interface ClockReadingData {
problems: ClockProblem[]; // 固定 12 个
timeMode: ClockReadingTimeMode;
}
```
1. 每页固定 12 题
2. 同一页内 `(hour, minute)` 不重复
3. 小时 112 均匀随机
4. 按所选时刻类型约束 `minute`
5. 纯函数输出 JSON,不依赖 Canvas
---
## 七、绘制服务拆分
```
clockReadingDraw.draw(data)
├── prepareDraw() / drawHeaderAndDivider({ title: '认识钟表' })
└── drawGrid(problems)
└── for each:
├── drawAnalogClock(cx, cy, radius, hour, minute)
└── drawTimeInputBox(x, y, w, h)
```
`drawAnalogClock.ts` 独立封装,便于后续「画指针」题型复用。
---
## 八、配置与注册
- Worksheet ID`clock-reading`
- 分类:`math` / `clock-reading`
- 路径:`/mathPages/clockReading/clockReading?id=clock-reading`
- 注册:`app.json` 分包、`config/worksheets/clockReading.ts``category.data.ts`
---
## 九、验收标准
- [ ] 预览区 4×3 共 12 组「钟表 + 填写框」
- [ ] 绿圈、红时针、黑分针、1–12 数字
- [ ] 填写框中间有冒号
- [ ] 默认随机;切换整点/半点/刻钟后分针位置正确
- [ ] 换一换生成新题且不重复
- [ ] A4 下载打印清晰
- [ ] 分享、收藏、打印解锁链路正常
---
## 十、后续演进(非 MVP
1. 定制页眉(日期/姓名/用时 + 装饰边框,更贴近教辅纸)
2. 对答案页 / 画指针题型
3. 5 分钟间隔、逐分钟进阶模式
4. AI 出题 tool`generate_clock_reading({ timeMode, count: 12 })`
+115
View File
@@ -0,0 +1,115 @@
现在我已经全面了解了项目和搜索规则。让我整理分析并给出建议。
---
## 项目功能总结
**涂鸦丫** 是一款面向 3-8 岁儿童的微信小程序,核心能力是通过 Canvas 动态生成可打印的 A4 教育练习纸(PNG),涵盖:
| 模块 | 题型数 | 典型内容 |
| -------------- | ------ | -------------------------------------- |
| 数学启蒙 | 26种 | 数字认知、加减法、凑十破十、九九乘法表 |
| 专注力培养 | 13种 | 格子仿画、连连看、找规律、方格推理 |
| 中文识字 | 1种 | 涂色识字卡(含8+词库分类) |
| 中文练字 | 1种 | 田字格笔顺描红 |
| 拼音(新增中) | — | 拼音描红、每日一练 |
| 英语(新增中) | — | 字母描红 letter tracing |
核心差异点:**动态生成 + 无限随机换一批 + 手机直出打印图片**,而非预制PDF下载。
---
## 微信小程序搜索关键词逻辑(2026年最新)
根据搜索研究,2026年微信搜索排名的核心规则:
1. **名称权重最高**:名称中含关键词 > 简介中含关键词 > 页面标题含关键词
2. **关键词前置**:核心搜索词放在名称最前面,权重最高
3. **字数控制 8-12 字**:过长会被截断且权重分散
4. **完全匹配 > 前缀匹配 > 分词匹配**:用户搜啥,名字正好包含这个词,排名最靠前
5. **禁止无关堆砌**:堆与服务无关的热词会被降权
6. **用户行为信号**:点击率、停留时长、复访率影响长期排名
7. **唯一性**:微信小程序名称全局唯一,热门词需尽早注册
**命名公式**`[核心关键词] + [品牌词]``[品牌词] + [核心功能词]`
---
## 竞品占位分析
| 竞品 | 名称策略 | 占领的关键词 |
| ---------- | -------------- | ---------------- |
| 奇多作业纸 | 品牌词+品类词 | 作业纸、幼小衔接 |
| 答印 | 品牌词即功能词 | 打印、练习题 |
| 字帖生成器 | 纯品类词 | 字帖、田字格 |
家长**高频搜索词**(按搜索意图排序):
- 🔥🔥🔥 **幼小衔接**(抖音该品类30天销量4.2万,极高热度)
- 🔥🔥🔥 **练习纸打印 / 幼儿练习纸**
- 🔥🔥 **数学练习题 / 口算题**
- 🔥🔥 **拼音练习 / 字帖**
- 🔥 **早教打印 / 启蒙练习**
---
## 推荐的 3 个名称方案
### 方案一(首推):`幼小衔接练习纸-涂鸦丫`
| 维度 | 分析 |
| ---------- | ----------------------------------------------------------- |
| 关键词覆盖 | ✅ 「幼小衔接」(超高热度词前置)+ 「练习纸」(精确品类词) |
| 搜索匹配 | 用户搜"幼小衔接"、"幼小衔接练习"、"练习纸"均可命中 |
| 字数 | 10字(含分隔符),在最优区间 |
| 品牌保留 | 「涂鸦丫」放后面保留辨识度 |
| 竞品差异 | 奇多占的是「作业纸」,你占「练习纸」+ 「幼小衔接」的组合位 |
| 风险 | 「幼小衔接」覆盖面最广(家长刚需场景),但偏向5-7岁段 |
**为什么首推**:「幼小衔接」是2026年家长群体最刚需、搜索量最大的教育关键词。你的产品实际内容(数学加减法、拼音、练字、专注力)本身就是幼小衔接的核心训练内容,定位天然匹配。
---
### 方案二:`幼儿练习纸打印-涂鸦丫`
| 维度 | 分析 |
| ---------- | --------------------------------------------------------- |
| 关键词覆盖 | ✅ 「幼儿」+ 「练习纸」+ 「打印」三词全命中 |
| 搜索匹配 | "幼儿练习纸"、"练习纸打印"、"幼儿打印"均可命中 |
| 字数 | 10字,在最优区间 |
| 品牌保留 | ✅ 涂鸦丫放后面 |
| 竞品差异 | 「打印」是你独有的核心差异(动态生成即时打印 vs 下载PDF) |
| 风险 | 「幼儿」偏低龄(3-5岁),可能覆盖不到6-8岁家长 |
**优势**:精确描述产品形态,搜索"幼儿练习纸"的用户转化率极高(搜索意图与产品完全匹配)。
---
### 方案三:`儿童早教打印-涂鸦丫`
| 维度 | 分析 |
| ---------- | ------------------------------------------------------------------------ |
| 关键词覆盖 | ✅ 「儿童」+ 「早教」+ 「打印」 |
| 搜索匹配 | "儿童早教"、"早教打印"、"儿童打印"可命中 |
| 字数 | 9字,简洁有力 |
| 品牌保留 | ✅ |
| 竞品差异 | 竞品没有人用"早教打印"这个组合占位 |
| 风险 | 「早教」稍显宽泛(可能联想到早教班、早教课),但"儿童"限定了年龄不会太偏 |
**优势**:覆盖面最广(3-8岁全覆盖),且"早教打印"是一个竞品空白的蓝海词组合,容易在搜索中脱颖而出。
---
## 最终建议
| 优先级 | 方案 | 核心理由 |
| ------ | ------------------------- | -------------------------------------------------------- |
| 🥇 | **幼小衔接练习纸-涂鸦丫** | 截获最大流量入口,家长搜索"幼小衔接"的意图最强、转化最高 |
| 🥈 | **幼儿练习纸打印-涂鸦丫** | 精准描述产品,搜索→转化路径最短 |
| 🥉 | **儿童早教打印-涂鸦丫** | 覆盖面最广,竞品蓝海 |
**额外建议**:无论选哪个名称,一定要在小程序简介中覆盖长尾词:
> 3-8岁幼儿启蒙打印练习纸|数学口算·识字练字·拼音描红·英语字母·专注力训练|A4一键生成,保存即可打印
这段简介会出现在微信搜索结果卡片上,相当于额外的 SEO 空间。同时记得在后台「推广」模块配置10个精准关键词(如:幼小衔接、练习纸、数学练习、拼音练习、打印练习题、口算题、幼儿启蒙、描红字帖、专注力训练、学前练习)。
+11 -3
View File
@@ -19,14 +19,15 @@
"homeContentManage/homeContentManage", "homeContentManage/homeContentManage",
"mathIndex/mathIndex", "mathIndex/mathIndex",
"focusIndex/focusIndex", "focusIndex/focusIndex",
"worksheetSync/worksheetSync" "worksheetSync/worksheetSync",
"unreleasedDebug/unreleasedDebug"
], ],
"independent": false "independent": false
}, },
{ {
"root": "mathPages", "root": "mathPages",
"name": "mathPages", "name": "mathPages",
"pages": ["mathDraw/mathDraw"], "pages": ["mathDraw/mathDraw", "clockReading/clockReading"],
"independent": false "independent": false
}, },
{ {
@@ -53,9 +54,16 @@
"pages": [ "pages": [
"wordColoring/wordColoring", "wordColoring/wordColoring",
"handwritingSheet/handwritingSheet", "handwritingSheet/handwritingSheet",
"penControlSheet/penControlSheet" "penControlSheet/penControlSheet",
"wordTestSheet/wordTestSheet"
], ],
"independent": false "independent": false
},
{
"root": "papersPages",
"name": "papersPages",
"pages": ["paperSheet/paperSheet"],
"independent": false
} }
], ],
"preloadRule": { "preloadRule": {
@@ -1,34 +1,9 @@
import type { DebugPublishMeta } from '../../utils/debugPublish'; import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish';
import { PEN_CONTROL_MODE } from './generators/penControlGenerator'; import { PEN_CONTROL_WORKSHEET_DEFINITIONS } from '../../config/worksheets/penControl';
interface PenControlWorksheetDefinition { type PenControlWorksheetRow =
id: typeof PEN_CONTROL_MODE; (typeof PEN_CONTROL_WORKSHEET_DEFINITIONS)[number];
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const PEN_CONTROL_WORKSHEET_DEFINITIONS = [
{
id: PEN_CONTROL_MODE,
icon: 'edit',
title: '控笔组合练习',
subtitle: '3–6 种图形,每种占两行田字格',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['控笔', '运笔', '田字格', '学前'],
sortOrder: 40,
},
] as const satisfies ReadonlyArray<PenControlWorksheetDefinition>;
type PenControlWorksheetRow = (typeof PEN_CONTROL_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries( const WORKSHEET_BY_ID = Object.fromEntries(
PEN_CONTROL_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]), PEN_CONTROL_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
@@ -1,78 +1,44 @@
import type { DebugPublishMeta } from '../../utils/debugPublish'; import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish';
import type { TemplateType } from '../shared/draw/drawServiceFactory'; import type { TemplateType } from './draw/drawServiceFactory';
import { WORD_COLORING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/wordColoring';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */ export { WORD_COLORING_WORKSHEET_DEFINITIONS };
interface WordColoringWorksheetDefinition {
id: string;
templateType: TemplateType;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const WORD_COLORING_WORKSHEET_DEFINITIONS = [
{
id: 'word-coloring-grid',
templateType: 'grid' as TemplateType,
icon: 'grid',
title: '网格涂色',
subtitle: '田字格涂色识字练习',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '田字格'],
sortOrder: 30,
},
{
id: 'word-coloring-find',
templateType: 'find' as TemplateType,
icon: 'search',
title: '找字涂色',
subtitle: '在字海中找到目标字并涂色',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '找字'],
sortOrder: 31,
},
] as const satisfies ReadonlyArray<WordColoringWorksheetDefinition>;
type WordColoringWorksheetRow = type WordColoringWorksheetRow =
(typeof WORD_COLORING_WORKSHEET_DEFINITIONS)[number]; (typeof WORD_COLORING_WORKSHEET_DEFINITIONS)[number];
const WORD_COLORING_WORKSHEET_BY_ID = Object.fromEntries( const WORKSHEET_BY_ID = Object.fromEntries(
WORD_COLORING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]), WORD_COLORING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, WordColoringWorksheetRow>; ) as Record<string, WordColoringWorksheetRow>;
/** 页面渲染用:模式选择器列表 */ /** 页面渲染用:模式选择器列表 */
export const WORD_COLORING_MODE_OPTIONS = WORD_COLORING_WORKSHEET_DEFINITIONS; export const WORD_COLORING_MODE_OPTIONS = WORD_COLORING_WORKSHEET_DEFINITIONS;
const WORD_COLORING_TEMPLATE_TYPES: Record<string, TemplateType> = {
'word-coloring-grid': 'grid',
'word-coloring-find': 'find',
};
/** 页面 pageInfoLookup 用 */ /** 页面 pageInfoLookup 用 */
export function getModeInfo(id: string) { export function getModeInfo(id: string) {
const m = WORD_COLORING_WORKSHEET_BY_ID[id]; const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined; return m ? { title: m.title, desc: m.subtitle } : undefined;
} }
/** 判断 id 是否有效 */ /** 判断 id 是否有效 */
export function isValidMode(id: string): boolean { export function isValidMode(id: string): boolean {
return id in WORD_COLORING_WORKSHEET_BY_ID; return id in WORKSHEET_BY_ID;
} }
/** 根据 id 获取 templateType */ /** 根据 id 获取 templateType */
export function getTemplateType(id: string): TemplateType { export function getTemplateType(id: string): TemplateType {
const m = WORD_COLORING_WORKSHEET_BY_ID[id]; return WORD_COLORING_TEMPLATE_TYPES[id] || 'grid';
return m?.templateType || 'grid';
} }
/** 发布用:从当前 mode 生成 DebugPublishMeta */ /** 发布用:从当前 mode 生成 DebugPublishMeta */
export function getPublishMetaByMode(id: string): DebugPublishMeta | null { export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORD_COLORING_WORKSHEET_BY_ID[id]; const m = WORKSHEET_BY_ID[id];
if (!m) return null; if (!m) return null;
return { return {
id: m.id, id: m.id,
@@ -0,0 +1,76 @@
import { WORDS } from '../../../core/data/words';
export const WORD_TEST_GROUP_SIZE = 50;
export interface WordTestCategory {
id: string;
icon: string;
name: string;
parentName?: string;
words: string[];
}
export interface WordTestGroup {
index: number;
label: string;
words: string[];
}
const EXCLUDED_CATEGORY_IDS = new Set([4, 5]);
/** 默认分类:一年级上册 */
export const DEFAULT_WORD_TEST_CATEGORY_ID = '26-一年级上册';
export function buildWordTestCategories(): WordTestCategory[] {
const result: WordTestCategory[] = [];
for (const cat of WORDS) {
if (EXCLUDED_CATEGORY_IDS.has(cat.categoryId)) continue;
if (cat.sections?.length) {
for (const section of cat.sections) {
result.push({
id: `${cat.categoryId}-${section.sectionName}`,
icon: cat.icon,
name: section.sectionName,
parentName: cat.categoryName,
words: section.words,
});
}
continue;
}
if (cat.words?.length) {
result.push({
id: String(cat.categoryId),
icon: cat.icon,
name: cat.categoryName,
words: cat.words,
});
}
}
return result;
}
export function buildWordTestGroups(words: string[]): WordTestGroup[] {
if (!words.length) return [];
const groups: WordTestGroup[] = [];
for (let i = 0; i < words.length; i += WORD_TEST_GROUP_SIZE) {
const index = Math.floor(i / WORD_TEST_GROUP_SIZE) + 1;
groups.push({
index,
label: `${index}`,
words: words.slice(i, i + WORD_TEST_GROUP_SIZE),
});
}
return groups;
}
export function findWordTestCategory(
categories: WordTestCategory[],
id: string,
): WordTestCategory | undefined {
return categories.find((cat) => cat.id === id);
}
@@ -0,0 +1,227 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
const COLS = 5;
const ROWS = 10;
const LAYOUT = {
topGap: 10,
leftMargin: 36,
rightMargin: 36,
bottomMargin: 40,
instructionGap: 18,
gridTopGap: 12,
instructionFont: 'bold 16px "KaiTi", "STKaiti", "Microsoft Yahei", serif',
instructionBoxSize: 12,
instructionCheckSize: 14,
instructionSymbolGap: 3,
rowGap: 10,
charBoxGap: 12,
boxSize: 14,
} as const;
export interface WordTestSheetDrawData {
words: string[];
pageNumber: number;
}
export default class WordTestDrawService extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '测字表',
...options,
});
}
async draw(data: WordTestSheetDrawData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawInstruction();
this.drawWordGrid(data.words);
this.drawPageNumber(data.pageNumber);
this.drawPrintFooter();
}
private getContentRect() {
const contentTop = this.currentY;
const contentWidth =
this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
const contentHeight =
this.canvasHeight - contentTop - LAYOUT.bottomMargin;
return {
top: contentTop,
left: LAYOUT.leftMargin,
width: contentWidth,
height: contentHeight,
};
}
private drawInstruction() {
const { ctx, canvasWidth } = this;
const y = this.currentY + LAYOUT.instructionGap;
const {
instructionFont,
instructionBoxSize,
instructionCheckSize,
instructionSymbolGap,
} = LAYOUT;
const prefix = '认识的字';
const middle = '里打';
ctx.save();
ctx.font = instructionFont;
ctx.fillStyle = '#322E25';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const prefixW = ctx.measureText(prefix).width;
const middleW = ctx.measureText(middle).width;
const totalW =
prefixW +
instructionSymbolGap +
instructionBoxSize +
instructionSymbolGap +
middleW +
instructionSymbolGap +
instructionCheckSize;
let x = (canvasWidth - totalW) / 2;
ctx.fillText(prefix, x, y);
x += prefixW + instructionSymbolGap;
this.drawBox(
ctx,
x,
y - instructionBoxSize / 2,
instructionBoxSize,
instructionBoxSize,
);
x += instructionBoxSize + instructionSymbolGap;
ctx.fillText(middle, x, y);
x += middleW + instructionSymbolGap;
this.drawCheckMark(ctx, x, y, instructionCheckSize);
const metrics = ctx.measureText(prefix);
const instructionFontSize = 16;
const halfH = Math.max(
metrics.actualBoundingBoxAscent ?? instructionFontSize * 0.5,
metrics.actualBoundingBoxDescent ?? instructionFontSize * 0.5,
);
const contentStartY = y + halfH + LAYOUT.gridTopGap;
ctx.restore();
this.currentY = contentStartY;
}
/** 绘制对勾(替代文字 V */
private drawCheckMark(
ctx: RenderingContext,
leftX: number,
centerY: number,
size: number,
) {
ctx.save();
ctx.strokeStyle = '#322E25';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(leftX + size * 0.12, centerY + size * 0.02);
ctx.lineTo(leftX + size * 0.38, centerY + size * 0.32);
ctx.lineTo(leftX + size * 0.88, centerY - size * 0.34);
ctx.stroke();
ctx.restore();
}
private getCharVerticalMetrics(
ctx: RenderingContext,
char: string,
fontSize: number,
) {
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(char);
const ascent = metrics.actualBoundingBoxAscent ?? fontSize * 0.82;
const descent = metrics.actualBoundingBoxDescent ?? fontSize * 0.18;
return { ascent, descent };
}
/** 以 centerY 为视觉中心,绘制汉字与右侧方框 */
private drawCharWithBox(
ctx: RenderingContext,
char: string,
startX: number,
centerY: number,
fontSize: number,
) {
const { charBoxGap, boxSize } = LAYOUT;
const { ascent, descent } = this.getCharVerticalMetrics(
ctx,
char,
fontSize,
);
ctx.textBaseline = 'alphabetic';
const textY = centerY + (ascent - descent) / 2;
ctx.fillText(char, startX, textY);
const boxX = startX + ctx.measureText(char).width + charBoxGap;
const boxY = centerY - boxSize / 2;
this.drawBox(ctx, boxX, boxY, boxSize, boxSize);
}
private drawWordGrid(words: string[]) {
const { ctx } = this;
const content = this.getContentRect();
const colWidth = content.width / COLS;
const rowHeight = content.height / ROWS;
const fontSize = 20;
ctx.save();
ctx.font = `${fontSize}px "KaiTi", "STKaiti", "Microsoft Yahei", serif`;
ctx.fillStyle = '#322E25';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const index = row * COLS + col;
const char = words[index];
if (!char) continue;
const cellLeft = content.left + col * colWidth;
const cellCenterY =
content.top + row * rowHeight + rowHeight / 2;
const charWidth = ctx.measureText(char).width;
const groupWidth =
charWidth + LAYOUT.charBoxGap + LAYOUT.boxSize;
const startX = cellLeft + (colWidth - groupWidth) / 2;
this.drawCharWithBox(ctx, char, startX, cellCenterY, fontSize);
}
}
ctx.restore();
}
private drawPageNumber(pageNumber: number) {
const { ctx, canvasWidth, canvasHeight } = this;
ctx.save();
ctx.font = '12px "Microsoft Yahei", sans-serif';
ctx.fillStyle = '#7C766A';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(pageNumber), canvasWidth / 2, canvasHeight - 28);
ctx.restore();
}
}
@@ -0,0 +1,43 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { WORD_TEST_WORKSHEET_DEFINITIONS } from '../../config/worksheets/wordTest';
type WordTestWorksheetRow = (typeof WORD_TEST_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
WORD_TEST_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, WordTestWorksheetRow>;
export const WORD_TEST_WORKSHEET_ID = WORD_TEST_WORKSHEET_DEFINITIONS[0].id;
export function getModeInfo(id: string) {
const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'chinese',
subcategory: 'word-test',
path: `/chinesePages/wordTestSheet/wordTestSheet?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: true,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,16 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "测字表",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -0,0 +1,114 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.wt-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.wt-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.wt-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.wt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wt-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.wt-category-grid,
.wt-group-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.wt-category-card,
.wt-group-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s,
background 0.12s;
}
.wt-group-card {
padding: 24rpx 12rpx;
}
.wt-category-card--active,
.wt-group-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.wt-category-card--pressed,
.wt-group-card--pressed {
transform: scale(0.95);
}
.wt-category-card__icon {
font-size: 36rpx;
line-height: 1.2;
}
.wt-category-card__name,
.wt-group-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
text-align: center;
}
.wt-category-card__cat {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
}
.wt-category-card__check,
.wt-group-card__check {
position: absolute;
top: 50%;
left: 18rpx;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #9cd343;
}
@@ -0,0 +1,265 @@
import WordTestDrawService from './draw/wordTestDrawService';
import {
buildWordTestCategories,
buildWordTestGroups,
DEFAULT_WORD_TEST_CATEGORY_ID,
findWordTestCategory,
type WordTestCategory,
type WordTestGroup,
} from './data/wordTestCategories';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
WORD_TEST_WORKSHEET_ID,
} from './wordTestSheet.config';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
const pageInfoLookup = getModeInfo;
function buildSelectedMap(id: string | null): Record<string, boolean> {
if (!id) return {};
return { [id]: true };
}
type PageData = CanvasDataState & {
worksheetId: string;
categoryList: WordTestCategory[];
groupList: WordTestGroup[];
selectedCategoryId: string;
selectedGroupIndex: number;
selectedCategoryMap: Record<string, boolean>;
selectedGroupMap: Record<string, boolean>;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as WordTestDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '测字表',
functionId: WORD_TEST_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: WORD_TEST_WORKSHEET_ID,
categoryList: [] as WordTestCategory[],
groupList: [] as WordTestGroup[],
selectedCategoryId: DEFAULT_WORD_TEST_CATEGORY_ID,
selectedGroupIndex: 1,
selectedCategoryMap: buildSelectedMap(
DEFAULT_WORD_TEST_CATEGORY_ID,
),
selectedGroupMap: buildSelectedMap('1'),
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
this.syncDebugPublishEnv();
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: WORD_TEST_WORKSHEET_ID;
const categoryList = buildWordTestCategories();
const defaultCategory =
findWordTestCategory(
categoryList,
DEFAULT_WORD_TEST_CATEGORY_ID,
) || categoryList[0];
const selectedCategoryId = defaultCategory?.id || '';
const groupList = buildWordTestGroups(defaultCategory?.words || []);
this.setData({
worksheetId,
functionId: worksheetId,
categoryList,
groupList,
selectedCategoryId,
selectedGroupIndex: 1,
selectedCategoryMap: buildSelectedMap(selectedCategoryId),
selectedGroupMap: buildSelectedMap('1'),
});
this.initPageInfo(worksheetId, '测字表');
this.loadFavoritedMap();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
const category = this.getSelectedCategory();
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new WordTestDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.getSheetTitle(category),
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
getSelectedCategory(): WordTestCategory | undefined {
return findWordTestCategory(
this.data.categoryList,
this.data.selectedCategoryId,
);
},
getSelectedGroup(): WordTestGroup | undefined {
return (this.data.groupList as WordTestGroup[]).find(
(group: WordTestGroup) =>
group.index === this.data.selectedGroupIndex,
);
},
getSheetTitle(category?: WordTestCategory): string {
if (!category) return '测字表';
return `${category.name}(测字表)`;
},
async drawCanvas() {
if (!this.drawService) return;
const group = this.getSelectedGroup();
if (!group || group.words.length === 0) {
this.setData({ hasContent: false });
return;
}
const category = this.getSelectedCategory();
const title = this.getSheetTitle(category);
try {
(this.drawService as WordTestDrawService).options.title = title;
await (this.drawService as WordTestDrawService).draw({
words: group.words,
pageNumber: group.index,
});
this.setData({ hasContent: true });
} catch (e) {
console.error('wordTestSheet draw failed', e);
this.setData({ hasContent: false });
}
},
onSelectCategory(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.selectedCategoryId) return;
const category = findWordTestCategory(this.data.categoryList, id);
if (!category) return;
const groupList = buildWordTestGroups(category.words);
this.setData(
{
selectedCategoryId: id,
selectedCategoryMap: buildSelectedMap(id),
groupList,
selectedGroupIndex: 1,
selectedGroupMap: buildSelectedMap('1'),
},
() => this.drawCanvas(),
);
},
onSelectGroup(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index);
if (!index || index === this.data.selectedGroupIndex) return;
this.setData(
{
selectedGroupIndex: index,
selectedGroupMap: buildSelectedMap(String(index)),
},
() => this.drawCanvas(),
);
},
onPreviewRefresh() {
const groupList = this.data.groupList as WordTestGroup[];
if (!groupList.length) {
this.drawCanvas();
return;
}
const currentIdx = groupList.findIndex(
(group) => group.index === this.data.selectedGroupIndex,
);
const nextIdx =
currentIdx < 0 ? 0 : (currentIdx + 1) % groupList.length;
const nextGroup = groupList[nextIdx];
this.setData(
{
selectedGroupIndex: nextGroup.index,
selectedGroupMap: buildSelectedMap(String(nextGroup.index)),
},
() => this.drawCanvas(),
);
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = [WORD_TEST_WORKSHEET_ID];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,83 @@
<nav-bar title="测字表" />
<view class="wt-page">
<view class="wt-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view wx:if="{{groupList.length > 0}}" class="wt-section">
<view class="wt-section-header">
<text class="wt-section-title">选择组</text>
</view>
<view class="wt-group-grid">
<view
wx:for="{{groupList}}"
wx:key="index"
class="wt-group-card {{selectedGroupMap[item.index] ? 'wt-group-card--active' : ''}}"
data-index="{{item.index}}"
hover-class="wt-group-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectGroup">
<view
wx:if="{{selectedGroupMap[item.index]}}"
class="wt-group-card__check">
<toy-icon name="check" size="20rpx" color="#fff" />
</view>
<text class="wt-group-card__name">{{item.label}}</text>
</view>
</view>
</view>
<view class="wt-section">
<view class="wt-section-header">
<text class="wt-section-title">汉字分类</text>
</view>
<view class="wt-category-grid">
<view
wx:for="{{categoryList}}"
wx:key="id"
class="wt-category-card {{selectedCategoryMap[item.id] ? 'wt-category-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="wt-category-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectCategory">
<text class="wt-category-card__icon">{{item.icon}}</text>
<text class="wt-category-card__name">{{item.name}}</text>
<text
wx:if="{{item.parentName}}"
class="wt-category-card__cat"
>{{item.parentName}}</text
>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
+5 -2
View File
@@ -6,6 +6,9 @@
// }, // },
// }; // };
/** 当前应用版本号;profile 页和首页公告共用 */
export const APP_VERSION = '3.4.0';
export const defaultPrintConfig: PrintConfig = { export const defaultPrintConfig: PrintConfig = {
// header: 'LogoImage', // header: 'LogoImage',
header: 'wechat', header: 'wechat',
@@ -25,11 +28,11 @@ export const defaultShareConfig = {
* - 当日第 1 次满批:弹分享窗,须分享后才能继续下载 * - 当日第 1 次满批:弹分享窗,须分享后才能继续下载
* - 当日第 2 次及以后满批:直接播放激励广告解锁 * - 当日第 2 次及以后满批:直接播放激励广告解锁
*/ */
export const downloadFreeBatchSize = 4; export const downloadFreeBatchSize = 3;
/** /**
* develop 包下载限制开关 * develop 包下载限制开关
* - develop:为 true 时启用下载配额/分享/广告(便于联调);为 false 时不限制 * - develop:为 true 时启用下载配额/分享/广告(便于联调);为 false 时不限制
* - 体验版 / 正式版:始终启用,不受此开关影响 * - 体验版 / 正式版:始终启用,不受此开关影响
*/ */
export const enableDownloadLimitInDevelop = false; export const enableDownloadLimitInDevelop = true;
+29
View File
@@ -0,0 +1,29 @@
import type { WorksheetDefinition } from './types';
export const CLOCK_READING_WORKSHEET_DEFINITIONS = [
{
id: 'clock-reading',
icon: 'clock',
title: '认识时钟',
subtitle: '看钟读时间,填写数字时刻',
ageMin: 4,
ageMax: 7,
difficulty: 2,
tags: ['数学', '钟表', '时间'],
sortOrder: 127,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
export const CLOCK_CONNECT_WORKSHEET_DEFINITIONS = [
{
id: 'clock-connect',
icon: 'clock',
title: '时钟连线',
subtitle: '看钟表连对应时间,认识时刻',
ageMin: 4,
ageMax: 7,
difficulty: 2,
tags: ['数学', '钟表', '时间', '连线'],
sortOrder: 128,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+124
View File
@@ -0,0 +1,124 @@
import type { WorksheetDefinition } from './types';
export const FOCUS_WORKSHEET_DEFINITIONS = [
{
id: 'color-shape-match',
title: '根据颜色画图形',
subtitle: '根据颜色画出对应图形',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '颜色', '图形'],
sortOrder: 201,
},
{
id: 'shape-symbol',
title: '图形符号配对',
subtitle: '根据图形画对应符号',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '图形', '符号'],
sortOrder: 202,
},
{
id: 'position-coloring',
title: '方位涂涂乐',
subtitle: '观察位置,在方格中涂色',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '方位', '涂色'],
sortOrder: 203,
},
{
id: 'color-pattern',
title: '颜色找规律',
subtitle: '观察颜色规律,涂色',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '颜色', '规律'],
sortOrder: 204,
},
{
id: 'match-connect',
title: '连连看',
subtitle: '根据物品连一连',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '连线', '匹配'],
sortOrder: 205,
},
{
id: 'line-recognition',
title: '线条识别',
subtitle: '认识不同线条,画对应线条',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '线条', '识别', '控笔'],
sortOrder: 206,
},
{
id: 'grid-reasoning',
title: '方格推理',
subtitle: '推理出合并方格并连线',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['专注力', '方格', '推理', '逻辑思维'],
sortOrder: 207,
},
{
id: 'code-connect',
title: '译码连线',
subtitle: '按数字顺序连线',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['专注力', '译码', '连线', '逻辑思维'],
sortOrder: 208,
},
{
id: 'dot-connect',
title: '数字点连线',
subtitle: '按数字顺序连点成图',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '数字', '连线'],
sortOrder: 209,
},
{
id: 'grid-drawing-3x3',
title: '格子仿画 3×3',
subtitle: '简单有趣,培养专注力',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['专注力', '格子仿画', '观察'],
sortOrder: 210,
},
{
id: 'grid-drawing-5x5',
title: '格子仿画 5×5',
subtitle: '创意挑战,提升观察力',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '格子仿画', '观察'],
sortOrder: 211,
},
{
id: 'grid-drawing-7x7',
title: '格子仿画 7×7',
subtitle: '大师挑战,锻炼耐心',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['专注力', '格子仿画', '耐心'],
sortOrder: 212,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+13
View File
@@ -0,0 +1,13 @@
export type { WorksheetDefinition } from './types';
export { MATH_WORKSHEET_DEFINITIONS } from './math';
export {
CLOCK_READING_WORKSHEET_DEFINITIONS,
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
} from './clock';
export { FOCUS_WORKSHEET_DEFINITIONS } from './focus';
export { LETTER_TRACING_WORKSHEET_DEFINITIONS } from './letterTracing';
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin';
export { PEN_CONTROL_WORKSHEET_DEFINITIONS } from './penControl';
export { WORD_COLORING_WORKSHEET_DEFINITIONS } from './wordColoring';
export { WORD_TEST_WORKSHEET_DEFINITIONS } from './wordTest';
export { PAPER_SHEET_WORKSHEET_DEFINITIONS } from './papers';
@@ -0,0 +1,81 @@
import type { WorksheetDefinition } from './types';
export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
{
id: 'letter-tracing-single',
icon: 'start-a',
title: '字母默认字帖',
subtitle: '配图、例句与描红',
ageMin: 4,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '看图描红'],
sortOrder: 42,
},
{
id: 'letter-tracing-upper-lower',
icon: 'draw-o',
title: '字母基础描红',
subtitle: 'Uppercase / Lowercase 总览',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '字母总览'],
sortOrder: 44,
},
{
id: 'letter-tracing-case-pairing',
icon: 'font-size',
title: '大小写对照',
subtitle: '半组字母左大写右小写',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '大小写练习'],
sortOrder: 45,
},
{
id: 'letter-tracing-two-column',
icon: 'two-columns',
title: '两列描红',
subtitle: '左 AM、右 NZ 配对描红',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '两列练习'],
sortOrder: 43,
},
{
id: 'letter-tracing-half',
icon: 'square-half',
title: '13字母半表',
subtitle: '每行一个字母,13 字母半表',
ageMin: 5,
ageMax: 8,
difficulty: 2,
tags: ['字母', '描红', '半表练习'],
sortOrder: 47,
},
{
id: 'letter-tracing-three',
icon: 'ABC-list',
title: '三字母精练',
subtitle: '每页聚焦 3 个字母深度书写',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '三字母精练'],
sortOrder: 46,
},
{
id: 'letter-tracing-daily-checkin',
icon: 'task-o',
title: '字母每日打卡',
subtitle: '四宫格每日字母打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '每日打卡'],
sortOrder: 48,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+264
View File
@@ -0,0 +1,264 @@
import type { WorksheetDefinition } from './types';
export const MATH_WORKSHEET_DEFINITIONS = [
{
id: 'number-find',
title: '找数字,涂一涂',
subtitle: '找出目标数字并涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 101,
},
{
id: 'number-write',
title: '看数字,写一写',
subtitle: '按笔画顺序书写数字',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '数字书写', '描红'],
sortOrder: 102,
},
{
id: 'number-coloring',
title: '按数字,涂颜色',
subtitle: '按指定数字涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 103,
},
{
id: 'counting-matching',
title: '数一数,连一连',
subtitle: '连线配对数字和数量',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '连线'],
sortOrder: 104,
},
{
id: 'number-object-match',
title: '数物连线',
subtitle: '连线数量相同的物体和数字',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数物对应', '连线'],
sortOrder: 105,
},
{
id: 'number-object-fill',
title: '数物填写',
subtitle: '数出数量,填写对应数字',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '数物对应', '填写'],
sortOrder: 106,
},
{
id: 'counting-select',
title: '数一数,选一选',
subtitle: '数出数量,圈出正确答案',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '选择'],
sortOrder: 107,
},
{
id: 'counting-fill',
title: '数一数,填一填',
subtitle: '数出物品数量,填写数字',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '计数', '填写'],
sortOrder: 108,
},
{
id: 'compare',
title: '数一数,比大小',
subtitle: '比较数量,填入 ><=',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '比较', '数量'],
sortOrder: 109,
},
{
id: 'number-sort',
title: '数字排序',
subtitle: '写出正确的数字顺序',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '数字排序', '序列'],
sortOrder: 110,
},
{
id: 'missing-number',
title: '填上缺少的数字',
subtitle: '找出并填写缺失数字',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '数字序列', '填写'],
sortOrder: 111,
},
{
id: 'number-decompose',
title: '10以内数的分与合',
subtitle: '把数字分一分,合一合',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['数学', '分与合', '10以内'],
sortOrder: 112,
},
{
id: 'number-decompose-20',
title: '20以内数的分与合',
subtitle: '把数字分一分,合一合',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '分与合', '20以内'],
sortOrder: 113,
},
{
id: 'one-digit-addition',
title: '一位数加法',
subtitle: '通过圆点学习一位数加法',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '加法', '一位数'],
sortOrder: 114,
},
{
id: 'addition-5',
title: '5以内加法',
subtitle: '图形化展示5以内加法',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '加法', '5以内', '计算题'],
sortOrder: 115,
},
{
id: 'addition-10',
title: '10以内加法',
subtitle: '图形化展示10以内加法',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '加法', '10以内', '计算题'],
sortOrder: 116,
},
{
id: 'subtraction-10',
title: '10以内减法',
subtitle: '图形化展示10以内减法',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '减法', '10以内', '计算题'],
sortOrder: 117,
},
{
id: 'addition-subtraction-10',
title: '10以内加减法',
subtitle: '加减法混合运算',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '加减法', '10以内', '计算题'],
sortOrder: 118,
},
{
id: 'make-ten',
title: '凑十法练习',
subtitle: '20以内进位加法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '凑十法', '进位加法', '计算题'],
sortOrder: 119,
},
{
id: 'break-ten',
title: '破十法练习',
subtitle: '20以内退位减法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '破十法', '退位减法', '计算题'],
sortOrder: 120,
},
{
id: 'flat-ten',
title: '平十法练习',
subtitle: '20以内退位减法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '平十法', '退位减法', '计算题'],
sortOrder: 121,
},
{
id: 'borrow-ten',
title: '借十法练习',
subtitle: '20 以上退位减法',
ageMin: 6,
ageMax: 8,
difficulty: 4,
tags: ['数学', '借十法', '退位减法', '计算题'],
sortOrder: 122,
},
{
id: 'practice-addition',
title: '加法运算',
subtitle: '10/20/50/100 以内加法',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加法', '计算题'],
sortOrder: 123,
},
{
id: 'practice-subtraction',
title: '减法运算',
subtitle: '10/20/50/100以内减法',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '减法', '计算题'],
sortOrder: 124,
},
{
id: 'practice-mixed',
title: '混合运算',
subtitle: '10/20/50/100以内加减法混合',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加减法', '计算题'],
sortOrder: 125,
},
{
id: 'multiplication-table',
title: '九九乘法表',
subtitle: '学习九九乘法口诀',
ageMin: 7,
ageMax: 8,
difficulty: 3,
tags: ['数学', '乘法', '九九乘法表'],
sortOrder: 126,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+26
View File
@@ -0,0 +1,26 @@
import type { WorksheetDefinition } from './types';
export const PAPER_SHEET_WORKSHEET_DEFINITIONS = [
{
id: 'paper-sheet',
icon: 'draw-o',
title: '作业纸',
subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印',
ageMin: 3,
ageMax: 12,
difficulty: 1,
tags: [
'作业纸',
'田字格',
'米字格',
'方格',
'四线三格',
'信纸',
'横线',
'竖线',
'听写',
'作业登记表',
],
sortOrder: 50,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
@@ -0,0 +1,15 @@
import type { WorksheetDefinition } from './types';
export const PEN_CONTROL_WORKSHEET_DEFINITIONS = [
{
id: 'pen-control-mix',
icon: 'edit',
title: '控笔练习',
subtitle: '多种控笔图形,运笔更轻松',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['控笔', '运笔', '田字格', '学前'],
sortOrder: 40,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+59
View File
@@ -0,0 +1,59 @@
import type { WorksheetDefinition } from './types';
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS = [
{
id: 'pinyin-tracing',
icon: 'draw-o',
title: '拼音描红练习',
subtitle: '跟着描红学拼音字母',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 50,
},
{
id: 'pinyin-dictation',
icon: 'start-a',
title: '拼音默写练习',
subtitle: '空白格子默写拼音字母',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 51,
},
{
id: 'pinyin-tracing-v2',
icon: 'pen-draw',
title: '拼音描红 8 列',
subtitle: '跟写描红,声韵分块',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 52,
},
{
id: 'pinyin-dictation-v2',
icon: 'ABC-underline',
title: '拼音默写 8 列',
subtitle: '空白默写,声韵分块',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 53,
},
{
id: 'pinyin-daily',
icon: 'task-o',
title: '拼音每日打卡',
subtitle: '四宫格每日打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['拼音', '每日练习', '打卡'],
sortOrder: 54,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+13
View File
@@ -0,0 +1,13 @@
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */
export interface WorksheetDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
/** 页面渲染时使用的图标,仅部分页面有 */
icon?: string;
}
@@ -0,0 +1,26 @@
import type { WorksheetDefinition } from './types';
export const WORD_COLORING_WORKSHEET_DEFINITIONS = [
{
id: 'word-coloring-grid',
icon: 'grid',
title: '网格涂色',
subtitle: '田字格涂色识字练习',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '田字格'],
sortOrder: 30,
},
{
id: 'word-coloring-find',
icon: 'search',
title: '找字涂色',
subtitle: '在字海中找到目标字并涂色',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['识字', '涂色', '找字'],
sortOrder: 31,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+15
View File
@@ -0,0 +1,15 @@
import type { WorksheetDefinition } from './types';
export const WORD_TEST_WORKSHEET_DEFINITIONS = [
{
id: 'word-test-sheet',
icon: 'todo-list-o',
title: '测字表',
subtitle: '按分类测识字,勾选认识的字',
ageMin: 5,
ageMax: 8,
difficulty: 1,
tags: ['测字', '识字', '汉字', '一年级'],
sortOrder: 38,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+6
View File
@@ -38,6 +38,12 @@ export const CATEGORY_LIST: CategoryType[] = [
icon: '🔤', icon: '🔤',
path: '/pages/category/category?id=english', path: '/pages/category/category?id=english',
}, },
{
id: 'papers',
name: '作业纸',
icon: '📄',
path: '/pages/category/category?id=papers',
},
// { // {
// id: 'craft', // id: 'craft',
// name: '创意手工', // name: '创意手工',
+100 -36
View File
@@ -48,9 +48,7 @@ export const WORDS = [
'中', '个', '工', '王', '车', '儿', '女', '子', '门', '马', '牛', '羊', '米', '衣', '白', '中', '个', '工', '王', '车', '儿', '女', '子', '门', '马', '牛', '羊', '米', '衣', '白',
'田', '石', '雨', '电', '云', '花', '草', '叶', '果', '鸟', '虫', '鱼', '头', '目', '耳', '田', '石', '雨', '电', '云', '花', '草', '叶', '果', '鸟', '虫', '鱼', '头', '目', '耳',
'足', '心', '力', '立', '正', '足', '心', '力', '立', '正',
// 扩充(常见基础字) '刀', '又', '三', '四', '五', '六', '七', '八', '九', '十', '牙', '鼻', '眉', '竹', '金',
'刀', '又', '三', '四', '五', '六', '七', '八', '九', '十', '口', '门', '心', '耳', '目',
'牙', '鼻', '口', '眉', '田', '米', '木', '竹', '石', '土', '火', '水', '金'
], ],
}, },
{ {
@@ -62,10 +60,8 @@ export const WORDS = [
'灯', '家', '房', '床', '吃', '喝', '坐', '走', '跑', '看', '听', '说', '笑', '哭', '爱', '灯', '家', '房', '床', '吃', '喝', '坐', '走', '跑', '看', '听', '说', '笑', '哭', '爱',
'好', '有', '来', '去', '开', '关', '里', '外', '多', '少', '红', '黄', '蓝', '绿', '白', '好', '有', '来', '去', '开', '关', '里', '外', '多', '少', '红', '黄', '蓝', '绿', '白',
'黑', '是', '不', '我', '你', '黑', '是', '不', '我', '你',
// 扩充(贴近生活)
'他', '她', '它', '们', '在', '和', '把', '给', '用', '做', '玩', '买', '卖', '学', '读', '他', '她', '它', '们', '在', '和', '把', '给', '用', '做', '玩', '买', '卖', '学', '读',
'写', '问', '答', '听', '看', '吃', '喝', '睡', '起', '穿', '洗', '擦', '', '关', '拿', '写', '问', '答', '睡', '起', '穿', '洗', '擦', '', '',
'放'
], ],
}, },
{ {
@@ -77,17 +73,19 @@ export const WORDS = [
'南', '西', '北', '飞', '跳', '游', '唱', '画', '洗', '买', '卖', '问', '学', '读', '写', '南', '西', '北', '飞', '跳', '游', '唱', '画', '洗', '买', '卖', '问', '学', '读', '写',
'早', '晚', '明', '亮', '高', '长', '圆', '方', '快', '乐', '热', '冷', '轻', '重', '新', '早', '晚', '明', '亮', '高', '长', '圆', '方', '快', '乐', '热', '冷', '轻', '重', '新',
'旧', '和', '同', '会', '要', '旧', '和', '同', '会', '要',
// 扩充(自然与常见动作) '雨', '雷', '电', '云', '雾', '露', '霜', '月', '阳', '阴', '晴', '冰',
'', '', '', '', '', '', '', '', '', '', '阴', '晴', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'草', '花', '叶', '果', '根', '看', '听', '说', '读', '写', '跑', '跳', '走', '爬', '抓',
'推', '拉', '抱', '笑', '哭'
], ],
}, },
{ {
categoryId: 3, categoryId: 3,
icon: '🎨', icon: '🎨',
categoryName: '颜色', categoryName: '颜色',
words: ['红', '蓝', '绿', '黄', '黑', '白', '紫', '橙', '粉', '棕', '灰'], words: [
'红', '蓝', '绿', '黄', '黑', '白', '紫', '橙', '粉', '棕', '灰',
'青', '翠', '碧', '丹', '金', '银', '铜', '朱', '墨', '绛', '褐',
],
}, },
{ {
categoryId: 4, categoryId: 4,
@@ -123,61 +121,96 @@ export const WORDS = [
categoryId: 6, categoryId: 6,
icon: '☀️', icon: '☀️',
categoryName: '日字旁', categoryName: '日字旁',
words: ['日', '明', '早', '时', '晴', '春', '星', '晨', '晚', '晒', '照'], words: [
'日', '明', '早', '时', '晴', '春', '星', '晨', '晚', '晒', '照',
'旧', '昏', '旦', '旭', '暗', '显', '映', '晌', '昼', '晕', '昌', '易', '昂', '晶', '暄',
],
}, },
{ {
categoryId: 7, categoryId: 7,
icon: '💧', icon: '💧',
categoryName: '三点水', categoryName: '三点水',
words: ['水', '江', '河', '流', '沙', '洗', '海', '汗', '汽', '澡', '泡', '湖', '泉'], words: [
'水', '江', '河', '湖', '海', '泉', '汗', '汽', '沙', '洗', '波', '流', '游', '洪', '浴',
'浪', '池', '泡', '澡', '油', '洋', '溪', '泥', '深', '浅', '泪', '洁', '温', '漂', '混',
'涨', '滴', '港', '渔', '涛', '润', '消', '清', '渡', '洞',
],
}, },
{ {
categoryId: 8, categoryId: 8,
icon: '🐦', icon: '🐦',
categoryName: '鸟字', categoryName: '鸟字',
words: ['鸟', '鸡', '鸭', '鹅', '鸣', '鸽', '鸦', '鹊', '鹤'], words: [
'鸟', '鸡', '鸭', '鹅', '鸣', '鸽', '鸦', '鹊', '鹤', '鸳', '鸯', '鹰', '鹏', '鹦',
],
}, },
{ {
categoryId: 9, categoryId: 9,
icon: '🐾', icon: '🐾',
categoryName: '反犬旁', categoryName: '反犬旁',
words: ['狗', '猫', '猴', '狮', '狼', '猪', '狠', '独', '犯'], words: [
'狗', '猫', '猴', '狮', '狼', '猪', '狠', '独', '犯',
'猜', '猩', '猛', '猿', '猾', '猬', '猎', '狐', '狂', '猢',
],
}, },
{ {
categoryId: 10, categoryId: 10,
icon: '🪱', icon: '🪱',
categoryName: '虫字旁', categoryName: '虫字旁',
words: ['虫', '蚁', '蝶', '蜻', '蛙', '蛇', '蜘', '蜂', '蚊', '蛾'], words: [
'虫', '蚁', '蚂', '蚊', '蚕', '蛹', '蛙', '蛾', '蛇', '蝶',
'蜻', '蜓', '蜘', '蛛', '蜂', '蝉', '螃', '蟹', '螺', '蝌',
'蝇', '螳', '蟋', '蟀',
],
}, },
{ {
categoryId: 11, categoryId: 11,
icon: '🌧️', icon: '🌧️',
categoryName: '雨字头', categoryName: '雨字头',
words: ['雨', '雪', '雷', '露', '雾', '雹', '霜', '雯', '霖'], words: [
'雨', '雪', '雷', '露', '雾', '雹', '霜', '震', '零', '需', '霞', '霓', '霁',
],
}, },
{ {
categoryId: 12, categoryId: 12,
icon: '🌲', icon: '🌲',
categoryName: '木字旁', categoryName: '木字旁',
words: ['木', '林', '树', '桃', '森', '松', '桥', '枝', '板', '柜', '杯', '校'], words: [
'木', '林', '树', '桃', '森', '松', '桥', '枝', '板', '柜', '杯', '校',
'柳', '柏', '杨', '枫', '桐', '桂', '榕', '杉',
],
}, },
{ {
categoryId: 13, categoryId: 13,
icon: '👄', icon: '👄',
categoryName: '口字旁', categoryName: '口字旁',
words: ['口', '吃', '叫', '唱', '听', '吹', '叶', '和', '问', '品', '吐', '吸'], words: [
'口', '吃', '叫', '唱', '听', '吹', '叶', '和', '问', '品', '吐', '吸', '嘴', '咽', '喉',
],
}, },
{ {
categoryId: 14, categoryId: 14,
icon: '🧍', icon: '🧍',
categoryName: '单人旁', categoryName: '单人旁',
words: ['你', '他', '们', '作', '休', '住', '伙', '伴', '体', '位', '信', '化'], words: [
'你', '他', '们', '作', '休', '住', '伙', '伴', '体', '位', '信', '化',
'保', '什', '但', '代', '使', '俩', '便', '仰', '传', '伤', '伯', '佳',
],
}, },
{ {
categoryId: 15, categoryId: 15,
icon: '❤️', icon: '❤️',
categoryName: '竖心旁', categoryName: '竖心旁',
words: ['心', '情', '快', '怕', '惊', '忙', '怀', '爱', '想', '念'], words: [
'心', '情', '快', '怕', '惊', '忙', '怀', '爱', '想', '念',
'意', '愿', '态', '怪', '性', '悟', '惜', '懒', '慌',
],
}, },
// 新增适龄分类 // 新增适龄分类
@@ -186,7 +219,7 @@ export const WORDS = [
icon: '🧭', icon: '🧭',
categoryName: '方位方向', categoryName: '方位方向',
words: [ words: [
'上', '下', '左', '右', '前', '后', '里', '外', '东', '南', '西', '北', '中', '近', '远' '上', '下', '左', '右', '前', '后', '里', '外', '东', '南', '西', '北', '中', '近', '远',
], ],
}, },
{ {
@@ -195,7 +228,7 @@ export const WORDS = [
categoryName: '身体部位', categoryName: '身体部位',
words: [ words: [
'头', '脸', '目', '眼', '眉', '鼻', '口', '牙', '舌', '耳', '手', '指', '掌', '臂', '头', '脸', '目', '眼', '眉', '鼻', '口', '牙', '舌', '耳', '手', '指', '掌', '臂',
'足', '腿', '心' '足', '腿', '心', '发', '肩', '背', '肚', '腰', '皮', '骨', '血', '身',
], ],
}, },
{ {
@@ -204,14 +237,22 @@ export const WORDS = [
categoryName: '常见动物', categoryName: '常见动物',
words: [ words: [
'狗', '猫', '马', '牛', '羊', '鸡', '鸭', '鹅', '鱼', '兔', '猪', '熊', '虎', '鹿', '猴', '狗', '猫', '马', '牛', '羊', '鸡', '鸭', '鹅', '鱼', '兔', '猪', '熊', '虎', '鹿', '猴',
'狼', '狮', '虎', '豹', '象', '狼', '狮', '豹', '象', '鸟', '龟', '蛙', '蛇', '鼠', '狐', '狸', '蝶', '蜂', '蚁', '蚕',
'蝉', '蛛', '雀', '鸽', '鹰', '虾',
], ],
}, },
{ {
categoryId: 19, categoryId: 19,
icon: '🌼', icon: '🌼',
categoryName: '常见植物', categoryName: '常见植物',
words: ['花', '草', '树', '叶', '果', '根', '竹', '松', '柳', '桃', '梅', '荷', '菊'], words: [
'花', '草', '树', '叶', '果', '根', '竹', '松', '柳', '桃', '梅', '荷', '菊',
'兰', '桂', '杏', '槐', '柏', '杨', '榕', '杉', '枫', '桐', '瓜', '豆',
'蔬', '菜', '麦', '稻', '谷', '苹', '李', '梨', '橙', '柚', '柿', '葡',
'萄', '西', '荔', '枝', '椰', '蕉', '菠', '萝', '辣', '椒', '茄', '芹',
'卜', '葱', '姜', '蒜', '薯', '蘑', '菇', '笋', '苔', '蔓', '藕',
],
}, },
{ {
categoryId: 20, categoryId: 20,
@@ -219,40 +260,65 @@ export const WORDS = [
categoryName: '食物饮品', categoryName: '食物饮品',
words: [ words: [
'米', '饭', '面', '菜', '果', '肉', '蛋', '奶', '糖', '盐', '油', '水', '茶', '汤', '米', '饭', '面', '菜', '果', '肉', '蛋', '奶', '糖', '盐', '油', '水', '茶', '汤',
'粥', '饼' '粥', '饼', '薯', '菇', '竹', '笋', '苔', '蔓', '荷', '藕',
], ],
}, },
{ {
categoryId: 21, categoryId: 21,
icon: '🚗', icon: '🚗',
categoryName: '交通出行', categoryName: '交通出行',
words: ['车', '船', '飞', '机', '站', '路', '桥', '铁', '轨', '轮'], words: [
'车', '船', '飞', '机', '站', '路', '桥', '铁', '轨', '轮',
'汽', '地', '高', '速', '动', '单', '双',
'驾', '乘', '骑', '停', '票', '码', '行', '驶', '程', '过',
'隧', '道', '转', '步', '街', '灯', '红', '绿', '黄',
],
}, },
{ {
categoryId: 22, categoryId: 22,
icon: '📚', icon: '📚',
categoryName: '校园与文具', categoryName: '校园与文具',
words: ['书', '本', '笔', '尺', '刀', '纸', '包', '课', '桌', '椅', '图', '画', '作', '业'], words: [
'书', '本', '笔', '尺', '刀', '纸', '包', '课', '桌', '椅', '图', '画', '作', '业',
'教', '室', '校', '园', '板', '黑', '白', '擦', '讲', '台', '钟', '铃',
'练', '习', '册', '橡', '皮', '胶', '垫', '盒', '袋', '卷',
'订', '夹', '筒', '水', '彩', '颜', '料', '文', '具', '铅',
'钢', '毛', '红', '蓝', '字', '帖', '典', '母', '材', '布', '贴', '考', '试', '印', '章', '机',
],
}, },
{ {
categoryId: 23, categoryId: 23,
icon: '⏰', icon: '⏰',
categoryName: '时间与季节', categoryName: '时间与季节',
words: [ words: [
'日', '月', '年', '时', '分', '秒', '早', '', '', '', '', '', '', '', '冬' '日', '月', '年', '岁', '时', '分', '秒', '', '', '', '', '', '', '',
'今', '明', '昨', '春', '夏', '秋', '冬', '季', '节', '阳', '阴',
'钟', '点', '刻', '旬', '期', '当', '初', '终', '始', '末', '再', '曾', '旧', '新',
], ],
}, },
{ {
categoryId: 24, categoryId: 24,
icon: '🟠', icon: '🟠',
categoryName: '形状与图形', categoryName: '形状与图形',
words: ['点', '线', '面', '圆', '方', '角', '弧', '长', '宽', '高'], words: [
'点', '线', '面', '角', '边', '体', '圆', '方', '长', '宽', '高', '深', '矩', '形',
'弧', '棱', '柱', '锥', '球', '扇', '环', '扁', '厚', '薄',
],
}, },
{ {
categoryId: 25, categoryId: 25,
icon: '📏', icon: '📏',
categoryName: '量词常用', categoryName: '常用量词',
words: ['个', '只', '匹', '条', '朵', '棵', '片', '张', '本', '杯', '块', '双'], words: [
'个', '只', '匹', '条', '朵', '棵', '片', '张', '本', '杯', '块', '双',
'头', '位', '节', '根', '座', '群', '瓶', '颗', '段', '辆', '架',
'支', '面', '粒', '把', '首', '封', '页', '箱', '队',
],
}, },
]; ];
@@ -263,9 +329,7 @@ export function getCategoryTabIndex(categoryId: number): number {
} }
/** 汇总分类下全部汉字(含一二年级 sections) */ /** 汇总分类下全部汉字(含一二年级 sections) */
export function collectCategoryWords( export function collectCategoryWords(cat: (typeof WORDS)[number]): string[] {
cat: (typeof WORDS)[number],
): string[] {
if (cat.words?.length) { if (cat.words?.length) {
return cat.words; return cat.words;
} }
+85 -9
View File
@@ -16,6 +16,37 @@ import drawHeader from './drawHeader';
const LINE_COLOR = '#BCBAB2'; // 打印友好的深灰线色,避免渐变色 const LINE_COLOR = '#BCBAB2'; // 打印友好的深灰线色,避免渐变色
const LINE_WIDTH = 3; const LINE_WIDTH = 3;
/** 品牌水印模式:A 右下角醒目;B 页面中央大号旋转浅水印(默认) */
export type BrandWatermarkMode = 'A' | 'B';
export interface DrawPrintFooterOptions {
/** 默认 B */
mode?: BrandWatermarkMode;
/** 品牌文案,默认「涂鸦丫小程序」 */
text?: string;
opacity?: number;
fontSize?: number;
}
const BRAND_TEXT = '涂鸦丫小程序';
const BRAND_COLOR = '#7C766A';
/** 方案 A:右下角单行品牌水印 */
const FOOTER_MODE_A = {
fontSize: 20,
opacity: 0.12,
marginRight: 24,
marginBottom: 20,
} as const;
/** 方案 B:页面中央大号旋转浅水印,单行居中 */
const FOOTER_MODE_B = {
fontSize: 32,
opacity: 0.12,
rotationDeg: -25,
centerYRatio: 0.52,
} as const;
export class BaseDrawService { export class BaseDrawService {
canvas: WechatMiniprogram.Canvas; canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext; ctx: RenderingContext;
@@ -174,24 +205,69 @@ export class BaseDrawService {
} }
/** /**
* 页脚:页面底部居中绘制品牌文案「涂鸦丫小程序」(粗体)。 * 品牌水印页脚(默认「涂鸦丫小程序」单行)。
* - A:右下角水印,适合有底部页码的页面
* - B:页面中央大号旋转浅水印(默认)
* 请在整页正文绘制完成后调用,避免被内容覆盖。 * 请在整页正文绘制完成后调用,避免被内容覆盖。
*/ */
async drawPrintFooter(): Promise<void> { drawPrintFooter(options?: DrawPrintFooterOptions): void {
const mode = options?.mode ?? 'A';
const text = options?.text ?? BRAND_TEXT;
if (mode === 'A') {
this.drawPrintFooterModeA(text, options);
} else {
this.drawPrintFooterModeB(text, options);
}
}
/** 方案 A:右下角单行品牌水印 */
private drawPrintFooterModeA(
text: string,
options?: DrawPrintFooterOptions,
): void {
const { ctx, canvasWidth, canvasHeight } = this; const { ctx, canvasWidth, canvasHeight } = this;
const text = '涂鸦丫小程序'; const fontSize = options?.fontSize ?? FOOTER_MODE_A.fontSize;
const bottomMargin = 12; const opacity = options?.opacity ?? FOOTER_MODE_A.opacity;
const fontSize = 13; const { marginRight, marginBottom } = FOOTER_MODE_A;
const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`; const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`;
const textColor = '#7C766A';
ctx.save(); ctx.save();
ctx.font = font; ctx.font = font;
ctx.fillStyle = textColor; ctx.fillStyle = BRAND_COLOR;
ctx.globalAlpha = opacity;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText(
text,
canvasWidth - marginRight,
canvasHeight - marginBottom,
);
ctx.restore();
}
/** 方案 B:页面中央大号旋转浅水印,单行居中 */
private drawPrintFooterModeB(
text: string,
options?: DrawPrintFooterOptions,
): void {
const { ctx, canvasWidth, canvasHeight } = this;
const fontSize = options?.fontSize ?? FOOTER_MODE_B.fontSize;
const opacity = options?.opacity ?? FOOTER_MODE_B.opacity;
const { rotationDeg, centerYRatio } = FOOTER_MODE_B;
const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`;
const centerX = canvasWidth / 2;
const centerY = canvasHeight * centerYRatio;
ctx.save();
ctx.font = font;
ctx.fillStyle = BRAND_COLOR;
ctx.globalAlpha = opacity;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const y = canvasHeight - bottomMargin - fontSize / 2; ctx.translate(centerX, centerY);
ctx.fillText(text, canvasWidth / 2, y); ctx.rotate((rotationDeg * Math.PI) / 180);
ctx.fillText(text, 0, 0);
ctx.restore(); ctx.restore();
} }
+2 -1
View File
@@ -4,7 +4,8 @@ export type CategoryId =
| 'english' | 'english'
| 'puzzle' | 'puzzle'
| 'craft' | 'craft'
| 'pinyin'; | 'pinyin'
| 'papers';
/** Tab / 首页用的分类展示模型 */ /** Tab / 首页用的分类展示模型 */
export interface CategoryType { export interface CategoryType {
+1
View File
@@ -31,4 +31,5 @@ export interface WorksheetRecord extends WorksheetConfig {
_id: string; _id: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
contentUpdatedAt?: Date;
} }
@@ -1,99 +1,8 @@
import type { LetterTracingMode } from './generators/letter-tracing-generator';
import type { DebugPublishMeta } from '../../utils/debugPublish'; import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish';
import { LETTER_TRACING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/letterTracing';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */ export { LETTER_TRACING_WORKSHEET_DEFINITIONS };
interface LetterTracingWorksheetDefinition {
id: LetterTracingMode;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
{
id: 'letter-tracing-single',
icon: 'start-a',
title: '字母默认字帖',
subtitle: '配图、例句与描红',
ageMin: 4,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '看图描红'],
sortOrder: 42,
},
{
id: 'letter-tracing-upper-lower',
icon: 'draw-o',
title: '字母基础描红',
subtitle: 'Uppercase / Lowercase 总览',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '字母总览'],
sortOrder: 44,
},
{
id: 'letter-tracing-case-pairing',
icon: 'font-size',
title: '大小写对照',
subtitle: '半组字母左大写右小写',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '大小写练习'],
sortOrder: 45,
},
{
id: 'letter-tracing-two-column',
icon: 'two-columns',
title: '两列描红',
subtitle: '左 AM、右 NZ 配对描红',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['字母', '描红', '两列练习'],
sortOrder: 43,
},
{
id: 'letter-tracing-half',
icon: 'square-half',
title: '13字母半表',
subtitle: '每行一个字母,13 字母半表',
ageMin: 5,
ageMax: 8,
difficulty: 2,
tags: ['字母', '描红', '半表练习'],
sortOrder: 47,
},
{
id: 'letter-tracing-three',
icon: 'ABC-list',
title: '三字母精练',
subtitle: '每页聚焦 3 个字母深度书写',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '三字母精练'],
sortOrder: 46,
},
{
id: 'letter-tracing-daily-checkin',
icon: 'task-o',
title: '字母每日打卡',
subtitle: '四宫格每日字母打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['字母', '描红', '每日打卡'],
sortOrder: 48,
},
] as const satisfies ReadonlyArray<LetterTracingWorksheetDefinition>;
type LetterTracingWorksheetRow = type LetterTracingWorksheetRow =
(typeof LETTER_TRACING_WORKSHEET_DEFINITIONS)[number]; (typeof LETTER_TRACING_WORKSHEET_DEFINITIONS)[number];
@@ -1,140 +1,8 @@
import type { DebugPublishMeta } from '../../utils/debugPublish'; import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish';
import { FOCUS_WORKSHEET_DEFINITIONS } from '../../config/worksheets/focus';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐) */ export { FOCUS_WORKSHEET_DEFINITIONS };
interface FocusWorksheetDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const FOCUS_WORKSHEET_DEFINITIONS = [
{
id: 'color-shape-match',
title: '根据颜色画图形',
subtitle: '根据颜色画出对应图形',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '颜色', '图形'],
sortOrder: 201,
},
{
id: 'shape-symbol',
title: '图形符号配对',
subtitle: '根据图形画对应符号',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '图形', '符号'],
sortOrder: 202,
},
{
id: 'position-coloring',
title: '方位涂涂乐',
subtitle: '观察位置,在方格中涂色',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '方位', '涂色'],
sortOrder: 203,
},
{
id: 'color-pattern',
title: '颜色找规律',
subtitle: '观察颜色规律,涂色',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '颜色', '规律'],
sortOrder: 204,
},
{
id: 'match-connect',
title: '连连看',
subtitle: '根据物品连一连',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '连线', '匹配'],
sortOrder: 205,
},
{
id: 'line-recognition',
title: '线条识别',
subtitle: '认识不同线条,画对应线条',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '线条', '识别', '控笔'],
sortOrder: 206,
},
{
id: 'grid-reasoning',
title: '方格推理',
subtitle: '推理出合并方格并连线',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['专注力', '方格', '推理', '逻辑思维'],
sortOrder: 207,
},
{
id: 'code-connect',
title: '译码连线',
subtitle: '按数字顺序连线',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['专注力', '译码', '连线', '逻辑思维'],
sortOrder: 208,
},
{
id: 'dot-connect',
title: '数字点连线',
subtitle: '按数字顺序连点成图',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['专注力', '数字', '连线'],
sortOrder: 209,
},
{
id: 'grid-drawing-3x3',
title: '格子仿画 3×3',
subtitle: '简单有趣,培养专注力',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['专注力', '格子仿画', '观察'],
sortOrder: 210,
},
{
id: 'grid-drawing-5x5',
title: '格子仿画 5×5',
subtitle: '创意挑战,提升观察力',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['专注力', '格子仿画', '观察'],
sortOrder: 211,
},
{
id: 'grid-drawing-7x7',
title: '格子仿画 7×7',
subtitle: '大师挑战,锻炼耐心',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['专注力', '格子仿画', '耐心'],
sortOrder: 212,
},
] as const satisfies ReadonlyArray<FocusWorksheetDefinition>;
type FocusWorksheetDefinitionItem = type FocusWorksheetDefinitionItem =
(typeof FOCUS_WORKSHEET_DEFINITIONS)[number]; (typeof FOCUS_WORKSHEET_DEFINITIONS)[number];
@@ -0,0 +1,70 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import {
CLOCK_READING_WORKSHEET_DEFINITIONS,
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
} from '../../config/worksheets/clock';
export { CLOCK_READING_WORKSHEET_DEFINITIONS, CLOCK_CONNECT_WORKSHEET_DEFINITIONS };
/** 合并所有钟表类 worksheet 定义 */
const ALL_CLOCK_DEFINITIONS = [
...CLOCK_READING_WORKSHEET_DEFINITIONS,
...CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
];
type ClockWorksheetRow = (typeof ALL_CLOCK_DEFINITIONS)[number];
const CLOCK_WORKSHEET_BY_ID = Object.fromEntries(
ALL_CLOCK_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, ClockWorksheetRow>;
/** 时刻类型选项(顺序:随机、整点、半点、刻钟) */
export const CLOCK_TIME_MODE_OPTIONS = [
{ id: 'random' as const, label: '随机', subtitle: '059 分均可' },
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
];
export type ClockTimeMode = (typeof CLOCK_TIME_MODE_OPTIONS)[number]['id'];
/** 练习类型选项(顺序:认识时钟、时钟连线) */
export const CLOCK_EXERCISE_TYPE_OPTIONS = [
{ id: 'clock-reading' as const, label: '认识时钟', subtitle: '看钟写时间' },
{ id: 'clock-connect' as const, label: '时钟连线', subtitle: '钟表连对应时间' },
];
export type ClockExerciseType = (typeof CLOCK_EXERCISE_TYPE_OPTIONS)[number]['id'];
export function getModeInfo(id: string) {
const m = CLOCK_WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in CLOCK_WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = CLOCK_WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'math',
subcategory: 'clock',
path: `/mathPages/clockReading/clockReading?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: false,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,15 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "认识时钟",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -0,0 +1,96 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.cr-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.cr-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 64rpx;
}
.cr-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.cr-section-title {
font-size: 32rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.cr-chip-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24rpx;
}
.cr-chip {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 16rpx;
color: @text-secondary;
background: @bg-card;
border-radius: 32rpx;
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.cr-chip--pressed {
transform: scale(0.96);
background: @brand;
color: @text-selected-btn;
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.cr-chip--active {
background: @brand;
color: @text-selected-btn;
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.cr-chip__text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
width: 100%;
}
.cr-chip__label {
font-size: 28rpx;
font-weight: 700;
line-height: 36rpx;
}
.cr-chip__subtitle {
font-size: 20rpx;
font-weight: 500;
line-height: 28rpx;
opacity: 0.75;
text-align: center;
}
.cr-chip--active .cr-chip__subtitle,
.cr-chip--pressed .cr-chip__subtitle {
opacity: 0.85;
}
@@ -0,0 +1,231 @@
import ClockReadingDraw from './draw/clockReadingDraw';
import ClockConnectDraw from './draw/clockConnectDraw';
import {
buildClockReadingData,
type ClockReadingTimeMode,
} from './generators/clock-generator';
import { buildClockConnectData } from './generators/clock-connect-generator';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
CLOCK_TIME_MODE_OPTIONS,
CLOCK_EXERCISE_TYPE_OPTIONS,
type ClockTimeMode,
type ClockExerciseType,
} from './clockReading.config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
import type { BaseDrawService } from '../../core/draw/baseDraw';
const pageInfoLookup = getModeInfo;
const DEFAULT_WORKSHEET_ID = 'clock-reading';
type PageData = CanvasDataState & {
worksheetId: string;
timeMode: ClockTimeMode;
timeModeOptions: typeof CLOCK_TIME_MODE_OPTIONS;
exerciseType: ClockExerciseType;
exerciseTypeOptions: typeof CLOCK_EXERCISE_TYPE_OPTIONS;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as BaseDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '认识钟表',
functionId: DEFAULT_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: DEFAULT_WORKSHEET_ID,
timeMode: 'random' as ClockTimeMode,
timeModeOptions: CLOCK_TIME_MODE_OPTIONS,
exerciseType: 'clock-reading' as ClockExerciseType,
exerciseTypeOptions: CLOCK_EXERCISE_TYPE_OPTIONS,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: DEFAULT_WORKSHEET_ID;
this.syncDebugPublishEnv();
// 根据传入 id 判断练习类型
const exerciseType: ClockExerciseType =
worksheetId === 'clock-connect' ? 'clock-connect' : 'clock-reading';
this.setData({ exerciseType });
this.applyWorksheet(worksheetId);
this.loadFavoritedMap();
},
onSelectTimeMode(e: WechatMiniprogram.TouchEvent) {
const mode = e.currentTarget.dataset.mode as
| ClockTimeMode
| undefined;
if (!mode || mode === this.data.timeMode) return;
this.setData({ timeMode: mode }, () => this.drawCanvas());
},
onSelectExerciseType(e: WechatMiniprogram.TouchEvent) {
const type = e.currentTarget.dataset.type as
| ClockExerciseType
| undefined;
if (!type || type === this.data.exerciseType) return;
const worksheetId = type;
this.setData({ exerciseType: type });
this.applyWorksheet(worksheetId);
// 切换练习类型需要重新创建 drawService
this.recreateDrawService();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) => this.createDrawForType(canvas, ctx, options),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
createDrawForType(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
): BaseDrawService {
if (this.data.exerciseType === 'clock-connect') {
return new ClockConnectDraw(canvas, ctx, options);
}
return new ClockReadingDraw(canvas, ctx, options);
},
recreateDrawService() {
if (!this.canvas || !this.ctx) return;
const options = { title: this.data.pageTitle };
this.drawService = this.createDrawForType(
this.canvas,
this.ctx,
options,
);
this.drawCanvas();
},
async drawCanvas() {
if (!this.ctx || !this.drawService) return;
try {
if (this.data.exerciseType === 'clock-connect') {
const data = buildClockConnectData(
this.data.timeMode as ClockTimeMode,
);
await (this.drawService as ClockConnectDraw).draw(data);
} else {
const data = buildClockReadingData(
this.data.timeMode as ClockReadingTimeMode,
);
await (this.drawService as ClockReadingDraw).draw(data);
}
this.setData({ hasContent: true });
} catch (e) {
console.error('clock draw failed', e);
this.setData({ hasContent: false });
}
},
onPreviewRefresh() {
this.drawCanvas();
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id && this._favoritedMap) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = ['clock-reading', 'clock-connect'];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap?.[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
getWorksheetStatsId() {
return this.data.worksheetId;
},
applyWorksheet(worksheetId: string) {
if (!isValidMode(worksheetId)) return;
this.setData(
{
worksheetId,
functionId: worksheetId,
isPreviewFavorite: !!this._favoritedMap?.[worksheetId],
},
() => {
this.initPageInfo(worksheetId, '认识钟表');
if (this.drawService) {
this.drawService.options.title = this.data.pageTitle;
}
},
);
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,74 @@
<nav-bar title="认识钟表" />
<view class="cr-page">
<view class="cr-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="cr-section">
<text class="cr-section-title">时刻类型</text>
<view class="cr-chip-row">
<view
wx:for="{{timeModeOptions}}"
wx:key="id"
class="cr-chip {{timeMode === item.id ? 'cr-chip--active' : ''}}"
hover-class="cr-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-mode="{{item.id}}"
bind:tap="onSelectTimeMode">
<view class="cr-chip__text">
<text class="cr-chip__label">{{item.label}}</text>
<text class="cr-chip__subtitle">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
<view class="cr-section">
<text class="cr-section-title">练习类型</text>
<view class="cr-chip-row">
<view
wx:for="{{exerciseTypeOptions}}"
wx:key="id"
class="cr-chip {{exerciseType === item.id ? 'cr-chip--active' : ''}}"
hover-class="cr-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-type="{{item.id}}"
bind:tap="onSelectExerciseType">
<view class="cr-chip__text">
<text class="cr-chip__label">{{item.label}}</text>
<text class="cr-chip__subtitle">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -0,0 +1,152 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
ClockConnectData,
ClockConnectProblem,
} from '../generators/clock-connect-generator';
import { drawAnalogClock } from './drawAnalogClock';
import { NUMBER_COLORS } from '../../../constants/colors';
const ROWS = 6;
const MARGIN_X = 28;
const MARGIN_Y_TOP = 12;
const CLOCK_RADIUS = 38 * 1.2; // 放大 1.2 倍
const TIME_BOX_W = 70 * 3 * 0.8 * 0.75; // 放大3倍后缩80%,再缩短长度
const TIME_BOX_H = 28 * 3 * 0.8; // 放大3倍后缩80%
const TIME_BOX_RADIUS = 8 * 3 * 0.8; // 放大3倍后缩80%
const TIME_FONT_SIZE = 42 * 0.8; // 字体放大3倍后缩80%
const CONTENT_BOTTOM_PADDING = 28;
const DASH_PATTERN = [7, 5];
const BOX_COLOR = '#555555';
const LINE_POINT_RADIUS = 8;
const LINE_POINT_SPACING = 12;
const LINE_POINT_COLOR = '#93D333';
/** 从 NUMBER_COLORS 中随机取一个颜色 */
function getRandomColor(): string {
const keys = Object.keys(NUMBER_COLORS).map(Number);
const key = keys[Math.floor(Math.random() * keys.length)];
return NUMBER_COLORS[key];
}
/**
* 时钟连线绘制服务:
* 左侧 6 个钟表(右侧带连线圆点),右侧 6 个打乱顺序的时间(左侧带连线圆点)
* 用户需要将左侧的钟表和右侧对应的时间连线
*/
export default class ClockConnectDraw extends BaseDrawService {
async draw(data: ClockConnectData) {
if (!data?.clocks?.length) return;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawConnectGrid(data.clocks, data.shuffledTimes);
this.drawPrintFooter({ mode: 'A' });
}
private drawConnectGrid(
clocks: ClockConnectProblem[],
shuffledTimes: ClockConnectProblem[],
) {
const { ctx, canvasWidth, canvasHeight } = this;
const contentTop = this.currentY;
const availableH = canvasHeight - contentTop - CONTENT_BOTTOM_PADDING;
const clockBlockH = CLOCK_RADIUS * 2;
const totalGridH = ROWS * clockBlockH;
const rowGap = Math.max(
8,
(availableH - totalGridH - MARGIN_Y_TOP * 2) / (ROWS - 1),
);
const startY = contentTop + MARGIN_Y_TOP;
const rowH = clockBlockH + rowGap;
// 向中间靠拢
const leftCenterX = MARGIN_X + CLOCK_RADIUS + 40;
const rightCenterX = canvasWidth - MARGIN_X - TIME_BOX_W / 2 - 40;
for (let i = 0; i < ROWS && i < clocks.length; i++) {
const clock = clocks[i];
const cellTopY = startY + i * rowH;
const clockCy = cellTopY + CLOCK_RADIUS;
// 绘制左侧钟表
drawAnalogClock(ctx, {
cx: leftCenterX,
cy: clockCy,
radius: CLOCK_RADIUS,
hour: clock.hour,
minute: clock.minute,
});
// 绘制钟表右侧的连线圆点
const leftDotX =
leftCenterX + CLOCK_RADIUS + LINE_POINT_SPACING + LINE_POINT_RADIUS;
ctx.fillStyle = LINE_POINT_COLOR;
ctx.beginPath();
ctx.arc(leftDotX, clockCy, LINE_POINT_RADIUS, 0, Math.PI * 2);
ctx.fill();
// 绘制右侧时间框
const timeItem = shuffledTimes[i];
const boxX = rightCenterX - TIME_BOX_W / 2;
const boxY = clockCy - TIME_BOX_H / 2;
const color = getRandomColor();
this.drawTimeBox(boxX, boxY, timeItem.timeText, color);
// 绘制时间框左侧的连线圆点
const rightDotX = boxX - LINE_POINT_SPACING - LINE_POINT_RADIUS;
ctx.fillStyle = LINE_POINT_COLOR;
ctx.beginPath();
ctx.arc(rightDotX, clockCy, LINE_POINT_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
}
/** 绘制圆角虚线矩形 + 居中彩色时间文本 */
private drawTimeBox(x: number, y: number, text: string, color: string) {
const { ctx } = this;
ctx.save();
ctx.strokeStyle = BOX_COLOR;
ctx.lineWidth = 1.8;
ctx.setLineDash(DASH_PATTERN);
this.strokeRoundRect(x, y, TIME_BOX_W, TIME_BOX_H, TIME_BOX_RADIUS);
ctx.setLineDash([]);
ctx.fillStyle = color;
ctx.font = `bold ${TIME_FONT_SIZE}px "Microsoft Yahei", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + TIME_BOX_W / 2, y + TIME_BOX_H / 2);
ctx.restore();
}
private strokeRoundRect(
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const { ctx } = this;
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.arcTo(x + w, y, x + w, y + radius, radius);
ctx.lineTo(x + w, y + h - radius);
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
ctx.lineTo(x + radius, y + h);
ctx.arcTo(x, y + h, x, y + h - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
}
@@ -0,0 +1,63 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { ClockProblem, ClockReadingData } from '../generators/clock-generator';
import { drawAnalogClock, drawTimeInputBox } from './drawAnalogClock';
const ROWS = 4;
const COLS = 3;
const MARGIN_X = 28;
const CLOCK_RADIUS = 52; // 原 58 的 90%
const CLOCK_TO_BOX_GAP = 13; // 原 20 缩小三分之一
const INPUT_BOX_W = 88;
const INPUT_BOX_H = 30;
const ROW_GAP = 28; // 原 14 的两倍
const CONTENT_BOTTOM_PADDING = 28;
/**
* 认识钟表绘制服务:4×3 网格,每组钟表 + 填写框
*/
export default class ClockReadingDraw extends BaseDrawService {
async draw(data: ClockReadingData) {
if (!data?.problems?.length) return;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawGrid(data.problems);
this.drawPrintFooter({ mode: 'A' });
}
private drawGrid(problems: ClockProblem[]) {
const { ctx, canvasWidth, canvasHeight } = this;
const contentTop = this.currentY;
const availableH = canvasHeight - contentTop - CONTENT_BOTTOM_PADDING;
const clockBlockH =
CLOCK_RADIUS * 2 + CLOCK_TO_BOX_GAP + INPUT_BOX_H;
const totalGridH = ROWS * clockBlockH + (ROWS - 1) * ROW_GAP;
const startY = contentTop + Math.max(12, (availableH - totalGridH) / 2);
const availableW = canvasWidth - MARGIN_X * 2;
const colW = availableW / COLS;
const rowH = clockBlockH + ROW_GAP;
for (let i = 0; i < problems.length; i++) {
const row = Math.floor(i / COLS);
const col = i % COLS;
if (row >= ROWS) break;
const problem = problems[i];
const cellCenterX = MARGIN_X + col * colW + colW / 2;
const cellTopY = startY + row * rowH;
const clockCy = cellTopY + CLOCK_RADIUS;
drawAnalogClock(ctx, {
cx: cellCenterX,
cy: clockCy,
radius: CLOCK_RADIUS,
hour: problem.hour,
minute: problem.minute,
});
const boxX = cellCenterX - INPUT_BOX_W / 2;
const boxY = cellTopY + CLOCK_RADIUS * 2 + CLOCK_TO_BOX_GAP;
drawTimeInputBox(ctx, boxX, boxY, INPUT_BOX_W, INPUT_BOX_H);
}
}
}
@@ -0,0 +1,210 @@
const CLOCK_GREEN = '#3D9E47';
const CLOCK_BLACK = '#333333';
const HOUR_HAND_RED = '#E53935';
export interface DrawAnalogClockOptions {
cx: number;
cy: number;
radius: number;
hour: number;
minute: number;
}
function toCanvasAngle(degFromTwelve: number): number {
return ((degFromTwelve - 90) * Math.PI) / 180;
}
function handPoint(
cx: number,
cy: number,
angle: number,
perp: number,
along: number,
perpOffset: number,
) {
return {
x: cx + Math.cos(angle) * along + Math.cos(perp) * perpOffset,
y: cy + Math.sin(angle) * along + Math.sin(perp) * perpOffset,
};
}
/** 指针:近圆心 80% 为矩形,远端 20% 收尖为三角形 */
function drawCompositeHand(
ctx: RenderingContext,
cx: number,
cy: number,
angleDeg: number,
length: number,
width: number,
color: string,
) {
const angle = toCanvasAngle(angleDeg);
const perp = angle + Math.PI / 2;
const halfW = width / 2;
const rectEnd = length * 0.8;
const tip = handPoint(cx, cy, angle, perp, length, 0);
const rectEndR = handPoint(cx, cy, angle, perp, rectEnd, halfW);
const baseR = handPoint(cx, cy, angle, perp, 0, halfW);
const baseL = handPoint(cx, cy, angle, perp, 0, -halfW);
const rectEndL = handPoint(cx, cy, angle, perp, rectEnd, -halfW);
ctx.save();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(tip.x, tip.y);
ctx.lineTo(rectEndR.x, rectEndR.y);
ctx.lineTo(baseR.x, baseR.y);
ctx.lineTo(baseL.x, baseL.y);
ctx.lineTo(rectEndL.x, rectEndL.y);
ctx.closePath();
ctx.fill();
ctx.restore();
}
const HAND_WIDTH = 2.8;
/** 绘制模拟钟表:绿圈、刻度、数字、红时针、黑分针 */
export function drawAnalogClock(
ctx: RenderingContext,
options: DrawAnalogClockOptions,
): void {
const { cx, cy, radius, hour, minute } = options;
const faceRadius = radius - 4;
ctx.save();
// 外圈(线宽原 3.5 的 90%
ctx.strokeStyle = CLOCK_GREEN;
ctx.lineWidth = 3.15;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
// 刻度(长度缩短为原来的 80%)
const tickOuter = faceRadius - 1;
const hourTickLen = 7 * 0.8;
const minuteTickLen = 3 * 0.8;
for (let i = 0; i < 60; i++) {
const angle = toCanvasAngle(i * 6);
const isHour = i % 5 === 0;
const tickLen = isHour ? hourTickLen : minuteTickLen;
const inner = tickOuter - tickLen;
ctx.strokeStyle = CLOCK_BLACK;
ctx.lineWidth = isHour ? 1.5 : 0.8;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
ctx.lineTo(cx + Math.cos(angle) * tickOuter, cy + Math.sin(angle) * tickOuter);
ctx.stroke();
}
// 数字 112(原 13px 的 70%
ctx.fillStyle = CLOCK_BLACK;
ctx.font = 'bold 9px "Microsoft Yahei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const numberRadius = faceRadius - 14;
for (let n = 1; n <= 12; n++) {
const angle = toCanvasAngle(n * 30);
const nx = cx + Math.cos(angle) * numberRadius;
const ny = cy + Math.sin(angle) * numberRadius;
ctx.fillText(String(n), nx, ny);
}
const minuteAngle = minute * 6;
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// 指针:80% 矩形 + 20% 三角尖
const minuteHandLength = numberRadius - 3;
const hourHandLength = numberRadius * 0.67;
drawCompositeHand(
ctx,
cx,
cy,
minuteAngle,
minuteHandLength,
HAND_WIDTH,
CLOCK_BLACK,
);
drawCompositeHand(
ctx,
cx,
cy,
hourAngle,
hourHandLength,
HAND_WIDTH,
HOUR_HAND_RED,
);
// 中心点
ctx.fillStyle = CLOCK_BLACK;
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const INPUT_GREEN = '#3D9E47';
function strokeRoundRect(
ctx: RenderingContext,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.arcTo(x + w, y, x + w, y + radius, radius);
ctx.lineTo(x + w, y + h - radius);
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
ctx.lineTo(x + radius, y + h);
ctx.arcTo(x, y + h, x, y + h - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
/** 在矩形内水平、垂直居中绘制文字 */
function fillTextCentered(
ctx: RenderingContext,
text: string,
x: number,
y: number,
width: number,
height: number,
) {
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(text);
const ascent = metrics.actualBoundingBoxAscent ?? 10;
const descent = metrics.actualBoundingBoxDescent ?? 2;
const textH = ascent + descent;
const baselineY = y + (height - textH) / 2 + ascent;
ctx.fillText(text, x + width / 2, baselineY);
}
/** 绘制数字填写框(绿色圆角描边 + 居中冒号) */
export function drawTimeInputBox(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
): void {
ctx.save();
ctx.strokeStyle = INPUT_GREEN;
ctx.lineWidth = 1.5;
strokeRoundRect(ctx, x, y, width, height, 6);
ctx.fillStyle = CLOCK_BLACK;
ctx.font = 'bold 16px "Microsoft Yahei", sans-serif';
fillTextCentered(ctx, ':', x, y, width, height);
ctx.restore();
}
@@ -0,0 +1,94 @@
export type ClockConnectTimeMode =
| 'random'
| 'whole-hour'
| 'half-hour'
| 'quarter-hour';
export interface ClockConnectProblem {
hour: number;
minute: number;
/** 格式化后的时间文本,如 "6:00" */
timeText: string;
}
export interface ClockConnectData {
/** 左侧钟表列表(按原始顺序) */
clocks: ClockConnectProblem[];
/** 右侧时间文本列表(打乱顺序) */
shuffledTimes: ClockConnectProblem[];
timeMode: ClockConnectTimeMode;
}
export const CLOCK_CONNECT_PROBLEM_COUNT = 6;
const MINUTES_BY_MODE: Record<
Exclude<ClockConnectTimeMode, 'random'>,
readonly number[]
> = {
'whole-hour': [0],
'half-hour': [30],
'quarter-hour': [15, 45],
};
function pickMinute(timeMode: ClockConnectTimeMode): number {
if (timeMode === 'random') {
return Math.floor(Math.random() * 60);
}
const pool = MINUTES_BY_MODE[timeMode];
return pool[Math.floor(Math.random() * pool.length)] ?? 0;
}
function formatTime(hour: number, minute: number): string {
return `${hour}:${minute.toString().padStart(2, '0')}`;
}
/** Fisher-Yates shuffle */
function shuffle<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/** 生成一页 6 道时钟连线题,同一页内时刻不重复 */
export function generateClockConnectProblems(
timeMode: ClockConnectTimeMode,
): ClockConnectProblem[] {
const used = new Set<string>();
const problems: ClockConnectProblem[] = [];
let guard = 0;
while (problems.length < CLOCK_CONNECT_PROBLEM_COUNT && guard < 500) {
guard += 1;
const hour = Math.floor(Math.random() * 12) + 1;
const minute = pickMinute(timeMode);
const key = `${hour}:${minute}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ hour, minute, timeText: formatTime(hour, minute) });
}
return problems;
}
export function buildClockConnectData(
timeMode: ClockConnectTimeMode,
): ClockConnectData {
const clocks = generateClockConnectProblems(timeMode);
const shuffledTimes = shuffle(clocks);
return {
timeMode,
clocks,
shuffledTimes,
};
}
export const CLOCK_CONNECT_TIME_MODE_OPTIONS = [
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
{ id: 'random' as const, label: '随机', subtitle: '整点/半点/刻钟' },
];
@@ -0,0 +1,71 @@
export type ClockReadingTimeMode =
| 'random'
| 'whole-hour'
| 'half-hour'
| 'quarter-hour';
export interface ClockProblem {
hour: number;
minute: number;
}
export interface ClockReadingData {
problems: ClockProblem[];
timeMode: ClockReadingTimeMode;
}
export const CLOCK_PROBLEM_COUNT = 12;
const MINUTES_BY_MODE: Record<
Exclude<ClockReadingTimeMode, 'random'>,
readonly number[]
> = {
'whole-hour': [0],
'half-hour': [30],
'quarter-hour': [15, 45],
};
function pickMinute(timeMode: ClockReadingTimeMode): number {
if (timeMode === 'random') {
return Math.floor(Math.random() * 60);
}
const pool = MINUTES_BY_MODE[timeMode];
return pool[Math.floor(Math.random() * pool.length)] ?? 0;
}
/** 生成一页 12 道钟表题,同一页内时刻不重复 */
export function generateClockProblems(
timeMode: ClockReadingTimeMode,
): ClockProblem[] {
const used = new Set<string>();
const problems: ClockProblem[] = [];
let guard = 0;
while (problems.length < CLOCK_PROBLEM_COUNT && guard < 500) {
guard += 1;
const hour = Math.floor(Math.random() * 12) + 1;
const minute = pickMinute(timeMode);
const key = `${hour}:${minute}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ hour, minute });
}
return problems;
}
export function buildClockReadingData(
timeMode: ClockReadingTimeMode,
): ClockReadingData {
return {
timeMode,
problems: generateClockProblems(timeMode),
};
}
export const CLOCK_TIME_MODE_OPTIONS = [
{ id: 'random' as const, label: '随机', subtitle: '059 分均可' },
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
];
@@ -1,281 +1,8 @@
import type { DebugPublishMeta } from '../../utils/debugPublish'; import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish';
import { MATH_WORKSHEET_DEFINITIONS } from '../../config/worksheets/math';
/** 本地 worksheet 元数据(与云库 `worksheets` 文档 `_id` 对齐,用于发布与批量同步脚本) */ export { MATH_WORKSHEET_DEFINITIONS };
interface MathWorksheetDefinition {
id: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const MATH_WORKSHEET_DEFINITIONS = [
{
id: 'number-find',
title: '找数字,涂一涂',
subtitle: '找出目标数字并涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 101,
},
{
id: 'number-write',
title: '看数字,写一写',
subtitle: '按笔画顺序书写数字',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '数字书写', '描红'],
sortOrder: 102,
},
{
id: 'number-coloring',
title: '按数字,涂颜色',
subtitle: '按指定数字涂色',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数字认知', '涂色'],
sortOrder: 103,
},
{
id: 'counting-matching',
title: '数一数,连一连',
subtitle: '连线配对数字和数量',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '连线'],
sortOrder: 104,
},
{
id: 'number-object-match',
title: '数物连线',
subtitle: '连线数量相同的物体和数字',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '数物对应', '连线'],
sortOrder: 105,
},
{
id: 'number-object-fill',
title: '数物填写',
subtitle: '数出数量,填写对应数字',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '数物对应', '填写'],
sortOrder: 106,
},
{
id: 'counting-select',
title: '数一数,选一选',
subtitle: '数出数量,圈出正确答案',
ageMin: 3,
ageMax: 5,
difficulty: 1,
tags: ['数学', '计数', '选择'],
sortOrder: 107,
},
{
id: 'counting-fill',
title: '数一数,填一填',
subtitle: '数出物品数量,填写数字',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '计数', '填写'],
sortOrder: 108,
},
{
id: 'compare',
title: '数一数,比大小',
subtitle: '比较数量,填入 ><=',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '比较', '数量'],
sortOrder: 109,
},
{
id: 'number-sort',
title: '数字排序',
subtitle: '写出正确的数字顺序',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '数字排序', '序列'],
sortOrder: 110,
},
{
id: 'missing-number',
title: '填上缺少的数字',
subtitle: '找出并填写缺失数字',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '数字序列', '填写'],
sortOrder: 111,
},
{
id: 'number-decompose',
title: '10以内数的分与合',
subtitle: '把数字分一分,合一合',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['数学', '分与合', '10以内'],
sortOrder: 112,
},
{
id: 'number-decompose-20',
title: '20以内数的分与合',
subtitle: '把数字分一分,合一合',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '分与合', '20以内'],
sortOrder: 113,
},
{
id: 'one-digit-addition',
title: '一位数加法',
subtitle: '通过圆点学习一位数加法',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '加法', '一位数'],
sortOrder: 114,
},
{
id: 'addition-5',
title: '5以内加法',
subtitle: '图形化展示5以内加法',
ageMin: 4,
ageMax: 6,
difficulty: 1,
tags: ['数学', '加法', '5以内', '计算题'],
sortOrder: 115,
},
{
id: 'addition-10',
title: '10以内加法',
subtitle: '图形化展示10以内加法',
ageMin: 4,
ageMax: 6,
difficulty: 2,
tags: ['数学', '加法', '10以内', '计算题'],
sortOrder: 116,
},
{
id: 'subtraction-10',
title: '10以内减法',
subtitle: '图形化展示10以内减法',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '减法', '10以内', '计算题'],
sortOrder: 117,
},
{
id: 'addition-subtraction-10',
title: '10以内加减法',
subtitle: '加减法混合运算',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['数学', '加减法', '10以内', '计算题'],
sortOrder: 118,
},
{
id: 'make-ten',
title: '凑十法练习',
subtitle: '20以内进位加法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '凑十法', '进位加法', '计算题'],
sortOrder: 119,
},
{
id: 'break-ten',
title: '破十法练习',
subtitle: '20以内退位减法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '破十法', '退位减法', '计算题'],
sortOrder: 120,
},
{
id: 'flat-ten',
title: '平十法练习',
subtitle: '20以内退位减法',
ageMin: 5,
ageMax: 7,
difficulty: 3,
tags: ['数学', '平十法', '退位减法', '计算题'],
sortOrder: 121,
},
{
id: 'borrow-ten',
title: '借十法练习',
subtitle: '20 以上退位减法',
ageMin: 6,
ageMax: 8,
difficulty: 4,
tags: ['数学', '借十法', '退位减法', '计算题'],
sortOrder: 122,
},
{
id: 'practice-addition',
title: '加法运算',
subtitle: '10/20/50/100 以内加法',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加法', '计算题'],
sortOrder: 123,
},
{
id: 'practice-subtraction',
title: '减法运算',
subtitle: '10/20/50/100以内减法',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '减法', '计算题'],
sortOrder: 124,
},
{
id: 'practice-mixed',
title: '混合运算',
subtitle: '10/20/50/100以内加减法混合',
ageMin: 6,
ageMax: 8,
difficulty: 3,
tags: ['数学', '口算', '加减法', '计算题'],
sortOrder: 125,
},
{
id: 'multiplication-table',
title: '九九乘法表',
subtitle: '学习九九乘法口诀',
ageMin: 7,
ageMax: 8,
difficulty: 3,
tags: ['数学', '乘法', '九九乘法表'],
sortOrder: 126,
},
] as const satisfies ReadonlyArray<MathWorksheetDefinition>;
type MathWorksheetDefinitionItem = (typeof MATH_WORKSHEET_DEFINITIONS)[number]; type MathWorksheetDefinitionItem = (typeof MATH_WORKSHEET_DEFINITIONS)[number];
@@ -369,6 +369,16 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
path: '/mathPages/mathDraw/mathDraw?id=multiplication-table', path: '/mathPages/mathDraw/mathDraw?id=multiplication-table',
available: true, available: true,
}), }),
item({
id: 'clock-reading',
title: '认识钟表',
subtitle: '看钟读时间,填写数字时刻',
icon: '🕐',
ageBand: age(4, 7),
difficulty: 1,
path: '/mathPages/clockReading/clockReading?id=clock-reading',
available: true,
}),
], ],
pinyin: [ pinyin: [
item({ item({
@@ -677,6 +687,18 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
available: true, available: true,
}), }),
], ],
papers: [
item({
id: 'paper-sheet',
title: '作业纸',
subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印',
icon: '📄',
ageBand: age(3, 12),
difficulty: 1,
path: '/papersPages/paperSheet/paperSheet?id=paper-sheet',
available: true,
}),
],
craft: [ craft: [
item({ item({
id: 'craft-coloring', id: 'craft-coloring',
+9 -3
View File
@@ -82,6 +82,13 @@ function debugLogWorksheetStatsAfterCategoryLoad(
// --- 调试块结束 --- // --- 调试块结束 ---
function toContentDate(raw: Record<string, any>): string {
const ts = raw.contentUpdatedAt || raw.createdAt;
return ts
? new Date(ts).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
}
/** 将云端 worksheet 原始数据转换为 CategoryItem */ /** 将云端 worksheet 原始数据转换为 CategoryItem */
function toDisplayItem(raw: Record<string, any>): CategoryItem { function toDisplayItem(raw: Record<string, any>): CategoryItem {
const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4; const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4;
@@ -101,9 +108,7 @@ function toDisplayItem(raw: Record<string, any>): CategoryItem {
available: true, available: true,
likes: Number(raw.likes) || 0, likes: Number(raw.likes) || 0,
downloads: Number(raw.downloads) || 0, downloads: Number(raw.downloads) || 0,
date: raw.updatedAt date: toContentDate(raw),
? new Date(raw.updatedAt).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '', updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
}; };
} }
@@ -241,6 +246,7 @@ Page({
if (!result.success) throw new Error(result.message || '查询失败'); if (!result.success) throw new Error(result.message || '查询失败');
const rows = result.data || []; const rows = result.data || [];
console.log('rows', rows);
_categoryData = buildCategoryDataFromCloud(rows); _categoryData = buildCategoryDataFromCloud(rows);
debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows }); debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows });
} catch { } catch {
+22
View File
@@ -1,6 +1,7 @@
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories'; import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { AGE_BANDS } from '../../core/data/difficulty'; import { AGE_BANDS } from '../../core/data/difficulty';
import { CategoryId } from '../../core/models/category'; import { CategoryId } from '../../core/models/category';
import { APP_VERSION } from '../../config/config';
export type HomeDisplayItem = { export type HomeDisplayItem = {
id: string; id: string;
@@ -59,6 +60,27 @@ const HOME_AGE_BANDS: HomeDisplayDataset['ageBands'] = AGE_BANDS.map(
}), }),
); );
/** 首页跑马灯公告条(不随云端配置变化) */
export type HomeNotice = {
id: string;
text: string;
/** 可选跳转路径,为空则点击不跳转 */
path?: string;
};
export const HOME_NOTICES: HomeNotice[] = [
{
id: 'welcome',
text: '欢迎来到 涂鸦丫,趣味练习纸任你选',
path: '/mathPages/clockReading/clockReading?id=clock-connect',
},
{
id: `update-${APP_VERSION}`,
text: `${APP_VERSION} 上线:新增幼儿认识时钟,连线练习题`,
path: '/mathPages/clockReading/clockReading?id=clock-connect',
},
];
/** 首页固定:每日打卡练习(不随云端配置变化) */ /** 首页固定:每日打卡练习(不随云端配置变化) */
export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
{ {
+86
View File
@@ -48,6 +48,92 @@
margin-top: 48rpx; margin-top: 48rpx;
} }
.home-notice-bar {
display: flex;
align-items: center;
margin: 24rpx @page-padding-x 0;
padding: 0 24rpx;
height: 64rpx;
border-radius: 999rpx;
background: fade(#ff8f1f, 10%);
overflow: hidden;
box-sizing: border-box;
}
.home-notice-bar__label {
display: flex;
align-items: center;
flex-shrink: 0;
margin-right: 16rpx;
padding-right: 16rpx;
border-right: 2rpx solid fade(#b06b1a, 30%);
height: 36rpx;
}
.home-notice-bar__icon {
font-size: 26rpx;
line-height: 1;
}
.home-notice-bar__label-text {
margin-left: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: #b06b1a;
line-height: 1;
}
.home-notice-bar__viewport {
flex: 1;
min-width: 0;
overflow: hidden;
height: 100%;
display: flex;
align-items: center;
}
.home-notice-bar__track {
display: inline-flex;
flex-wrap: nowrap;
white-space: nowrap;
animation: home-notice-scroll 12s linear infinite;
will-change: transform;
}
.home-notice-bar__group {
display: inline-flex;
flex-shrink: 0;
}
.home-notice-bar__item {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.home-notice-bar__text {
font-size: 24rpx;
color: #7a4c12;
line-height: 1;
}
.home-notice-bar__sep {
margin: 0 24rpx;
font-size: 24rpx;
color: fade(#b06b1a, 50%);
line-height: 1;
}
@keyframes home-notice-scroll {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-50%, 0, 0);
}
}
.home-hero-placeholder { .home-hero-placeholder {
margin: @section-gap @page-padding-x 0; margin: @section-gap @page-padding-x 0;
height: 450rpx; height: 450rpx;
+8
View File
@@ -6,6 +6,7 @@ import {
} from '../../utils/index'; } from '../../utils/index';
import { import {
HOME_DAILY_CHECKIN_ITEMS, HOME_DAILY_CHECKIN_ITEMS,
HOME_NOTICES,
createEmptyHomeDataset, createEmptyHomeDataset,
type HomeDisplayItem, type HomeDisplayItem,
type HomeDisplaySection, type HomeDisplaySection,
@@ -190,6 +191,7 @@ Page({
hotRecommends: SKELETON_HOT, hotRecommends: SKELETON_HOT,
dailyCheckinItems: HOME_DAILY_CHECKIN_ITEMS.map(toItemView), dailyCheckinItems: HOME_DAILY_CHECKIN_ITEMS.map(toItemView),
sections: [] as HomeDisplaySectionView[], sections: [] as HomeDisplaySectionView[],
notices: HOME_NOTICES,
loading: true, loading: true,
}, },
@@ -295,6 +297,12 @@ Page({
navigateByPath(path, title); navigateByPath(path, title);
}, },
onTapNotice(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string | undefined;
if (!path) return;
navigateByPath(path);
},
onTapCard(e: WechatMiniprogram.CustomEvent) { onTapCard(e: WechatMiniprogram.CustomEvent) {
const detail = (e.detail || {}) as { title?: string; path?: string }; const detail = (e.detail || {}) as { title?: string; path?: string };
const path = const path =
+37
View File
@@ -22,6 +22,43 @@
bind:change="onTabChange" /> bind:change="onTabChange" />
</view> </view>
<view wx:if="{{notices.length > 0}}" class="home-notice-bar">
<view class="home-notice-bar__label">
<text class="home-notice-bar__icon">📣</text>
<text class="home-notice-bar__label-text">公告</text>
</view>
<view class="home-notice-bar__viewport">
<view class="home-notice-bar__track">
<view class="home-notice-bar__group">
<view
wx:for="{{notices}}"
wx:key="id"
class="home-notice-bar__item"
data-path="{{item.path}}"
bindtap="onTapNotice">
<text class="home-notice-bar__text"
>{{item.text}}</text
>
<text class="home-notice-bar__sep">·</text>
</view>
</view>
<view class="home-notice-bar__group" aria-hidden="true">
<view
wx:for="{{notices}}"
wx:key="id"
class="home-notice-bar__item"
data-path="{{item.path}}"
bindtap="onTapNotice">
<text class="home-notice-bar__text"
>{{item.text}}</text
>
<text class="home-notice-bar__sep">·</text>
</view>
</view>
</view>
</view>
</view>
<swiper <swiper
class="home-hero-swiper" class="home-hero-swiper"
previous-margin="80rpx" previous-margin="80rpx"
+2 -1
View File
@@ -1,4 +1,5 @@
import { disablePageShareMenu } from '../../base/pageMixin'; import { disablePageShareMenu } from '../../base/pageMixin';
import { APP_VERSION } from '../../config/config';
import { getDownloadLogCount } from '../../utils/downloadLogs'; import { getDownloadLogCount } from '../../utils/downloadLogs';
import { getFavoriteCount } from '../../utils/favorites'; import { getFavoriteCount } from '../../utils/favorites';
import { import {
@@ -16,7 +17,7 @@ Page({
isDevEnv: false, isDevEnv: false,
printCount: 0, printCount: 0,
favoriteCount: 0, favoriteCount: 0,
version: '3.0.0', version: APP_VERSION,
showProfileEditor: false, showProfileEditor: false,
draftNickName: '', draftNickName: '',
draftAvatarUrl: '/assets/imgs/doodle-head.png', draftAvatarUrl: '/assets/imgs/doodle-head.png',
@@ -0,0 +1,682 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { PaperType } from '../paperSheet.config';
/** 页面内容区边距(逻辑像素) */
const LAYOUT = {
topGap: 14,
leftMargin: 36,
rightMargin: 36,
bottomMargin: 36,
} as const;
interface ContentRect {
top: number;
left: number;
width: number;
height: number;
right: number;
bottom: number;
}
/** 将 #RRGGBB 转为带透明度的 rgba 字符串 */
function withAlpha(hex: string, alpha: number): string {
const normalized = hex.replace('#', '');
const full =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export default class PaperDrawService extends BaseDrawService {
/** 主线条颜色 */
private color: string = '#333333';
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '作业纸',
subTitle: '多种作业纸一键打印',
...options,
});
}
/**
* 绘制指定类型 + 颜色的作业纸
*/
async draw(paperType: PaperType, color: string) {
this.color = color;
this.options.title = this.optionTitleForType(paperType);
this.prepareDraw();
if (PaperDrawService.HEADER_TYPES.has(paperType)) {
// 听写、作业登记表:需要标注是谁的、哪天的,保留精简页眉
this.drawCompactHeader();
} else {
// 纯书写纸(田字格 / 米字格 / 方格 / 四线三格 / 信纸 / 横线 / 竖线)
// 不画页眉,最大化书写区,仅留顶部打印留白
this.currentY = 20;
}
const rect = this.getContentRect();
switch (paperType) {
case 'tian':
this.drawGridCells(rect, { diagonal: false });
break;
case 'mi':
this.drawGridCells(rect, { diagonal: true });
break;
case 'square':
this.drawSquareGrid(rect);
break;
case 'four-line':
this.drawFourLine(rect);
break;
case 'letter':
this.drawLetterPaper(rect);
break;
case 'horizontal':
this.drawHorizontalLines(rect);
break;
case 'vertical':
this.drawVerticalLines(rect);
break;
case 'dictation-hanzi':
this.drawDictation(rect, 'hanzi');
break;
case 'dictation-pinyin':
this.drawDictation(rect, 'pinyin');
break;
case 'homework-log':
this.drawHomeworkLog(rect);
break;
}
this.drawCenteredFooter();
}
/** 需要保留精简页眉(标题 + 姓名/日期)的纸张类型 */
private static readonly HEADER_TYPES = new Set<PaperType>([
'dictation-hanzi',
'dictation-pinyin',
'homework-log',
]);
/**
* 自定义精简页眉:无 Logo、无分割线,标题字体较小、整体高度更矮,
* 保留 姓名 / 日期 两个填写项(不含得分)。
* 绘制完成后设置 currentY 供正文使用。
*/
private drawCompactHeader() {
const { ctx, canvasWidth } = this;
const marginX = 24;
// 标题(较小字号,居中)
const titleY = 12;
ctx.fillStyle = '#322e25';
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(
String(this.options.title || '作业纸'),
canvasWidth / 2,
titleY,
);
// 姓名 / 日期(与标题拉开间距)
const metaY = 48;
const metaFontPx = 11;
ctx.font = `${metaFontPx}px "Microsoft Yahei"`;
ctx.fillStyle = '#7c766a';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const innerLeft = marginX + 6;
const innerRight = canvasWidth - marginX - 6;
const innerW = innerRight - innerLeft;
const colW = innerW / 2;
const drawMetaField = (label: string, x: number, right: number) => {
ctx.fillText(label, x, metaY);
const labelW = ctx.measureText(label).width;
const lineY = metaY + metaFontPx + 3;
ctx.strokeStyle = '#E5DCC9';
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x + labelW + 2, lineY);
ctx.lineTo(Math.max(x + labelW + 2, right), lineY);
ctx.stroke();
};
drawMetaField('姓名:', innerLeft, innerLeft + colW - 24);
drawMetaField('日期:', innerLeft + colW, innerRight);
// 正文起始 Y(不画分割线)
this.currentY = metaY + metaFontPx + 12;
}
/** 底部品牌水印:居中展示 */
private drawCenteredFooter() {
const { ctx, canvasWidth, canvasHeight } = this;
ctx.save();
ctx.font = 'bold 13px "Microsoft Yahei"';
ctx.fillStyle = '#7c766a';
ctx.globalAlpha = 0.35;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('涂鸦丫小程序', canvasWidth / 2, canvasHeight - 16);
ctx.restore();
}
private optionTitleForType(paperType: PaperType): string {
const map: Record<PaperType, string> = {
tian: '田字格作业纸',
mi: '米字格作业纸',
square: '方格作业纸',
'four-line': '四线三格作业纸',
letter: '信纸',
horizontal: '横线本',
vertical: '竖格作业纸',
'dictation-hanzi': '汉字听写',
'dictation-pinyin': '拼音 / 英语听写',
'homework-log': '作业登记表',
};
return map[paperType];
}
private getContentRect(): ContentRect {
const top = this.currentY + LAYOUT.topGap;
const left = LAYOUT.leftMargin;
const width = this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
const height = this.canvasHeight - top - LAYOUT.bottomMargin;
return {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
};
}
/** 主色 / 辅助色 */
private get borderColor(): string {
return this.color;
}
private get midColor(): string {
return withAlpha(this.color, 0.55);
}
private get diagColor(): string {
return withAlpha(this.color, 0.4);
}
// ─────────────────────────── 田字格 / 米字格 ───────────────────────────
private drawGridCells(rect: ContentRect, opts: { diagonal: boolean }) {
const { ctx } = this;
const targetCell = 46;
const gap = 8;
const cols = Math.max(
1,
Math.floor((rect.width + gap) / (targetCell + gap)),
);
const rows = Math.max(
1,
Math.floor((rect.height + gap) / (targetCell + gap)),
);
const cell = Math.min(
(rect.width - (cols - 1) * gap) / cols,
(rect.height - (rows - 1) * gap) / rows,
);
// 居中排布
const gridW = cols * cell + (cols - 1) * gap;
const startX = rect.left + (rect.width - gridW) / 2;
const startY = rect.top;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = startX + c * (cell + gap);
const y = startY + r * (cell + gap);
this.drawSingleGridCell(ctx, x, y, cell, opts.diagonal);
}
}
}
private drawSingleGridCell(
ctx: RenderingContext,
x: number,
y: number,
size: number,
diagonal: boolean,
) {
// 外框实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(x, y, size, size);
const cx = x + size / 2;
const cy = y + size / 2;
// 十字虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(cx, y);
ctx.lineTo(cx, y + size);
ctx.moveTo(x, cy);
ctx.lineTo(x + size, cy);
ctx.stroke();
// 对角虚线(米字格)
if (diagonal) {
ctx.strokeStyle = this.diagColor;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y);
ctx.lineTo(x, y + size);
ctx.stroke();
}
ctx.setLineDash([]);
}
// ─────────────────────────── 方格 ───────────────────────────
private drawSquareGrid(rect: ContentRect) {
const { ctx } = this;
const targetCell = 27;
const cols = Math.max(1, Math.round(rect.width / targetCell));
const cell = rect.width / cols;
const rows = Math.max(1, Math.floor(rect.height / cell));
const gridW = cols * cell;
const gridH = rows * cell;
const startX = rect.left;
const startY = rect.top;
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
for (let c = 0; c <= cols; c++) {
const x = startX + c * cell;
ctx.moveTo(x, startY);
ctx.lineTo(x, startY + gridH);
}
for (let r = 0; r <= rows; r++) {
const y = startY + r * cell;
ctx.moveTo(startX, y);
ctx.lineTo(startX + gridW, y);
}
ctx.stroke();
}
// ─────────────────────────── 四线三格 ───────────────────────────
private drawFourLine(rect: ContentRect) {
const { ctx } = this;
const groupHeight = 34; // 一组四线三格的总高
const groupGap = 24; // 组间距
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.floor((rect.height + groupGap) / unit));
for (let i = 0; i < count; i++) {
const topY = rect.top + i * unit;
const l1 = topY;
const l2 = topY + groupHeight / 3;
const l3 = topY + (groupHeight * 2) / 3;
const l4 = topY + groupHeight;
// 上下两条实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, l1);
this.strokeHLine(rect.left, rect.right, l4);
// 中间两条虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
this.strokeHLine(rect.left, rect.right, l2);
this.strokeHLine(rect.left, rect.right, l3);
ctx.setLineDash([]);
}
}
// ─────────────────────────── 信纸 ───────────────────────────
private drawLetterPaper(rect: ContentRect) {
const { ctx } = this;
const lineGap = 34;
const innerTop = rect.top + 10;
const innerBottom = rect.bottom - 24;
const lineCount = Math.floor((innerBottom - innerTop) / lineGap);
// 顶部双线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.6;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, rect.top);
this.strokeHLine(rect.left, rect.right, rect.top + 4);
// 中间横线
ctx.lineWidth = 1;
ctx.strokeStyle = withAlpha(this.color, 0.75);
for (let i = 1; i <= lineCount; i++) {
const y = innerTop + i * lineGap;
if (y >= innerBottom) break;
this.strokeHLine(rect.left, rect.right, y);
}
// 底部双线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.6;
this.strokeHLine(rect.left, rect.right, innerBottom);
this.strokeHLine(rect.left, rect.right, innerBottom + 4);
// 右下角「第 页」
ctx.fillStyle = this.borderColor;
ctx.font = '13px "Microsoft Yahei"';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText('第 页', rect.right, innerBottom + 10);
}
// ─────────────────────────── 横线 ───────────────────────────
private drawHorizontalLines(rect: ContentRect) {
const { ctx } = this;
const lineGap = 34;
const count = Math.floor(rect.height / lineGap);
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
for (let i = 0; i <= count; i++) {
const y = rect.top + i * lineGap;
if (y > rect.bottom) break;
this.strokeHLine(rect.left, rect.right, y);
}
}
// ─────────────────────────── 竖线 ───────────────────────────
private drawVerticalLines(rect: ContentRect) {
const { ctx } = this;
const targetGap = 34;
const count = Math.max(1, Math.round(rect.width / targetGap));
const gap = rect.width / count;
// 上下边界实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, rect.top);
this.strokeHLine(rect.left, rect.right, rect.bottom);
// 竖线
ctx.beginPath();
for (let c = 0; c <= count; c++) {
const x = rect.left + c * gap;
ctx.moveTo(x, rect.top);
ctx.lineTo(x, rect.bottom);
}
ctx.stroke();
}
// ─────────────────────────── 听写本(汉字 / 拼音英语) ───────────────────────────
/** 汉字听写:田字格行布局参数 */
private static readonly DICT_HANZI = { cell: 40, rowGap: 20 };
/** 拼音 / 英语听写:四线三格行布局参数 */
private static readonly DICT_PINYIN = { groupHeight: 34, groupGap: 22 };
private drawDictation(rect: ContentRect, variant: 'hanzi' | 'pinyin') {
// 先按行布局算出左侧书写区实际占用高度,使右侧订正栏与其等高
const usedHeight = this.dictationUsedHeight(rect.height, variant);
const alignedRect: ContentRect = {
...rect,
height: usedHeight,
bottom: rect.top + usedHeight,
};
// 右侧订正栏(与左侧等高),返回左侧书写区右边界
const leftAreaRight = this.drawCorrectionColumn(alignedRect);
const leftRect: ContentRect = {
...alignedRect,
width: leftAreaRight - rect.left,
right: leftAreaRight,
};
if (variant === 'hanzi') {
this.drawDictationHanzi(leftRect);
} else {
this.drawDictationPinyin(leftRect);
}
}
/** 计算听写本左侧行区实际占用高度(末行不含尾部间距) */
private dictationUsedHeight(
available: number,
variant: 'hanzi' | 'pinyin',
): number {
if (variant === 'hanzi') {
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
const unit = cell + rowGap;
const rows = Math.max(1, Math.floor((available + rowGap) / unit));
return rows * unit - rowGap;
}
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.floor((available + groupGap) / unit));
return count * unit - groupGap;
}
/** 绘制右侧订正栏,返回左侧书写区的右边界 x */
private drawCorrectionColumn(rect: ContentRect): number {
const { ctx } = this;
const correctW = 120;
const gapToCorrect = 16;
const correctX = rect.right - correctW;
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
ctx.setLineDash([]);
ctx.strokeRect(correctX, rect.top, correctW, rect.height);
ctx.fillStyle = this.borderColor;
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('订正栏', correctX + correctW / 2, rect.top + 14);
this.strokeHLine(correctX, correctX + correctW, rect.top + 40);
return correctX - gapToCorrect;
}
/** 汉字听写:整齐的田字格行(无上方提示线) */
private drawDictationHanzi(rect: ContentRect) {
const { ctx } = this;
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
const unit = cell + rowGap;
const rowCount = Math.max(1, Math.round((rect.height + rowGap) / unit));
const cols = Math.max(1, Math.floor(rect.width / cell));
for (let r = 0; r < rowCount; r++) {
const rowTop = rect.top + r * unit;
for (let c = 0; c < cols; c++) {
const x = rect.left + c * cell;
this.drawSingleGridCell(ctx, x, rowTop, cell, false);
}
}
}
/** 拼音 / 英语听写:四线三格行 */
private drawDictationPinyin(rect: ContentRect) {
const { ctx } = this;
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.round((rect.height + groupGap) / unit));
for (let i = 0; i < count; i++) {
const topY = rect.top + i * unit;
const l1 = topY;
const l2 = topY + groupHeight / 3;
const l3 = topY + (groupHeight * 2) / 3;
const l4 = topY + groupHeight;
// 上下实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, l1);
this.strokeHLine(rect.left, rect.right, l4);
// 中间两条虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
this.strokeHLine(rect.left, rect.right, l2);
this.strokeHLine(rect.left, rect.right, l3);
ctx.setLineDash([]);
}
}
// ─────────────────────────── 作业登记表 ───────────────────────────
private drawHomeworkLog(rect: ContentRect) {
const { ctx } = this;
const subjects = ['语文', '数学', '英语', '其他'];
const rowsPerSubject = 5;
const totalRows = subjects.length * rowsPerSubject;
const subjectColW = 60; // 左侧科目列
const numColW = 44; // 序号列
const checkColW = 56; // 右侧完成勾选列
const contentColX = rect.left + subjectColW + numColW;
const checkColX = rect.right - checkColW;
const rowHeight = rect.height / totalRows;
const tableTop = rect.top;
const tableBottom = rect.top + rowHeight * totalRows;
ctx.setLineDash([]);
// 外框
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
ctx.strokeRect(rect.left, tableTop, rect.width, tableBottom - tableTop);
// 竖分割线
ctx.lineWidth = 1;
this.strokeVLine(rect.left + subjectColW, tableTop, tableBottom);
this.strokeVLine(contentColX, tableTop, tableBottom);
this.strokeVLine(checkColX, tableTop, tableBottom);
ctx.font = '13px "Microsoft Yahei"';
for (let s = 0; s < subjects.length; s++) {
const blockTop = tableTop + s * rowsPerSubject * rowHeight;
const blockBottom = blockTop + rowsPerSubject * rowHeight;
// 科目分组分隔(实线)
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
this.strokeHLine(rect.left, rect.right, blockBottom);
// 科目名称(竖排居中)
ctx.fillStyle = this.borderColor;
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const subjectCx = rect.left + subjectColW / 2;
const subjectCy = (blockTop + blockBottom) / 2;
this.drawVerticalText(subjects[s], subjectCx, subjectCy, 18);
for (let r = 0; r < rowsPerSubject; r++) {
const rowTop = blockTop + r * rowHeight;
const rowCy = rowTop + rowHeight / 2;
// 序号
ctx.fillStyle = this.borderColor;
ctx.font = '13px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
String(r + 1),
rect.left + subjectColW + numColW / 2,
rowCy,
);
// 内容区书写虚线
ctx.strokeStyle = this.midColor;
ctx.lineWidth = 0.8;
ctx.setLineDash([3, 3]);
this.strokeHLine(
contentColX + 8,
checkColX - 8,
rowCy + rowHeight / 2 - 4,
);
ctx.setLineDash([]);
// 勾选方框
const boxSize = Math.min(18, rowHeight - 10);
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.strokeRect(
checkColX + (checkColW - boxSize) / 2,
rowCy - boxSize / 2,
boxSize,
boxSize,
);
}
}
}
// ─────────────────────────── 工具方法 ───────────────────────────
private strokeHLine(x1: number, x2: number, y: number) {
const { ctx } = this;
ctx.beginPath();
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.stroke();
}
private strokeVLine(x: number, y1: number, y2: number) {
const { ctx } = this;
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x, y2);
ctx.stroke();
}
/** 竖排文字(逐字向下) */
private drawVerticalText(
text: string,
cx: number,
cy: number,
lineHeight: number,
) {
const { ctx } = this;
const chars = text.split('');
const totalH = chars.length * lineHeight;
let y = cy - totalH / 2 + lineHeight / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const ch of chars) {
ctx.fillText(ch, cx, y);
y += lineHeight;
}
}
}
@@ -0,0 +1,107 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { PAPER_SHEET_WORKSHEET_DEFINITIONS } from '../../config/worksheets/papers';
type PaperSheetWorksheetRow =
(typeof PAPER_SHEET_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
PAPER_SHEET_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, PaperSheetWorksheetRow>;
export const PAPER_SHEET_WORKSHEET_ID = PAPER_SHEET_WORKSHEET_DEFINITIONS[0].id;
/** 纸张类型 id */
export type PaperType =
| 'tian'
| 'mi'
| 'square'
| 'four-line'
| 'letter'
| 'horizontal'
| 'vertical'
| 'dictation-hanzi'
| 'dictation-pinyin'
| 'homework-log';
export interface PaperTypeOption {
id: PaperType;
name: string;
/** 次要说明 */
desc: string;
}
/** 纸张类型选项(展示顺序与用户需求一致) */
export const PAPER_TYPE_OPTIONS: PaperTypeOption[] = [
{ id: 'tian', name: '田字格', desc: '汉字练习' },
{ id: 'mi', name: '米字格', desc: '书法练习' },
{ id: 'square', name: '方格', desc: '书法练字纸' },
{ id: 'four-line', name: '四线三格', desc: '拼音 / 字母' },
{ id: 'letter', name: '信纸', desc: '横线信笺' },
{ id: 'horizontal', name: '横线', desc: '横线本' },
{ id: 'vertical', name: '竖线', desc: '竖排书写' },
{ id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' },
{
id: 'dictation-pinyin',
name: '拼音/英语听写',
desc: '四线三格 + 订正栏',
},
{ id: 'homework-log', name: '作业登记表', desc: '分科登记' },
];
export interface PaperColorOption {
id: string;
name: string;
/** 主线条颜色 */
value: string;
}
/** 颜色选项:浅红、浅绿、黑色 */
export const PAPER_COLOR_OPTIONS: PaperColorOption[] = [
{ id: 'red', name: '浅红', value: '#E48A8A' },
{ id: 'green', name: '浅绿', value: '#7FB069' },
{ id: 'black', name: '黑色', value: '#333333' },
];
export const DEFAULT_PAPER_TYPE: PaperType = 'tian';
export const DEFAULT_PAPER_COLOR = PAPER_COLOR_OPTIONS[0].value;
export function getPaperTypeName(id: string): string {
return PAPER_TYPE_OPTIONS.find((p) => p.id === id)?.name || '作业纸';
}
export function isValidPaperType(id: string): id is PaperType {
return PAPER_TYPE_OPTIONS.some((p) => p.id === id);
}
export function getModeInfo(id: string) {
const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'papers',
subcategory: 'paper-sheet',
path: `/papersPages/paperSheet/paperSheet?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: true,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,16 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "作业纸",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -0,0 +1,133 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.ps-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.ps-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.ps-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.ps-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.ps-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
// ─────────── 纸张类型 ───────────
.ps-type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.ps-type-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition:
transform 0.12s,
box-shadow 0.12s,
border-color 0.12s,
background 0.12s;
}
.ps-type-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.ps-type-card--pressed {
transform: scale(0.95);
}
.ps-type-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
}
.ps-type-card__desc {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
}
// ─────────── 颜色 ───────────
.ps-color-list {
display: flex;
gap: 24rpx;
}
.ps-color-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
padding: 24rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition:
transform 0.12s,
border-color 0.12s,
background 0.12s;
}
.ps-color-item--active {
background: #ffffff;
border-color: @brand;
}
.ps-color-item--pressed {
transform: scale(0.95);
}
.ps-color-dot {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
box-shadow: inset 0 0 0 2rpx rgba(0, 0, 0, 0.08);
}
.ps-color-item__name {
font-size: 26rpx;
font-weight: 700;
color: @text-title;
}
@@ -0,0 +1,165 @@
import PaperDrawService from './draw/paperDrawService';
import {
PAPER_SHEET_WORKSHEET_ID,
PAPER_TYPE_OPTIONS,
PAPER_COLOR_OPTIONS,
DEFAULT_PAPER_TYPE,
DEFAULT_PAPER_COLOR,
getModeInfo,
getPublishMetaByMode,
isValidPaperType,
type PaperType,
} from './paperSheet.config';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
const pageInfoLookup = getModeInfo;
type PageData = CanvasDataState & {
worksheetId: string;
paperTypeOptions: typeof PAPER_TYPE_OPTIONS;
colorOptions: typeof PAPER_COLOR_OPTIONS;
selectedPaperType: PaperType;
selectedColor: string;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as PaperDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '作业纸',
functionId: PAPER_SHEET_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: PAPER_SHEET_WORKSHEET_ID,
paperTypeOptions: PAPER_TYPE_OPTIONS,
colorOptions: PAPER_COLOR_OPTIONS,
selectedPaperType: DEFAULT_PAPER_TYPE,
selectedColor: DEFAULT_PAPER_COLOR,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string; type?: string }) {
this.syncDebugPublishEnv();
const initialType =
options.type && isValidPaperType(options.type)
? (options.type as PaperType)
: DEFAULT_PAPER_TYPE;
this.setData({
selectedPaperType: initialType,
});
this.initPageInfo(PAPER_SHEET_WORKSHEET_ID, '作业纸');
this.loadFavoritedMap();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new PaperDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
async drawCanvas() {
if (!this.drawService) return;
try {
await (this.drawService as PaperDrawService).draw(
this.data.selectedPaperType,
this.data.selectedColor,
);
this.setData({ hasContent: true });
} catch (err) {
console.error('paperSheet draw failed', err);
this.setData({ hasContent: false });
}
},
onSelectPaperType(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as PaperType | undefined;
if (!id || id === this.data.selectedPaperType) return;
this.setData({ selectedPaperType: id }, () => this.drawCanvas());
},
onSelectColor(e: WechatMiniprogram.TouchEvent) {
const value = e.currentTarget.dataset.value as string | undefined;
if (!value || value === this.data.selectedColor) return;
this.setData({ selectedColor: value }, () => this.drawCanvas());
},
onPreviewRefresh() {
this.drawCanvas();
},
onShare() {},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = [PAPER_SHEET_WORKSHEET_ID];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,76 @@
<nav-bar title="作业纸" />
<view class="ps-page">
<view class="ps-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="ps-section">
<view class="ps-section-header">
<text class="ps-section-title">纸张类型</text>
</view>
<view class="ps-type-grid">
<view
wx:for="{{paperTypeOptions}}"
wx:key="id"
class="ps-type-card {{selectedPaperType === item.id ? 'ps-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="ps-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectPaperType">
<text class="ps-type-card__name">{{item.name}}</text>
<text class="ps-type-card__desc">{{item.desc}}</text>
</view>
</view>
</view>
<view class="ps-section">
<view class="ps-section-header">
<text class="ps-section-title">颜色</text>
</view>
<view class="ps-color-list">
<view
wx:for="{{colorOptions}}"
wx:key="id"
class="ps-color-item {{selectedColor === item.value ? 'ps-color-item--active' : ''}}"
data-value="{{item.value}}"
hover-class="ps-color-item--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectColor">
<view
class="ps-color-dot"
style="background:{{item.value}}" />
<text class="ps-color-item__name">{{item.name}}</text>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -1,5 +1,8 @@
import type { DebugPublishMeta } from '../../utils/debugPublish'; import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish';
import { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from '../../config/worksheets/pinyin';
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS };
export type PinyinDictationMode = export type PinyinDictationMode =
| 'pinyin-tracing' | 'pinyin-tracing'
@@ -8,77 +11,6 @@ export type PinyinDictationMode =
| 'pinyin-dictation-v2' | 'pinyin-dictation-v2'
| 'pinyin-daily'; | 'pinyin-daily';
interface PinyinDictationWorksheetDefinition {
id: string; // 使用 string 以兼容动态生成的 ID
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS: PinyinDictationWorksheetDefinition[] =
[
{
id: 'pinyin-tracing',
icon: 'draw-o',
title: '拼音描红练习',
subtitle: '跟着描红学拼音字母',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 50,
},
{
id: 'pinyin-dictation',
icon: 'start-a',
title: '拼音默写练习',
subtitle: '空白格子默写拼音字母',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 51,
},
{
id: 'pinyin-tracing-v2',
icon: 'pen-draw',
title: '拼音描红 8 列',
subtitle: '跟写描红,声韵分块',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 52,
},
{
id: 'pinyin-dictation-v2',
icon: 'ABC-underline',
title: '拼音默写 8 列',
subtitle: '空白默写,声韵分块',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 53,
},
{
id: 'pinyin-daily',
icon: 'task-o',
title: '拼音每日打卡',
subtitle: '四宫格每日打卡练习',
ageMin: 5,
ageMax: 7,
difficulty: 2,
tags: ['拼音', '每日练习', '打卡'],
sortOrder: 54,
},
];
type PinyinDictationWorksheetRow = type PinyinDictationWorksheetRow =
(typeof PINYIN_DICTATION_WORKSHEET_DEFINITIONS)[number]; (typeof PINYIN_DICTATION_WORKSHEET_DEFINITIONS)[number];
@@ -235,9 +235,13 @@ page {
.ws-card__sort { .ws-card__sort {
flex: 1; flex: 1;
min-width: 0;
margin-left: 16rpx; margin-left: 16rpx;
font-size: 22rpx; font-size: 22rpx;
color: @text-gray; color: @text-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
// ── Build Button ── // ── Build Button ──
@@ -13,6 +13,8 @@ type WorksheetItem = {
tagsText: string; tagsText: string;
status: 'draft' | 'active' | 'hidden'; status: 'draft' | 'active' | 'hidden';
sortOrder: number; sortOrder: number;
downloads: number;
likes: number;
updatedAt: string; updatedAt: string;
// display helpers // display helpers
ageBand: string; ageBand: string;
@@ -48,9 +50,24 @@ const STATUS_CONFIG: Record<
string, string,
{ text: string; action: string; target: string; actionType: string } { text: string; action: string; target: string; actionType: string }
> = { > = {
draft: { text: '草稿', action: '激活', target: 'active', actionType: 'green' }, draft: {
active: { text: '线上', action: '下架', target: 'hidden', actionType: 'default' }, text: '草稿',
hidden: { text: '已隐藏', action: '恢复', target: 'draft', actionType: 'primary' }, action: '激活',
target: 'active',
actionType: 'green',
},
active: {
text: '线上',
action: '下架',
target: 'hidden',
actionType: 'default',
},
hidden: {
text: '已隐藏',
action: '恢复',
target: 'draft',
actionType: 'primary',
},
}; };
function formatWorksheet(raw: Record<string, unknown>): WorksheetItem { function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
@@ -70,9 +87,13 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
ageMax, ageMax,
difficulty, difficulty,
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [], tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
tagsText: Array.isArray(raw.tags) ? raw.tags.map(String).join('、') : '', tagsText: Array.isArray(raw.tags)
? raw.tags.map(String).join('、')
: '',
status: status as WorksheetItem['status'], status: status as WorksheetItem['status'],
sortOrder: Number(raw.sortOrder) || 0, sortOrder: Number(raw.sortOrder) || 0,
downloads: Number(raw.downloads) || 0,
likes: Number(raw.likes) || 0,
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '', updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
ageBand: `${ageMin}-${ageMax}`, ageBand: `${ageMin}-${ageMax}`,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础', difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
@@ -120,16 +141,21 @@ Page({
try { try {
const result = await callCloudFunction<WorksheetItem[]>( const result = await callCloudFunction<WorksheetItem[]>(
'worksheetsQuery', 'worksheetsQuery',
{ category: this.data.activeCategoryId }, {
category: this.data.activeCategoryId,
rawStats: true,
},
); );
if (!result.success) { if (!result.success) {
throw new Error(result.message || '查询失败'); throw new Error(result.message || '查询失败');
} }
const worksheets = (result.data || []).map((raw) => const worksheets = (result.data || [])
.map((raw) =>
formatWorksheet(raw as unknown as Record<string, unknown>), formatWorksheet(raw as unknown as Record<string, unknown>),
); )
.sort((a, b) => b.downloads - a.downloads);
this.setData({ this.setData({
loading: false, loading: false,
@@ -191,8 +217,7 @@ Page({
await this.loadWorksheets(); await this.loadWorksheets();
} catch (error) { } catch (error) {
wx.showToast({ wx.showToast({
title: title: error instanceof Error ? error.message : '操作失败',
error instanceof Error ? error.message : '操作失败',
icon: 'none', icon: 'none',
}); });
} finally { } finally {
@@ -77,6 +77,9 @@
<text>{{item.statusText}}</text> <text>{{item.statusText}}</text>
</view> </view>
<text class="ws-card__sort">排序 {{item.sortOrder}}</text> <text class="ws-card__sort">排序 {{item.sortOrder}}</text>
<text class="ws-card__sort"
>下载 {{item.downloads}} · 喜欢 {{item.likes}}</text
>
<toy-button <toy-button
type="{{item.actionType}}" type="{{item.actionType}}"
size="small" size="small"
+7
View File
@@ -7,6 +7,13 @@ type DebugEntry = {
}; };
const DEBUG_ENTRIES: DebugEntry[] = [ const DEBUG_ENTRIES: DebugEntry[] = [
{
id: 'unreleased-debug',
title: '未上线页面 Debug',
subtitle: '手动配置未发布页面的标题和路由,方便手机调试。',
icon: '🧪',
path: '/supportPages/unreleasedDebug/unreleasedDebug',
},
{ {
id: 'home-content', id: 'home-content',
title: '首页内容管理', title: '首页内容管理',
@@ -0,0 +1,28 @@
export interface UnreleasedPageEntry {
id: string;
title: string;
subtitle: string;
path: string;
}
export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
// 在这里手动添加未上线页面
{
id: 'papers',
title: '作业纸页面',
subtitle: '作业纸页面',
path: '/papersPages/paperSheet/paperSheet',
},
{
id: 'clock-reading',
title: '认识时钟',
subtitle: '看钟读时间,填写数字时刻',
path: '/mathPages/clockReading/clockReading',
},
{
id: 'clock-connect',
title: '时钟连线',
subtitle: '看钟表连对应时间,认识时刻',
path: '/mathPages/clockConnect/clockConnect',
},
];
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "未上线页面 Debug",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#F8F0E0",
"backgroundColor": "#F8F0E0"
}
@@ -0,0 +1,136 @@
@import '../../style/theme.less';
page {
min-height: 100%;
background: @bg-header;
}
.debug-page {
min-height: 100vh;
padding: 24rpx;
background: @bg-header;
box-sizing: border-box;
}
.debug-page__hero {
padding: 48rpx 40rpx;
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
}
.debug-page__hero-title {
display: block;
font-size: 40rpx;
font-weight: 800;
color: @text-title;
}
.debug-page__hero-desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.6;
color: @text-secondary;
}
.debug-page__section {
margin-top: 32rpx;
}
.debug-page__section-title {
display: block;
margin: 0 8rpx 20rpx;
font-size: 26rpx;
font-weight: 700;
color: @text-secondary;
}
.debug-page__rows {
display: flex;
flex-direction: column;
}
.debug-row + .debug-row {
margin-top: 24rpx;
}
.debug-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 34rpx 36rpx;
background: @bg-white;
border-radius: 999rpx;
border: 1rpx solid rgba(124, 118, 106, 0.12);
box-shadow: @shadow;
}
.debug-row__left {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
}
.debug-row__icon-bg {
width: 84rpx;
height: 84rpx;
border-radius: 50%;
background: fade(@brand, 18%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.debug-row__icon {
font-size: 36rpx;
line-height: 1;
}
.debug-row__content {
min-width: 0;
margin-left: 24rpx;
}
.debug-row__label {
display: block;
font-size: 30rpx;
font-weight: 700;
color: @text-title;
}
.debug-row__desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
color: @text-secondary;
}
.debug-row__chevron {
margin-left: 16rpx;
font-size: 40rpx;
font-weight: 300;
color: @text-gray;
}
.debug-page__empty {
padding: 48rpx 32rpx;
text-align: center;
font-size: 26rpx;
color: @text-secondary;
background: @bg-white;
border-radius: @radius-xl;
}
.debug-page__notice {
margin-top: 32rpx;
padding: 24rpx 28rpx;
border-radius: @radius;
background: fade(#ff8f1f, 12%);
color: #b06b1a;
font-size: 24rpx;
line-height: 1.6;
}
@@ -0,0 +1,33 @@
import { UNRELEASED_PAGE_ENTRIES } from './unreleasedDebug.config';
Page({
data: {
entries: UNRELEASED_PAGE_ENTRIES,
isDevEnv: true,
},
onLoad() {
const envVersion = wx.getAccountInfoSync().miniProgram.envVersion;
const isDevEnv = envVersion === 'develop';
this.setData({ isDevEnv });
if (!isDevEnv) {
wx.showToast({ title: '仅开发版可用', icon: 'none' });
}
},
onTapEntry(e: WechatMiniprogram.TouchEvent) {
if (!this.data.isDevEnv) return;
let path = e.currentTarget.dataset.path as string;
if (!path) return;
if (!path.startsWith('/')) {
path = `/${path}`;
}
wx.navigateTo({
url: path,
fail: () => {
wx.showToast({ title: '页面跳转失败', icon: 'none' });
},
});
},
});
@@ -0,0 +1,41 @@
<view class="debug-page">
<view class="debug-page__hero">
<text class="debug-page__hero-title">未上线页面 Debug</text>
<text class="debug-page__hero-desc">
手动配置未发布页面的标题和路由,方便在手机上调试。
</text>
</view>
<view class="debug-page__section">
<text class="debug-page__section-title"
>入口列表({{entries.length}}</text
>
<view class="debug-page__rows">
<view
wx:for="{{entries}}"
wx:key="id"
class="debug-row"
data-path="{{item.path}}"
bindtap="onTapEntry">
<view class="debug-row__left">
<view class="debug-row__icon-bg">
<text class="debug-row__icon">🧪</text>
</view>
<view class="debug-row__content">
<text class="debug-row__label">{{item.title}}</text>
<text class="debug-row__desc">{{item.subtitle}}</text>
</view>
</view>
<text class="debug-row__chevron"></text>
</view>
</view>
<view wx:if="{{entries.length === 0}}" class="debug-page__empty">
暂无入口,请在 unreleasedDebug.config.ts 中配置。
</view>
</view>
<view wx:if="{{!isDevEnv}}" class="debug-page__notice">
当前不是开发环境,此页仅供调试使用。
</view>
</view>
@@ -1,8 +1,15 @@
import { MOCK_SEED_COUNTS } from './mockLikes'; import { MOCK_SEED_COUNTS } from './mockLikes';
import { MATH_WORKSHEET_DEFINITIONS } from '../../mathPages/mathDraw/mathDraw.config'; import {
import { FOCUS_WORKSHEET_DEFINITIONS } from '../../focusPages/focusDraw/focusDraw.config'; MATH_WORKSHEET_DEFINITIONS,
import { LETTER_TRACING_WORKSHEET_DEFINITIONS } from '../../englishPages/letterTracing/letterTracing.config'; CLOCK_READING_WORKSHEET_DEFINITIONS,
import { PINYIN_DICTATION_MODE_OPTIONS } from '../../pinyinPages/pinyinDictation/pinyinDictation.config'; CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
FOCUS_WORKSHEET_DEFINITIONS,
LETTER_TRACING_WORKSHEET_DEFINITIONS,
PINYIN_DICTATION_WORKSHEET_DEFINITIONS,
PEN_CONTROL_WORKSHEET_DEFINITIONS,
WORD_COLORING_WORKSHEET_DEFINITIONS,
type WorksheetDefinition,
} from '../../config/worksheets';
type CloudFunctionResult<T> = { type CloudFunctionResult<T> = {
success?: boolean; success?: boolean;
@@ -25,13 +32,6 @@ async function callCloudFunction<T>(
return response.result || {}; return response.result || {};
} }
type LocalDef = {
id: string;
ageMin: number;
ageMax: number;
tags: readonly string[];
};
function inferGradeFromAge(ageMin: number, ageMax: number): number { function inferGradeFromAge(ageMin: number, ageMax: number): number {
const centerAge = Math.round((ageMin + ageMax) / 2); const centerAge = Math.round((ageMin + ageMax) / 2);
const ageGradeMap: Record<number, number> = { const ageGradeMap: Record<number, number> = {
@@ -53,30 +53,46 @@ function inferGradeFromAge(ageMin: number, ageMax: number): number {
type ConfigSource = { type ConfigSource = {
label: string; label: string;
data: readonly LocalDef[]; data: ReadonlyArray<WorksheetDefinition>;
}; };
const CONFIG_SOURCES: ConfigSource[] = [ const CONFIG_SOURCES: ConfigSource[] = [
{ {
label: 'math', label: 'math',
data: MATH_WORKSHEET_DEFINITIONS as unknown as LocalDef[], data: MATH_WORKSHEET_DEFINITIONS,
}, },
{ {
label: 'focus', label: 'focus',
data: FOCUS_WORKSHEET_DEFINITIONS as unknown as LocalDef[], data: FOCUS_WORKSHEET_DEFINITIONS,
}, },
{ {
label: 'letterTracing', label: 'letterTracing',
data: LETTER_TRACING_WORKSHEET_DEFINITIONS as unknown as LocalDef[], data: LETTER_TRACING_WORKSHEET_DEFINITIONS,
}, },
{ {
label: 'pinyin', label: 'pinyin',
data: PINYIN_DICTATION_MODE_OPTIONS as unknown as LocalDef[], data: PINYIN_DICTATION_WORKSHEET_DEFINITIONS,
},
{
label: 'penControl',
data: PEN_CONTROL_WORKSHEET_DEFINITIONS,
},
{
label: 'wordColoring',
data: WORD_COLORING_WORKSHEET_DEFINITIONS,
},
{
label: 'clockReading',
data: CLOCK_READING_WORKSHEET_DEFINITIONS,
},
{
label: 'clockConnect',
data: CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
}, },
]; ];
function isLocalDef(value: unknown): value is LocalDef { function isWorksheetDefinition(value: unknown): value is WorksheetDefinition {
const row = value as Partial<LocalDef>; const row = value as Partial<WorksheetDefinition>;
return ( return (
!!row && !!row &&
typeof row.id === 'string' && typeof row.id === 'string' &&
@@ -86,22 +102,24 @@ function isLocalDef(value: unknown): value is LocalDef {
); );
} }
async function loadConfigSource(source: ConfigSource): Promise<LocalDef[]> { async function loadConfigSource(
source: ConfigSource,
): Promise<WorksheetDefinition[]> {
const rows = source.data; const rows = source.data;
if (!Array.isArray(rows) || !rows.every(isLocalDef)) { if (!Array.isArray(rows) || !rows.every(isWorksheetDefinition)) {
throw new Error(`${source.label} 配置格式不正确`); throw new Error(`${source.label} 配置格式不正确`);
} }
return [...rows]; return [...rows];
} }
async function collectAll(): Promise<LocalDef[]> { async function collectAll(): Promise<WorksheetDefinition[]> {
const groups = await Promise.all(CONFIG_SOURCES.map(loadConfigSource)); const groups = await Promise.all(CONFIG_SOURCES.map(loadConfigSource));
return groups.flat(); return groups.flat();
} }
function assertUniqueIds(rows: LocalDef[]) { function assertUniqueIds(rows: WorksheetDefinition[]) {
const set = new Set<string>(); const set = new Set<string>();
for (const r of rows) { for (const r of rows) {
if (set.has(r.id)) { if (set.has(r.id)) {
+9 -2
View File
@@ -24,12 +24,19 @@
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "chinesePages/penControlSheet/penControlSheet", "name": "chinesePages/wordTestSheet/wordTestSheet",
"pathName": "chinesePages/penControlSheet/penControlSheet", "pathName": "chinesePages/wordTestSheet/wordTestSheet",
"query": "", "query": "",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
}, },
{
"name": "chinesePages/penControlSheet/penControlSheet",
"pathName": "chinesePages/penControlSheet/penControlSheet",
"query": "",
"launchMode": "default",
"scene": null
},
{ {
"name": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix", "name": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix",
"pathName": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix", "pathName": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix",