diff --git a/docs/AI生成题目方案.md b/docs/AI生成题目方案.md new file mode 100644 index 0000000..b005c93 --- /dev/null +++ b/docs/AI生成题目方案.md @@ -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 灰度,观察家长真实输入行为与转化数据。 \ No newline at end of file diff --git a/docs/生成方案/认识钟表设计方案.md b/docs/生成方案/认识钟表设计方案.md new file mode 100644 index 0000000..9276053 --- /dev/null +++ b/docs/生成方案/认识钟表设计方案.md @@ -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 })` diff --git a/miniprogram/app.json b/miniprogram/app.json index 14148f4..9bbafe0 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -27,7 +27,7 @@ { "root": "mathPages", "name": "mathPages", - "pages": ["mathDraw/mathDraw"], + "pages": ["mathDraw/mathDraw", "clockReading/clockReading"], "independent": false }, { diff --git a/miniprogram/config/worksheets/clockReading.ts b/miniprogram/config/worksheets/clockReading.ts new file mode 100644 index 0000000..9a9601a --- /dev/null +++ b/miniprogram/config/worksheets/clockReading.ts @@ -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; diff --git a/miniprogram/config/worksheets/index.ts b/miniprogram/config/worksheets/index.ts index 35b2925..c8b082b 100644 --- a/miniprogram/config/worksheets/index.ts +++ b/miniprogram/config/worksheets/index.ts @@ -1,5 +1,6 @@ export type { WorksheetDefinition } from './types'; export { MATH_WORKSHEET_DEFINITIONS } from './math'; +export { CLOCK_READING_WORKSHEET_DEFINITIONS } from './clockReading'; export { FOCUS_WORKSHEET_DEFINITIONS } from './focus'; export { LETTER_TRACING_WORKSHEET_DEFINITIONS } from './letterTracing'; export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin'; diff --git a/miniprogram/mathPages/clockReading/clockReading.config.ts b/miniprogram/mathPages/clockReading/clockReading.config.ts new file mode 100644 index 0000000..48229e6 --- /dev/null +++ b/miniprogram/mathPages/clockReading/clockReading.config.ts @@ -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; + +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', + }; +} diff --git a/miniprogram/mathPages/clockReading/clockReading.json b/miniprogram/mathPages/clockReading/clockReading.json new file mode 100644 index 0000000..e2c6515 --- /dev/null +++ b/miniprogram/mathPages/clockReading/clockReading.json @@ -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" + } +} diff --git a/miniprogram/mathPages/clockReading/clockReading.less b/miniprogram/mathPages/clockReading/clockReading.less new file mode 100644 index 0000000..a6732a0 --- /dev/null +++ b/miniprogram/mathPages/clockReading/clockReading.less @@ -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; +} diff --git a/miniprogram/mathPages/clockReading/clockReading.ts b/miniprogram/mathPages/clockReading/clockReading.ts new file mode 100644 index 0000000..5c2ff38 --- /dev/null +++ b/miniprogram/mathPages/clockReading/clockReading.ts @@ -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, + + 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, + ) => 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, + }, +); diff --git a/miniprogram/mathPages/clockReading/clockReading.wxml b/miniprogram/mathPages/clockReading/clockReading.wxml new file mode 100644 index 0000000..407c00b --- /dev/null +++ b/miniprogram/mathPages/clockReading/clockReading.wxml @@ -0,0 +1,54 @@ + + + + + + + + 时刻类型 + + + + {{item.label}} + {{item.subtitle}} + + + + + + + + + + + + diff --git a/miniprogram/mathPages/clockReading/draw/clockReadingDraw.ts b/miniprogram/mathPages/clockReading/draw/clockReadingDraw.ts new file mode 100644 index 0000000..eb6d4ca --- /dev/null +++ b/miniprogram/mathPages/clockReading/draw/clockReadingDraw.ts @@ -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); + } + } +} diff --git a/miniprogram/mathPages/clockReading/draw/drawAnalogClock.ts b/miniprogram/mathPages/clockReading/draw/drawAnalogClock.ts new file mode 100644 index 0000000..d1f15d5 --- /dev/null +++ b/miniprogram/mathPages/clockReading/draw/drawAnalogClock.ts @@ -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(); +} diff --git a/miniprogram/mathPages/clockReading/generators/clock-generator.ts b/miniprogram/mathPages/clockReading/generators/clock-generator.ts new file mode 100644 index 0000000..613b22d --- /dev/null +++ b/miniprogram/mathPages/clockReading/generators/clock-generator.ts @@ -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, + 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(); + 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' }, +]; diff --git a/miniprogram/pages/category/category.data.ts b/miniprogram/pages/category/category.data.ts index 770301b..766d036 100644 --- a/miniprogram/pages/category/category.data.ts +++ b/miniprogram/pages/category/category.data.ts @@ -369,6 +369,16 @@ const CATEGORY_ITEMS_BY_ID: Record = { 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({ diff --git a/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts b/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts index 0353f1d..bdf9e16 100644 --- a/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts +++ b/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts @@ -7,6 +7,12 @@ export interface UnreleasedPageEntry { export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [ // 在这里手动添加未上线页面 + { + id: 'clock-reading', + title: '认识时钟', + subtitle: '看钟读时间,填写数字时刻', + path: '/mathPages/clockReading/clockReading', + }, { id: 'pen-control-sheet', title: '控笔',