feat: 开发完成拼音每日一练

This commit is contained in:
R524809
2026-05-20 15:33:06 +08:00
parent cab4e9c33f
commit d52339c0fa
25 changed files with 1478 additions and 428 deletions
@@ -0,0 +1,121 @@
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 { PINYIN_STYLE } from './pinyinConstants';
export abstract class PinyinBaseDraw extends BaseDrawService {
/**
* 绘制一行四线三格
*/
protected drawFourLineRow(
x: number,
y: number,
cols: number,
cellW: number,
cellH: number,
) {
const ctx = this.ctx;
const totalW = cols * cellW;
const yTop = y;
const y1 = y + cellH / 3;
const y2 = y + (cellH * 2) / 3;
const yBot = y + cellH;
ctx.lineWidth = 1;
// 外边框(上下)
ctx.strokeStyle = GRID_COLORS.border;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x, yTop);
ctx.lineTo(x + totalW, yTop);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, yBot);
ctx.lineTo(x + totalW, yBot);
ctx.stroke();
// 中间虚线
ctx.strokeStyle = GRID_COLORS.middleLine;
ctx.setLineDash([...GRID_DASH]);
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x + totalW, y1);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y2);
ctx.lineTo(x + totalW, y2);
ctx.stroke();
// 垂直分割线
ctx.strokeStyle = GRID_COLORS.border;
ctx.setLineDash([]);
for (let i = 0; i <= cols; i++) {
const vx = x + i * cellW;
ctx.beginPath();
ctx.moveTo(vx, yTop);
ctx.lineTo(vx, yBot);
ctx.stroke();
}
}
/**
* 绘制单个封闭四线三格(用于逐格绘制场景,如每日打卡)
*/
protected drawFourLineGrid(
x: number,
y: number,
w: number,
h: number,
) {
const ctx = this.ctx;
const y1 = y + h / 3;
const y2 = y + (h * 2) / 3;
ctx.save();
ctx.strokeStyle = GRID_COLORS.border;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(x, y, w, h);
ctx.strokeStyle = GRID_COLORS.middleLine;
ctx.setLineDash([...GRID_DASH]);
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x + w, y1);
ctx.moveTo(x, y2);
ctx.lineTo(x + w, y2);
ctx.stroke();
ctx.restore();
}
/**
* 在四线三格中绘制拼音文本
*/
protected drawPinyinText(
pinyin: string,
cx: number,
gridY: number,
gridH: number,
fontSizeRatio: number = PINYIN_STYLE.PINYIN_FONT_SIZE_RATIO,
) {
const ctx = this.ctx;
const fontSize = Math.round(gridH * fontSizeRatio);
const baselineY = gridY + (gridH * 2) / 3;
ctx.save();
ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`;
ctx.fillStyle = TRACING_COLORS.guide;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(pinyin, cx, baselineY);
ctx.restore();
}
}
@@ -0,0 +1,20 @@
export const PINYIN_STYLE = {
MARGIN_X: 24, // 左右页边距
CELL_H: 30, // 四线三格高度
CELL_H_V2: 30, // V2 布局高度
ROW_GAP: 5, // 同一分类中,行与行之间的纵向间距
ROW_GAP_V2: 5, // V2 布局纵向间距
SECTION_GAP: 8, // 大分类之间的间距
TITLE_AFTER_GAP: 24, // 分类标题到其下方格子的间距
CONTENT_TOP_GAP: 8, // Header 分割线到第一个分类标题的间距
// 字体
SECTION_TITLE_FONT: 'bold 14px "Microsoft Yahei"',
SUB_LABEL_FONT: '11px "Microsoft Yahei"',
PINYIN_FONT_SIZE_RATIO: 0.7,
PINYIN_FONT_SIZE_RATIO_V2: 0.72,
// 颜色
SECTION_TITLE_COLOR: '#322E25',
SUB_LABEL_COLOR: '#c0392b',
};
@@ -0,0 +1,186 @@
/**
* 拼音每日一练分组数据
* 每页 4 个部分(四宫格),每个部分聚焦一个拼音及其相关练习
*/
export interface PinyinDailyTask {
char: string; // 核心字母,如 'a'
variants: string[]; // 变体,如 ['ā', 'á', 'ǎ', 'à']
type: 'shengmu' | 'yunmu' | 'zhengti';
}
export const PINYIN_DAILY_GROUPS: PinyinDailyTask[][] = [
// Day 1: 单韵母 a o e i
[
{ char: 'a', variants: ['ā', 'á', 'ǎ', 'à'], type: 'yunmu' },
{ char: 'o', variants: ['ō', 'ó', 'ǒ', 'ò'], type: 'yunmu' },
{ char: 'e', variants: ['ē', 'é', 'ě', 'è'], type: 'yunmu' },
{ char: 'i', variants: ['ī', 'í', 'ǐ', 'ì'], type: 'yunmu' },
],
// Day 2: 单韵母 u ü + 声母 b p
[
{ char: 'u', variants: ['ū', 'ú', 'ǔ', 'ù'], type: 'yunmu' },
{ char: 'ü', variants: ['ǖ', 'ǘ', 'ǚ', 'ǜ'], type: 'yunmu' },
{ char: 'b', variants: [], type: 'shengmu' },
{ char: 'p', variants: [], type: 'shengmu' },
],
// Day 3: 声母 m f d t
[
{ char: 'm', variants: [], type: 'shengmu' },
{ char: 'f', variants: [], type: 'shengmu' },
{ char: 'd', variants: [], type: 'shengmu' },
{ char: 't', variants: [], type: 'shengmu' },
],
// Day 4: 声母 n l g k
[
{ char: 'n', variants: [], type: 'shengmu' },
{ char: 'l', variants: [], type: 'shengmu' },
{ char: 'g', variants: [], type: 'shengmu' },
{ char: 'k', variants: [], type: 'shengmu' },
],
// Day 5: 声母 h j q x
[
{ char: 'h', variants: [], type: 'shengmu' },
{ char: 'j', variants: [], type: 'shengmu' },
{ char: 'q', variants: [], type: 'shengmu' },
{ char: 'x', variants: [], type: 'shengmu' },
],
// Day 6: 声母 zh ch sh r
[
{ char: 'zh', variants: [], type: 'shengmu' },
{ char: 'ch', variants: [], type: 'shengmu' },
{ char: 'sh', variants: [], type: 'shengmu' },
{ char: 'r', variants: [], type: 'shengmu' },
],
// Day 7: 声母 z c s y
[
{ char: 'z', variants: [], type: 'shengmu' },
{ char: 'c', variants: [], type: 'shengmu' },
{ char: 's', variants: [], type: 'shengmu' },
{ char: 'y', variants: [], type: 'shengmu' },
],
// Day 8: 声母 w + 复韵母 ai ei ui
[
{ char: 'w', variants: [], type: 'shengmu' },
{ char: 'ai', variants: ['āi', 'ái', 'ǎi', 'ài'], type: 'yunmu' },
{ char: 'ei', variants: ['ēi', 'éi', 'ěi', 'èi'], type: 'yunmu' },
{ char: 'ui', variants: ['uī', 'uí', 'uǐ', 'uì'], type: 'yunmu' },
],
// Day 9: 复韵母 ao ou iu ie
[
{ char: 'ao', variants: ['āo', 'áo', 'ǎo', 'ào'], type: 'yunmu' },
{ char: 'ou', variants: ['ōu', 'óu', 'ǒu', 'òu'], type: 'yunmu' },
{ char: 'iu', variants: ['iū', 'iú', 'iǔ', 'iù'], type: 'yunmu' },
{ char: 'ie', variants: ['iē', 'ié', 'iě', 'iè'], type: 'yunmu' },
],
// Day 10: 复韵母 üe er + 前鼻韵母 an en
[
{ char: 'üe', variants: ['üē', 'üé', 'üě', 'üè'], type: 'yunmu' },
{ char: 'er', variants: ['ēr', 'ér', 'ěr', 'èr'], type: 'yunmu' },
{ char: 'an', variants: ['ān', 'án', 'ǎn', 'àn'], type: 'yunmu' },
{ char: 'en', variants: ['ēn', 'én', 'ěn', 'èn'], type: 'yunmu' },
],
// Day 11: 前鼻韵母 in un ün + 后鼻韵母 ang
[
{ char: 'in', variants: ['īn', 'ín', 'ǐn', 'ìn'], type: 'yunmu' },
{ char: 'un', variants: ['ūn', 'ún', 'ǔn', 'ùn'], type: 'yunmu' },
{ char: 'ün', variants: ['ǖn', 'ǘn', 'ǚn', 'ǜn'], type: 'yunmu' },
{ char: 'ang', variants: ['āng', 'áng', 'ǎng', 'àng'], type: 'yunmu' },
],
// Day 12: 后鼻韵母 eng ing ong + 整体认读 zhi
[
{ char: 'eng', variants: ['ēng', 'éng', 'ěng', 'èng'], type: 'yunmu' },
{ char: 'ing', variants: ['īng', 'íng', 'ǐng', 'ìng'], type: 'yunmu' },
{ char: 'ong', variants: ['ōng', 'óng', 'ǒng', 'òng'], type: 'yunmu' },
{
char: 'zhi',
variants: ['zhī', 'zhí', 'zhǐ', 'zhì'],
type: 'zhengti',
},
],
// Day 13: 整体认读 chi shi ri zi
[
{
char: 'chi',
variants: ['chī', 'chí', 'chǐ', 'chì'],
type: 'zhengti',
},
{
char: 'shi',
variants: ['shī', 'shí', 'shǐ', 'shì'],
type: 'zhengti',
},
{ char: 'ri', variants: ['rī', 'rí', 'rǐ', 'rì'], type: 'zhengti' },
{ char: 'zi', variants: ['zī', 'zí', 'zǐ', 'zì'], type: 'zhengti' },
],
// Day 14: 整体认读 ci si yi wu
[
{ char: 'ci', variants: ['cī', 'cí', 'cǐ', 'cì'], type: 'zhengti' },
{ char: 'si', variants: ['sī', 'sí', 'sǐ', 'sì'], type: 'zhengti' },
{ char: 'yi', variants: ['yī', 'yí', 'yǐ', 'yì'], type: 'zhengti' },
{ char: 'wu', variants: ['wū', 'wú', 'wǔ', 'wù'], type: 'zhengti' },
],
// Day 15: 整体认读 yu ye yue yuan
[
{ char: 'yu', variants: ['yū', 'yú', 'yǔ', 'yù'], type: 'zhengti' },
{ char: 'ye', variants: ['yē', 'yé', 'yě', 'yè'], type: 'zhengti' },
{
char: 'yue',
variants: ['yuē', 'yué', 'yuě', 'yuè'],
type: 'zhengti',
},
{
char: 'yuan',
variants: ['yuān', 'yuán', 'yuǎn', 'yuàn'],
type: 'zhengti',
},
],
// Day 16: 整体认读 yin yun ying
[
{
char: 'yin',
variants: ['yīn', 'yín', 'yǐn', 'yìn'],
type: 'zhengti',
},
{
char: 'yun',
variants: ['yūn', 'yún', 'yǔn', 'yùn'],
type: 'zhengti',
},
{
char: 'ying',
variants: ['yīng', 'yíng', 'yǐng', 'yìng'],
type: 'zhengti',
},
],
];
/** 每日打卡选择器展示项 */
export interface PinyinDailyPickerItem {
index: number;
label: string;
subtitle: string;
}
const CHIP_CHAR_MAX = 3;
const CHIP_SUBTITLE_MAX = 14;
function shortCharForChip(char: string): string {
if (char.length <= CHIP_CHAR_MAX) return char;
return `${char.slice(0, CHIP_CHAR_MAX - 1)}`;
}
/** 构建「第 N 组 + 拼音摘要」选择器数据 */
export function buildPinyinDailyPickerItems(): PinyinDailyPickerItem[] {
return PINYIN_DAILY_GROUPS.map((group, idx) => {
let subtitle = group.map((t) => shortCharForChip(t.char)).join('·');
if (subtitle.length > CHIP_SUBTITLE_MAX) {
subtitle = `${subtitle.slice(0, CHIP_SUBTITLE_MAX - 1)}`;
}
return {
index: idx,
label: `${idx + 1}`,
subtitle,
};
});
}
@@ -0,0 +1,80 @@
import { PINYIN_DAILY_GROUPS, type PinyinDailyTask } from './pinyinDailyData';
import { TRACING_COLORS } from '../../core/data/tracingStyles';
export interface PinyinDailyCell {
char: string;
style: 'solid' | 'dashed';
color: string;
alpha: number;
}
export interface PinyinDailyRow {
cells: (PinyinDailyCell | null)[];
}
export interface PinyinDailySection {
task: PinyinDailyTask;
rows: PinyinDailyRow[];
cols: number;
}
export interface PinyinDailyData {
dayIndex: number;
sections: PinyinDailySection[];
}
const COLOR_BLACK = TRACING_COLORS.strong;
const COLOR_LIGHT_RED = TRACING_COLORS.guide;
function buildRows(task: PinyinDailyTask, cols: number): PinyinDailyRow[] {
const rows: PinyinDailyRow[] = [];
const char = task.char;
// 每日打卡固定 7 行
for (let r = 0; r < 7; r++) {
const cells: (PinyinDailyCell | null)[] = [];
let style: 'solid' | 'dashed' = 'solid';
let alpha = 1;
// 样式:
// 0-1行:实线描红 (第0行第0个黑色,其余浅红)
// 2, 4, 6行:留空(null
// 3, 5行:虚线描红
if (r === 2 || r === 4 || r === 6) {
for (let c = 0; c < cols; c++) cells.push(null);
} else {
if (r >= 3) {
style = 'dashed';
alpha = 0.5;
}
for (let c = 0; c < cols; c++) {
const color =
r === 0 && c === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
cells.push({ char, style, color, alpha });
}
}
rows.push({ cells });
}
return rows;
}
export function generatePinyinDaily(dayIndex: number): PinyinDailyData {
const group = PINYIN_DAILY_GROUPS[dayIndex] || PINYIN_DAILY_GROUPS[0];
const sections: PinyinDailySection[] = group.map((task) => {
const cols = task.char.length <= 2 ? 5 : 4;
return {
task,
rows: buildRows(task, cols),
cols,
};
});
return {
dayIndex: dayIndex + 1,
sections,
};
}