feat: 汉字每天打卡基本完成,细节还待优化

This commit is contained in:
R524809
2026-05-21 18:23:00 +08:00
parent e86e5f5aa7
commit a9bd118ec7
15 changed files with 1013 additions and 235 deletions
@@ -0,0 +1,353 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import {
GRID_COLORS,
GRID_DASH,
TRACING_COLORS,
} from '../../../core/data/tracingStyles';
import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles';
import { loadFontFace } from '../../../core/font/fontLoader';
import {
SECTION_PADDING_TOP,
SECTION_PADDING_BOTTOM,
HEADER_TITLE_GAP,
HEADER_META_GAP,
CONTENT_TOP_GAP,
FOOTER_PHRASE_GAP,
FOOTER_INDEX_GAP,
drawSectionHeader,
drawSectionFooter,
getRandomEncouragementPhrase,
} from '../../../core/draw/dailyPracticeDrawHelper';
import {
getScalingTransform,
drawSvgPath,
drawTianZiGrid,
} from '../../shared/drawUtils';
export interface DailyCheckinHanziItem {
character: string;
pinyin: string;
strokes: string[];
}
const PAGE_MARGIN = 14;
const SECTION_PADDING_X = 18;
const COLUMNS = 11;
const ROWS_PER_SECTION = 4;
const BLOCK_GAP = 0;
const PINYIN_ROW_RATIO = 0.52; // 拼音/笔画行高度占田字格高度比例
interface DrawSingleStrokeParams {
ctx: RenderingContext;
strokes: string[];
offsetX: number;
offsetY: number;
width: number;
height: number;
fromIndex: number;
toIndex: number;
fillStyle: string;
}
function drawStrokesRange({
ctx,
strokes,
offsetX,
offsetY,
width,
height,
fromIndex,
toIndex,
fillStyle,
}: DrawSingleStrokeParams) {
const padding = Math.min(width, height) * 0.1;
const transform = getScalingTransform(width, height, padding);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.miterLimit = 10;
ctx.fillStyle = fillStyle;
for (let s = fromIndex; s <= toIndex && s < strokes.length; s++) {
ctx.save();
ctx.translate(
offsetX + transform.xOffset,
offsetY + height - transform.yOffset,
);
ctx.scale(transform.scale, -transform.scale);
ctx.beginPath();
drawSvgPath(ctx, strokes[s]);
ctx.fill();
ctx.restore();
}
ctx.restore();
}
export default class WordDailyCheckinDraw extends BaseDrawService {
async draw(items: DailyCheckinHanziItem[]) {
this.prepareDraw();
await loadFontFace(TONEOZ_PINYIN);
this.drawPageDivider();
const sectionRects = this.getSectionRects();
const itemsPerSection = ROWS_PER_SECTION;
for (let i = 0; i < sectionRects.length; i++) {
const rect = sectionRects[i];
const sectionItems = items.slice(
i * itemsPerSection,
(i + 1) * itemsPerSection,
);
if (sectionItems.length === 0) continue;
this.drawSection(rect, sectionItems, i + 1);
}
}
private drawPageDivider() {
const cy = this.canvasHeight / 2;
this.drawLine(PAGE_MARGIN, cy, this.canvasWidth - PAGE_MARGIN, cy, {
isDashed: true,
dashPattern: [5, 5],
color: '#D6D2CC',
lineWidth: 1,
});
}
private getSectionRects() {
const halfH = this.canvasHeight / 2;
const fullW = this.canvasWidth;
return [
{
x: PAGE_MARGIN,
y: PAGE_MARGIN,
w: fullW - PAGE_MARGIN * 2,
h: halfH - PAGE_MARGIN,
},
{
x: PAGE_MARGIN,
y: halfH,
w: fullW - PAGE_MARGIN * 2,
h: this.canvasHeight - PAGE_MARGIN - halfH,
},
];
}
private drawSection(
rect: { x: number; y: number; w: number; h: number },
items: DailyCheckinHanziItem[],
sectionIndex: number,
) {
const phrase = getRandomEncouragementPhrase();
drawSectionHeader(this.ctx, rect, '汉字每日打卡');
drawSectionFooter(this.ctx, rect, phrase, `- ${sectionIndex} -`);
this.drawSectionContent(rect, items);
}
private drawSectionContent(
rect: { x: number; y: number; w: number; h: number },
items: DailyCheckinHanziItem[],
) {
const contentTop =
rect.y +
SECTION_PADDING_TOP +
HEADER_TITLE_GAP +
HEADER_META_GAP +
CONTENT_TOP_GAP;
const contentBottom =
rect.y +
rect.h -
SECTION_PADDING_BOTTOM -
FOOTER_PHRASE_GAP -
FOOTER_INDEX_GAP;
const contentHeight = contentBottom - contentTop;
const contentLeft = rect.x + SECTION_PADDING_X;
const contentWidth = rect.w - SECTION_PADDING_X * 2;
// 单元尺寸:田字格为正方形,受宽高约束取较小值
const cellWFromWidth = contentWidth / COLUMNS;
// 每个 hanzi 块高度 = pinyinRow + tianzigeRow,取 pinyin = ratio * tianzige
const blockHeightFactor = 1 + PINYIN_ROW_RATIO;
const totalGap = (ROWS_PER_SECTION - 1) * BLOCK_GAP;
const cellWFromHeight =
(contentHeight - totalGap) / (blockHeightFactor * ROWS_PER_SECTION);
const cellW = Math.min(cellWFromWidth, cellWFromHeight);
const tianzigeH = cellW;
const pinyinH = cellW * PINYIN_ROW_RATIO;
const blockH = tianzigeH + pinyinH;
const totalH = ROWS_PER_SECTION * blockH + totalGap;
const startX = contentLeft + (contentWidth - cellW * COLUMNS) / 2;
const startY = contentTop + (contentHeight - totalH) / 2;
for (let i = 0; i < items.length && i < ROWS_PER_SECTION; i++) {
const blockY = startY + i * (blockH + BLOCK_GAP);
this.drawHanziBlock(items[i], startX, blockY, cellW, pinyinH);
}
}
private drawHanziBlock(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
cellW: number,
pinyinH: number,
) {
const tianzigeH = cellW;
const totalW = cellW * COLUMNS;
// 拼音 + 笔画行(顶部)
this.drawPinyinStrokeRow(item, startX, startY, totalW, pinyinH, cellW);
// 田字格练习行(底部)
const tianzigeTop = startY + pinyinH;
this.drawTianzigeRow(item, startX, tianzigeTop, cellW, tianzigeH);
}
private drawPinyinStrokeRow(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
totalW: number,
rowH: number,
cellW: number,
) {
const ctx = this.ctx;
const y1 = startY + rowH / 3;
const y2 = startY + (rowH * 2) / 3;
const yBot = startY + rowH;
ctx.save();
// 外边框
ctx.strokeStyle = GRID_COLORS.border;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(startX, startY, totalW, rowH);
// 中间两条虚线(四线三格)—— 仅第一格
ctx.strokeStyle = GRID_COLORS.middleLine;
ctx.setLineDash([...GRID_DASH]);
ctx.beginPath();
ctx.moveTo(startX, y1);
ctx.lineTo(startX + cellW, y1);
ctx.moveTo(startX, y2);
ctx.lineTo(startX + cellW, y2);
ctx.stroke();
ctx.beginPath();
ctx.setLineDash([]);
const vx = startX + cellW;
ctx.moveTo(vx, startY);
ctx.lineTo(vx, yBot);
ctx.stroke();
ctx.restore();
// 列分隔线
// ctx.strokeStyle = GRID_COLORS.border;
// ctx.setLineDash([]);
// for (let c = 1; c < COLUMNS; c++) {
// const vx = startX + c * cellW;
// ctx.beginPath();
// ctx.moveTo(vx, startY);
// ctx.lineTo(vx, yBot);
// ctx.stroke();
// }
// 第一格:绘制拼音
if (item.pinyin) {
const cx = startX + cellW / 2;
const fontSize = Math.round(rowH * 0.65);
ctx.save();
ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`;
ctx.fillStyle = TRACING_COLORS.strong;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(item.pinyin, cx, y2);
ctx.restore();
}
// 笔画格:第 2 格起,按笔画顺序展示当前进度
const strokeCount = item.strokes.length;
for (let k = 1; k <= strokeCount && k < COLUMNS; k++) {
const cellX = startX + cellW / 2 + k * 15;
const currentIdx = k - 1;
// 已绘制的历史笔画:浅灰
if (currentIdx > 0) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: rowH,
fromIndex: 0,
toIndex: currentIdx - 1,
fillStyle: TRACING_COLORS.guide,
});
}
// 当前笔画:红色高亮
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: rowH,
fromIndex: currentIdx,
toIndex: currentIdx,
fillStyle: '#D94B4B',
});
}
}
private drawTianzigeRow(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
cellW: number,
cellH: number,
) {
const ctx = this.ctx;
const TRACING_INDICES = new Set<number>([5, 8]); // 第 6 个和第 9 个(0-based: 5, 8
for (let c = 0; c < COLUMNS; c++) {
const cellX = startX + c * cellW;
const cx = cellX + cellW / 2;
const cy = startY + cellH / 2;
drawTianZiGrid({ ctx, cx, cy, size: cellW });
if (c === 0) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: cellH,
fromIndex: 0,
toIndex: item.strokes.length - 1,
fillStyle: TRACING_COLORS.strong,
});
} else if (TRACING_INDICES.has(c)) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: cellH,
fromIndex: 0,
toIndex: item.strokes.length - 1,
fillStyle: TRACING_COLORS.guide,
});
}
}
}
}
@@ -9,123 +9,13 @@
* - symbol:符号
*/
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { GRID_COLORS, TRACING_COLORS } from '../../../core/data/tracingStyles';
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
import { CharacterItem } from '../../../types/characterType';
/**
* cnchar-data 坐标系配置
* 参考 hanzi-writer 的实现逻辑
* 字符数据的边界框:左上角 (0, -124),右下角 (1024, 900)
*/
const CHAR_BOUNDS = {
minX: 0,
minY: -124,
maxX: 1024,
maxY: 900,
};
const CHAR_WIDTH = CHAR_BOUNDS.maxX - CHAR_BOUNDS.minX; // 1024
const CHAR_HEIGHT = CHAR_BOUNDS.maxY - CHAR_BOUNDS.minY; // 1024
/**
* 计算缩放变换参数(模仿 cnchar.draw 的 getScalingTransform 函数)
* 用于将 1024x1024 坐标系的字符数据变换到目标画布尺寸
*/
interface ScalingTransform {
xOffset: number;
yOffset: number;
scale: number;
}
function getScalingTransform(
width: number,
height: number,
padding: number,
): ScalingTransform {
// 计算可用空间
const availableWidth = width - 2 * padding;
const availableHeight = height - 2 * padding;
// 计算缩放比例(取较小的比例以确保字符完整显示)
const scaleX = availableWidth / CHAR_WIDTH;
const scaleY = availableHeight / CHAR_HEIGHT;
const scale = Math.min(scaleX, scaleY);
// 计算偏移量(居中显示)
const scaledWidth = CHAR_WIDTH * scale;
const scaledHeight = CHAR_HEIGHT * scale;
const centerX = padding + (availableWidth - scaledWidth) / 2;
const centerY = padding + (availableHeight - scaledHeight) / 2;
// 计算 xOffset 和 yOffset(考虑字符边界框的偏移)
const xOffset = -CHAR_BOUNDS.minX * scale + centerX;
const yOffset = -CHAR_BOUNDS.minY * scale + centerY;
return { xOffset, yOffset, scale };
}
/**
* SVG 路径解析与绘制(支持 M/L/Q/Z 命令)
* 参考 cnchar.draw 的实现,使用 Canvas transform 进行坐标变换
*/
interface DrawSvgPathParams {
ctx: RenderingContext;
pathD: string;
}
function drawSvgPath({ ctx, pathD }: DrawSvgPathParams) {
// 使用正则表达式匹配 SVG 路径命令
// 支持 MmoveTo)、L(lineTo)、Q(二次贝塞尔曲线)、ZclosePath)
const commandRegex = /([MLQZ])([^MLQZ]*?)(?=[MLQZ]|$)/gi;
const commands: Array<{ cmd: string; coords: string }> = [];
let match;
while ((match = commandRegex.exec(pathD)) !== null) {
const cmd = match[1].toUpperCase();
const coords = match[2].trim();
commands.push({ cmd, coords });
}
// 解析坐标的辅助函数
const parseCoords = (coords: string): number[] => {
return coords
.split(/[\s,]+/)
.filter((part) => part.trim() !== '')
.map((part) => parseFloat(part));
};
for (const { cmd, coords } of commands) {
const coordValues = parseCoords(coords);
switch (cmd) {
case 'M':
if (coordValues.length >= 2) {
ctx.moveTo(coordValues[0], coordValues[1]);
}
break;
case 'L':
if (coordValues.length >= 2) {
ctx.lineTo(coordValues[0], coordValues[1]);
}
break;
case 'Q':
// 二次贝塞尔曲线:Q cpx cpy x y
if (coordValues.length >= 4) {
ctx.quadraticCurveTo(
coordValues[0],
coordValues[1],
coordValues[2],
coordValues[3],
);
}
break;
case 'Z':
ctx.closePath();
break;
default:
console.warn(`未知的 SVG 路径命令: ${cmd}`);
}
}
}
import {
getScalingTransform,
drawSvgPath,
drawTianZiGrid,
} from '../../shared/drawUtils';
interface DrawStrokesParams {
ctx: RenderingContext;
@@ -187,7 +77,7 @@ function drawStrokes({
// 绘制路径
ctx.beginPath();
drawSvgPath({ ctx, pathD: strokes[s] });
drawSvgPath(ctx, strokes[s]);
// 使用 fill 绘制(cnchar 只使用 fill
ctx.fillStyle = fillStyle;
@@ -197,48 +87,6 @@ function drawStrokes({
}
}
interface DrawTianZiGridParams {
ctx: RenderingContext;
x: number;
y: number;
size: number;
lineColor?: string;
boldColor?: string;
}
function drawTianZiGrid({
ctx,
x,
y,
size,
lineColor = GRID_COLORS.middleLine,
boldColor = GRID_COLORS.border,
}: DrawTianZiGridParams) {
ctx.strokeStyle = boldColor;
ctx.lineWidth = 1;
ctx.strokeRect(x - size / 2, y - size / 2, size, size);
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y - size / 2);
ctx.lineTo(x, y + size / 2);
ctx.moveTo(x - size / 2, y);
ctx.lineTo(x + size / 2, y);
ctx.stroke();
ctx.closePath();
ctx.strokeStyle = GRID_COLORS.diagonal;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x - size / 2, y - size / 2);
ctx.lineTo(x + size / 2, y + size / 2);
ctx.moveTo(x + size / 2, y - size / 2);
ctx.lineTo(x - size / 2, y + size / 2);
ctx.stroke();
ctx.closePath();
}
class WordDrawService extends BaseDrawService {
constructor(
canvas: Canvas,
@@ -421,7 +269,7 @@ class WordDrawService extends BaseDrawService {
for (let col = 0; col < maxCol; col++) {
const x = startX + col * (cellSize + colGap) + cellSize / 2;
const y = startY + row * (cellSize + rowGap) + cellSize / 2;
drawTianZiGrid({ ctx, x, y, size: cellSize });
drawTianZiGrid({ ctx, cx: x, cy: y, size: cellSize });
}
}
}
@@ -1,8 +1,13 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
export type HandwritingSheetMode =
| 'handwriting-sheet'
| 'handwriting-daily-checkin';
interface HandwritingSheetWorksheetDefinition {
id: string;
id: HandwritingSheetMode;
icon: string;
title: string;
subtitle: string;
ageMin: number;
@@ -12,18 +17,31 @@ interface HandwritingSheetWorksheetDefinition {
sortOrder: number;
}
export const HANDWRITING_SHEET_WORKSHEET_DEFINITIONS = [
{
id: 'handwriting-sheet',
title: '自定义练字帖',
subtitle: '自定义田字格练字帖',
ageMin: 4,
ageMax: 8,
difficulty: 2,
tags: ['练字', '字帖', '田字格', '汉字'],
sortOrder: 35,
},
] as const satisfies ReadonlyArray<HandwritingSheetWorksheetDefinition>;
export const HANDWRITING_SHEET_WORKSHEET_DEFINITIONS: HandwritingSheetWorksheetDefinition[] =
[
{
id: 'handwriting-sheet',
icon: 'draw-o',
title: '自定义练字帖',
subtitle: '自定义田字格练字帖',
ageMin: 4,
ageMax: 8,
difficulty: 2,
tags: ['练字', '字帖', '田字格', '汉字'],
sortOrder: 35,
},
{
id: 'handwriting-daily-checkin',
icon: 'task-o',
title: '汉字每日打卡',
subtitle: '每日 8 字带拼音笔顺打卡',
ageMin: 5,
ageMax: 8,
difficulty: 2,
tags: ['汉字', '每日练习', '打卡', '拼音', '笔顺'],
sortOrder: 36,
},
];
type HandwritingSheetWorksheetRow =
(typeof HANDWRITING_SHEET_WORKSHEET_DEFINITIONS)[number];
@@ -32,6 +50,9 @@ const HANDWRITING_SHEET_WORKSHEET_BY_ID = Object.fromEntries(
HANDWRITING_SHEET_WORKSHEET_DEFINITIONS.map((item) => [item.id, item]),
) as Record<string, HandwritingSheetWorksheetRow>;
export const HANDWRITING_SHEET_MODE_OPTIONS =
HANDWRITING_SHEET_WORKSHEET_DEFINITIONS;
export const HANDWRITING_SHEET_WORKSHEET_ID =
HANDWRITING_SHEET_WORKSHEET_DEFINITIONS[0].id;
@@ -24,6 +24,65 @@
margin-bottom: 20rpx;
}
.cb-mode-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.cb-mode-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 20rpx;
border-radius: 24rpx;
background: #f8f0e0;
box-sizing: border-box;
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.cb-mode-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.cb-mode-card--active {
background: linear-gradient(145deg, #ffd709 0%, #efc900 100%);
box-shadow: 0 8rpx 32rpx rgba(50, 46, 37, 0.06);
}
.cb-mode-card--pressed {
transform: scale(0.96);
}
.cb-mode-card__label {
font-size: 28rpx;
font-weight: 700;
color: @text-secondary;
text-align: center;
}
.cb-mode-card--active .cb-mode-card__label {
color: #453900;
}
.cb-mode-card__desc {
font-size: 22rpx;
color: @text-secondary;
text-align: center;
opacity: 0.7;
}
.cb-mode-card--active .cb-mode-card__desc {
color: #453900;
opacity: 0.85;
}
.cb-content-panel {
background: rgba(240, 231, 214, 0.5);
border-radius: @radius-lg;
@@ -1,5 +1,13 @@
import WordDrawService from './draw/wordDrawService';
import { getWordsSvgData } from '../../utils/getWordsSvgJson';
import WordDailyCheckinDraw, {
DailyCheckinHanziItem,
} from './draw/wordDailyCheckinDraw';
import {
getWordsSvgData,
getHanziReadingsData,
pickPrimaryPinyin,
HanziReadingsMap,
} from '../shared/getWordsSvgJson';
import { WORDS } from '../../core/data/words';
import { CharacterItem } from '../../types/characterType';
import tracker from '../../utils/tracker';
@@ -13,26 +21,43 @@ import {
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
HANDWRITING_SHEET_WORKSHEET_DEFINITIONS,
HANDWRITING_SHEET_MODE_OPTIONS,
HANDWRITING_SHEET_WORKSHEET_ID,
HandwritingSheetMode,
getModeInfo,
getPublishMetaByMode,
isValidMode,
} from './handwritingSheet.config';
const MAX_WORDS = 11;
const CUSTOM_MAX_WORDS = 11;
const CHECKIN_MAX_WORDS = 8;
const RANDOM_WORD_COUNT = 8;
const DEFAULT_WORDS = '东西南北日月山河风雨';
const CATEGORY_TAGS = WORDS.slice(0, 3).map((cat) => ({
const CUSTOM_DEFAULT_WORDS = '东西南北日月山河风雨';
const CUSTOM_CATEGORY_TAGS = WORDS.slice(0, 3).map((cat) => ({
categoryId: cat.categoryId,
icon: cat.icon,
categoryName: cat.categoryName,
}));
function collectFirstGradeWords(): string[] {
const grade = WORDS.find((cat) => cat.categoryId === 26);
if (!grade || !grade.sections) return [];
const merged: string[] = [];
for (const section of grade.sections) {
for (const w of section.words) merged.push(w);
}
return Array.from(new Set(merged));
}
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
wordDrawService: null as WordDrawService | null,
dailyDrawService: null as WordDailyCheckinDraw | null,
svgWords: {} as Record<string, string[]>,
readings: {} as HanziReadingsMap,
readingsLoaded: false,
maxRow: 0 as number,
maxCol: 0 as number,
_favoritedMap: {} as Record<string, boolean>,
@@ -40,6 +65,9 @@ createPage(
data: {
pageTitle: HANDWRITING_SHEET_WORKSHEET_DEFINITIONS[0].title,
functionId: HANDWRITING_SHEET_WORKSHEET_ID,
currentMode:
HANDWRITING_SHEET_WORKSHEET_ID as HandwritingSheetMode,
modeOptions: HANDWRITING_SHEET_MODE_OPTIONS,
hasContent: false,
words: [] as string[],
inputWords: [] as string[],
@@ -47,7 +75,9 @@ createPage(
inputValue: '',
showSelectWordPopup: false,
pickerCurrentTab: 0,
categoryTags: CATEGORY_TAGS,
pickerMax: CUSTOM_MAX_WORDS,
categoryTags: CUSTOM_CATEGORY_TAGS,
showCustomPanel: true,
showShareDialog: false,
isPreviewFavorite: false,
isDevEnv: false,
@@ -56,13 +86,16 @@ createPage(
debugPublishMeta: null as DebugPublishMeta | null,
},
async onLoad() {
async onLoad(options: { id?: string }) {
this.syncDebugPublishEnv();
this.loadFavoritedMap();
this.initPageInfo(
HANDWRITING_SHEET_WORKSHEET_ID,
HANDWRITING_SHEET_WORKSHEET_DEFINITIONS[0].title,
);
const initialMode: HandwritingSheetMode =
options.id && isValidMode(options.id)
? (options.id as HandwritingSheetMode)
: HANDWRITING_SHEET_WORKSHEET_ID;
this.applyMode(initialMode, { redraw: false });
await this.loadSvgWords();
this.initDefaultWords();
},
@@ -94,6 +127,16 @@ createPage(
}
},
async ensureReadingsLoaded(): Promise<void> {
if (this.readingsLoaded) return;
try {
this.readings = await getHanziReadingsData();
this.readingsLoaded = true;
} catch (error) {
console.error('加载汉字读音数据失败:', error);
}
},
async onCanvasReady(e: WechatMiniprogram.CustomEvent) {
const { canvas, ctx } = e.detail;
if (!canvas || !ctx) return;
@@ -101,31 +144,81 @@ createPage(
this.canvas = canvas;
this.ctx = ctx;
this.wordDrawService = new WordDrawService(canvas, ctx);
this.dailyDrawService = new WordDailyCheckinDraw(canvas, ctx);
await this.wordDrawService.drawLayout();
const { maxRow, maxCol } = this.wordDrawService.getMaxGridLayout();
this.maxRow = maxRow;
this.maxCol = maxCol;
await this.prepareCanvasForCurrentMode();
if (this.data.words.length > 0) {
await this.renderPracticeContent();
}
},
async prepareCanvasForCurrentMode() {
if (!this.canvas) return;
if (this.data.currentMode === 'handwriting-sheet') {
if (!this.wordDrawService) return;
await this.wordDrawService.drawLayout();
const { maxRow, maxCol } =
this.wordDrawService.getMaxGridLayout();
this.maxRow = maxRow;
this.maxCol = maxCol;
} else {
this.dailyDrawService?.prepareDraw();
}
},
applyMode(
mode: HandwritingSheetMode,
options?: { redraw?: boolean },
) {
const isCheckin = mode === 'handwriting-daily-checkin';
const max = isCheckin ? CHECKIN_MAX_WORDS : CUSTOM_MAX_WORDS;
this.setData(
{
currentMode: mode,
functionId: mode,
pickerMax: max,
showCustomPanel: !isCheckin,
},
() => {
this.initPageInfo(mode);
if (options?.redraw) {
this.resetForModeChange();
}
},
);
},
async onSelectMode(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as
| HandwritingSheetMode
| undefined;
if (!id || id === this.data.currentMode) return;
this.applyMode(id, { redraw: true });
},
async resetForModeChange() {
await this.prepareCanvasForCurrentMode();
// 切换模式后用对应模式的默认数据填充
this.initDefaultWords();
},
onPreviewRefresh() {
this.refreshWords();
},
async onPreviewFavorite() {
const id = this.data.currentMode;
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
this._favoritedMap[HANDWRITING_SHEET_WORKSHEET_ID] = next;
this._favoritedMap[id] = next;
if (next) {
await addFavorite(HANDWRITING_SHEET_WORKSHEET_ID);
await addFavorite(id);
} else {
await removeFavorite(HANDWRITING_SHEET_WORKSHEET_ID);
await removeFavorite(id);
}
wx.showToast({
@@ -135,11 +228,12 @@ createPage(
},
async loadFavoritedMap() {
this._favoritedMap = await batchCheckFavorited([
HANDWRITING_SHEET_WORKSHEET_ID,
]);
const ids = HANDWRITING_SHEET_WORKSHEET_DEFINITIONS.map(
(d) => d.id,
);
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[HANDWRITING_SHEET_WORKSHEET_ID]) {
if (this._favoritedMap[this.data.currentMode]) {
this.setData({ isPreviewFavorite: true });
}
},
@@ -158,9 +252,16 @@ createPage(
this.clearWords();
},
getMaxWordsForMode(): number {
return this.data.currentMode === 'handwriting-daily-checkin'
? CHECKIN_MAX_WORDS
: CUSTOM_MAX_WORDS;
},
syncWordsFromInput(value: string, showLimitToast: boolean) {
const max = this.getMaxWordsForMode();
const selectedWords = this.data.selectedWords as string[];
const maxInputCount = Math.max(0, MAX_WORDS - selectedWords.length);
const maxInputCount = Math.max(0, max - selectedWords.length);
const nextInputWords = this.splitToSingleChars(value).slice(
0,
maxInputCount,
@@ -204,12 +305,13 @@ createPage(
},
onChangeWord(e: WechatMiniprogram.CustomEvent) {
const max = this.getMaxWordsForMode();
const nextSelectedWords = e.detail.selectedWords as string[];
const inputWords = this.data.inputWords as string[];
if (inputWords.length + nextSelectedWords.length > MAX_WORDS) {
if (inputWords.length + nextSelectedWords.length > max) {
wx.showToast({
title: `最多只能添加 ${MAX_WORDS} 个字`,
title: `最多只能添加 ${max} 个字`,
icon: 'none',
});
return;
@@ -273,9 +375,14 @@ createPage(
},
initDefaultWords() {
if (this.data.currentMode === 'handwriting-daily-checkin') {
this.refreshWords();
return;
}
const defaults = this.getSupportedWords(
this.splitToSingleChars(DEFAULT_WORDS),
).slice(0, MAX_WORDS);
this.splitToSingleChars(CUSTOM_DEFAULT_WORDS),
).slice(0, CUSTOM_MAX_WORDS);
if (defaults.length === 0) {
this.refreshWords();
@@ -294,17 +401,21 @@ createPage(
},
refreshWords() {
const candidates = WORDS.slice(0, 3).reduce(
(acc: string[], category) => acc.concat(category.words),
[] as string[],
);
const isCheckin =
this.data.currentMode === 'handwriting-daily-checkin';
const candidates = isCheckin
? collectFirstGradeWords()
: WORDS.slice(0, 3).reduce(
(acc: string[], category) =>
acc.concat(category.words || []),
[] as string[],
);
const shuffled = Array.from(new Set(candidates)).sort(
() => Math.random() - 0.5,
);
const supported = this.getSupportedWords(shuffled).slice(
0,
RANDOM_WORD_COUNT,
);
const limit = isCheckin ? CHECKIN_MAX_WORDS : RANDOM_WORD_COUNT;
const supported = this.getSupportedWords(shuffled).slice(0, limit);
if (supported.length === 0) {
wx.showToast({
@@ -317,9 +428,9 @@ createPage(
this.setData(
{
words: supported,
inputWords: supported,
selectedWords: [],
inputValue: supported.join(''),
inputWords: isCheckin ? [] : supported,
selectedWords: isCheckin ? supported : [],
inputValue: isCheckin ? '' : supported.join(''),
},
() => this.renderPracticeContent().catch(console.error),
);
@@ -331,9 +442,21 @@ createPage(
},
async renderPracticeContent() {
if (!this.canvas || !this.wordDrawService) return;
if (!this.canvas) return;
const words = this.data.words as string[];
if (this.data.currentMode === 'handwriting-daily-checkin') {
await this.renderCheckinContent(words);
return;
}
await this.renderCustomContent(words);
},
async renderCustomContent(words: string[]) {
if (!this.wordDrawService) return;
if (words.length === 0) {
await this.wordDrawService.drawContentEmpty();
this.setData({ hasContent: false });
@@ -368,6 +491,34 @@ createPage(
this.setData({ hasContent: characterData.length > 0 });
},
async renderCheckinContent(words: string[]) {
if (!this.dailyDrawService) return;
const supportedWords = this.getSupportedWords(words).slice(
0,
CHECKIN_MAX_WORDS,
);
if (supportedWords.length === 0) {
this.dailyDrawService.prepareDraw();
this.setData({ hasContent: false });
return;
}
await this.ensureReadingsLoaded();
const items: DailyCheckinHanziItem[] = supportedWords.map(
(char: string) => ({
character: char,
pinyin: pickPrimaryPinyin(this.readings, char),
strokes: this.svgWords[char] || [],
}),
);
await this.dailyDrawService.draw(items);
this.setData({ hasContent: items.length > 0 });
},
getSupportedWords(words: string[]): string[] {
return words.filter((word) => this.svgWords[word]);
},
@@ -401,7 +552,7 @@ createPage(
tracker.reportShare('练字贴');
return {
...defaultShareConfig,
path: `/chinesePages/handwritingSheet/handwritingSheet?id=${HANDWRITING_SHEET_WORKSHEET_ID}`,
path: `/chinesePages/handwritingSheet/handwritingSheet?id=${this.data.currentMode}`,
};
},
@@ -409,12 +560,12 @@ createPage(
tracker.reportShare('练字贴');
return {
...defaultShareConfig,
query: `id=${HANDWRITING_SHEET_WORKSHEET_ID}`,
query: `id=${this.data.currentMode}`,
};
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(HANDWRITING_SHEET_WORKSHEET_ID);
const meta = getPublishMetaByMode(this.data.currentMode);
if (!meta) {
throw new Error('当前题型配置不存在');
}
@@ -11,13 +11,38 @@
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="cb-section">
<text class="cb-section-title">练习类型</text>
<view class="cb-mode-grid">
<view
wx:for="{{modeOptions}}"
wx:key="id"
class="cb-mode-card {{currentMode === item.id ? 'cb-mode-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="cb-mode-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
<toy-icon
name="{{item.icon}}"
size="42rpx"
color="{{currentMode === item.id ? '#453900' : '#605b50'}}"
custom-class="cb-mode-card__icon" />
<text class="cb-mode-card__label">{{item.title}}</text>
<text class="cb-mode-card__desc">{{item.subtitle}}</text>
</view>
</view>
</view>
<view class="cb-section">
<text class="cb-section-title">内容设置</text>
<view class="cb-content-panel">
<view class="cb-field">
<view wx:if="{{showCustomPanel}}" class="cb-field">
<view class="cb-field-header">
<text class="cb-field-label">自定义文字</text>
<text class="cb-field-hint">最多输入11个字</text>
<text class="cb-field-hint"
>最多输入{{pickerMax}}个字</text
>
</view>
<view class="cb-input-wrapper">
<input
@@ -42,7 +67,9 @@
<view class="cb-field">
<view class="cb-field-header">
<text class="cb-field-label">已选文字</text>
<text class="cb-field-label"
>已选文字 ({{words.length}}/{{pickerMax}})</text
>
<text class="cb-field-hint">点击文字可删除</text>
</view>
<scroll-view scroll-x class="cb-word-scroll">
@@ -123,7 +150,7 @@
show="{{showSelectWordPopup}}"
selectedWords="{{selectedWords}}"
currentTab="{{pickerCurrentTab}}"
max="11"
max="{{pickerMax}}"
bind:onClose="closeSelectWordPopup"
bind:onChange="onChangeWord" />
@@ -0,0 +1,137 @@
import { GRID_COLORS } from '../../core/data/tracingStyles';
/**
* cnchar-data 1024×1024 坐标系
*/
export const CHAR_BOUNDS = {
minX: 0,
minY: -124,
maxX: 1024,
maxY: 900,
};
export const CHAR_WIDTH = CHAR_BOUNDS.maxX - CHAR_BOUNDS.minX;
export const CHAR_HEIGHT = CHAR_BOUNDS.maxY - CHAR_BOUNDS.minY;
export interface ScalingTransform {
xOffset: number;
yOffset: number;
scale: number;
}
export function getScalingTransform(
width: number,
height: number,
padding: number,
): ScalingTransform {
const availableWidth = width - 2 * padding;
const availableHeight = height - 2 * padding;
const scaleX = availableWidth / CHAR_WIDTH;
const scaleY = availableHeight / CHAR_HEIGHT;
const scale = Math.min(scaleX, scaleY);
const scaledWidth = CHAR_WIDTH * scale;
const scaledHeight = CHAR_HEIGHT * scale;
const centerX = padding + (availableWidth - scaledWidth) / 2;
const centerY = padding + (availableHeight - scaledHeight) / 2;
const xOffset = -CHAR_BOUNDS.minX * scale + centerX;
const yOffset = -CHAR_BOUNDS.minY * scale + centerY;
return { xOffset, yOffset, scale };
}
/**
* SVG 路径解析与绘制(支持 M/L/Q/Z 命令)
*/
export function drawSvgPath(ctx: RenderingContext, pathD: string) {
const commandRegex = /([MLQZ])([^MLQZ]*?)(?=[MLQZ]|$)/gi;
const commands: Array<{ cmd: string; coords: string }> = [];
let match;
while ((match = commandRegex.exec(pathD)) !== null) {
commands.push({ cmd: match[1].toUpperCase(), coords: match[2].trim() });
}
const parseCoords = (coords: string): number[] =>
coords
.split(/[\s,]+/)
.filter((part) => part.trim() !== '')
.map((part) => parseFloat(part));
for (const { cmd, coords } of commands) {
const v = parseCoords(coords);
switch (cmd) {
case 'M':
if (v.length >= 2) ctx.moveTo(v[0], v[1]);
break;
case 'L':
if (v.length >= 2) ctx.lineTo(v[0], v[1]);
break;
case 'Q':
if (v.length >= 4) ctx.quadraticCurveTo(v[0], v[1], v[2], v[3]);
break;
case 'Z':
ctx.closePath();
break;
}
}
}
const GRID_CROSS_DASH = [4, 3];
export interface DrawTianZiGridParams {
ctx: RenderingContext;
/** 田字格中心 x */
cx: number;
/** 田字格中心 y */
cy: number;
/** 田字格边长 */
size: number;
borderColor?: string;
middleLineColor?: string;
diagonalColor?: string;
/** 是否绘制对角线(X线),默认不绘制 */
showDiagonal?: boolean;
}
export function drawTianZiGrid({
ctx,
cx,
cy,
size,
borderColor = GRID_COLORS.border,
middleLineColor = GRID_COLORS.middleLine,
diagonalColor = GRID_COLORS.diagonal,
showDiagonal = false,
}: DrawTianZiGridParams) {
const half = size / 2;
// 外边框 — 实线
ctx.strokeStyle = borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(cx - half, cy - half, size, size);
// 十字线 — 虚线
ctx.strokeStyle = middleLineColor;
ctx.setLineDash(GRID_CROSS_DASH);
ctx.beginPath();
ctx.moveTo(cx, cy - half);
ctx.lineTo(cx, cy + half);
ctx.moveTo(cx - half, cy);
ctx.lineTo(cx + half, cy);
ctx.stroke();
// 对角线 — 虚线
if (showDiagonal) {
ctx.strokeStyle = diagonalColor;
ctx.setLineDash(GRID_CROSS_DASH);
ctx.beginPath();
ctx.moveTo(cx - half, cy - half);
ctx.lineTo(cx + half, cy + half);
ctx.moveTo(cx + half, cy - half);
ctx.lineTo(cx - half, cy + half);
ctx.stroke();
}
ctx.setLineDash([]);
}
@@ -0,0 +1,266 @@
import { getJson } from '../../utils/http';
/**
* SVG汉字数据缓存配置
*/
const CACHE_CONFIG = {
key: 'svgWordsData', // 缓存键名
expireDays: 7, // 缓存过期天数
// url: 'https://cdn.joeyone.cn/doodle/words-svg-all.json' // 远程数据地址
// url: 'https://cdn.joeyone.cn/doodle/words-svg-3000.json' // 远程数据地址
// url: 'https://cdn.joeyone.cn/doodle/char_svg_3500.json' // 远程数据地址
url: 'https://cdn.joeyone.cn/doodle/hanzi/hanzi_stroke_3500.json', // 远程数据地址
hanziReadingsUrl:
'https://cdn.joeyone.cn/doodle/hanzi/hanzi_readings_3500.json', // 远程数据地址
};
/**
* 汉字读音数据缓存配置
*/
const READINGS_CACHE_CONFIG = {
key: 'hanziReadingsData',
expireDays: 7,
url: CACHE_CONFIG.hanziReadingsUrl,
};
/**
* 汉字读音原始数据条目:
* - 单音字:[pinyin, gloss, introduce]
* - 多音字:[[pinyin, gloss, introduce], ...]
*/
export type HanziReadingEntry =
| [string, string, string]
| [string, string, string][];
export type HanziReadingsMap = Record<string, HanziReadingEntry>;
/**
* 缓存数据结构
*/
interface CacheData {
data: Record<string, string[]>; // SVG汉字数据
timestamp: number; // 缓存时间戳
version?: string; // 数据版本(可选)
}
/**
* 检查缓存是否过期
* @param timestamp 缓存时间戳
* @returns 是否过期
*/
function isCacheExpired(timestamp: number): boolean {
const now = Date.now();
const expireTime = CACHE_CONFIG.expireDays * 24 * 60 * 60 * 1000; // 7天的毫秒数
return now - timestamp > expireTime;
}
/**
* 从本地存储获取缓存数据
* @returns 缓存数据或null
*/
function getCacheData(): CacheData | null {
try {
const cacheStr = wx.getStorageSync(CACHE_CONFIG.key);
if (!cacheStr) {
return null;
}
const cacheData: CacheData = JSON.parse(cacheStr);
// 检查缓存是否过期
if (isCacheExpired(cacheData.timestamp)) {
console.log('SVG汉字数据缓存已过期,将重新获取');
// 清除过期缓存
wx.removeStorageSync(CACHE_CONFIG.key);
return null;
}
console.log(
'使用本地缓存的SVG汉字数据,缓存时间:',
new Date(cacheData.timestamp).toLocaleString(),
);
return cacheData;
} catch (error) {
console.error('读取SVG汉字数据缓存失败:', error);
return null;
}
}
/**
* 保存数据到本地存储
* @param data SVG汉字数据
*/
function saveCacheData(data: Record<string, string[]>): void {
try {
const cacheData: CacheData = {
data,
timestamp: Date.now(),
version: '1.0', // 可以用于后续版本控制
};
wx.setStorageSync(CACHE_CONFIG.key, JSON.stringify(cacheData));
console.log('SVG汉字数据已缓存到本地存储');
} catch (error) {
console.error('保存SVG汉字数据缓存失败:', error);
}
}
/**
* 从远程服务器获取SVG汉字数据
* @returns Promise<Record<string, string[]>>
*/
async function fetchRemoteData(): Promise<Record<string, string[]>> {
console.log('从远程服务器获取SVG汉字数据...');
const svgWordsData = await getJson<Record<string, string[]>>(
CACHE_CONFIG.url,
{
timeout: 15000, // 15秒超时
header: {
Accept: 'application/json',
'Cache-Control': 'no-cache',
},
},
);
console.log(
'远程SVG汉字数据获取成功,共',
Object.keys(svgWordsData).length,
'个汉字',
);
return svgWordsData;
}
/**
* 获取SVG汉字数据(带缓存机制)
* 优先使用本地缓存,如果缓存不存在或已过期,则从远程获取
* @returns Promise<Record<string, string[]>>
*/
export async function getWordsSvgData(): Promise<Record<string, string[]>> {
try {
// 1. 尝试从本地缓存获取数据
const cacheData = getCacheData();
if (cacheData) {
return cacheData.data;
}
// 2. 缓存不存在或已过期,从远程获取
const remoteData = await fetchRemoteData();
// 3. 保存到本地缓存
saveCacheData(remoteData);
return remoteData;
} catch (error) {
console.error('获取SVG汉字数据失败:', error);
throw error;
}
}
/**
* 获取汉字读音数据(带缓存机制)
* 数据格式参见 HanziReadingsMap 类型说明,单音字为一维数组,多音字为二维数组。
*/
export async function getHanziReadingsData(): Promise<HanziReadingsMap> {
try {
const cacheStr = wx.getStorageSync(READINGS_CACHE_CONFIG.key);
if (cacheStr) {
const cacheData = JSON.parse(cacheStr) as {
data: HanziReadingsMap;
timestamp: number;
};
const expireMs =
READINGS_CACHE_CONFIG.expireDays * 24 * 60 * 60 * 1000;
if (Date.now() - cacheData.timestamp <= expireMs) {
return cacheData.data;
}
wx.removeStorageSync(READINGS_CACHE_CONFIG.key);
}
} catch (error) {
console.error('读取汉字读音缓存失败:', error);
}
const remote = await getJson<HanziReadingsMap>(READINGS_CACHE_CONFIG.url, {
timeout: 15000,
header: {
Accept: 'application/json',
'Cache-Control': 'no-cache',
},
});
try {
wx.setStorageSync(
READINGS_CACHE_CONFIG.key,
JSON.stringify({ data: remote, timestamp: Date.now() }),
);
} catch (error) {
console.error('保存汉字读音缓存失败:', error);
}
return remote;
}
/**
* 取出汉字的主拼音(多音字取第一个发音),未命中或数据非法时返回空串。
*/
export function pickPrimaryPinyin(
readings: HanziReadingsMap,
char: string,
): string {
const entry = readings[char];
if (!entry) return '';
if (typeof entry[0] === 'string') {
return (entry as [string, string, string])[0] || '';
}
const first = (entry as [string, string, string][])[0];
return Array.isArray(first) ? first[0] || '' : '';
}
/**
* 清除SVG汉字数据缓存
*/
export function clearWordsSvgCache(): void {
try {
wx.removeStorageSync(CACHE_CONFIG.key);
console.log('SVG汉字数据缓存已清除');
} catch (error) {
console.error('清除SVG汉字数据缓存失败:', error);
}
}
/**
* 获取缓存信息
* @returns 缓存信息对象
*/
export function getCacheInfo(): {
exists: boolean;
timestamp?: number;
isExpired?: boolean;
daysLeft?: number;
} {
try {
const cacheStr = wx.getStorageSync(CACHE_CONFIG.key);
if (!cacheStr) {
return { exists: false };
}
const cacheData: CacheData = JSON.parse(cacheStr);
const isExpired = isCacheExpired(cacheData.timestamp);
const daysLeft = isExpired
? 0
: Math.ceil(
(CACHE_CONFIG.expireDays * 24 * 60 * 60 * 1000 -
(Date.now() - cacheData.timestamp)) /
(24 * 60 * 60 * 1000),
);
return {
exists: true,
timestamp: cacheData.timestamp,
isExpired,
daysLeft,
};
} catch (error) {
console.error('获取缓存信息失败:', error);
return { exists: false };
}
}