594 lines
20 KiB
TypeScript
594 lines
20 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,
|
|
words: [] as string[],
|
|
inputWords: [] as string[],
|
|
selectedWords: [] 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;
|
|
},
|
|
|
|
syncWordsFromInput(value: string, showLimitToast: boolean) {
|
|
const max = this.getMaxWordsForMode();
|
|
const selectedWords = this.data.selectedWords as string[];
|
|
const maxInputCount = Math.max(0, max - selectedWords.length);
|
|
const nextInputWords = this.splitToSingleChars(value).slice(
|
|
0,
|
|
maxInputCount,
|
|
);
|
|
const nextWords = [...nextInputWords, ...selectedWords];
|
|
|
|
if (
|
|
showLimitToast &&
|
|
nextInputWords.length < this.splitToSingleChars(value).length
|
|
) {
|
|
wx.showToast({
|
|
title: `最多输入 ${maxInputCount} 个字`,
|
|
icon: 'none',
|
|
});
|
|
}
|
|
|
|
this.setData(
|
|
{
|
|
inputValue: nextInputWords.join(''),
|
|
inputWords: nextInputWords,
|
|
words: nextWords,
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
},
|
|
|
|
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 nextSelectedWords = e.detail.selectedWords as string[];
|
|
const inputWords = this.data.inputWords as string[];
|
|
|
|
if (inputWords.length + nextSelectedWords.length > max) {
|
|
wx.showToast({
|
|
title: `最多只能添加 ${max} 个字`,
|
|
icon: 'none',
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.setData(
|
|
{
|
|
selectedWords: nextSelectedWords,
|
|
words: [...inputWords, ...nextSelectedWords],
|
|
showSelectWordPopup: false,
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
},
|
|
|
|
deleteWordChip(e: WechatMiniprogram.TouchEvent) {
|
|
const index = Number(e.currentTarget.dataset.index);
|
|
if (Number.isNaN(index)) return;
|
|
|
|
const inputWords = [...(this.data.inputWords as string[])];
|
|
const selectedWords = [...(this.data.selectedWords as string[])];
|
|
|
|
if (index < inputWords.length) {
|
|
inputWords.splice(index, 1);
|
|
this.setData(
|
|
{
|
|
inputWords,
|
|
inputValue: inputWords.join(''),
|
|
words: [...inputWords, ...selectedWords],
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const selectedIndex = index - inputWords.length;
|
|
if (selectedIndex < 0 || selectedIndex >= selectedWords.length)
|
|
return;
|
|
|
|
selectedWords.splice(selectedIndex, 1);
|
|
this.setData(
|
|
{
|
|
selectedWords,
|
|
words: [...inputWords, ...selectedWords],
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
},
|
|
|
|
clearWords() {
|
|
this.setData(
|
|
{
|
|
words: [],
|
|
inputWords: [],
|
|
selectedWords: [],
|
|
inputValue: '',
|
|
showSelectWordPopup: false,
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
},
|
|
|
|
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.setData(
|
|
{
|
|
words: defaults,
|
|
inputWords: defaults,
|
|
selectedWords: [],
|
|
inputValue: defaults.join(''),
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const defaults = this.getSupportedWords(
|
|
this.splitToSingleChars(CUSTOM_DEFAULT_WORDS),
|
|
).slice(0, CUSTOM_MAX_WORDS);
|
|
|
|
if (defaults.length === 0) {
|
|
this.refreshWords();
|
|
return;
|
|
}
|
|
|
|
this.setData(
|
|
{
|
|
words: defaults,
|
|
inputWords: defaults,
|
|
selectedWords: [],
|
|
inputValue: defaults.join(''),
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
},
|
|
|
|
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.setData(
|
|
{
|
|
words: supported,
|
|
inputWords: supported,
|
|
selectedWords: [],
|
|
inputValue: supported.join(''),
|
|
},
|
|
() => this.renderPracticeContent().catch(console.error),
|
|
);
|
|
},
|
|
|
|
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,
|
|
},
|
|
);
|