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