feat: 时钟连线和认识时钟合并

This commit is contained in:
R524809
2026-07-29 17:15:25 +08:00
parent 2ffd0b302d
commit c0b8666257
14 changed files with 141 additions and 427 deletions
@@ -1,44 +1,60 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { CLOCK_READING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/clock';
import {
CLOCK_TIME_MODE_OPTIONS,
type ClockReadingTimeMode,
} from './generators/clock-generator';
CLOCK_READING_WORKSHEET_DEFINITIONS,
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
} from '../../config/worksheets/clock';
export { CLOCK_READING_WORKSHEET_DEFINITIONS };
export { CLOCK_READING_WORKSHEET_DEFINITIONS, CLOCK_CONNECT_WORKSHEET_DEFINITIONS };
export type { ClockReadingTimeMode };
/** 合并所有钟表类 worksheet 定义 */
const ALL_CLOCK_DEFINITIONS = [
...CLOCK_READING_WORKSHEET_DEFINITIONS,
...CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
];
type ClockReadingWorksheetRow =
(typeof CLOCK_READING_WORKSHEET_DEFINITIONS)[number];
type ClockWorksheetRow = (typeof ALL_CLOCK_DEFINITIONS)[number];
const CLOCK_READING_WORKSHEET_BY_ID = Object.fromEntries(
CLOCK_READING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, ClockReadingWorksheetRow>;
const CLOCK_WORKSHEET_BY_ID = Object.fromEntries(
ALL_CLOCK_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, ClockWorksheetRow>;
export const CLOCK_READING_MODE_OPTIONS = CLOCK_READING_WORKSHEET_DEFINITIONS;
/** 时刻类型选项(顺序:随机、整点、半点、刻钟) */
export const CLOCK_TIME_MODE_OPTIONS = [
{ id: 'random' as const, label: '随机', subtitle: '059 分均可' },
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
];
export { CLOCK_TIME_MODE_OPTIONS };
export type ClockTimeMode = (typeof CLOCK_TIME_MODE_OPTIONS)[number]['id'];
/** 练习类型选项(顺序:认识时钟、时钟连线) */
export const CLOCK_EXERCISE_TYPE_OPTIONS = [
{ id: 'clock-reading' as const, label: '认识时钟', subtitle: '看钟写时间' },
{ id: 'clock-connect' as const, label: '时钟连线', subtitle: '钟表连对应时间' },
];
export type ClockExerciseType = (typeof CLOCK_EXERCISE_TYPE_OPTIONS)[number]['id'];
export function getModeInfo(id: string) {
const m = CLOCK_READING_WORKSHEET_BY_ID[id];
const m = CLOCK_WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in CLOCK_READING_WORKSHEET_BY_ID;
return id in CLOCK_WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = CLOCK_READING_WORKSHEET_BY_ID[id];
const m = CLOCK_WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'math',
subcategory: 'clock-reading',
subcategory: 'clock',
path: `/mathPages/clockReading/clockReading?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
@@ -1,8 +1,10 @@
import ClockReadingDraw from './draw/clockReadingDraw';
import ClockConnectDraw from './draw/clockConnectDraw';
import {
buildClockReadingData,
type ClockReadingTimeMode,
} from './generators/clock-generator';
import { buildClockConnectData } from './generators/clock-connect-generator';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import {
@@ -10,6 +12,9 @@ import {
getPublishMetaByMode,
isValidMode,
CLOCK_TIME_MODE_OPTIONS,
CLOCK_EXERCISE_TYPE_OPTIONS,
type ClockTimeMode,
type ClockExerciseType,
} from './clockReading.config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
@@ -17,14 +22,17 @@ import {
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
import type { BaseDrawService } from '../../core/draw/baseDraw';
const pageInfoLookup = getModeInfo;
const WORKSHEET_ID = 'clock-reading';
const DEFAULT_WORKSHEET_ID = 'clock-reading';
type PageData = CanvasDataState & {
worksheetId: string;
timeMode: ClockReadingTimeMode;
timeMode: ClockTimeMode;
timeModeOptions: typeof CLOCK_TIME_MODE_OPTIONS;
exerciseType: ClockExerciseType;
exerciseTypeOptions: typeof CLOCK_EXERCISE_TYPE_OPTIONS;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
@@ -38,17 +46,19 @@ createPage(
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as ClockReadingDraw | null,
drawService: null as BaseDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '认识钟表',
functionId: WORKSHEET_ID,
functionId: DEFAULT_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: WORKSHEET_ID,
timeMode: 'random' as ClockReadingTimeMode,
worksheetId: DEFAULT_WORKSHEET_ID,
timeMode: 'random' as ClockTimeMode,
timeModeOptions: CLOCK_TIME_MODE_OPTIONS,
exerciseType: 'clock-reading' as ClockExerciseType,
exerciseTypeOptions: CLOCK_EXERCISE_TYPE_OPTIONS,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
@@ -60,27 +70,48 @@ createPage(
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: WORKSHEET_ID;
: DEFAULT_WORKSHEET_ID;
this.syncDebugPublishEnv();
// 根据传入 id 判断练习类型
const exerciseType: ClockExerciseType =
worksheetId === 'clock-connect' ? 'clock-connect' : 'clock-reading';
this.setData({ exerciseType });
this.applyWorksheet(worksheetId);
this.loadFavoritedMap();
},
onSelectTimeMode(e: WechatMiniprogram.TouchEvent) {
const mode = e.currentTarget.dataset.mode as
| ClockReadingTimeMode
| ClockTimeMode
| undefined;
if (!mode || mode === this.data.timeMode) return;
this.setData({ timeMode: mode }, () => this.drawCanvas());
},
onSelectExerciseType(e: WechatMiniprogram.TouchEvent) {
const type = e.currentTarget.dataset.type as
| ClockExerciseType
| undefined;
if (!type || type === this.data.exerciseType) return;
const worksheetId = type;
this.setData({ exerciseType: type });
this.applyWorksheet(worksheetId);
// 切换练习类型需要重新创建 drawService
this.recreateDrawService();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) => new ClockReadingDraw(canvas, ctx, options),
) => this.createDrawForType(canvas, ctx, options),
drawServiceOptions: {
title: this.data.pageTitle,
},
@@ -90,14 +121,45 @@ createPage(
});
},
createDrawForType(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
): BaseDrawService {
if (this.data.exerciseType === 'clock-connect') {
return new ClockConnectDraw(canvas, ctx, options);
}
return new ClockReadingDraw(canvas, ctx, options);
},
recreateDrawService() {
if (!this.canvas || !this.ctx) return;
const options = { title: this.data.pageTitle };
this.drawService = this.createDrawForType(
this.canvas,
this.ctx,
options,
);
this.drawCanvas();
},
async drawCanvas() {
if (!this.ctx || !this.drawService) return;
try {
const data = buildClockReadingData(this.data.timeMode);
await (this.drawService as ClockReadingDraw).draw(data);
if (this.data.exerciseType === 'clock-connect') {
const data = buildClockConnectData(
this.data.timeMode as ClockTimeMode,
);
await (this.drawService as ClockConnectDraw).draw(data);
} else {
const data = buildClockReadingData(
this.data.timeMode as ClockReadingTimeMode,
);
await (this.drawService as ClockReadingDraw).draw(data);
}
this.setData({ hasContent: true });
} catch (e) {
console.error('clockReading draw failed', e);
console.error('clock draw failed', e);
this.setData({ hasContent: false });
}
},
@@ -125,9 +187,9 @@ createPage(
},
async loadFavoritedMap() {
const ids = [WORKSHEET_ID];
const ids = ['clock-reading', 'clock-connect'];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap?.[WORKSHEET_ID]) {
if (this._favoritedMap?.[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
@@ -30,6 +30,26 @@
</view>
</view>
</view>
<view class="cr-section">
<text class="cr-section-title">练习类型</text>
<view class="cr-chip-row">
<view
wx:for="{{exerciseTypeOptions}}"
wx:key="id"
class="cr-chip {{exerciseType === item.id ? 'cr-chip--active' : ''}}"
hover-class="cr-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-type="{{item.id}}"
bind:tap="onSelectExerciseType">
<view class="cr-chip__text">
<text class="cr-chip__label">{{item.label}}</text>
<text class="cr-chip__subtitle">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
@@ -0,0 +1,152 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
ClockConnectData,
ClockConnectProblem,
} from '../generators/clock-connect-generator';
import { drawAnalogClock } from './drawAnalogClock';
import { NUMBER_COLORS } from '../../../constants/colors';
const ROWS = 6;
const MARGIN_X = 28;
const MARGIN_Y_TOP = 12;
const CLOCK_RADIUS = 38 * 1.2; // 放大 1.2 倍
const TIME_BOX_W = 70 * 3 * 0.8 * 0.75; // 放大3倍后缩80%,再缩短长度
const TIME_BOX_H = 28 * 3 * 0.8; // 放大3倍后缩80%
const TIME_BOX_RADIUS = 8 * 3 * 0.8; // 放大3倍后缩80%
const TIME_FONT_SIZE = 42 * 0.8; // 字体放大3倍后缩80%
const CONTENT_BOTTOM_PADDING = 28;
const DASH_PATTERN = [7, 5];
const BOX_COLOR = '#555555';
const LINE_POINT_RADIUS = 8;
const LINE_POINT_SPACING = 12;
const LINE_POINT_COLOR = '#93D333';
/** 从 NUMBER_COLORS 中随机取一个颜色 */
function getRandomColor(): string {
const keys = Object.keys(NUMBER_COLORS).map(Number);
const key = keys[Math.floor(Math.random() * keys.length)];
return NUMBER_COLORS[key];
}
/**
* 时钟连线绘制服务:
* 左侧 6 个钟表(右侧带连线圆点),右侧 6 个打乱顺序的时间(左侧带连线圆点)
* 用户需要将左侧的钟表和右侧对应的时间连线
*/
export default class ClockConnectDraw extends BaseDrawService {
async draw(data: ClockConnectData) {
if (!data?.clocks?.length) return;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawConnectGrid(data.clocks, data.shuffledTimes);
this.drawPrintFooter({ mode: 'A' });
}
private drawConnectGrid(
clocks: ClockConnectProblem[],
shuffledTimes: ClockConnectProblem[],
) {
const { ctx, canvasWidth, canvasHeight } = this;
const contentTop = this.currentY;
const availableH = canvasHeight - contentTop - CONTENT_BOTTOM_PADDING;
const clockBlockH = CLOCK_RADIUS * 2;
const totalGridH = ROWS * clockBlockH;
const rowGap = Math.max(
8,
(availableH - totalGridH - MARGIN_Y_TOP * 2) / (ROWS - 1),
);
const startY = contentTop + MARGIN_Y_TOP;
const rowH = clockBlockH + rowGap;
// 向中间靠拢
const leftCenterX = MARGIN_X + CLOCK_RADIUS + 40;
const rightCenterX = canvasWidth - MARGIN_X - TIME_BOX_W / 2 - 40;
for (let i = 0; i < ROWS && i < clocks.length; i++) {
const clock = clocks[i];
const cellTopY = startY + i * rowH;
const clockCy = cellTopY + CLOCK_RADIUS;
// 绘制左侧钟表
drawAnalogClock(ctx, {
cx: leftCenterX,
cy: clockCy,
radius: CLOCK_RADIUS,
hour: clock.hour,
minute: clock.minute,
});
// 绘制钟表右侧的连线圆点
const leftDotX =
leftCenterX + CLOCK_RADIUS + LINE_POINT_SPACING + LINE_POINT_RADIUS;
ctx.fillStyle = LINE_POINT_COLOR;
ctx.beginPath();
ctx.arc(leftDotX, clockCy, LINE_POINT_RADIUS, 0, Math.PI * 2);
ctx.fill();
// 绘制右侧时间框
const timeItem = shuffledTimes[i];
const boxX = rightCenterX - TIME_BOX_W / 2;
const boxY = clockCy - TIME_BOX_H / 2;
const color = getRandomColor();
this.drawTimeBox(boxX, boxY, timeItem.timeText, color);
// 绘制时间框左侧的连线圆点
const rightDotX = boxX - LINE_POINT_SPACING - LINE_POINT_RADIUS;
ctx.fillStyle = LINE_POINT_COLOR;
ctx.beginPath();
ctx.arc(rightDotX, clockCy, LINE_POINT_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
}
/** 绘制圆角虚线矩形 + 居中彩色时间文本 */
private drawTimeBox(x: number, y: number, text: string, color: string) {
const { ctx } = this;
ctx.save();
ctx.strokeStyle = BOX_COLOR;
ctx.lineWidth = 1.8;
ctx.setLineDash(DASH_PATTERN);
this.strokeRoundRect(x, y, TIME_BOX_W, TIME_BOX_H, TIME_BOX_RADIUS);
ctx.setLineDash([]);
ctx.fillStyle = color;
ctx.font = `bold ${TIME_FONT_SIZE}px "Microsoft Yahei", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + TIME_BOX_W / 2, y + TIME_BOX_H / 2);
ctx.restore();
}
private strokeRoundRect(
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const { ctx } = this;
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.arcTo(x + w, y, x + w, y + radius, radius);
ctx.lineTo(x + w, y + h - radius);
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
ctx.lineTo(x + radius, y + h);
ctx.arcTo(x, y + h, x, y + h - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
}
@@ -0,0 +1,94 @@
export type ClockConnectTimeMode =
| 'random'
| 'whole-hour'
| 'half-hour'
| 'quarter-hour';
export interface ClockConnectProblem {
hour: number;
minute: number;
/** 格式化后的时间文本,如 "6:00" */
timeText: string;
}
export interface ClockConnectData {
/** 左侧钟表列表(按原始顺序) */
clocks: ClockConnectProblem[];
/** 右侧时间文本列表(打乱顺序) */
shuffledTimes: ClockConnectProblem[];
timeMode: ClockConnectTimeMode;
}
export const CLOCK_CONNECT_PROBLEM_COUNT = 6;
const MINUTES_BY_MODE: Record<
Exclude<ClockConnectTimeMode, 'random'>,
readonly number[]
> = {
'whole-hour': [0],
'half-hour': [30],
'quarter-hour': [15, 45],
};
function pickMinute(timeMode: ClockConnectTimeMode): number {
if (timeMode === 'random') {
return Math.floor(Math.random() * 60);
}
const pool = MINUTES_BY_MODE[timeMode];
return pool[Math.floor(Math.random() * pool.length)] ?? 0;
}
function formatTime(hour: number, minute: number): string {
return `${hour}:${minute.toString().padStart(2, '0')}`;
}
/** Fisher-Yates shuffle */
function shuffle<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/** 生成一页 6 道时钟连线题,同一页内时刻不重复 */
export function generateClockConnectProblems(
timeMode: ClockConnectTimeMode,
): ClockConnectProblem[] {
const used = new Set<string>();
const problems: ClockConnectProblem[] = [];
let guard = 0;
while (problems.length < CLOCK_CONNECT_PROBLEM_COUNT && guard < 500) {
guard += 1;
const hour = Math.floor(Math.random() * 12) + 1;
const minute = pickMinute(timeMode);
const key = `${hour}:${minute}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ hour, minute, timeText: formatTime(hour, minute) });
}
return problems;
}
export function buildClockConnectData(
timeMode: ClockConnectTimeMode,
): ClockConnectData {
const clocks = generateClockConnectProblems(timeMode);
const shuffledTimes = shuffle(clocks);
return {
timeMode,
clocks,
shuffledTimes,
};
}
export const CLOCK_CONNECT_TIME_MODE_OPTIONS = [
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
{ id: 'random' as const, label: '随机', subtitle: '整点/半点/刻钟' },
];