feat: 开发完成拼音每日一练
This commit is contained in:
@@ -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`,第 2~5 列分别展示 `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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 287 KiB |
Reference in New Issue
Block a user