diff --git a/docs/运营与推广/名称选择.md b/docs/运营与推广/名称选择.md new file mode 100644 index 0000000..4a908e2 --- /dev/null +++ b/docs/运营与推广/名称选择.md @@ -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个精准关键词(如:幼小衔接、练习纸、数学练习、拼音练习、打印练习题、口算题、幼儿启蒙、描红字帖、专注力训练、学前练习)。 diff --git a/miniprogram/app.json b/miniprogram/app.json index 9bbafe0..fe14b8f 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -27,7 +27,7 @@ { "root": "mathPages", "name": "mathPages", - "pages": ["mathDraw/mathDraw", "clockReading/clockReading"], + "pages": ["mathDraw/mathDraw", "clockReading/clockReading", "clockConnect/clockConnect"], "independent": false }, { diff --git a/miniprogram/config/worksheets/clockReading.ts b/miniprogram/config/worksheets/clock.ts similarity index 52% rename from miniprogram/config/worksheets/clockReading.ts rename to miniprogram/config/worksheets/clock.ts index 9a9601a..35ff223 100644 --- a/miniprogram/config/worksheets/clockReading.ts +++ b/miniprogram/config/worksheets/clock.ts @@ -13,3 +13,17 @@ export const CLOCK_READING_WORKSHEET_DEFINITIONS = [ sortOrder: 127, }, ] as const satisfies ReadonlyArray; + +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; diff --git a/miniprogram/config/worksheets/index.ts b/miniprogram/config/worksheets/index.ts index c8b082b..a79284e 100644 --- a/miniprogram/config/worksheets/index.ts +++ b/miniprogram/config/worksheets/index.ts @@ -1,6 +1,9 @@ export type { WorksheetDefinition } from './types'; export { MATH_WORKSHEET_DEFINITIONS } from './math'; -export { CLOCK_READING_WORKSHEET_DEFINITIONS } from './clockReading'; +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'; diff --git a/miniprogram/mathPages/clockConnect/clockConnect.config.ts b/miniprogram/mathPages/clockConnect/clockConnect.config.ts new file mode 100644 index 0000000..a7e226a --- /dev/null +++ b/miniprogram/mathPages/clockConnect/clockConnect.config.ts @@ -0,0 +1,54 @@ +import type { DebugPublishMeta } from '../../utils/debugPublish'; +import { inferGradeFromAge } from '../../utils/debugPublish'; +import { CLOCK_CONNECT_WORKSHEET_DEFINITIONS } from '../../config/worksheets/clock'; +import { + CLOCK_CONNECT_TIME_MODE_OPTIONS, + type ClockConnectTimeMode, +} from './generators/clock-connect-generator'; + +export { CLOCK_CONNECT_WORKSHEET_DEFINITIONS }; + +export type { ClockConnectTimeMode }; + +type ClockConnectWorksheetRow = + (typeof CLOCK_CONNECT_WORKSHEET_DEFINITIONS)[number]; + +const CLOCK_CONNECT_WORKSHEET_BY_ID = Object.fromEntries( + CLOCK_CONNECT_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]), +) as Record; + +export const CLOCK_CONNECT_MODE_OPTIONS = CLOCK_CONNECT_WORKSHEET_DEFINITIONS; + +export { CLOCK_CONNECT_TIME_MODE_OPTIONS }; + +export function getModeInfo(id: string) { + const m = CLOCK_CONNECT_WORKSHEET_BY_ID[id]; + return m ? { title: m.title, desc: m.subtitle } : undefined; +} + +export function isValidMode(id: string): boolean { + return id in CLOCK_CONNECT_WORKSHEET_BY_ID; +} + +export function getPublishMetaByMode(id: string): DebugPublishMeta | null { + const m = CLOCK_CONNECT_WORKSHEET_BY_ID[id]; + if (!m) return null; + return { + id: m.id, + title: m.title, + subtitle: m.subtitle, + category: 'math', + subcategory: 'clock-connect', + path: `/mathPages/clockConnect/clockConnect?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/clockConnect/clockConnect.json b/miniprogram/mathPages/clockConnect/clockConnect.json new file mode 100644 index 0000000..42a8a65 --- /dev/null +++ b/miniprogram/mathPages/clockConnect/clockConnect.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/clockConnect/clockConnect.less b/miniprogram/mathPages/clockConnect/clockConnect.less new file mode 100644 index 0000000..36fdd00 --- /dev/null +++ b/miniprogram/mathPages/clockConnect/clockConnect.less @@ -0,0 +1,96 @@ +@import '../../style/theme.less'; + +page { + background-color: @bg-page; +} + +.cc-page { + min-height: 100vh; + padding: 0 @page-padding-x; + padding-bottom: calc(200rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} + +.cc-main { + padding-top: 24rpx; + display: flex; + flex-direction: column; + gap: 64rpx; +} + +.cc-section { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.cc-section-title { + font-size: 32rpx; + font-weight: 700; + color: #6d3b00; + padding-left: 8rpx; +} + +.cc-chip-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24rpx; +} + +.cc-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); +} + +.cc-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); +} + +.cc-chip--active { + background: @brand; + color: @text-selected-btn; + font-weight: 700; + box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08); +} + +.cc-chip__text { + display: flex; + flex-direction: column; + align-items: center; + gap: 4rpx; + width: 100%; +} + +.cc-chip__label { + font-size: 28rpx; + font-weight: 700; + line-height: 36rpx; +} + +.cc-chip__subtitle { + font-size: 20rpx; + font-weight: 500; + line-height: 28rpx; + opacity: 0.75; + text-align: center; +} + +.cc-chip--active .cc-chip__subtitle, +.cc-chip--pressed .cc-chip__subtitle { + opacity: 0.85; +} diff --git a/miniprogram/mathPages/clockConnect/clockConnect.ts b/miniprogram/mathPages/clockConnect/clockConnect.ts new file mode 100644 index 0000000..8691247 --- /dev/null +++ b/miniprogram/mathPages/clockConnect/clockConnect.ts @@ -0,0 +1,169 @@ +import ClockConnectDraw from './draw/clockConnectDraw'; +import { + buildClockConnectData, + type ClockConnectTimeMode, +} from './generators/clock-connect-generator'; +import { createPage, type CanvasDataState } from '../../base/pageMixin'; +import { defaultShareConfig } from '../../config/config'; +import { + getModeInfo, + getPublishMetaByMode, + isValidMode, + CLOCK_CONNECT_TIME_MODE_OPTIONS, +} from './clockConnect.config'; +import type { DebugPublishMeta } from '../../utils/debugPublish'; +import { + addFavorite, + removeFavorite, + batchCheckFavorited, +} from '../../utils/favorites'; + +const pageInfoLookup = getModeInfo; +const WORKSHEET_ID = 'clock-connect'; + +type PageData = CanvasDataState & { + worksheetId: string; + timeMode: ClockConnectTimeMode; + timeModeOptions: typeof CLOCK_CONNECT_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 ClockConnectDraw | null, + _favoritedMap: {} as Record, + + data: { + pageTitle: '时钟连线', + functionId: WORKSHEET_ID, + hasContent: false, + showShareDialog: false, + worksheetId: WORKSHEET_ID, + timeMode: 'whole-hour' as ClockConnectTimeMode, + timeModeOptions: CLOCK_CONNECT_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 + | ClockConnectTimeMode + | 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 ClockConnectDraw(canvas, ctx, options), + drawServiceOptions: { + title: this.data.pageTitle, + }, + onCanvasReady: () => { + this.drawCanvas(); + }, + }); + }, + + async drawCanvas() { + if (!this.ctx || !this.drawService) return; + try { + const data = buildClockConnectData(this.data.timeMode); + await (this.drawService as ClockConnectDraw).draw(data); + this.setData({ hasContent: true }); + } catch (e) { + console.error('clockConnect 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/clockConnect/clockConnect.wxml b/miniprogram/mathPages/clockConnect/clockConnect.wxml new file mode 100644 index 0000000..e2fb39f --- /dev/null +++ b/miniprogram/mathPages/clockConnect/clockConnect.wxml @@ -0,0 +1,54 @@ + + + + + + + + 时刻类型 + + + + {{item.label}} + {{item.subtitle}} + + + + + + + + + + + + diff --git a/miniprogram/mathPages/clockConnect/draw/clockConnectDraw.ts b/miniprogram/mathPages/clockConnect/draw/clockConnectDraw.ts new file mode 100644 index 0000000..b08e4d2 --- /dev/null +++ b/miniprogram/mathPages/clockConnect/draw/clockConnectDraw.ts @@ -0,0 +1,152 @@ +import { BaseDrawService } from '../../../core/draw/baseDraw'; +import type { + ClockConnectData, + ClockConnectProblem, +} from '../generators/clock-connect-generator'; +import { drawAnalogClock } from '../../clockReading/draw/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(); + } +} diff --git a/miniprogram/mathPages/clockConnect/generators/clock-connect-generator.ts b/miniprogram/mathPages/clockConnect/generators/clock-connect-generator.ts new file mode 100644 index 0000000..81af1a7 --- /dev/null +++ b/miniprogram/mathPages/clockConnect/generators/clock-connect-generator.ts @@ -0,0 +1,95 @@ +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, + readonly number[] +> = { + 'whole-hour': [0], + 'half-hour': [30], + 'quarter-hour': [15, 45], +}; + +function pickMinute(timeMode: ClockConnectTimeMode): number { + if (timeMode === 'random') { + const options = [0, 15, 30, 45]; + return options[Math.floor(Math.random() * options.length)]; + } + 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(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(); + 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: '整点/半点/刻钟' }, +]; diff --git a/miniprogram/mathPages/clockReading/clockReading.config.ts b/miniprogram/mathPages/clockReading/clockReading.config.ts index 48229e6..6eb4cea 100644 --- a/miniprogram/mathPages/clockReading/clockReading.config.ts +++ b/miniprogram/mathPages/clockReading/clockReading.config.ts @@ -1,6 +1,6 @@ import type { DebugPublishMeta } from '../../utils/debugPublish'; import { inferGradeFromAge } from '../../utils/debugPublish'; -import { CLOCK_READING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/clockReading'; +import { CLOCK_READING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/clock'; import { CLOCK_TIME_MODE_OPTIONS, type ClockReadingTimeMode, diff --git a/miniprogram/pages/home/home.data.ts b/miniprogram/pages/home/home.data.ts index f35a850..1be93aa 100644 --- a/miniprogram/pages/home/home.data.ts +++ b/miniprogram/pages/home/home.data.ts @@ -72,7 +72,7 @@ export const HOME_NOTICES: HomeNotice[] = [ { id: 'welcome', text: '欢迎来到 涂鸦丫,趣味练习纸任你选', - path: '/chinesePages/wordTestSheet/wordTestSheet', + path: '/mathPages/clockReading/clockReading', }, { id: `update-${APP_VERSION}`, diff --git a/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts b/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts index bdf9e16..3e58b37 100644 --- a/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts +++ b/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts @@ -14,15 +14,9 @@ export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [ path: '/mathPages/clockReading/clockReading', }, { - id: 'pen-control-sheet', - title: '控笔', - subtitle: '控笔练习', - path: '/chinesePages/penControlSheet/penControlSheet', - }, - { - id: 'word-test-sheet', - title: '测字表', - subtitle: '测字表', - path: '/chinesePages/wordTestSheet/wordTestSheet', + id: 'clock-connect', + title: '时钟连线', + subtitle: '看钟表连对应时间,认识时刻', + path: '/mathPages/clockConnect/clockConnect', }, ]; diff --git a/miniprogram/supportPages/worksheetSync/worksheetSync.ts b/miniprogram/supportPages/worksheetSync/worksheetSync.ts index cbba998..715efec 100644 --- a/miniprogram/supportPages/worksheetSync/worksheetSync.ts +++ b/miniprogram/supportPages/worksheetSync/worksheetSync.ts @@ -1,6 +1,7 @@ import { MOCK_SEED_COUNTS } from './mockLikes'; import { MATH_WORKSHEET_DEFINITIONS, + CLOCK_CONNECT_WORKSHEET_DEFINITIONS, FOCUS_WORKSHEET_DEFINITIONS, LETTER_TRACING_WORKSHEET_DEFINITIONS, PINYIN_DICTATION_WORKSHEET_DEFINITIONS, @@ -79,6 +80,10 @@ const CONFIG_SOURCES: ConfigSource[] = [ label: 'wordColoring', data: WORD_COLORING_WORKSHEET_DEFINITIONS, }, + { + label: 'clockConnect', + data: CLOCK_CONNECT_WORKSHEET_DEFINITIONS, + }, ]; function isWorksheetDefinition(value: unknown): value is WorksheetDefinition {