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 { 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, readings: {} as HanziReadingsMap, readingsLoaded: false, maxRow: 0 as number, maxCol: 0 as number, _favoritedMap: {} as Record, 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 { 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 = { 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() {}, getPublishMeta(): DebugPublishMeta { const meta = getPublishMetaByMode(this.data.currentMode); if (!meta) { throw new Error('当前题型配置不存在'); } return meta; }, }, { shareConfig: defaultShareConfig, pageInfoLookup: getModeInfo, }, );