feat: 新增幼儿测字表

This commit is contained in:
R524809
2026-06-11 14:39:06 +08:00
parent a6e8f4df02
commit 25a37fc5e2
15 changed files with 1046 additions and 52 deletions
@@ -0,0 +1,76 @@
import { WORDS } from '../../../core/data/words';
export const WORD_TEST_GROUP_SIZE = 50;
export interface WordTestCategory {
id: string;
icon: string;
name: string;
parentName?: string;
words: string[];
}
export interface WordTestGroup {
index: number;
label: string;
words: string[];
}
const EXCLUDED_CATEGORY_IDS = new Set([4, 5]);
/** 默认分类:一年级上册 */
export const DEFAULT_WORD_TEST_CATEGORY_ID = '26-一年级上册';
export function buildWordTestCategories(): WordTestCategory[] {
const result: WordTestCategory[] = [];
for (const cat of WORDS) {
if (EXCLUDED_CATEGORY_IDS.has(cat.categoryId)) continue;
if (cat.sections?.length) {
for (const section of cat.sections) {
result.push({
id: `${cat.categoryId}-${section.sectionName}`,
icon: cat.icon,
name: section.sectionName,
parentName: cat.categoryName,
words: section.words,
});
}
continue;
}
if (cat.words?.length) {
result.push({
id: String(cat.categoryId),
icon: cat.icon,
name: cat.categoryName,
words: cat.words,
});
}
}
return result;
}
export function buildWordTestGroups(words: string[]): WordTestGroup[] {
if (!words.length) return [];
const groups: WordTestGroup[] = [];
for (let i = 0; i < words.length; i += WORD_TEST_GROUP_SIZE) {
const index = Math.floor(i / WORD_TEST_GROUP_SIZE) + 1;
groups.push({
index,
label: `${index}`,
words: words.slice(i, i + WORD_TEST_GROUP_SIZE),
});
}
return groups;
}
export function findWordTestCategory(
categories: WordTestCategory[],
id: string,
): WordTestCategory | undefined {
return categories.find((cat) => cat.id === id);
}
@@ -0,0 +1,227 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
const COLS = 5;
const ROWS = 10;
const LAYOUT = {
topGap: 10,
leftMargin: 36,
rightMargin: 36,
bottomMargin: 40,
instructionGap: 18,
gridTopGap: 12,
instructionFont: 'bold 16px "KaiTi", "STKaiti", "Microsoft Yahei", serif',
instructionBoxSize: 12,
instructionCheckSize: 14,
instructionSymbolGap: 3,
rowGap: 10,
charBoxGap: 12,
boxSize: 14,
} as const;
export interface WordTestSheetDrawData {
words: string[];
pageNumber: number;
}
export default class WordTestDrawService extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '测字表',
...options,
});
}
async draw(data: WordTestSheetDrawData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawInstruction();
this.drawWordGrid(data.words);
this.drawPageNumber(data.pageNumber);
this.drawPrintFooter();
}
private getContentRect() {
const contentTop = this.currentY;
const contentWidth =
this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
const contentHeight =
this.canvasHeight - contentTop - LAYOUT.bottomMargin;
return {
top: contentTop,
left: LAYOUT.leftMargin,
width: contentWidth,
height: contentHeight,
};
}
private drawInstruction() {
const { ctx, canvasWidth } = this;
const y = this.currentY + LAYOUT.instructionGap;
const {
instructionFont,
instructionBoxSize,
instructionCheckSize,
instructionSymbolGap,
} = LAYOUT;
const prefix = '认识的字';
const middle = '里打';
ctx.save();
ctx.font = instructionFont;
ctx.fillStyle = '#322E25';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const prefixW = ctx.measureText(prefix).width;
const middleW = ctx.measureText(middle).width;
const totalW =
prefixW +
instructionSymbolGap +
instructionBoxSize +
instructionSymbolGap +
middleW +
instructionSymbolGap +
instructionCheckSize;
let x = (canvasWidth - totalW) / 2;
ctx.fillText(prefix, x, y);
x += prefixW + instructionSymbolGap;
this.drawBox(
ctx,
x,
y - instructionBoxSize / 2,
instructionBoxSize,
instructionBoxSize,
);
x += instructionBoxSize + instructionSymbolGap;
ctx.fillText(middle, x, y);
x += middleW + instructionSymbolGap;
this.drawCheckMark(ctx, x, y, instructionCheckSize);
const metrics = ctx.measureText(prefix);
const instructionFontSize = 16;
const halfH = Math.max(
metrics.actualBoundingBoxAscent ?? instructionFontSize * 0.5,
metrics.actualBoundingBoxDescent ?? instructionFontSize * 0.5,
);
const contentStartY = y + halfH + LAYOUT.gridTopGap;
ctx.restore();
this.currentY = contentStartY;
}
/** 绘制对勾(替代文字 V */
private drawCheckMark(
ctx: RenderingContext,
leftX: number,
centerY: number,
size: number,
) {
ctx.save();
ctx.strokeStyle = '#322E25';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(leftX + size * 0.12, centerY + size * 0.02);
ctx.lineTo(leftX + size * 0.38, centerY + size * 0.32);
ctx.lineTo(leftX + size * 0.88, centerY - size * 0.34);
ctx.stroke();
ctx.restore();
}
private getCharVerticalMetrics(
ctx: RenderingContext,
char: string,
fontSize: number,
) {
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(char);
const ascent = metrics.actualBoundingBoxAscent ?? fontSize * 0.82;
const descent = metrics.actualBoundingBoxDescent ?? fontSize * 0.18;
return { ascent, descent };
}
/** 以 centerY 为视觉中心,绘制汉字与右侧方框 */
private drawCharWithBox(
ctx: RenderingContext,
char: string,
startX: number,
centerY: number,
fontSize: number,
) {
const { charBoxGap, boxSize } = LAYOUT;
const { ascent, descent } = this.getCharVerticalMetrics(
ctx,
char,
fontSize,
);
ctx.textBaseline = 'alphabetic';
const textY = centerY + (ascent - descent) / 2;
ctx.fillText(char, startX, textY);
const boxX = startX + ctx.measureText(char).width + charBoxGap;
const boxY = centerY - boxSize / 2;
this.drawBox(ctx, boxX, boxY, boxSize, boxSize);
}
private drawWordGrid(words: string[]) {
const { ctx } = this;
const content = this.getContentRect();
const colWidth = content.width / COLS;
const rowHeight = content.height / ROWS;
const fontSize = 20;
ctx.save();
ctx.font = `${fontSize}px "KaiTi", "STKaiti", "Microsoft Yahei", serif`;
ctx.fillStyle = '#322E25';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const index = row * COLS + col;
const char = words[index];
if (!char) continue;
const cellLeft = content.left + col * colWidth;
const cellCenterY =
content.top + row * rowHeight + rowHeight / 2;
const charWidth = ctx.measureText(char).width;
const groupWidth =
charWidth + LAYOUT.charBoxGap + LAYOUT.boxSize;
const startX = cellLeft + (colWidth - groupWidth) / 2;
this.drawCharWithBox(ctx, char, startX, cellCenterY, fontSize);
}
}
ctx.restore();
}
private drawPageNumber(pageNumber: number) {
const { ctx, canvasWidth, canvasHeight } = this;
ctx.save();
ctx.font = '12px "Microsoft Yahei", sans-serif';
ctx.fillStyle = '#7C766A';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(pageNumber), canvasWidth / 2, canvasHeight - 28);
ctx.restore();
}
}
@@ -0,0 +1,43 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { WORD_TEST_WORKSHEET_DEFINITIONS } from '../../config/worksheets/wordTest';
type WordTestWorksheetRow = (typeof WORD_TEST_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
WORD_TEST_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, WordTestWorksheetRow>;
export const WORD_TEST_WORKSHEET_ID = WORD_TEST_WORKSHEET_DEFINITIONS[0].id;
export function getModeInfo(id: string) {
const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'chinese',
subcategory: 'word-test',
path: `/chinesePages/wordTestSheet/wordTestSheet?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: true,
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,114 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.wt-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.wt-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.wt-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.wt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wt-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.wt-category-grid,
.wt-group-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.wt-category-card,
.wt-group-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s,
background 0.12s;
}
.wt-group-card {
padding: 24rpx 12rpx;
}
.wt-category-card--active,
.wt-group-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.wt-category-card--pressed,
.wt-group-card--pressed {
transform: scale(0.95);
}
.wt-category-card__icon {
font-size: 36rpx;
line-height: 1.2;
}
.wt-category-card__name,
.wt-group-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
text-align: center;
}
.wt-category-card__cat {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
}
.wt-category-card__check,
.wt-group-card__check {
position: absolute;
top: 50%;
left: 18rpx;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #9cd343;
}
@@ -0,0 +1,265 @@
import WordTestDrawService from './draw/wordTestDrawService';
import {
buildWordTestCategories,
buildWordTestGroups,
DEFAULT_WORD_TEST_CATEGORY_ID,
findWordTestCategory,
type WordTestCategory,
type WordTestGroup,
} from './data/wordTestCategories';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
WORD_TEST_WORKSHEET_ID,
} from './wordTestSheet.config';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
const pageInfoLookup = getModeInfo;
function buildSelectedMap(id: string | null): Record<string, boolean> {
if (!id) return {};
return { [id]: true };
}
type PageData = CanvasDataState & {
worksheetId: string;
categoryList: WordTestCategory[];
groupList: WordTestGroup[];
selectedCategoryId: string;
selectedGroupIndex: number;
selectedCategoryMap: Record<string, boolean>;
selectedGroupMap: Record<string, boolean>;
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 WordTestDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '测字表',
functionId: WORD_TEST_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: WORD_TEST_WORKSHEET_ID,
categoryList: [] as WordTestCategory[],
groupList: [] as WordTestGroup[],
selectedCategoryId: DEFAULT_WORD_TEST_CATEGORY_ID,
selectedGroupIndex: 1,
selectedCategoryMap: buildSelectedMap(
DEFAULT_WORD_TEST_CATEGORY_ID,
),
selectedGroupMap: buildSelectedMap('1'),
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
this.syncDebugPublishEnv();
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: WORD_TEST_WORKSHEET_ID;
const categoryList = buildWordTestCategories();
const defaultCategory =
findWordTestCategory(
categoryList,
DEFAULT_WORD_TEST_CATEGORY_ID,
) || categoryList[0];
const selectedCategoryId = defaultCategory?.id || '';
const groupList = buildWordTestGroups(defaultCategory?.words || []);
this.setData({
worksheetId,
functionId: worksheetId,
categoryList,
groupList,
selectedCategoryId,
selectedGroupIndex: 1,
selectedCategoryMap: buildSelectedMap(selectedCategoryId),
selectedGroupMap: buildSelectedMap('1'),
});
this.initPageInfo(worksheetId, '测字表');
this.loadFavoritedMap();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
const category = this.getSelectedCategory();
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new WordTestDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.getSheetTitle(category),
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
getSelectedCategory(): WordTestCategory | undefined {
return findWordTestCategory(
this.data.categoryList,
this.data.selectedCategoryId,
);
},
getSelectedGroup(): WordTestGroup | undefined {
return (this.data.groupList as WordTestGroup[]).find(
(group: WordTestGroup) =>
group.index === this.data.selectedGroupIndex,
);
},
getSheetTitle(category?: WordTestCategory): string {
if (!category) return '测字表';
return `${category.name}(测字表)`;
},
async drawCanvas() {
if (!this.drawService) return;
const group = this.getSelectedGroup();
if (!group || group.words.length === 0) {
this.setData({ hasContent: false });
return;
}
const category = this.getSelectedCategory();
const title = this.getSheetTitle(category);
try {
(this.drawService as WordTestDrawService).options.title = title;
await (this.drawService as WordTestDrawService).draw({
words: group.words,
pageNumber: group.index,
});
this.setData({ hasContent: true });
} catch (e) {
console.error('wordTestSheet draw failed', e);
this.setData({ hasContent: false });
}
},
onSelectCategory(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.selectedCategoryId) return;
const category = findWordTestCategory(this.data.categoryList, id);
if (!category) return;
const groupList = buildWordTestGroups(category.words);
this.setData(
{
selectedCategoryId: id,
selectedCategoryMap: buildSelectedMap(id),
groupList,
selectedGroupIndex: 1,
selectedGroupMap: buildSelectedMap('1'),
},
() => this.drawCanvas(),
);
},
onSelectGroup(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index);
if (!index || index === this.data.selectedGroupIndex) return;
this.setData(
{
selectedGroupIndex: index,
selectedGroupMap: buildSelectedMap(String(index)),
},
() => this.drawCanvas(),
);
},
onPreviewRefresh() {
const groupList = this.data.groupList as WordTestGroup[];
if (!groupList.length) {
this.drawCanvas();
return;
}
const currentIdx = groupList.findIndex(
(group) => group.index === this.data.selectedGroupIndex,
);
const nextIdx =
currentIdx < 0 ? 0 : (currentIdx + 1) % groupList.length;
const nextGroup = groupList[nextIdx];
this.setData(
{
selectedGroupIndex: nextGroup.index,
selectedGroupMap: buildSelectedMap(String(nextGroup.index)),
},
() => this.drawCanvas(),
);
},
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 = [WORD_TEST_WORKSHEET_ID];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,83 @@
<nav-bar title="测字表" />
<view class="wt-page">
<view class="wt-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view wx:if="{{groupList.length > 0}}" class="wt-section">
<view class="wt-section-header">
<text class="wt-section-title">选择组</text>
</view>
<view class="wt-group-grid">
<view
wx:for="{{groupList}}"
wx:key="index"
class="wt-group-card {{selectedGroupMap[item.index] ? 'wt-group-card--active' : ''}}"
data-index="{{item.index}}"
hover-class="wt-group-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectGroup">
<view
wx:if="{{selectedGroupMap[item.index]}}"
class="wt-group-card__check">
<toy-icon name="check" size="20rpx" color="#fff" />
</view>
<text class="wt-group-card__name">{{item.label}}</text>
</view>
</view>
</view>
<view class="wt-section">
<view class="wt-section-header">
<text class="wt-section-title">汉字分类</text>
</view>
<view class="wt-category-grid">
<view
wx:for="{{categoryList}}"
wx:key="id"
class="wt-category-card {{selectedCategoryMap[item.id] ? 'wt-category-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="wt-category-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectCategory">
<text class="wt-category-card__icon">{{item.icon}}</text>
<text class="wt-category-card__name">{{item.name}}</text>
<text
wx:if="{{item.parentName}}"
class="wt-category-card__cat"
>{{item.parentName}}</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" />