feat: 新增认识时钟绘制
This commit is contained in:
@@ -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 })`
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"root": "mathPages",
|
"root": "mathPages",
|
||||||
"name": "mathPages",
|
"name": "mathPages",
|
||||||
"pages": ["mathDraw/mathDraw"],
|
"pages": ["mathDraw/mathDraw", "clockReading/clockReading"],
|
||||||
"independent": false
|
"independent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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>;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export type { WorksheetDefinition } from './types';
|
export type { WorksheetDefinition } from './types';
|
||||||
export { MATH_WORKSHEET_DEFINITIONS } from './math';
|
export { MATH_WORKSHEET_DEFINITIONS } from './math';
|
||||||
|
export { CLOCK_READING_WORKSHEET_DEFINITIONS } from './clockReading';
|
||||||
export { FOCUS_WORKSHEET_DEFINITIONS } from './focus';
|
export { FOCUS_WORKSHEET_DEFINITIONS } from './focus';
|
||||||
export { LETTER_TRACING_WORKSHEET_DEFINITIONS } from './letterTracing';
|
export { LETTER_TRACING_WORKSHEET_DEFINITIONS } from './letterTracing';
|
||||||
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin';
|
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin';
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||||
|
import { inferGradeFromAge } from '../../utils/debugPublish';
|
||||||
|
import { CLOCK_READING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/clockReading';
|
||||||
|
import {
|
||||||
|
CLOCK_TIME_MODE_OPTIONS,
|
||||||
|
type ClockReadingTimeMode,
|
||||||
|
} from './generators/clock-generator';
|
||||||
|
|
||||||
|
export { CLOCK_READING_WORKSHEET_DEFINITIONS };
|
||||||
|
|
||||||
|
export type { ClockReadingTimeMode };
|
||||||
|
|
||||||
|
type ClockReadingWorksheetRow =
|
||||||
|
(typeof CLOCK_READING_WORKSHEET_DEFINITIONS)[number];
|
||||||
|
|
||||||
|
const CLOCK_READING_WORKSHEET_BY_ID = Object.fromEntries(
|
||||||
|
CLOCK_READING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
|
||||||
|
) as Record<string, ClockReadingWorksheetRow>;
|
||||||
|
|
||||||
|
export const CLOCK_READING_MODE_OPTIONS = CLOCK_READING_WORKSHEET_DEFINITIONS;
|
||||||
|
|
||||||
|
export { CLOCK_TIME_MODE_OPTIONS };
|
||||||
|
|
||||||
|
export function getModeInfo(id: string) {
|
||||||
|
const m = CLOCK_READING_WORKSHEET_BY_ID[id];
|
||||||
|
return m ? { title: m.title, desc: m.subtitle } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidMode(id: string): boolean {
|
||||||
|
return id in CLOCK_READING_WORKSHEET_BY_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
|
||||||
|
const m = CLOCK_READING_WORKSHEET_BY_ID[id];
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
subtitle: m.subtitle,
|
||||||
|
category: 'math',
|
||||||
|
subcategory: 'clock-reading',
|
||||||
|
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,169 @@
|
|||||||
|
import ClockReadingDraw from './draw/clockReadingDraw';
|
||||||
|
import {
|
||||||
|
buildClockReadingData,
|
||||||
|
type ClockReadingTimeMode,
|
||||||
|
} from './generators/clock-generator';
|
||||||
|
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
||||||
|
import { defaultShareConfig } from '../../config/config';
|
||||||
|
import {
|
||||||
|
getModeInfo,
|
||||||
|
getPublishMetaByMode,
|
||||||
|
isValidMode,
|
||||||
|
CLOCK_TIME_MODE_OPTIONS,
|
||||||
|
} from './clockReading.config';
|
||||||
|
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||||
|
import {
|
||||||
|
addFavorite,
|
||||||
|
removeFavorite,
|
||||||
|
batchCheckFavorited,
|
||||||
|
} from '../../utils/favorites';
|
||||||
|
|
||||||
|
const pageInfoLookup = getModeInfo;
|
||||||
|
const WORKSHEET_ID = 'clock-reading';
|
||||||
|
|
||||||
|
type PageData = CanvasDataState & {
|
||||||
|
worksheetId: string;
|
||||||
|
timeMode: ClockReadingTimeMode;
|
||||||
|
timeModeOptions: typeof CLOCK_TIME_MODE_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 ClockReadingDraw | null,
|
||||||
|
_favoritedMap: {} as Record<string, boolean>,
|
||||||
|
|
||||||
|
data: {
|
||||||
|
pageTitle: '认识钟表',
|
||||||
|
functionId: WORKSHEET_ID,
|
||||||
|
hasContent: false,
|
||||||
|
showShareDialog: false,
|
||||||
|
worksheetId: WORKSHEET_ID,
|
||||||
|
timeMode: 'random' as ClockReadingTimeMode,
|
||||||
|
timeModeOptions: CLOCK_TIME_MODE_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
|
||||||
|
: WORKSHEET_ID;
|
||||||
|
this.syncDebugPublishEnv();
|
||||||
|
this.applyWorksheet(worksheetId);
|
||||||
|
this.loadFavoritedMap();
|
||||||
|
},
|
||||||
|
|
||||||
|
onSelectTimeMode(e: WechatMiniprogram.TouchEvent) {
|
||||||
|
const mode = e.currentTarget.dataset.mode as
|
||||||
|
| ClockReadingTimeMode
|
||||||
|
| undefined;
|
||||||
|
if (!mode || mode === this.data.timeMode) return;
|
||||||
|
this.setData({ timeMode: mode }, () => this.drawCanvas());
|
||||||
|
},
|
||||||
|
|
||||||
|
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
|
||||||
|
this.initCanvasFromComponent(e.detail, {
|
||||||
|
createDrawService: (
|
||||||
|
canvas: Canvas,
|
||||||
|
ctx: RenderingContext,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
) => new ClockReadingDraw(canvas, ctx, options),
|
||||||
|
drawServiceOptions: {
|
||||||
|
title: this.data.pageTitle,
|
||||||
|
},
|
||||||
|
onCanvasReady: () => {
|
||||||
|
this.drawCanvas();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async drawCanvas() {
|
||||||
|
if (!this.ctx || !this.drawService) return;
|
||||||
|
try {
|
||||||
|
const data = buildClockReadingData(this.data.timeMode);
|
||||||
|
await (this.drawService as ClockReadingDraw).draw(data);
|
||||||
|
this.setData({ hasContent: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('clockReading 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 = [WORKSHEET_ID];
|
||||||
|
this._favoritedMap = await batchCheckFavorited(ids);
|
||||||
|
if (this._favoritedMap?.[WORKSHEET_ID]) {
|
||||||
|
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,54 @@
|
|||||||
|
<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>
|
||||||
|
</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,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,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' },
|
||||||
|
];
|
||||||
@@ -369,6 +369,16 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
|
|||||||
path: '/mathPages/mathDraw/mathDraw?id=multiplication-table',
|
path: '/mathPages/mathDraw/mathDraw?id=multiplication-table',
|
||||||
available: true,
|
available: true,
|
||||||
}),
|
}),
|
||||||
|
item({
|
||||||
|
id: 'clock-reading',
|
||||||
|
title: '认识钟表',
|
||||||
|
subtitle: '看钟读时间,填写数字时刻',
|
||||||
|
icon: '🕐',
|
||||||
|
ageBand: age(4, 7),
|
||||||
|
difficulty: 1,
|
||||||
|
path: '/mathPages/clockReading/clockReading?id=clock-reading',
|
||||||
|
available: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
pinyin: [
|
pinyin: [
|
||||||
item({
|
item({
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export interface UnreleasedPageEntry {
|
|||||||
|
|
||||||
export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
|
export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
|
||||||
// 在这里手动添加未上线页面
|
// 在这里手动添加未上线页面
|
||||||
|
{
|
||||||
|
id: 'clock-reading',
|
||||||
|
title: '认识时钟',
|
||||||
|
subtitle: '看钟读时间,填写数字时刻',
|
||||||
|
path: '/mathPages/clockReading/clockReading',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pen-control-sheet',
|
id: 'pen-control-sheet',
|
||||||
title: '控笔',
|
title: '控笔',
|
||||||
|
|||||||
Reference in New Issue
Block a user