feat: 开发时钟连线
This commit is contained in:
@@ -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个精准关键词(如:幼小衔接、练习纸、数学练习、拼音练习、打印练习题、口算题、幼儿启蒙、描红字帖、专注力训练、学前练习)。
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"root": "mathPages",
|
"root": "mathPages",
|
||||||
"name": "mathPages",
|
"name": "mathPages",
|
||||||
"pages": ["mathDraw/mathDraw", "clockReading/clockReading"],
|
"pages": ["mathDraw/mathDraw", "clockReading/clockReading", "clockConnect/clockConnect"],
|
||||||
"independent": false
|
"independent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+14
@@ -13,3 +13,17 @@ export const CLOCK_READING_WORKSHEET_DEFINITIONS = [
|
|||||||
sortOrder: 127,
|
sortOrder: 127,
|
||||||
},
|
},
|
||||||
] as const satisfies ReadonlyArray<WorksheetDefinition>;
|
] as const satisfies ReadonlyArray<WorksheetDefinition>;
|
||||||
|
|
||||||
|
export const CLOCK_CONNECT_WORKSHEET_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: 'clock-connect',
|
||||||
|
icon: 'clock',
|
||||||
|
title: '时钟连线',
|
||||||
|
subtitle: '看钟表连对应时间,认识时刻',
|
||||||
|
ageMin: 4,
|
||||||
|
ageMax: 7,
|
||||||
|
difficulty: 2,
|
||||||
|
tags: ['数学', '钟表', '时间', '连线'],
|
||||||
|
sortOrder: 128,
|
||||||
|
},
|
||||||
|
] as const satisfies ReadonlyArray<WorksheetDefinition>;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
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 {
|
||||||
|
CLOCK_READING_WORKSHEET_DEFINITIONS,
|
||||||
|
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
|
||||||
|
} from './clock';
|
||||||
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_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<string, ClockConnectWorksheetRow>;
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -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<string, boolean>,
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
) => 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<nav-bar title="时钟连线" />
|
||||||
|
|
||||||
|
<view class="cc-page">
|
||||||
|
<view class="cc-main">
|
||||||
|
<preview-card
|
||||||
|
id="previewCard"
|
||||||
|
showRefresh="{{true}}"
|
||||||
|
showFavorite="{{true}}"
|
||||||
|
favorited="{{isPreviewFavorite}}"
|
||||||
|
bind:canvas-ready="onCanvasReady"
|
||||||
|
bind:refresh="onPreviewRefresh"
|
||||||
|
bind:favorite="onPreviewFavorite" />
|
||||||
|
|
||||||
|
<view class="cc-section">
|
||||||
|
<text class="cc-section-title">时刻类型</text>
|
||||||
|
<view class="cc-chip-row">
|
||||||
|
<view
|
||||||
|
wx:for="{{timeModeOptions}}"
|
||||||
|
wx:key="id"
|
||||||
|
class="cc-chip {{timeMode === item.id ? 'cc-chip--active' : ''}}"
|
||||||
|
hover-class="cc-chip--pressed"
|
||||||
|
hover-start-time="0"
|
||||||
|
hover-stay-time="70"
|
||||||
|
data-mode="{{item.id}}"
|
||||||
|
bind:tap="onSelectTimeMode">
|
||||||
|
<view class="cc-chip__text">
|
||||||
|
<text class="cc-chip__label">{{item.label}}</text>
|
||||||
|
<text class="cc-chip__subtitle">{{item.subtitle}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<preview-footer-actions
|
||||||
|
disabled="{{!hasContent}}"
|
||||||
|
bind:primary="exportToPrint"
|
||||||
|
bind:secondary="onShare" />
|
||||||
|
|
||||||
|
<debug-publish-tools
|
||||||
|
wx:if="{{isDevEnv && hasContent}}"
|
||||||
|
id="debugPublishTools"
|
||||||
|
visible="{{debugPublishVisible}}"
|
||||||
|
loading="{{debugPublishLoading}}"
|
||||||
|
meta="{{debugPublishMeta}}"
|
||||||
|
bind:open="onOpenDebugPublish"
|
||||||
|
bind:close="onCloseDebugPublish"
|
||||||
|
bind:confirm="onConfirmDebugPublish" />
|
||||||
|
|
||||||
|
<share-guide-popup
|
||||||
|
show="{{showShareDialog}}"
|
||||||
|
bind:onClose="onCloseShareDialog"
|
||||||
|
bind:onShareSuccess="onShareSuccess" />
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||||
|
import type {
|
||||||
|
ClockConnectData,
|
||||||
|
ClockConnectProblem,
|
||||||
|
} from '../generators/clock-connect-generator';
|
||||||
|
import { drawAnalogClock } from '../../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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClockConnectTimeMode, 'random'>,
|
||||||
|
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<T>(arr: T[]): T[] {
|
||||||
|
const result = [...arr];
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[result[i], result[j]] = [result[j], result[i]];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一页 6 道时钟连线题,同一页内时刻不重复 */
|
||||||
|
export function generateClockConnectProblems(
|
||||||
|
timeMode: ClockConnectTimeMode,
|
||||||
|
): ClockConnectProblem[] {
|
||||||
|
const used = new Set<string>();
|
||||||
|
const problems: ClockConnectProblem[] = [];
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (problems.length < CLOCK_CONNECT_PROBLEM_COUNT && guard < 500) {
|
||||||
|
guard += 1;
|
||||||
|
const hour = Math.floor(Math.random() * 12) + 1;
|
||||||
|
const minute = pickMinute(timeMode);
|
||||||
|
const key = `${hour}:${minute}`;
|
||||||
|
if (used.has(key)) continue;
|
||||||
|
used.add(key);
|
||||||
|
problems.push({ hour, minute, timeText: formatTime(hour, minute) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClockConnectData(
|
||||||
|
timeMode: ClockConnectTimeMode,
|
||||||
|
): ClockConnectData {
|
||||||
|
const clocks = generateClockConnectProblems(timeMode);
|
||||||
|
const shuffledTimes = shuffle(clocks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeMode,
|
||||||
|
clocks,
|
||||||
|
shuffledTimes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLOCK_CONNECT_TIME_MODE_OPTIONS = [
|
||||||
|
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
|
||||||
|
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
|
||||||
|
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
|
||||||
|
{ id: 'random' as const, label: '随机', subtitle: '整点/半点/刻钟' },
|
||||||
|
];
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||||
import { inferGradeFromAge } 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 {
|
import {
|
||||||
CLOCK_TIME_MODE_OPTIONS,
|
CLOCK_TIME_MODE_OPTIONS,
|
||||||
type ClockReadingTimeMode,
|
type ClockReadingTimeMode,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const HOME_NOTICES: HomeNotice[] = [
|
|||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
text: '欢迎来到 涂鸦丫,趣味练习纸任你选',
|
text: '欢迎来到 涂鸦丫,趣味练习纸任你选',
|
||||||
path: '/chinesePages/wordTestSheet/wordTestSheet',
|
path: '/mathPages/clockReading/clockReading',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `update-${APP_VERSION}`,
|
id: `update-${APP_VERSION}`,
|
||||||
|
|||||||
@@ -14,15 +14,9 @@ export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
|
|||||||
path: '/mathPages/clockReading/clockReading',
|
path: '/mathPages/clockReading/clockReading',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pen-control-sheet',
|
id: 'clock-connect',
|
||||||
title: '控笔',
|
title: '时钟连线',
|
||||||
subtitle: '控笔练习',
|
subtitle: '看钟表连对应时间,认识时刻',
|
||||||
path: '/chinesePages/penControlSheet/penControlSheet',
|
path: '/mathPages/clockConnect/clockConnect',
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'word-test-sheet',
|
|
||||||
title: '测字表',
|
|
||||||
subtitle: '测字表',
|
|
||||||
path: '/chinesePages/wordTestSheet/wordTestSheet',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MOCK_SEED_COUNTS } from './mockLikes';
|
import { MOCK_SEED_COUNTS } from './mockLikes';
|
||||||
import {
|
import {
|
||||||
MATH_WORKSHEET_DEFINITIONS,
|
MATH_WORKSHEET_DEFINITIONS,
|
||||||
|
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
|
||||||
FOCUS_WORKSHEET_DEFINITIONS,
|
FOCUS_WORKSHEET_DEFINITIONS,
|
||||||
LETTER_TRACING_WORKSHEET_DEFINITIONS,
|
LETTER_TRACING_WORKSHEET_DEFINITIONS,
|
||||||
PINYIN_DICTATION_WORKSHEET_DEFINITIONS,
|
PINYIN_DICTATION_WORKSHEET_DEFINITIONS,
|
||||||
@@ -79,6 +80,10 @@ const CONFIG_SOURCES: ConfigSource[] = [
|
|||||||
label: 'wordColoring',
|
label: 'wordColoring',
|
||||||
data: WORD_COLORING_WORKSHEET_DEFINITIONS,
|
data: WORD_COLORING_WORKSHEET_DEFINITIONS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'clockConnect',
|
||||||
|
data: CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function isWorksheetDefinition(value: unknown): value is WorksheetDefinition {
|
function isWorksheetDefinition(value: unknown): value is WorksheetDefinition {
|
||||||
|
|||||||
Reference in New Issue
Block a user