feat: 汉字每天打卡基本完成,细节还待优化
This commit is contained in:
@@ -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 路径命令
|
||||
// 支持 M(moveTo)、L(lineTo)、Q(二次贝塞尔曲线)、Z(closePath)
|
||||
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([]);
|
||||
}
|
||||
+82
-2
@@ -1,4 +1,4 @@
|
||||
import { getJson } from './http';
|
||||
import { getJson } from '../../utils/http';
|
||||
|
||||
/**
|
||||
* SVG汉字数据缓存配置
|
||||
@@ -9,9 +9,30 @@ const CACHE_CONFIG = {
|
||||
// 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/char_common_stroke.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>;
|
||||
|
||||
/**
|
||||
* 缓存数据结构
|
||||
*/
|
||||
@@ -135,6 +156,65 @@ export async function getWordsSvgData(): Promise<Record<string, string[]>> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取汉字读音数据(带缓存机制)
|
||||
* 数据格式参见 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汉字数据缓存
|
||||
*/
|
||||
@@ -37,6 +37,34 @@
|
||||
overflow-y: auto;
|
||||
height: 500rpx;
|
||||
|
||||
.word-section {
|
||||
margin-bottom: 36rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.word-section-title {
|
||||
font-size: 26rpx;
|
||||
color: #888888;
|
||||
padding-left: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 6rpx;
|
||||
height: 24rpx;
|
||||
background-color: #93d333;
|
||||
margin-right: 12rpx;
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.word-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 72rpx);
|
||||
|
||||
@@ -31,17 +31,42 @@
|
||||
wx:key="categoryIndex"
|
||||
title="{{wordList[categoryIndex].icon}} {{wordList[categoryIndex].categoryName}}">
|
||||
<view class="word-list-wrapper">
|
||||
<view class="word-list">
|
||||
<block
|
||||
wx:if="{{wordList[categoryIndex].sections && wordList[categoryIndex].sections.length > 0}}">
|
||||
<view
|
||||
class="word-item {{wxs.isSelected(selectedWords, item) ? 'selected' : ''}}"
|
||||
wx:for="{{wordList[categoryIndex].words}}"
|
||||
wx:key="index"
|
||||
data-word="{{item}}"
|
||||
bind:tap="onWordClick">
|
||||
<text class="word-item-text">{{item}}</text>
|
||||
<!-- <text class="word-item-icon icon-success"></text> -->
|
||||
class="word-section"
|
||||
wx:for="{{wordList[categoryIndex].sections}}"
|
||||
wx:for-item="section"
|
||||
wx:for-index="sectionIndex"
|
||||
wx:key="sectionIndex">
|
||||
<view class="word-section-title"
|
||||
>{{section.sectionName}}</view
|
||||
>
|
||||
<view class="word-list">
|
||||
<view
|
||||
class="word-item {{wxs.isSelected(selectedWords, item) ? 'selected' : ''}}"
|
||||
wx:for="{{section.words}}"
|
||||
wx:key="index"
|
||||
data-word="{{item}}"
|
||||
bind:tap="onWordClick">
|
||||
<text class="word-item-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="word-list">
|
||||
<view
|
||||
class="word-item {{wxs.isSelected(selectedWords, item) ? 'selected' : ''}}"
|
||||
wx:for="{{wordList[categoryIndex].words}}"
|
||||
wx:key="index"
|
||||
data-word="{{item}}"
|
||||
bind:tap="onWordClick">
|
||||
<text class="word-item-text">{{item}}</text>
|
||||
<!-- <text class="word-item-icon icon-success"></text> -->
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="word-item-empty"></view>
|
||||
</view>
|
||||
</van-tab>
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
// 为避免保存时的自动格式化将数组元素强制换行,保持紧凑展示
|
||||
// prettier-ignore
|
||||
export const WORDS = [
|
||||
{
|
||||
categoryId: 26,
|
||||
icon: '🎒',
|
||||
categoryName: '一年级',
|
||||
sections: [
|
||||
{
|
||||
sectionName: '一年级上册',
|
||||
words: [
|
||||
'天', '地', '人', '你', '我', '他', '一', '二', '三', '四', '五', '上', '下', '口', '耳', '目', '手', '足', '站', '坐', '日', '月', '水', '火', '山', '石', '田', '禾', '对', '云', '雨', '风', '花', '鸟', '虫', '六', '七', '八', '九', '十', '爸', '妈', '马', '土', '不', '画', '打', '棋', '鸡', '字', '词', '语', '句', '子', '桌', '纸', '文', '数', '学', '音', '乐', '妹', '奶', '白', '皮', '草', '家', '是', '小', '桥', '台', '车', '羊', '走', '也', '雪', '儿', '秋', '气', '了', '树', '叶', '片', '大', '飞', '会', '个', '的', '船', '两', '头', '在', '里', '看', '见', '闪', '星', '江', '南', '可', '采', '莲', '鱼', '东', '西', '北', '尖', '说', '春', '青', '蛙', '夏', '弯', '冬', '男', '女', '开', '关', '正', '反', '远', '有', '色', '近', '听', '无', '声', '去', '还', '来', '多', '少', '黄', '牛', '只', '猫', '边', '鸭', '苹', '果', '杏', '桃', '书', '包', '尺', '作', '业', '本', '笔', '刀', '课', '早', '校', '明', '力', '尘', '从', '众', '双', '木', '林', '森', '条', '心', '升', '国', '旗', '中', '红', '歌', '起', '么', '美', '丽', '立', '午', '晚', '昨', '今', '年', '影', '前', '后', '黑', '狗', '左', '右', '它', '好', '朋', '友', '比', '尾', '巴', '谁', '长', '短', '把', '伞', '兔', '最', '公', '写', '诗', '点', '要', '过', '给', '当', '串', '们', '以', '成', '彩', '半', '空', '问', '到', '方', '没', '更', '绿', '出', '睡', '那', '海', '真', '老', '师', '吗', '同', '什', '才', '亮', '时', '候', '觉', '得', '自', '己', '很', '穿', '衣', '服', '门', '快', '蓝', '又', '笑', '着', '向', '和', '贝', '娃', '挂', '活', '金', '哥', '姐', '弟', '叔', '爷', '群', '竹', '牙', '用', '几', '步', '为', '参', '加', '洞', '乌', '鸦', '处', '找', '办', '旁', '许', '法', '放', '进', '高', '住', '孩', '玩', '吧', '发', '芽', '爬', '呀', '久', '回', '全', '变', '工', '厂', '医', '院', '生'
|
||||
]
|
||||
},
|
||||
{
|
||||
sectionName: '一年级下册',
|
||||
words: [
|
||||
'春', '夏', '秋', '冬', '风', '雪', '花', '入', '吹', '落', '降', '飘', '池', '游', '姓', '氏', '李', '张', '古', '吴', '赵', '钱', '孙', '周', '王', '官', '什', '么', '双', '国', '方', '青', '清', '气', '晴', '情', '请', '生', '眼', '睛', '保', '护', '害', '事', '让', '病', '相', '遇', '喜', '欢', '怕', '言', '互', '令', '动', '万', '纯', '净', '阴', '雷', '电', '阵', '雨', '冰', '冻', '夹', '吃', '忘', '井', '村', '叫', '毛', '主', '席', '乡', '亲', '战', '士', '面', '想', '告', '诉', '路', '京', '安', '门', '广', '非', '常', '壮', '观', '接', '觉', '再', '做', '各', '种', '样', '梦', '伙', '伴', '却', '趣', '这', '些', '都', '太', '阳', '道', '送', '忙', '尝', '香', '甜', '温', '暖', '该', '颜', '因', '只', '窝', '孤', '单', '邻', '居', '招', '呼', '静', '夜', '床', '光', '疑', '举', '望', '低', '故', '色', '外', '看', '爸', '晚', '笑', '偏', '散', '勇', '敢', '往', '胆', '微', '端', '粽', '节', '叶', '分', '总', '米', '间', '肉', '带', '知', '据', '念', '红', '小', '豆', '虹', '座', '浇', '提', '洒', '水', '挑', '兴', '镜', '照', '千', '秋', '裙', '蜻', '体', '之', '初', '性', '善', '专', '幼', '玉', '器', '义', '教', '首', '眠', '处', '闻', '散', '歌', '惜', '柔', '露', '荷', '珠', '摇', '篮', '晶', '停', '坪', '透', '翅', '膀', '蹲', '嘻', '展', '叹', '哈', '呀', '钟', '元', '洗', '背', '汽', '决', '定', '交', '共', '已', '经', '物', '外', '看', '爸', '晚', '笑', '偏', '散', '勇', '敢', '往', '胆', '微', '端', '粽', '节', '叶', '分', '总', '米', '间', '肉', '带', '知', '据', '念', '红', '小', '豆', '虹', '座', '浇', '提', '洒', '水', '挑', '兴', '镜', '照', '千', '秋', '裙', '蜻', '蜓', '迷', '藏', '造', '蚂', '蚁', '食', '粮', '蜘', '蛛', '网', '凉', '细', '夕', '李', '语', '香', '操', '场', '拔', '拍', '跑', '踢', '铃', '热', '闹', '锻', '炼', '体', '之', '初', '性', '善', '专', '幼', '玉', '器', '义', '教', '首', '眠', '处', '闻', '散', '歌', '惜', '柔', '露', '荷', '珠', '摇', '篮', '晶', '停', '坪', '透', '翅', '膀', '蹲', '嘻', '展', '叹', '哈', '呀', '钟', '元', '洗', '背', '汽', '决', '定', '交', '共', '已', '经', '物', '虎', '熊', '准', '备', '第', '次', '播', '百', '齐', '盼', '店', '迈', '卖', '怎', '独', '绳', '讲', '得', '阻', '领', '壁', '借', '健', '康', '寿', '新'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
categoryId: 27,
|
||||
icon: '🏫',
|
||||
categoryName: '二年级',
|
||||
sections: [
|
||||
{
|
||||
sectionName: '二年级上册',
|
||||
words: [
|
||||
'塘', '脑', '袋', '灰', '哇', '教', '捕', '迎', '阿', '姨', '宽', '龟', '顶', '披', '鼓', '两', '哪', '睛', '肚', '皮', '孩', '跳', '晒', '极', '傍', '管', '越', '滴', '溪', '奔', '坏', '淹', '没', '冲', '毁', '屋', '猜', '变', '片', '海', '作', '给', '带', '植', '如', '为', '旅', '靠', '备', '纷', '刺', '底', '炸', '离', '察', '识', '粗', '得', '套', '帽', '登', '鞋', '裤', '图', '意', '指', '针', '滩', '艘', '军', '舰', '帆', '稻', '园', '孔', '翠', '队', '铜', '号', '领', '巾', '杨', '壮', '桐', '枫', '松', '柏', '装', '桦', '耐', '守', '疆', '银', '杉', '化', '桂', '牢', '记', '歌', '丛', '深', '处', '六', '熊', '猫', '九', '朋', '友', '季', '蝴', '蝶', '麦', '苗', '嫩', '桑', '肥', '农', '归', '戴', '场', '谷', '粒', '虽', '辛', '苦', '洋', '了', '葡', '萄', '紫', '狐', '狸', '笨', '酸', '曹', '称', '员', '柱', '议', '论', '重', '杆', '秤', '砍', '倒', '割', '线', '止', '量', '级', '术', '由', '挥', '粉', '板', '妙', '瓶', '合', '盛', '丑', '兴', '奋', '干', '标', '补', '认', '哄', '先', '闭', '紧', '润', '蛋', '等', '吸', '发', '粘', '意', '额', '沙', '乏', '弹', '钢', '琴', '泥', '滚', '铁', '环', '荡', '滑', '梯', '楼', '依', '尽', '欲', '穷', '层', '瀑', '布', '炉', '烟', '遥', '川', '闻', '名', '景', '区', '省', '部', '秀', '神', '尤', '其', '仙', '巨', '位', '著', '形', '状', '湖', '绕', '围', '胜', '央', '岛', '华', '隐', '约', '纱', '童', '境', '引', '客', '沟', '产', '梨', '份', '种', '搭', '棚', '淡', '够', '好', '收', '城', '市', '留', '定', '利', '分', '味', '康', '寿', '轿', '救', '摩', '托', '防', '渔', '货', '科', '考', '宿', '寺', '危', '辰', '恐', '惊', '似', '庐', '笼', '苍', '茫', '雾', '淘', '气', '于', '暗', '岸', '街', '梁', '甚', '至', '切', '躲', '失', '呀', '累', '淋', '灭', '激', '唱', '赶', '旺', '旁', '浑', '谁', '轻', '汽', '假', '威', '寻', '扑', '转', '扯', '嗓', '派', '抗', '爪', '趟', '神', '猪', '受', '骗', '酪', '捡', '俩', '始', '拌', '帮', '匀', '嚷', '瞧', '剩', '整', '筝', '鼠', '折', '漂', '啦', '抓', '幸', '福', '受', '愿', '哭', '取', '抽', '续', '吸', '极', '弯', '表', '示', '汗', '责', '伤', '路', '急', '帮', '泼', '族', '民', '度', '敲', '龙', '驶', '铺', '盛', '碗', '朱', '德', '扁', '担', '志', '伍', '敌', '抽', '仗', '难', '陡', '难', '占', '攻', '洪', '毒', '蛇', '兽', '伤', '灾', '难', '退', '被', '耕', '恢', '复', '治', '葫', '芦', '藤', '谢', '蚜', '盯', '赛', '感', '怪', '慢', '治'
|
||||
]
|
||||
},
|
||||
{
|
||||
sectionName: '二年级下册',
|
||||
words: [
|
||||
'诗', '村', '童', '碧', '妆', '绿', '丝', '剪', '冲', '寻', '姑', '娘', '吐', '柳', '荡', '桃', '杏', '鲜', '邮', '递', '员', '原', '叔', '局', '堆', '礼', '邓', '植', '格', '引', '注', '满', '休', '息', '锋', '昨', '冒', '留', '弯', '背', '洒', '温', '暖', '能', '桌', '味', '买', '具', '甘', '甜', '菜', '劳', '匹', '妹', '波', '纹', '像', '景', '恋', '舍', '求', '州', '湾', '岛', '港', '民', '族', '谊', '齐', '奋', '贴', '街', '舟', '艾', '敬', '转', '团', '热', '闹', '贝', '壳', '甲', '骨', '钱', '币', '与', '财', '关', '烧', '茄', '烤', '鸭', '肉', '鸡', '蛋', '炒', '饭', '彩', '梦', '森', '拉', '结', '苹', '般', '精', '灵', '伞', '边', '意', '弟', '便', '教', '游', '戏', '母', '周', '围', '句', '补', '充', '药', '合', '死', '记', '屁', '股', '尿', '净', '屎', '幸', '使', '劲', '亡', '牢', '钻', '劝', '丢', '告', '筋', '疲', '图', '课', '摆', '座', '交', '哈', '页', '抢', '嘻', '愿', '意', '麦', '该', '伯', '刻', '突', '掉', '湖', '莲', '穷', '荷', '绝', '含', '岭', '吴', '博', '馆', '览', '器', '梢', '帮', '助', '导', '碰', '特', '积', '密', '稀', '针', '沟', '闯', '川', '灾', '户', '容', '易', '损', '失', '赚', '赔', '购', '贫', '富', '竖', '茸', '兔', '撑', '扇', '慢', '遇', '丰', '最', '喂', '痛', '耷', '拉', '视', '频', '织', '编', '怎', '抽', '纺', '阻', '碍', '阻', '怜', '扁', '敏', '挣', '脱', '绒', '毛', '扫', '拂', '帚', '抹', '擦', '拖', '倒', '桶', '缸', '脏', '熟', '祖', '浓', '望', '蓝', '摘', '掏', '赛', '忆', '双', '啊', '逮', '蝈', '蛛', '蜘', '蝉', '挖', '铲', '由', '先', '绿', '边', '天', '野', '果', '雀', '跑', '红', '蜻', '蜓', '捉', '简', '单', '纪', '幼', '粗', '糙', '睁', '式', '此', '重', '复'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
categoryId: 0,
|
||||
icon: '🌟',
|
||||
|
||||
@@ -19,7 +19,7 @@ export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
|
||||
{
|
||||
id: 'letter-tracing-single',
|
||||
icon: 'start-a',
|
||||
title: '默认字帖',
|
||||
title: '字母默认字帖',
|
||||
subtitle: '配图、例句与描红',
|
||||
ageMin: 4,
|
||||
ageMax: 7,
|
||||
@@ -30,7 +30,7 @@ export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
|
||||
{
|
||||
id: 'letter-tracing-upper-lower',
|
||||
icon: 'draw-o',
|
||||
title: '基础描红',
|
||||
title: '字母基础描红',
|
||||
subtitle: 'Uppercase / Lowercase 总览',
|
||||
ageMin: 5,
|
||||
ageMax: 7,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// 导出HTTP模块
|
||||
export * from './http';
|
||||
|
||||
// 导出SVG汉字数据获取模块
|
||||
export * from './getWordsSvgJson';
|
||||
|
||||
export async function getMiniCodeImage(canvas: Canvas) {
|
||||
return getImage(canvas, '/assets/imgs/doodle-mini-logo.jpg');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user