Files
doodle-mini/miniprogram/chinesePages/handwritingSheet/handwritingSheet.ts
T
2026-05-25 14:18:28 +08:00

530 lines
17 KiB
TypeScript

import WordDrawService from './draw/wordDrawService';
import WordDailyCheckinDraw, {
DailyCheckinHanziItem,
} from './draw/wordDailyCheckinDraw';
import {
getWordsSvgData,
getHanziReadingsData,
pickPrimaryPinyin,
HanziReadingsMap,
} from '../shared/getWordsSvgJson';
import { WORDS, getCategoryTabIndex } from '../../core/data/words';
import { CharacterItem } from '../../types/characterType';
import tracker from '../../utils/tracker';
import { createPage } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
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 CUSTOM_MAX_WORDS = 11;
const CHECKIN_MAX_WORDS = 8;
const RANDOM_WORD_COUNT = 8;
const CUSTOM_DEFAULT_WORDS = '东西南北日月山河风雨';
/** 汉字每日打卡默认文案(8 字,与 CHECKIN_MAX_WORDS 一致) */
const CHECKIN_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>,
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,
/** 练习字表(唯一数据源);inputValue 与弹窗均为其编辑入口 */
words: [] as string[],
inputValue: '',
showSelectWordPopup: false,
pickerCurrentTab: 0,
pickerMax: CUSTOM_MAX_WORDS,
categoryTags: CUSTOM_CATEGORY_TAGS,
showCustomPanel: true,
showShareDialog: false,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null as DebugPublishMeta | null,
},
async onLoad(options: { id?: string }) {
this.syncDebugPublishEnv();
this.loadFavoritedMap();
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();
},
async loadSvgWords() {
try {
wx.showToast({ title: '加载字体中...', icon: 'loading' });
this.svgWords = await getWordsSvgData();
wx.hideToast();
if (this.data.words.length > 0) {
this.renderPracticeContent().catch(console.error);
}
} catch (error) {
console.error('加载SVG汉字数据失败:', error);
wx.hideToast();
wx.showModal({
title: '加载失败',
content: '无法加载汉字数据,请检查网络连接后重试',
showCancel: true,
cancelText: '取消',
confirmText: '重试',
success: (res) => {
if (res.confirm) {
this.loadSvgWords();
}
},
});
}
},
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;
this.canvas = canvas;
this.ctx = ctx;
this.wordDrawService = new WordDrawService(canvas, ctx);
this.dailyDrawService = new WordDailyCheckinDraw(canvas, ctx);
await this.prepareCanvasForCurrentMode();
if (this.data.words.length > 0) {
await this.renderPracticeContent();
}
},
async prepareCanvasForCurrentMode() {
if (!this.canvas) return;
if (this.data.currentMode === 'hanzi-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 === 'hanzi-daily-checkin';
const max = isCheckin ? CHECKIN_MAX_WORDS : CUSTOM_MAX_WORDS;
this.setData(
{
currentMode: mode,
functionId: mode,
pickerMax: max,
showCustomPanel: true,
},
() => {
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[id] = next;
if (next) {
await addFavorite(id);
} else {
await removeFavorite(id);
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = HANDWRITING_SHEET_WORKSHEET_DEFINITIONS.map(
(d) => d.id,
);
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[this.data.currentMode]) {
this.setData({ isPreviewFavorite: true });
}
},
onInputChange(e: WechatMiniprogram.Input) {
const value = e.detail.value as string;
this.syncWordsFromInput(value, false);
},
onInputConfirm(e: WechatMiniprogram.Input) {
const value = e.detail.value as string;
this.syncWordsFromInput(value, true);
},
onClearInput() {
this.clearWords();
},
getMaxWordsForMode(): number {
return this.data.currentMode === 'hanzi-daily-checkin'
? CHECKIN_MAX_WORDS
: CUSTOM_MAX_WORDS;
},
/** 以 words 为准,同步输入框镜像并刷新预览 */
applyWords(nextWords: string[], options?: { closePicker?: boolean }) {
const inputValue = nextWords.join('');
const patch: Record<string, unknown> = {
words: nextWords,
inputValue,
};
if (options?.closePicker) {
patch.showSelectWordPopup = false;
}
this.setData(patch, () =>
this.renderPracticeContent().catch(console.error),
);
},
syncWordsFromInput(value: string, showLimitToast: boolean) {
const max = this.getMaxWordsForMode();
const parsed = this.splitToSingleChars(value);
const nextWords = parsed.slice(0, max);
if (showLimitToast && parsed.length > max) {
wx.showToast({
title: `最多输入 ${max} 个字`,
icon: 'none',
});
}
this.applyWords(nextWords);
},
openSelectWordPopup() {
this.setData({ showSelectWordPopup: true, pickerCurrentTab: 0 });
},
closeSelectWordPopup() {
this.setData({ showSelectWordPopup: false });
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const categoryId = Number(e.currentTarget.dataset.categoryId);
this.setData({
showSelectWordPopup: true,
pickerCurrentTab: getCategoryTabIndex(categoryId),
});
},
onChangeWord(e: WechatMiniprogram.CustomEvent) {
const max = this.getMaxWordsForMode();
const nextWords = e.detail.selectedWords as string[];
if (nextWords.length > max) {
wx.showToast({
title: `最多只能添加 ${max} 个字`,
icon: 'none',
});
return;
}
this.applyWords(nextWords, { closePicker: true });
},
deleteWordChip(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index);
if (Number.isNaN(index)) return;
const words = [...(this.data.words as string[])];
if (index < 0 || index >= words.length) return;
words.splice(index, 1);
this.applyWords(words);
},
clearWords() {
this.applyWords([], { closePicker: true });
},
initDefaultWords() {
if (this.data.currentMode === 'hanzi-daily-checkin') {
const defaults = this.getSupportedWords(
this.splitToSingleChars(CHECKIN_DEFAULT_WORDS),
).slice(0, CHECKIN_MAX_WORDS);
if (defaults.length === 0) {
this.refreshWords();
return;
}
this.applyWords(defaults);
return;
}
const defaults = this.getSupportedWords(
this.splitToSingleChars(CUSTOM_DEFAULT_WORDS),
).slice(0, CUSTOM_MAX_WORDS);
if (defaults.length === 0) {
this.refreshWords();
return;
}
this.applyWords(defaults);
},
refreshWords() {
const isCheckin = this.data.currentMode === 'hanzi-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 limit = isCheckin ? CHECKIN_MAX_WORDS : RANDOM_WORD_COUNT;
const supported = this.getSupportedWords(shuffled).slice(0, limit);
if (supported.length === 0) {
wx.showToast({
title: '随机到的字暂不支持,重试一下',
icon: 'none',
});
return;
}
this.applyWords(supported);
},
splitToSingleChars(text: string): string[] {
const matches = text.match(/[\u4e00-\u9fff]/g);
return matches || [];
},
async renderPracticeContent() {
if (!this.canvas) return;
const words = this.data.words as string[];
if (this.data.currentMode === 'hanzi-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 });
return;
}
const supportedWords = this.getSupportedWords(words);
if (supportedWords.length === 0) {
wx.showToast({ title: '暂不支持这些汉字', icon: 'none' });
await this.wordDrawService.drawContentEmpty();
this.setData({ hasContent: false });
return;
}
let maxRow = this.maxRow;
let maxCol = this.maxCol;
if (!maxRow || !maxCol) {
const layout = this.wordDrawService.getMaxGridLayout();
this.maxRow = layout.maxRow;
this.maxCol = layout.maxCol;
maxRow = layout.maxRow;
maxCol = layout.maxCol;
}
const characterData = this.processCharacterLayout(
supportedWords,
maxRow,
maxCol,
);
await this.wordDrawService.drawPracticeContent(characterData);
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]);
},
processCharacterLayout(
words: string[],
maxRow: number,
maxCol: number,
): CharacterItem[] {
let rowIndex = 0;
const characterData: CharacterItem[] = [];
words.forEach((word: string) => {
const strokes = this.svgWords[word];
const strokeCount = strokes.length;
const totalCells = 1 + strokeCount;
const totalRows = Math.ceil(totalCells / maxCol);
rowIndex += totalRows;
if (rowIndex <= maxRow) {
characterData.push({ character: word, strokes });
}
});
return characterData;
},
onShare() {},
onShareAppMessage() {
tracker.reportShare('练字贴');
return {
...defaultShareConfig,
path: `/chinesePages/handwritingSheet/handwritingSheet?id=${this.data.currentMode}`,
};
},
onShareTimeline() {
tracker.reportShare('练字贴');
return {
...defaultShareConfig,
query: `id=${this.data.currentMode}`,
};
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.currentMode);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup: getModeInfo,
},
);