feat: 拼音字母选择

This commit is contained in:
R524809
2026-05-19 18:24:43 +08:00
parent 39b8e01c23
commit b70e54d6b0
40 changed files with 1197 additions and 759 deletions
@@ -0,0 +1,327 @@
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';
export interface PinyinDictationData {
mode: PinyinDictationMode;
}
// ── 布局 ──
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'; // 「其中——」文字色
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 (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.drawLabeledSubBlock(
`${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.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();
}
}
@@ -0,0 +1,83 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
export type PinyinDictationMode = 'pinyin-tracing' | 'pinyin-dictation';
interface PinyinDictationWorksheetDefinition {
id: PinyinDictationMode;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const PINYIN_DICTATION_WORKSHEET_DEFINITIONS = [
{
id: 'pinyin-tracing',
icon: 'draw-o',
title: '描红练习',
subtitle: '跟着描红学拼音字母',
ageMin: 5,
ageMax: 7,
difficulty: 1,
tags: ['拼音', '描红', '声母', '韵母'],
sortOrder: 50,
},
{
id: 'pinyin-dictation',
icon: 'start-a',
title: '默写练习',
subtitle: '空白格子默写拼音字母',
ageMin: 6,
ageMax: 8,
difficulty: 2,
tags: ['拼音', '默写', '声母', '韵母'],
sortOrder: 51,
},
] as const satisfies ReadonlyArray<PinyinDictationWorksheetDefinition>;
type PinyinDictationWorksheetRow =
(typeof PINYIN_DICTATION_WORKSHEET_DEFINITIONS)[number];
const PINYIN_DICTATION_WORKSHEET_BY_ID = Object.fromEntries(
PINYIN_DICTATION_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, PinyinDictationWorksheetRow>;
export const PINYIN_DICTATION_MODE_OPTIONS =
PINYIN_DICTATION_WORKSHEET_DEFINITIONS;
export function getModeInfo(id: string) {
const m = PINYIN_DICTATION_WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in PINYIN_DICTATION_WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = PINYIN_DICTATION_WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'pinyin',
subcategory: 'pinyin-dictation',
path: `/pinyinPages/pinyinDictation/pinyinDictation?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: false,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,16 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "拼音字母默写",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -0,0 +1,93 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.pd-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.pd-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 64rpx;
}
.pd-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.pd-section-title {
font-size: 32rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.pd-mode-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32rpx;
}
.pd-mode-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 32rpx 24rpx;
border-radius: 24rpx;
background: #f8f0e0;
box-sizing: border-box;
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.pd-mode-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 4rpx;
}
.pd-mode-card--active {
background: linear-gradient(145deg, #ffd709 0%, #efc900 100%);
box-shadow: 0 8rpx 32rpx rgba(50, 46, 37, 0.06);
}
.pd-mode-card--pressed {
transform: scale(0.95);
}
.pd-mode-card__label {
font-size: 30rpx;
font-weight: 700;
color: @text-secondary;
text-align: center;
}
.pd-mode-card--active .pd-mode-card__label {
color: #453900;
}
.pd-mode-card__desc {
font-size: 22rpx;
color: @text-secondary;
text-align: center;
opacity: 0.7;
}
.pd-mode-card--active .pd-mode-card__desc {
color: #453900;
opacity: 0.8;
}
@@ -0,0 +1,168 @@
import PinyinDictationDraw from './draw/pinyinDictationDraw';
import type { PinyinDictationData } from './draw/pinyinDictationDraw';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
PINYIN_DICTATION_MODE_OPTIONS,
type PinyinDictationMode,
} from './pinyinDictation.config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
import { loadFontFace } from '../../core/font/fontLoader';
import { TONEOZ_PINYIN } from '../../core/font/fontProfiles';
const pageInfoLookup = getModeInfo;
type PageData = CanvasDataState & {
worksheetId: string;
currentMode: PinyinDictationMode;
modeOptions: typeof PINYIN_DICTATION_MODE_OPTIONS;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as PinyinDictationDraw | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '拼音字母默写',
functionId: '',
hasContent: false,
showShareDialog: false,
worksheetId: 'pinyin-tracing',
currentMode: 'pinyin-tracing' as PinyinDictationMode,
modeOptions: PINYIN_DICTATION_MODE_OPTIONS,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: 'pinyin-tracing';
this.syncDebugPublishEnv();
this.applyWorksheet(worksheetId);
this.loadFavoritedMap();
},
onSelectMode(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string | undefined;
if (!id || id === this.data.worksheetId) return;
this.applyWorksheet(id, { redraw: true });
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) => new PinyinDictationDraw(canvas, ctx, options),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
async drawCanvas() {
if (!this.ctx || !this.drawService) return;
try {
await loadFontFace(TONEOZ_PINYIN);
const data: PinyinDictationData = {
mode: this.data.currentMode,
};
await (this.drawService as PinyinDictationDraw).draw(data);
this.setData({ hasContent: true });
} catch (e) {
console.error('pinyinDictation draw failed', e);
this.setData({ hasContent: false });
}
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = PINYIN_DICTATION_MODE_OPTIONS.map((d) => d.id);
this._favoritedMap = await batchCheckFavorited(ids);
const currentId = this.data.worksheetId;
if (this._favoritedMap[currentId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
applyWorksheet(worksheetId: string, options?: { redraw?: boolean }) {
if (!isValidMode(worksheetId)) return;
this.setData(
{
worksheetId,
functionId: worksheetId,
currentMode: worksheetId as PinyinDictationMode,
isPreviewFavorite: !!this._favoritedMap[worksheetId],
},
() => {
this.initPageInfo(worksheetId, '拼音字母默写');
if (this.drawService) {
this.drawService.options.title = this.data.pageTitle;
}
if (options?.redraw) {
this.drawCanvas();
}
},
);
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,57 @@
<nav-bar title="拼音字母默写" />
<view class="pd-page">
<view class="pd-main">
<preview-card
id="previewCard"
showRefresh="{{false}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:favorite="onPreviewFavorite" />
<!-- 练习类型切换(描红 / 默写) -->
<view class="pd-section">
<text class="pd-section-title">练习类型</text>
<view class="pd-mode-grid">
<view
wx:for="{{modeOptions}}"
wx:key="id"
class="pd-mode-card {{worksheetId === item.id ? 'pd-mode-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="pd-mode-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
<toy-icon
name="{{item.icon}}"
size="42rpx"
color="{{worksheetId === item.id ? '#453900' : '#605b50'}}"
custom-class="pd-mode-card__icon" />
<text class="pd-mode-card__label">{{item.title}}</text>
<text class="pd-mode-card__desc">{{item.subtitle}}</text>
</view>
</view>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />