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
+87
View File
@@ -0,0 +1,87 @@
# 每日一练绘制服务重构方案
本项目对英语字母描红打卡服务(`dailyCheckinDraw.ts`)与拼音每日一练打卡服务(`PinyinDailyDraw.ts`)的绘制逻辑进行重构。主要目标是提取公共的 Header/Footer 绘制方法,统一绘制样式(字体、字号等与英语打卡一致),并在 Footer 引入随机鼓励语功能。
## 1. 重构目标
1. **样式与布局统一**
- 提取每日练习每个 Section 的 Header(包含标题、日期/姓名输入横线)和 Footer(包含鼓励语、页码/天数)的绘制逻辑。
- 所有字体、字号、颜色和边距均统一以 `dailyCheckinDraw.ts` 中的设计规范为准(原拼音打卡字体使用了 Microsoft Yahei,重构后统一使用 `serif` 字体,以与英语打卡一致)。
2. **随机鼓励语机制**
- 在 Footer 中引入 20 条经典励志鼓励语(如“书山有路勤为径”、“日拱一卒 不期而至”等),不再固定显示单一短语,每次绘制时随机展示。
3. **移除拼音展示区**
-`PinyinDailyDraw.ts` 中完全移除原有的 `drawSectionDisplay` 绘制区域。
- 相应调整拼音网格的起始高度,使其能够填满去除了展示区后的上方空白,以获得更好的排版空间。
---
## 2. 详细设计与实现方案
### 2.1 新增公共模块:`dailyPracticeDrawHelper.ts`
`miniprogram/core/draw` 目录下新建 `dailyPracticeDrawHelper.ts`,内容包含:
1. **统一布局常量**
- `SECTION_PADDING_TOP = 12`
- `SECTION_PADDING_BOTTOM = 10`
- `HEADER_TITLE_GAP = 32`
- `HEADER_META_GAP = 22`
- `CONTENT_TOP_GAP = 14`
- `FOOTER_PHRASE_GAP = 26`
- `FOOTER_INDEX_GAP = 22`
2. **鼓励语词库**
- 维护一个包含 20 条无标点符号的励志短语数组 `ENCOURAGEMENT_PHRASES`
- 提供 `getRandomEncouragementPhrase(): string` 方法进行随机抽取。
3. **通用绘制函数**
- `drawSectionHeader(ctx, rect, title)`: 绘制统一字体的 Header。
- `drawSectionFooter(ctx, rect, phrase, labelText)`: 绘制统一字体的 Footer。
### 2.2 英语打卡绘制服务重构:`dailyCheckinDraw.ts`
- 移除本地冗余定义的布局常量和绘制 Header/Footer 的私有方法。
- 引入 `dailyPracticeDrawHelper.ts` 的常量及绘制函数。
-`drawSection` 方法中:
1. 调用 `getRandomEncouragementPhrase()` 随机获取鼓励语。
2. 调用 `drawSectionHeader`(标题传 `"每日打卡"`)。
3. 调用 `drawSectionFooter`(索引标签传 `"- ${section.pageNumber} -"`)。
### 2.3 拼音打卡绘制服务重构:`PinyinDailyDraw.ts`
- 移除本地定义的布局常量及 `drawSectionHeader` / `drawSectionFooter` / `drawSectionDisplay` 等方法。
- 引入 `dailyPracticeDrawHelper.ts` 中的常量和绘制函数。
-`drawSectionGrid` 方法中:
-`contentTop` 原来的 `+ 40 + 10` 偏置去除,修正为与 `dailyCheckinDraw.ts` 完全一致的逻辑高度起点。这样能使网格向上延伸,充分填充空出的上方空间。
- **实现多列拼音声调绘制逻辑**(全部采用 `TONEOZ_PINYIN` 字体绘制):
- **5 列布局**(字符长度 <= 2):若 `variants` 不为空,则在绘制描红行时,第 1 列展示基础字符 `char`,第 25 列分别展示 `variants` 中带声调的拼音;若 `variants` 为空,则全部列均展示基础字符 `char`
- **4 列布局**(字符长度 > 2):共绘制 4 个非空行。其中第 1 行(Row 0)全列展示 `variants[0]`,第 2 行(Row 1)全列展示 `variants[1]`,第 3 行(Row 3)全列展示 `variants[2]`,第 4 行(Row 5)全列展示 `variants[3]`
-`drawSection` 方法中:
1. 移除对 `drawSectionDisplay` 的调用。
2. 调用 `getRandomEncouragementPhrase()` 随机获取鼓励语。
3. 调用 `drawSectionHeader`(标题传 `"拼音每日一练"`)。
4. 调用 `drawSectionFooter`(索引标签传 `"第 ${dayIndex} 天"`)。
---
## 3. 20 条鼓励语词库清单
重构方案中使用的鼓励语清单如下(均不带标点符号,排版更美观):
1. 书山有路勤为径
2. 日拱一卒 不期而至
3. 宝剑锋从磨砺出
4. 梅花香自苦寒来
5. 业精于勤荒于嬉
6. 行百里者半九十
7. 锲而不舍金石可镂
8. 读万卷书行万里路
9. 滴水穿石非一日之功
10. 天下无难事只怕有心人
11. 熟能生巧功到自然成
12. 敏而好学不耻下问
13. 学而不思则罔
14. 思而不学则殆
15. 欲速则不达
16. 不积跬步无以至千里
17. 不积小流无以成江海
18. 温故而知新
19. 读书破万卷下笔如有神
20. 千里之行始于足下
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+21
View File
@@ -5,6 +5,27 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "47616452",
"name": "abc",
"font_class": "abc",
"unicode": "e635",
"unicode_decimal": 58933
},
{
"icon_id": "47616453",
"name": "ABC-underline",
"font_class": "ABC-underline",
"unicode": "e636",
"unicode_decimal": 58934
},
{
"icon_id": "47616451",
"name": "pen-draw",
"font_class": "pen-draw",
"unicode": "e637",
"unicode_decimal": 58935
},
{
"icon_id": "47521297",
"name": "more-vert",
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,130 @@
// 每个分区内容区顶部内边距(px)
export const SECTION_PADDING_TOP = 12;
// 每个分区内容区底部内边距(px)
export const SECTION_PADDING_BOTTOM = 10;
// 标题与上一元素的垂直间距(px)
export const HEADER_TITLE_GAP = 32;
// 标题与元信息(如日期、学号等)的间距(px)
export const HEADER_META_GAP = 22;
// 正文内容距标题/元信息顶部的间距(px)
export const CONTENT_TOP_GAP = 14;
// 页脚短语与内容之间的间距(px)
export const FOOTER_PHRASE_GAP = 26;
// 页脚索引与短语之间的间距(px)
export const FOOTER_INDEX_GAP = 22;
// 20 条鼓励语词库
export const ENCOURAGEMENT_PHRASES: ReadonlyArray<string> = [
'书山有路勤为径',
'日拱一卒 不期而至',
'宝剑锋从磨砺出',
'梅花香自苦寒来',
'业精于勤荒于嬉',
'行百里者半九十',
'锲而不舍金石可镂',
'读万卷书行万里路',
'滴水穿石非一日之功',
'天下无难事只怕有心人',
'熟能生巧功到自然成',
'敏而好学不耻下问',
'学而不思则罔',
'思而不学则殆',
'欲速则不达',
'不积跬步无以至千里',
'不积小流无以成江海',
'温故而知新',
'读书破万卷下笔如有神',
'千里之行始于足下',
];
/**
* 随机获取一条鼓励语
*/
export function getRandomEncouragementPhrase(): string {
const idx = Math.floor(Math.random() * ENCOURAGEMENT_PHRASES.length);
return ENCOURAGEMENT_PHRASES[idx];
}
/**
* 绘制统一的每日练习每个 Section 中的 Header
* @param ctx 微信小程序 Canvas 绘制上下文
* @param rect 目标 Section 的位置与宽高信息
* @param title 标题文本,如 “每日打卡” 或 “拼音每日一练”
*/
export function drawSectionHeader(
ctx: RenderingContext,
rect: { x: number; y: number; w: number; h: number },
title: string,
): void {
const cx = rect.x + rect.w / 2;
const titleY = rect.y + SECTION_PADDING_TOP + 12;
const metaY = titleY + HEADER_TITLE_GAP;
const metaUnderlineY = metaY + 5;
const canvasCtx = ctx as any;
canvasCtx.save();
canvasCtx.fillStyle = '#322E25';
canvasCtx.textAlign = 'center';
canvasCtx.textBaseline = 'middle';
// 字体样式与 dailyCheckinDraw.ts 保持一致
canvasCtx.font = '20px serif';
canvasCtx.fillText(title, cx, titleY);
canvasCtx.font = '12px serif';
canvasCtx.fillText('月', rect.x + 76, metaY);
canvasCtx.fillText('日', rect.x + 122, metaY);
canvasCtx.fillText('姓名:', rect.x + 176, metaY);
canvasCtx.strokeStyle = '#8E897E';
canvasCtx.lineWidth = 1;
canvasCtx.setLineDash([]);
canvasCtx.beginPath();
canvasCtx.moveTo(rect.x + 40, metaUnderlineY);
canvasCtx.lineTo(rect.x + 68, metaUnderlineY);
canvasCtx.moveTo(rect.x + 85, metaUnderlineY);
canvasCtx.lineTo(rect.x + 113, metaUnderlineY);
canvasCtx.moveTo(rect.x + 195, metaUnderlineY);
canvasCtx.lineTo(rect.x + 248, metaUnderlineY);
canvasCtx.stroke();
canvasCtx.restore();
}
/**
* 绘制统一的每日练习每个 Section 中的 Footer
* @param ctx 微信小程序 Canvas 绘制上下文
* @param rect 目标 Section 的位置与宽高信息
* @param phrase 鼓励语文本
* @param labelText 页面/天数索引文本,如 “- 1 -” 或 “第 1 天”
*/
export function drawSectionFooter(
ctx: RenderingContext,
rect: { x: number; y: number; w: number; h: number },
phrase: string,
labelText: string,
): void {
const cx = rect.x + rect.w / 2;
const phraseY =
rect.y + rect.h - SECTION_PADDING_BOTTOM - FOOTER_PHRASE_GAP;
const indexY = rect.y + rect.h - SECTION_PADDING_BOTTOM - 6;
const canvasCtx = ctx as any;
canvasCtx.save();
canvasCtx.fillStyle = '#3E3A33';
canvasCtx.textAlign = 'center';
canvasCtx.textBaseline = 'middle';
// 字体样式与 dailyCheckinDraw.ts 保持一致
canvasCtx.font = '14px serif';
canvasCtx.fillText(phrase, cx, phraseY);
canvasCtx.font = '12px serif';
canvasCtx.fillText(labelText, cx, indexY);
canvasCtx.restore();
}
@@ -15,6 +15,18 @@ import {
drawLetterInFourLineGrid,
loadLetterFont,
} from '../../shared/draw/drawTools';
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';
type DailyCheckinData = Extract<
LetterTracingData,
@@ -27,22 +39,6 @@ const PAGE_MARGIN_X = 14;
const PAGE_MARGIN_Y = 14;
// 每个分区内容区左右内边距(px)
const SECTION_PADDING_X = 18;
// 每个分区内容区顶部内边距(px)
const SECTION_PADDING_TOP = 12;
// 每个分区内容区底部内边距(px)
const SECTION_PADDING_BOTTOM = 10;
// 标题与上一元素的垂直间距(px)
const HEADER_TITLE_GAP = 32;
// 标题与元信息(如日期、学号等)的间距(px)
const HEADER_META_GAP = 22;
// 正文内容距标题/元信息顶部的间距(px)
const CONTENT_TOP_GAP = 14;
// 页脚短语与内容之间的间距(px)
const FOOTER_PHRASE_GAP = 26;
// 页脚索引与短语之间的间距(px)
const FOOTER_INDEX_GAP = 22;
// 单个格子区列数
const GRID_COLS = 5;
@@ -146,70 +142,12 @@ export default class DailyCheckinDraw extends BaseDrawService {
) {
if (!section) return;
this.drawSectionHeader(rect);
this.drawSectionFooter(rect, section.pageNumber);
const phrase = getRandomEncouragementPhrase();
drawSectionHeader(this.ctx, rect, '字母每日打卡');
drawSectionFooter(this.ctx, rect, phrase, `- ${section.pageNumber} -`);
this.drawSectionGrid(rect, section.rows);
}
private drawSectionHeader(rect: {
x: number;
y: number;
w: number;
h: number;
}) {
const cx = rect.x + rect.w / 2;
const titleY = rect.y + SECTION_PADDING_TOP + 12;
const metaY = titleY + HEADER_TITLE_GAP;
const metaUnderlineY = metaY + 5;
this.ctx.save();
this.ctx.fillStyle = '#322E25';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.font = '20px serif';
this.ctx.fillText('每日打卡', cx, titleY);
this.ctx.font = '12px serif';
this.ctx.fillText('月', rect.x + 76, metaY);
this.ctx.fillText('日', rect.x + 122, metaY);
this.ctx.fillText('姓名:', rect.x + 176, metaY);
this.ctx.strokeStyle = '#8E897E';
this.ctx.lineWidth = 1;
this.ctx.setLineDash([]);
this.ctx.beginPath();
this.ctx.moveTo(rect.x + 40, metaUnderlineY);
this.ctx.lineTo(rect.x + 68, metaUnderlineY);
this.ctx.moveTo(rect.x + 85, metaUnderlineY);
this.ctx.lineTo(rect.x + 113, metaUnderlineY);
this.ctx.moveTo(rect.x + 195, metaUnderlineY);
this.ctx.lineTo(rect.x + 248, metaUnderlineY);
this.ctx.stroke();
this.ctx.restore();
}
private drawSectionFooter(
rect: { x: number; y: number; w: number; h: number },
pageNumber: number,
) {
const cx = rect.x + rect.w / 2;
const phraseY =
rect.y + rect.h - SECTION_PADDING_BOTTOM - FOOTER_PHRASE_GAP;
const indexY = rect.y + rect.h - SECTION_PADDING_BOTTOM - 6;
this.ctx.save();
this.ctx.fillStyle = '#3E3A33';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.font = '14px serif';
this.ctx.fillText('日拱一卒 不期而至', cx, phraseY);
this.ctx.font = '12px serif';
this.ctx.fillText(`- ${pageNumber} -`, cx, indexY);
this.ctx.restore();
}
private drawSectionGrid(
rect: { x: number; y: number; w: number; h: number },
rows: DailyCheckinRow[],
@@ -85,7 +85,7 @@ export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
{
id: 'letter-tracing-daily-checkin',
icon: 'task-o',
title: '每日打卡',
title: '字母每日打卡',
subtitle: '四宫格每日字母打卡练习',
ageMin: 6,
ageMax: 8,
@@ -95,7 +95,8 @@ export const LETTER_TRACING_WORKSHEET_DEFINITIONS = [
},
] as const satisfies ReadonlyArray<LetterTracingWorksheetDefinition>;
type LetterTracingWorksheetRow = (typeof LETTER_TRACING_WORKSHEET_DEFINITIONS)[number];
type LetterTracingWorksheetRow =
(typeof LETTER_TRACING_WORKSHEET_DEFINITIONS)[number];
const LETTER_TRACING_WORKSHEET_BY_ID = Object.fromEntries(
LETTER_TRACING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
@@ -0,0 +1,200 @@
import { PinyinBaseDraw } from '../../shared/pinyinBaseDraw';
import {
generatePinyinDaily,
type PinyinDailySection,
} from '../../shared/pinyinDailyGenerator';
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';
const PAGE_MARGIN = 14;
const SECTION_PADDING_X = 18;
const GRID_COL_GAP = 6;
const GRID_ROW_GAP = 8;
export default class PinyinDailyDraw extends PinyinBaseDraw {
async draw(dayIndex: number) {
const dailyData = generatePinyinDaily(dayIndex);
this.prepareDraw();
await loadFontFace(TONEOZ_PINYIN);
this.drawPageDividers();
const sectionRects = this.getSectionRects();
for (let i = 0; i < sectionRects.length; i++) {
const section = dailyData.sections[i];
if (section) {
this.drawSection(sectionRects[i], section, i + 1);
}
}
}
private drawPageDividers() {
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2;
const dashStyle = {
isDashed: true,
dashPattern: [5, 5],
color: '#D6D2CC',
lineWidth: 1,
};
this.drawLine(
cx,
PAGE_MARGIN,
cx,
this.canvasHeight - PAGE_MARGIN,
dashStyle,
);
this.drawLine(
PAGE_MARGIN,
cy,
this.canvasWidth - PAGE_MARGIN,
cy,
dashStyle,
);
}
private getSectionRects() {
const halfW = this.canvasWidth / 2;
const halfH = this.canvasHeight / 2;
return [
{
x: PAGE_MARGIN,
y: PAGE_MARGIN,
w: halfW - PAGE_MARGIN,
h: halfH - PAGE_MARGIN,
},
{
x: halfW,
y: PAGE_MARGIN,
w: this.canvasWidth - PAGE_MARGIN - halfW,
h: halfH - PAGE_MARGIN,
},
{
x: PAGE_MARGIN,
y: halfH,
w: halfW - PAGE_MARGIN,
h: this.canvasHeight - PAGE_MARGIN - halfH,
},
{
x: halfW,
y: halfH,
w: this.canvasWidth - PAGE_MARGIN - halfW,
h: this.canvasHeight - PAGE_MARGIN - halfH,
},
];
}
private drawSection(
rect: { x: number; y: number; w: number; h: number },
section: PinyinDailySection,
sectionIndex: number,
) {
const phrase = getRandomEncouragementPhrase();
drawSectionHeader(this.ctx, rect, '拼音每日打卡');
this.drawSectionGrid(rect, section);
drawSectionFooter(this.ctx, rect, phrase, `- ${sectionIndex} -`);
}
private drawSectionGrid(
rect: { x: number; y: number; w: number; h: number },
section: PinyinDailySection,
) {
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 contentWidth = rect.w - SECTION_PADDING_X * 2;
const cols = section.cols;
const rows = section.rows;
const cellW = (contentWidth - GRID_COL_GAP * (cols - 1)) / cols;
const cellH =
(contentHeight - GRID_ROW_GAP * (rows.length - 1)) / rows.length;
const fontFamily = fontFamilyOf(TONEOZ_PINYIN);
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
const row = rows[rowIdx];
const y = contentTop + rowIdx * (cellH + GRID_ROW_GAP);
for (let colIdx = 0; colIdx < cols; colIdx++) {
const cell = row.cells[colIdx];
const x =
rect.x +
SECTION_PADDING_X +
colIdx * (cellW + GRID_COL_GAP);
this.drawFourLineGrid(x, y, cellW, cellH);
if (cell) {
const cx = x + cellW / 2;
const fontSize = Math.round(cellH * 0.7);
const baselineY = y + (cellH * 2) / 3;
let charToDraw = cell.char;
const variants = section.task.variants || [];
const hasVariants = variants.length > 0;
if (cols === 5) {
if (hasVariants) {
if (colIdx === 0) {
charToDraw = section.task.char;
} else {
charToDraw =
variants[colIdx - 1] || section.task.char;
}
} else {
charToDraw = section.task.char;
}
} else if (cols === 4) {
let drawnRowIdx = -1;
if (rowIdx === 0) drawnRowIdx = 0;
else if (rowIdx === 1) drawnRowIdx = 1;
else if (rowIdx === 3) drawnRowIdx = 2;
else if (rowIdx === 5) drawnRowIdx = 3;
if (drawnRowIdx !== -1 && hasVariants) {
charToDraw =
variants[drawnRowIdx] || section.task.char;
} else {
charToDraw = section.task.char;
}
}
this.ctx.save();
this.ctx.font = `${fontSize}px ${fontFamily}`;
this.ctx.fillStyle = cell.color;
this.ctx.globalAlpha = cell.alpha;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'alphabetic';
this.ctx.fillText(charToDraw, cx, baselineY);
this.ctx.restore();
}
}
}
}
}
@@ -0,0 +1,296 @@
import {
PINYIN_SECTIONS,
type PinyinSection,
type PinyinSubCategory,
SHENGMU,
DAN_YUNMU,
FU_YUNMU,
QIANBI_YUNMU,
HOUBI_YUNMU,
TESHU_YUNMU,
ZHENGTI_RENDU,
PINGSHEYIN,
QIAOSHEYIN,
} from '../../../core/data/pinyin';
import { PINYIN_STYLE } from '../../shared/pinyinConstants';
import { PinyinBaseDraw } from '../../shared/pinyinBaseDraw';
import type { PinyinDictationData } from './pinyinDictationDraw';
const {
MARGIN_X,
CELL_H,
CELL_H_V2,
ROW_GAP,
ROW_GAP_V2,
SECTION_GAP,
TITLE_AFTER_GAP,
CONTENT_TOP_GAP,
SECTION_TITLE_FONT,
SUB_LABEL_FONT,
SECTION_TITLE_COLOR,
SUB_LABEL_COLOR,
PINYIN_FONT_SIZE_RATIO,
PINYIN_FONT_SIZE_RATIO_V2,
} = PINYIN_STYLE;
const MIN_CELL_W = 44;
const SUB_INDENT = 20;
const SECTION_TO_SUB_GAP = 8;
const QIZHONG_GAP = 16;
const SUB_LABEL_H = 14;
const SUB_LABEL_GAP = 4;
const SUB_BLOCK_GAP = 8;
/** V1 + V2 描红 / 默写练习 */
export default class PinyinDictationSheetDraw extends PinyinBaseDraw {
private cellW = MIN_CELL_W;
private contentW = 0;
private currentCellH = CELL_H;
private currentRowGap = ROW_GAP;
private currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO;
async draw(data: PinyinDictationData) {
this.prepareDraw();
this.contentW = this.canvasWidth - 2 * MARGIN_X;
if (data.mode.endsWith('-v2')) {
this.cellW = this.contentW / 8;
this.currentCellH = CELL_H_V2;
this.currentRowGap = ROW_GAP_V2;
this.currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO_V2;
} else {
const maxCols = Math.floor(this.contentW / MIN_CELL_W);
this.cellW = this.contentW / maxCols;
this.currentCellH = CELL_H;
this.currentRowGap = ROW_GAP;
this.currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO;
}
await this.drawHeaderAndDivider();
this.drawContent(data);
}
// ── 内容入口 ──
private drawContent(data: PinyinDictationData) {
const isTracing = data.mode.includes('tracing');
let y = this.currentY + CONTENT_TOP_GAP;
if (data.mode.endsWith('-v2')) {
this.drawV2Content(y, isTracing);
} else {
for (const section of PINYIN_SECTIONS) {
y = this.drawV1Section(section, y, isTracing);
}
}
}
// ── V1:原有布局(声母 / 韵母 / 整体认读 + 子分类) ──
private drawV1Section(
section: PinyinSection,
startY: number,
isTracing: boolean,
): number {
let y = startY;
y = this.drawTitle(section.title, section.count, y);
y += TITLE_AFTER_GAP;
y = this.drawItemRows(
section.items,
MARGIN_X,
y,
isTracing,
this.cellW,
this.currentCellH,
);
if (section.subCategories && section.subCategories.length > 0) {
y += SECTION_TO_SUB_GAP;
y = this.drawSubCategories(section.subCategories, y, isTracing, section.key);
}
y += SECTION_GAP;
return y;
}
private drawSubCategories(
subCategories: PinyinSubCategory[],
startY: number,
isTracing: boolean,
sectionKey: string,
): number {
const ctx = this.ctx;
let y = startY;
ctx.font = SUB_LABEL_FONT;
ctx.fillStyle = SUB_LABEL_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('其中——', MARGIN_X, y);
y += QIZHONG_GAP;
const subX = MARGIN_X + SUB_INDENT;
if (sectionKey === 'shengmu') {
y = this.drawShengmuSubs(subCategories, subX, y, isTracing);
} else {
for (const sub of subCategories) {
y = this.drawLabeledBlock(
`${sub.label}${sub.count}个)`,
sub.items,
subX,
y,
isTracing,
);
y += SUB_BLOCK_GAP;
}
}
return y;
}
private drawShengmuSubs(
subs: PinyinSubCategory[],
subX: number,
startY: number,
isTracing: boolean,
): number {
const qiaoshe = subs.find((s) => s.key === 'qiaoshe');
const pingshe = subs.find((s) => s.key === 'pingshe');
const col2X = MARGIN_X + Math.round(this.contentW / 2);
let bottomY = startY;
if (qiaoshe) {
const endY = this.drawLabeledBlock(
`翘舌音(${qiaoshe.count}个)`,
qiaoshe.items,
subX,
startY,
isTracing,
);
bottomY = Math.max(bottomY, endY);
}
if (pingshe) {
const endY = this.drawLabeledBlock(
`平舌音(${pingshe.count}个)`,
pingshe.items,
col2X,
startY,
isTracing,
);
bottomY = Math.max(bottomY, endY);
}
return bottomY + SUB_BLOCK_GAP;
}
private drawLabeledBlock(
label: string,
items: string[],
startX: number,
startY: number,
isTracing: boolean,
): number {
const ctx = this.ctx;
let y = startY;
ctx.save();
ctx.font = SUB_LABEL_FONT;
ctx.fillStyle = SECTION_TITLE_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(label, startX, y);
ctx.restore();
y += SUB_LABEL_H + SUB_LABEL_GAP;
return this.drawItemRows(items, startX, y, isTracing, this.cellW, this.currentCellH);
}
// ── V2:8 列 + 扁平分块 ──
private drawV2Content(startY: number, isTracing: boolean): void {
const sections = [
{ title: '声母', count: 23, items: SHENGMU },
{ title: '单韵母', count: 6, items: DAN_YUNMU },
{ title: '复韵母', count: 8, items: FU_YUNMU },
{ title: '前鼻韵母', count: 5, items: QIANBI_YUNMU },
{ title: '后鼻韵母', count: 4, items: HOUBI_YUNMU },
{ title: '特殊韵母', count: 1, items: TESHU_YUNMU },
{ title: '整体认读音节', count: 16, items: ZHENGTI_RENDU },
{ title: '平舌音', count: 3, items: PINGSHEYIN },
{ title: '翘舌音', count: 4, items: QIAOSHEYIN },
];
let y = startY;
for (const section of sections) {
y = this.drawTitle(section.title, section.count, y);
y += TITLE_AFTER_GAP;
y = this.drawFixedColsRows(section.items, MARGIN_X, y, isTracing, 8);
y += SECTION_GAP;
}
}
// ── 共用绘制方法 ──
private drawTitle(title: string, count: number, y: number): number {
const ctx = this.ctx;
ctx.font = SECTION_TITLE_FONT;
ctx.fillStyle = SECTION_TITLE_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(title, MARGIN_X, y);
const titleW = ctx.measureText(title).width;
ctx.fillStyle = SUB_LABEL_COLOR;
ctx.fillText(`${count}个)`, MARGIN_X + titleW, y);
return y;
}
private drawItemRows(
items: string[],
startX: number,
startY: number,
isTracing: boolean,
cellW: number,
cellH: number,
): number {
const maxWidth = this.canvasWidth - startX - MARGIN_X;
const maxCols = Math.max(1, Math.floor(maxWidth / cellW));
return this.drawFixedColsRows(items, startX, startY, isTracing, maxCols);
}
private drawFixedColsRows(
items: string[],
startX: number,
startY: number,
isTracing: boolean,
cols: number,
): number {
let y = startY;
const cellW = this.cellW;
const cellH = this.currentCellH;
for (let i = 0; i < items.length; i += cols) {
const rowItems = items.slice(i, i + cols);
this.drawFourLineRow(startX, y, cols, cellW, cellH);
if (isTracing) {
for (let j = 0; j < rowItems.length; j++) {
const cx = startX + j * cellW + cellW / 2;
this.drawPinyinText(rowItems[j], cx, y, cellH, this.currentFontSizeRatio);
}
}
y += cellH + this.currentRowGap;
}
return y;
}
}
@@ -1,327 +1,33 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import {
PINYIN_SECTIONS,
type PinyinSection,
type PinyinSubCategory,
} from '../../../core/data/pinyin';
import {
GRID_COLORS,
GRID_DASH,
TRACING_COLORS,
} from '../../../core/data/tracingStyles';
import type { PinyinDictationMode } from '../pinyinDictation.config';
import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles';
import PinyinDictationSheetDraw from './PinyinDictationSheetDraw';
import PinyinDailyDraw from './PinyinDailyDraw';
export interface PinyinDictationData {
mode: PinyinDictationMode;
dayIndex?: number;
}
// ── 布局 ──
const MARGIN_X = 24; // 左右页边距
const MIN_CELL_W = 44; // 格子最小宽度,实际宽度按内容区等分后可能略大
const CELL_H = 30; // 四线三格高度
const ROW_GAP = 5; // 同一分类中,行与行之间的纵向间距
const SUB_INDENT = 20; // 子分类(翘舌音、单韵母等)相对 MARGIN_X 的额外缩进
// ── 垂直间距 ──
const CONTENT_TOP_GAP = 8; // Header 分割线到第一个分类标题的间距
const TITLE_AFTER_GAP = 24; // 分类标题(一、声母)到其下方格子的间距
const SECTION_GAP = 8; // 大分类之间(声母 ↔ 韵母 ↔ 整体认读)的间距
const SECTION_TO_SUB_GAP = 8; // 主格子到「其中——」的间距
const QIZHONG_GAP = 16; // 「其中——」到第一个子分类块的间距
const SUB_LABEL_H = 14; // 子分类标签文字占用高度
const SUB_LABEL_GAP = 4; // 子分类标签到其下方格子的间距
const SUB_BLOCK_GAP = 8; // 相邻子分类块之间的间距
// ── 字体 & 颜色 ──
const SECTION_TITLE_FONT = 'bold 14px "Microsoft Yahei"'; // 大分类标题
const SUB_LABEL_FONT = '11px "Microsoft Yahei"'; // 子分类标签
const SECTION_TITLE_COLOR = '#322E25'; // 标题/标签文字色
const SUB_LABEL_COLOR = '#c0392b'; // 「其中——」文字色
/**
* 拼音听写/描红绘制分发层。
* 根据 data.mode 委托给对应的独立 DrawService 完成实际绘制。
*/
export default class PinyinDictationDraw extends BaseDrawService {
private cellW = MIN_CELL_W;
private contentW = 0;
async draw(data: PinyinDictationData) {
this.prepareDraw();
this.contentW = this.canvasWidth - 2 * MARGIN_X;
const maxCols = Math.floor(this.contentW / MIN_CELL_W);
this.cellW = this.contentW / maxCols;
await this.drawHeaderAndDivider();
this.drawContent(data);
}
private drawContent(data: PinyinDictationData) {
const isTracing = data.mode === 'pinyin-tracing';
let y = this.currentY + CONTENT_TOP_GAP;
for (const section of PINYIN_SECTIONS) {
y = this.drawSection(section, y, isTracing);
}
}
private drawSection(
section: PinyinSection,
startY: number,
isTracing: boolean,
): number {
const ctx = this.ctx;
let y = startY;
ctx.font = SECTION_TITLE_FONT;
ctx.fillStyle = SECTION_TITLE_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(`${section.title}${section.count}个)`, MARGIN_X, y);
y += TITLE_AFTER_GAP;
y = this.drawItemRows(
section.items,
MARGIN_X,
y,
isTracing,
this.cellW,
CELL_H,
if (data.mode === 'pinyin-daily') {
const delegate = new PinyinDailyDraw(
this.canvas,
this.ctx,
this.options,
);
if (section.subCategories && section.subCategories.length > 0) {
y += SECTION_TO_SUB_GAP;
y = this.drawSubCategories(
section.subCategories,
y,
isTracing,
section.key,
);
}
y += SECTION_GAP;
return y;
}
private drawSubCategories(
subCategories: PinyinSubCategory[],
startY: number,
isTracing: boolean,
sectionKey: string,
): number {
const ctx = this.ctx;
let y = startY;
ctx.font = SUB_LABEL_FONT;
ctx.fillStyle = SUB_LABEL_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('其中——', MARGIN_X, y);
y += QIZHONG_GAP;
const subX = MARGIN_X + SUB_INDENT;
if (sectionKey === 'shengmu') {
y = this.drawShengmuSubs(subCategories, subX, y, isTracing);
await delegate.draw(data.dayIndex ?? 0);
} else {
for (const sub of subCategories) {
y = this.drawLabeledSubBlock(
`${sub.label}${sub.count}个)`,
sub.items,
subX,
y,
isTracing,
const delegate = new PinyinDictationSheetDraw(
this.canvas,
this.ctx,
this.options,
);
y += SUB_BLOCK_GAP;
await delegate.draw(data);
}
}
return y;
}
/**
* 声母子分类:翘舌音 / 平舌音左右并列,标签在格子上方
*/
private drawShengmuSubs(
subs: PinyinSubCategory[],
subX: number,
startY: number,
isTracing: boolean,
): number {
const qiaoshe = subs.find((s) => s.key === 'qiaoshe');
const pingshe = subs.find((s) => s.key === 'pingshe');
const col2X = MARGIN_X + Math.round(this.contentW / 2);
let bottomY = startY;
if (qiaoshe) {
const endY = this.drawLabeledSubBlock(
`翘舌音(${qiaoshe.count}个)`,
qiaoshe.items,
subX,
startY,
isTracing,
);
bottomY = Math.max(bottomY, endY);
}
if (pingshe) {
const endY = this.drawLabeledSubBlock(
`平舌音(${pingshe.count}个)`,
pingshe.items,
col2X,
startY,
isTracing,
);
bottomY = Math.max(bottomY, endY);
}
return bottomY + SUB_BLOCK_GAP;
}
/**
* 带标签的子分类块:标签在格子上方
*/
private drawLabeledSubBlock(
label: string,
items: string[],
startX: number,
startY: number,
isTracing: boolean,
): number {
const ctx = this.ctx;
let y = startY;
ctx.save();
ctx.font = SUB_LABEL_FONT;
ctx.fillStyle = SECTION_TITLE_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(label, startX, y);
ctx.restore();
y += SUB_LABEL_H + SUB_LABEL_GAP;
return this.drawItemRows(
items,
startX,
y,
isTracing,
this.cellW,
CELL_H,
);
}
/**
* 绘制四线三格拼音行,同一行的格子共享边框
*/
private drawItemRows(
items: string[],
startX: number,
startY: number,
isTracing: boolean,
cellW: number,
cellH: number,
): number {
const maxWidth = this.canvasWidth - startX - MARGIN_X;
const maxCols = Math.max(1, Math.floor(maxWidth / cellW));
let y = startY;
for (let i = 0; i < items.length; i += maxCols) {
const rowItems = items.slice(i, i + maxCols);
const cols = rowItems.length;
this.drawFourLineRow(startX, y, cols, cellW, cellH);
if (isTracing) {
for (let j = 0; j < cols; j++) {
const cx = startX + j * cellW + cellW / 2;
this.drawPinyinText(rowItems[j], cx, y, cellH);
}
}
y += cellH + ROW_GAP;
}
return y;
}
/**
* 绘制一行四线三格
*/
private 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.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([]);
ctx.beginPath();
ctx.moveTo(x, yBot);
ctx.lineTo(x + totalW, yBot);
ctx.stroke();
for (let i = 0; i <= cols; i++) {
const vx = x + i * cellW;
ctx.beginPath();
ctx.moveTo(vx, yTop);
ctx.lineTo(vx, yBot);
ctx.stroke();
}
}
/**
* 在四线三格中绘制拼音:
* alphabetic 基线对齐第 3 条线(中下虚线),
* 小写字母主体填充中间格(第 2~3 线之间),
* 声调标记占据上格(第 1~2 线之间)。
*/
private drawPinyinText(
pinyin: string,
cx: number,
gridY: number,
gridH: number,
) {
const ctx = this.ctx;
const fontSize = Math.round(gridH * 0.65);
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();
}
}
@@ -1,10 +1,15 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
export type PinyinDictationMode = 'pinyin-tracing' | 'pinyin-dictation';
export type PinyinDictationMode =
| 'pinyin-tracing'
| 'pinyin-dictation'
| 'pinyin-tracing-v2'
| 'pinyin-dictation-v2'
| 'pinyin-daily';
interface PinyinDictationWorksheetDefinition {
id: PinyinDictationMode;
id: string; // 使用 string 以兼容动态生成的 ID
icon: string;
title: string;
subtitle: string;
@@ -15,7 +20,8 @@ interface PinyinDictationWorksheetDefinition {
sortOrder: number;
}
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS = [
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS: PinyinDictationWorksheetDefinition[] =
[
{
id: 'pinyin-tracing',
icon: 'draw-o',
@@ -38,7 +44,40 @@ export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS = [
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 51,
},
] as const satisfies ReadonlyArray<PinyinDictationWorksheetDefinition>;
{
id: 'pinyin-tracing-v2',
icon: 'pen-draw',
title: '拼音描红 8 列',
subtitle: '跟写描红,声韵分块',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 52,
},
{
id: 'pinyin-dictation-v2',
icon: 'ABC-underline',
title: '拼音默写 8 列',
subtitle: '空白默写,声韵分块',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 53,
},
{
id: 'pinyin-daily',
icon: 'task-o',
title: '拼音每日打卡',
subtitle: '四宫格每日打卡练习',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '每日练习', '打卡'],
sortOrder: 54,
},
];
type PinyinDictationWorksheetRow =
(typeof PINYIN_DICTATION_WORKSHEET_DEFINITIONS)[number];
@@ -91,3 +91,92 @@ page {
color: #453900;
opacity: 0.8;
}
/* ===== 拼音组选择芯片 ===== */
.pd-chip-row {
display: flex;
flex-direction: row;
gap: 32rpx;
}
.pd-chip-row--wrap {
flex-wrap: wrap;
.pd-chip {
flex: none;
}
}
.pd-chip-row--triple {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
.pd-chip {
flex: unset;
width: 100%;
padding: 24rpx 16rpx;
}
}
.pd-chip {
flex: 1;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 12rpx;
color: @text-secondary;
background: @bg-card;
border-radius: 32rpx;
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.pd-chip--pressed {
transform: scale(0.96);
background: @brand;
color: @text-selected-btn;
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.pd-chip--active {
background: @brand;
color: @text-selected-btn;
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.pd-chip__text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
width: 100%;
}
.pd-chip__label {
font-size: 28rpx;
font-weight: 700;
line-height: 36rpx;
}
.pd-chip__subtitle {
font-size: 20rpx;
font-weight: 500;
line-height: 28rpx;
opacity: 0.75;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pd-chip--active .pd-chip__subtitle,
.pd-chip--pressed .pd-chip__subtitle {
opacity: 0.85;
}
@@ -17,9 +17,12 @@ import {
} from '../../utils/favorites';
import { loadFontFace } from '../../core/font/fontLoader';
import { TONEOZ_PINYIN } from '../../core/font/fontProfiles';
import { buildPinyinDailyPickerItems } from '../shared/pinyinDailyData';
const pageInfoLookup = getModeInfo;
const DAILY_GROUPS = buildPinyinDailyPickerItems();
type PageData = CanvasDataState & {
worksheetId: string;
currentMode: PinyinDictationMode;
@@ -29,6 +32,9 @@ type PageData = CanvasDataState & {
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
showDailyGroupPicker: boolean;
dailyGroups: typeof DAILY_GROUPS;
selectedDailyGroupIdx: number;
};
createPage(
@@ -53,6 +59,9 @@ createPage(
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
showDailyGroupPicker: false,
dailyGroups: DAILY_GROUPS,
selectedDailyGroupIdx: 0,
} as unknown as PageData,
onLoad(options: { id?: string }) {
@@ -93,6 +102,7 @@ createPage(
await loadFontFace(TONEOZ_PINYIN);
const data: PinyinDictationData = {
mode: this.data.currentMode,
dayIndex: this.data.selectedDailyGroupIdx,
};
await (this.drawService as PinyinDictationDraw).draw(data);
this.setData({ hasContent: true });
@@ -102,6 +112,26 @@ createPage(
}
},
onPreviewRefresh() {
if (this.data.currentMode === 'pinyin-daily') {
const total = DAILY_GROUPS.length;
// 随机到不同的组
let next = Math.floor(Math.random() * total);
if (total > 1 && next === this.data.selectedDailyGroupIdx) {
next = (next + 1) % total;
}
this.setData({ selectedDailyGroupIdx: next });
}
this.drawCanvas();
},
onSelectDailyGroup(e: WechatMiniprogram.TouchEvent) {
const idx = Number(e.currentTarget.dataset.idx);
if (isNaN(idx) || idx === this.data.selectedDailyGroupIdx) return;
this.setData({ selectedDailyGroupIdx: idx });
this.drawCanvas();
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
@@ -140,12 +170,15 @@ createPage(
applyWorksheet(worksheetId: string, options?: { redraw?: boolean }) {
if (!isValidMode(worksheetId)) return;
const isDaily = worksheetId === 'pinyin-daily';
this.setData(
{
worksheetId,
functionId: worksheetId,
currentMode: worksheetId as PinyinDictationMode,
isPreviewFavorite: !!this._favoritedMap?.[worksheetId],
showDailyGroupPicker: isDaily,
},
() => {
this.initPageInfo(worksheetId, '拼音字母默写');
@@ -4,12 +4,34 @@
<view class="pd-main">
<preview-card
id="previewCard"
showRefresh="{{false}}"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<!-- 选择练习组(拼音每日一练模式) -->
<view wx:if="{{showDailyGroupPicker}}" class="pd-section">
<text class="pd-section-title">选择练习组</text>
<view class="pd-chip-row pd-chip-row--wrap pd-chip-row--triple">
<view
wx:for="{{dailyGroups}}"
wx:key="index"
class="pd-chip {{selectedDailyGroupIdx === item.index ? 'pd-chip--active' : ''}}"
hover-class="pd-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-idx="{{item.index}}"
bind:tap="onSelectDailyGroup">
<view class="pd-chip__text">
<text class="pd-chip__label">{{item.label}}</text>
<text class="pd-chip__subtitle">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
<!-- 练习类型切换(描红 / 默写) -->
<view class="pd-section">
<text class="pd-section-title">练习类型</text>
@@ -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,
};
}
+2 -2
View File
@@ -27,8 +27,8 @@
"name": "pinyinPages/pinyinDictation/pinyinDictation",
"pathName": "pinyinPages/pinyinDictation/pinyinDictation",
"query": "",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "chinesePages/handwritingSheet/handwritingSheet",
+2 -2
View File
@@ -7,14 +7,14 @@ from pathlib import Path
BASE = Path(__file__).resolve().parent
INPUT = BASE / "input-fonts" / "ToneOZ-Pinyin-WenKai-Regular.ttf"
OUTPUT = BASE / "output-fonts" / "ToneOZ-Pinyin-Kai-Regular.ttf"
OUTPUT = BASE / "output-fonts" / "ToneOZ-Pinyin-Regular.ttf"
# Unicode 码点范围
RANGES = [
(0x0020, 0x007E), # 基本 ASCII(字母、数字、标点)
(0x00C0, 0x00FF), # 拉丁字母-1 补充
(0x0100, 0x017F), # 拉丁字母扩展 A
(0x01D6, 0x01DC), # ü 的四个声调变体
(0x01CD, 0x01DC), # 拉丁字母扩展 B(含 ǎ ǐ ǒ ǔ 以及 ü 的四个声调变体
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB