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
+2 -1
View File
@@ -54,7 +54,8 @@
"pages": [ "pages": [
"wordColoring/wordColoring", "wordColoring/wordColoring",
"handwritingSheet/handwritingSheet", "handwritingSheet/handwritingSheet",
"penControlSheet/penControlSheet" "penControlSheet/penControlSheet",
"wordTestSheet/wordTestSheet"
], ],
"independent": false "independent": false
} }
@@ -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" />
+4 -4
View File
@@ -7,10 +7,10 @@
// }; // };
/** 当前应用版本号;profile 页和首页公告共用 */ /** 当前应用版本号;profile 页和首页公告共用 */
export const APP_VERSION = '3.0.1'; export const APP_VERSION = '3.2.0';
/** 当前版本的更新摘要,用于首页公告;改版本时一起改 */ /** 当前版本的更新摘要,用于首页公告;改版本时一起改 */
export const APP_VERSION_NOTE = '新增控笔练习'; export const APP_VERSION_NOTE = '新增幼儿识字测字表';
export const defaultPrintConfig: PrintConfig = { export const defaultPrintConfig: PrintConfig = {
// header: 'LogoImage', // header: 'LogoImage',
@@ -31,11 +31,11 @@ export const defaultShareConfig = {
* - 当日第 1 次满批:弹分享窗,须分享后才能继续下载 * - 当日第 1 次满批:弹分享窗,须分享后才能继续下载
* - 当日第 2 次及以后满批:直接播放激励广告解锁 * - 当日第 2 次及以后满批:直接播放激励广告解锁
*/ */
export const downloadFreeBatchSize = 4; export const downloadFreeBatchSize = 3;
/** /**
* develop 包下载限制开关 * develop 包下载限制开关
* - develop:为 true 时启用下载配额/分享/广告(便于联调);为 false 时不限制 * - develop:为 true 时启用下载配额/分享/广告(便于联调);为 false 时不限制
* - 体验版 / 正式版:始终启用,不受此开关影响 * - 体验版 / 正式版:始终启用,不受此开关影响
*/ */
export const enableDownloadLimitInDevelop = false; export const enableDownloadLimitInDevelop = true;
+1
View File
@@ -5,3 +5,4 @@ export { LETTER_TRACING_WORKSHEET_DEFINITIONS } from './letterTracing';
export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin'; export { PINYIN_DICTATION_WORKSHEET_DEFINITIONS } from './pinyin';
export { PEN_CONTROL_WORKSHEET_DEFINITIONS } from './penControl'; export { PEN_CONTROL_WORKSHEET_DEFINITIONS } from './penControl';
export { WORD_COLORING_WORKSHEET_DEFINITIONS } from './wordColoring'; export { WORD_COLORING_WORKSHEET_DEFINITIONS } from './wordColoring';
export { WORD_TEST_WORKSHEET_DEFINITIONS } from './wordTest';
+15
View File
@@ -0,0 +1,15 @@
import type { WorksheetDefinition } from './types';
export const WORD_TEST_WORKSHEET_DEFINITIONS = [
{
id: 'word-test-sheet',
icon: 'todo-list-o',
title: '测字表',
subtitle: '按分类测识字,勾选认识的字',
ageMin: 5,
ageMax: 8,
difficulty: 1,
tags: ['测字', '识字', '汉字', '一年级'],
sortOrder: 38,
},
] as const satisfies ReadonlyArray<WorksheetDefinition>;
+100 -36
View File
@@ -48,9 +48,7 @@ export const WORDS = [
'中', '个', '工', '王', '车', '儿', '女', '子', '门', '马', '牛', '羊', '米', '衣', '白', '中', '个', '工', '王', '车', '儿', '女', '子', '门', '马', '牛', '羊', '米', '衣', '白',
'田', '石', '雨', '电', '云', '花', '草', '叶', '果', '鸟', '虫', '鱼', '头', '目', '耳', '田', '石', '雨', '电', '云', '花', '草', '叶', '果', '鸟', '虫', '鱼', '头', '目', '耳',
'足', '心', '力', '立', '正', '足', '心', '力', '立', '正',
// 扩充(常见基础字) '刀', '又', '三', '四', '五', '六', '七', '八', '九', '十', '牙', '鼻', '眉', '竹', '金',
'刀', '又', '三', '四', '五', '六', '七', '八', '九', '十', '口', '门', '心', '耳', '目',
'牙', '鼻', '口', '眉', '田', '米', '木', '竹', '石', '土', '火', '水', '金'
], ],
}, },
{ {
@@ -62,10 +60,8 @@ export const WORDS = [
'灯', '家', '房', '床', '吃', '喝', '坐', '走', '跑', '看', '听', '说', '笑', '哭', '爱', '灯', '家', '房', '床', '吃', '喝', '坐', '走', '跑', '看', '听', '说', '笑', '哭', '爱',
'好', '有', '来', '去', '开', '关', '里', '外', '多', '少', '红', '黄', '蓝', '绿', '白', '好', '有', '来', '去', '开', '关', '里', '外', '多', '少', '红', '黄', '蓝', '绿', '白',
'黑', '是', '不', '我', '你', '黑', '是', '不', '我', '你',
// 扩充(贴近生活)
'他', '她', '它', '们', '在', '和', '把', '给', '用', '做', '玩', '买', '卖', '学', '读', '他', '她', '它', '们', '在', '和', '把', '给', '用', '做', '玩', '买', '卖', '学', '读',
'写', '问', '答', '听', '看', '吃', '喝', '睡', '起', '穿', '洗', '擦', '', '关', '拿', '写', '问', '答', '睡', '起', '穿', '洗', '擦', '', '',
'放'
], ],
}, },
{ {
@@ -77,17 +73,19 @@ export const WORDS = [
'南', '西', '北', '飞', '跳', '游', '唱', '画', '洗', '买', '卖', '问', '学', '读', '写', '南', '西', '北', '飞', '跳', '游', '唱', '画', '洗', '买', '卖', '问', '学', '读', '写',
'早', '晚', '明', '亮', '高', '长', '圆', '方', '快', '乐', '热', '冷', '轻', '重', '新', '早', '晚', '明', '亮', '高', '长', '圆', '方', '快', '乐', '热', '冷', '轻', '重', '新',
'旧', '和', '同', '会', '要', '旧', '和', '同', '会', '要',
// 扩充(自然与常见动作) '雨', '雷', '电', '云', '雾', '露', '霜', '月', '阳', '阴', '晴', '冰',
'', '', '', '', '', '', '', '', '', '', '阴', '晴', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'草', '花', '叶', '果', '根', '看', '听', '说', '读', '写', '跑', '跳', '走', '爬', '抓',
'推', '拉', '抱', '笑', '哭'
], ],
}, },
{ {
categoryId: 3, categoryId: 3,
icon: '🎨', icon: '🎨',
categoryName: '颜色', categoryName: '颜色',
words: ['红', '蓝', '绿', '黄', '黑', '白', '紫', '橙', '粉', '棕', '灰'], words: [
'红', '蓝', '绿', '黄', '黑', '白', '紫', '橙', '粉', '棕', '灰',
'青', '翠', '碧', '丹', '金', '银', '铜', '朱', '墨', '绛', '褐',
],
}, },
{ {
categoryId: 4, categoryId: 4,
@@ -123,61 +121,96 @@ export const WORDS = [
categoryId: 6, categoryId: 6,
icon: '☀️', icon: '☀️',
categoryName: '日字旁', categoryName: '日字旁',
words: ['日', '明', '早', '时', '晴', '春', '星', '晨', '晚', '晒', '照'], words: [
'日', '明', '早', '时', '晴', '春', '星', '晨', '晚', '晒', '照',
'旧', '昏', '旦', '旭', '暗', '显', '映', '晌', '昼', '晕', '昌', '易', '昂', '晶', '暄',
],
}, },
{ {
categoryId: 7, categoryId: 7,
icon: '💧', icon: '💧',
categoryName: '三点水', categoryName: '三点水',
words: ['水', '江', '河', '流', '沙', '洗', '海', '汗', '汽', '澡', '泡', '湖', '泉'], words: [
'水', '江', '河', '湖', '海', '泉', '汗', '汽', '沙', '洗', '波', '流', '游', '洪', '浴',
'浪', '池', '泡', '澡', '油', '洋', '溪', '泥', '深', '浅', '泪', '洁', '温', '漂', '混',
'涨', '滴', '港', '渔', '涛', '润', '消', '清', '渡', '洞',
],
}, },
{ {
categoryId: 8, categoryId: 8,
icon: '🐦', icon: '🐦',
categoryName: '鸟字', categoryName: '鸟字',
words: ['鸟', '鸡', '鸭', '鹅', '鸣', '鸽', '鸦', '鹊', '鹤'], words: [
'鸟', '鸡', '鸭', '鹅', '鸣', '鸽', '鸦', '鹊', '鹤', '鸳', '鸯', '鹰', '鹏', '鹦',
],
}, },
{ {
categoryId: 9, categoryId: 9,
icon: '🐾', icon: '🐾',
categoryName: '反犬旁', categoryName: '反犬旁',
words: ['狗', '猫', '猴', '狮', '狼', '猪', '狠', '独', '犯'], words: [
'狗', '猫', '猴', '狮', '狼', '猪', '狠', '独', '犯',
'猜', '猩', '猛', '猿', '猾', '猬', '猎', '狐', '狂', '猢',
],
}, },
{ {
categoryId: 10, categoryId: 10,
icon: '🪱', icon: '🪱',
categoryName: '虫字旁', categoryName: '虫字旁',
words: ['虫', '蚁', '蝶', '蜻', '蛙', '蛇', '蜘', '蜂', '蚊', '蛾'], words: [
'虫', '蚁', '蚂', '蚊', '蚕', '蛹', '蛙', '蛾', '蛇', '蝶',
'蜻', '蜓', '蜘', '蛛', '蜂', '蝉', '螃', '蟹', '螺', '蝌',
'蝇', '螳', '蟋', '蟀',
],
}, },
{ {
categoryId: 11, categoryId: 11,
icon: '🌧️', icon: '🌧️',
categoryName: '雨字头', categoryName: '雨字头',
words: ['雨', '雪', '雷', '露', '雾', '雹', '霜', '雯', '霖'], words: [
'雨', '雪', '雷', '露', '雾', '雹', '霜', '震', '零', '需', '霞', '霓', '霁',
],
}, },
{ {
categoryId: 12, categoryId: 12,
icon: '🌲', icon: '🌲',
categoryName: '木字旁', categoryName: '木字旁',
words: ['木', '林', '树', '桃', '森', '松', '桥', '枝', '板', '柜', '杯', '校'], words: [
'木', '林', '树', '桃', '森', '松', '桥', '枝', '板', '柜', '杯', '校',
'柳', '柏', '杨', '枫', '桐', '桂', '榕', '杉',
],
}, },
{ {
categoryId: 13, categoryId: 13,
icon: '👄', icon: '👄',
categoryName: '口字旁', categoryName: '口字旁',
words: ['口', '吃', '叫', '唱', '听', '吹', '叶', '和', '问', '品', '吐', '吸'], words: [
'口', '吃', '叫', '唱', '听', '吹', '叶', '和', '问', '品', '吐', '吸', '嘴', '咽', '喉',
],
}, },
{ {
categoryId: 14, categoryId: 14,
icon: '🧍', icon: '🧍',
categoryName: '单人旁', categoryName: '单人旁',
words: ['你', '他', '们', '作', '休', '住', '伙', '伴', '体', '位', '信', '化'], words: [
'你', '他', '们', '作', '休', '住', '伙', '伴', '体', '位', '信', '化',
'保', '什', '但', '代', '使', '俩', '便', '仰', '传', '伤', '伯', '佳',
],
}, },
{ {
categoryId: 15, categoryId: 15,
icon: '❤️', icon: '❤️',
categoryName: '竖心旁', categoryName: '竖心旁',
words: ['心', '情', '快', '怕', '惊', '忙', '怀', '爱', '想', '念'], words: [
'心', '情', '快', '怕', '惊', '忙', '怀', '爱', '想', '念',
'意', '愿', '态', '怪', '性', '悟', '惜', '懒', '慌',
],
}, },
// 新增适龄分类 // 新增适龄分类
@@ -186,7 +219,7 @@ export const WORDS = [
icon: '🧭', icon: '🧭',
categoryName: '方位方向', categoryName: '方位方向',
words: [ words: [
'上', '下', '左', '右', '前', '后', '里', '外', '东', '南', '西', '北', '中', '近', '远' '上', '下', '左', '右', '前', '后', '里', '外', '东', '南', '西', '北', '中', '近', '远',
], ],
}, },
{ {
@@ -195,7 +228,7 @@ export const WORDS = [
categoryName: '身体部位', categoryName: '身体部位',
words: [ words: [
'头', '脸', '目', '眼', '眉', '鼻', '口', '牙', '舌', '耳', '手', '指', '掌', '臂', '头', '脸', '目', '眼', '眉', '鼻', '口', '牙', '舌', '耳', '手', '指', '掌', '臂',
'足', '腿', '心' '足', '腿', '心', '发', '肩', '背', '肚', '腰', '皮', '骨', '血', '身',
], ],
}, },
{ {
@@ -204,14 +237,22 @@ export const WORDS = [
categoryName: '常见动物', categoryName: '常见动物',
words: [ words: [
'狗', '猫', '马', '牛', '羊', '鸡', '鸭', '鹅', '鱼', '兔', '猪', '熊', '虎', '鹿', '猴', '狗', '猫', '马', '牛', '羊', '鸡', '鸭', '鹅', '鱼', '兔', '猪', '熊', '虎', '鹿', '猴',
'狼', '狮', '虎', '豹', '象', '狼', '狮', '豹', '象', '鸟', '龟', '蛙', '蛇', '鼠', '狐', '狸', '蝶', '蜂', '蚁', '蚕',
'蝉', '蛛', '雀', '鸽', '鹰', '虾',
], ],
}, },
{ {
categoryId: 19, categoryId: 19,
icon: '🌼', icon: '🌼',
categoryName: '常见植物', categoryName: '常见植物',
words: ['花', '草', '树', '叶', '果', '根', '竹', '松', '柳', '桃', '梅', '荷', '菊'], words: [
'花', '草', '树', '叶', '果', '根', '竹', '松', '柳', '桃', '梅', '荷', '菊',
'兰', '桂', '杏', '槐', '柏', '杨', '榕', '杉', '枫', '桐', '瓜', '豆',
'蔬', '菜', '麦', '稻', '谷', '苹', '李', '梨', '橙', '柚', '柿', '葡',
'萄', '西', '荔', '枝', '椰', '蕉', '菠', '萝', '辣', '椒', '茄', '芹',
'卜', '葱', '姜', '蒜', '薯', '蘑', '菇', '笋', '苔', '蔓', '藕',
],
}, },
{ {
categoryId: 20, categoryId: 20,
@@ -219,40 +260,65 @@ export const WORDS = [
categoryName: '食物饮品', categoryName: '食物饮品',
words: [ words: [
'米', '饭', '面', '菜', '果', '肉', '蛋', '奶', '糖', '盐', '油', '水', '茶', '汤', '米', '饭', '面', '菜', '果', '肉', '蛋', '奶', '糖', '盐', '油', '水', '茶', '汤',
'粥', '饼' '粥', '饼', '薯', '菇', '竹', '笋', '苔', '蔓', '荷', '藕',
], ],
}, },
{ {
categoryId: 21, categoryId: 21,
icon: '🚗', icon: '🚗',
categoryName: '交通出行', categoryName: '交通出行',
words: ['车', '船', '飞', '机', '站', '路', '桥', '铁', '轨', '轮'], words: [
'车', '船', '飞', '机', '站', '路', '桥', '铁', '轨', '轮',
'汽', '地', '高', '速', '动', '单', '双',
'驾', '乘', '骑', '停', '票', '码', '行', '驶', '程', '过',
'隧', '道', '转', '步', '街', '灯', '红', '绿', '黄',
],
}, },
{ {
categoryId: 22, categoryId: 22,
icon: '📚', icon: '📚',
categoryName: '校园与文具', categoryName: '校园与文具',
words: ['书', '本', '笔', '尺', '刀', '纸', '包', '课', '桌', '椅', '图', '画', '作', '业'], words: [
'书', '本', '笔', '尺', '刀', '纸', '包', '课', '桌', '椅', '图', '画', '作', '业',
'教', '室', '校', '园', '板', '黑', '白', '擦', '讲', '台', '钟', '铃',
'练', '习', '册', '橡', '皮', '胶', '垫', '盒', '袋', '卷',
'订', '夹', '筒', '水', '彩', '颜', '料', '文', '具', '铅',
'钢', '毛', '红', '蓝', '字', '帖', '典', '母', '材', '布', '贴', '考', '试', '印', '章', '机',
],
}, },
{ {
categoryId: 23, categoryId: 23,
icon: '⏰', icon: '⏰',
categoryName: '时间与季节', categoryName: '时间与季节',
words: [ words: [
'日', '月', '年', '时', '分', '秒', '早', '', '', '', '', '', '', '', '冬' '日', '月', '年', '岁', '时', '分', '秒', '', '', '', '', '', '', '',
'今', '明', '昨', '春', '夏', '秋', '冬', '季', '节', '阳', '阴',
'钟', '点', '刻', '旬', '期', '当', '初', '终', '始', '末', '再', '曾', '旧', '新',
], ],
}, },
{ {
categoryId: 24, categoryId: 24,
icon: '🟠', icon: '🟠',
categoryName: '形状与图形', categoryName: '形状与图形',
words: ['点', '线', '面', '圆', '方', '角', '弧', '长', '宽', '高'], words: [
'点', '线', '面', '角', '边', '体', '圆', '方', '长', '宽', '高', '深', '矩', '形',
'弧', '棱', '柱', '锥', '球', '扇', '环', '扁', '厚', '薄',
],
}, },
{ {
categoryId: 25, categoryId: 25,
icon: '📏', icon: '📏',
categoryName: '量词常用', categoryName: '常用量词',
words: ['个', '只', '匹', '条', '朵', '棵', '片', '张', '本', '杯', '块', '双'], words: [
'个', '只', '匹', '条', '朵', '棵', '片', '张', '本', '杯', '块', '双',
'头', '位', '节', '根', '座', '群', '瓶', '颗', '段', '辆', '架',
'支', '面', '粒', '把', '首', '封', '页', '箱', '队',
],
}, },
]; ];
@@ -263,9 +329,7 @@ export function getCategoryTabIndex(categoryId: number): number {
} }
/** 汇总分类下全部汉字(含一二年级 sections) */ /** 汇总分类下全部汉字(含一二年级 sections) */
export function collectCategoryWords( export function collectCategoryWords(cat: (typeof WORDS)[number]): string[] {
cat: (typeof WORDS)[number],
): string[] {
if (cat.words?.length) { if (cat.words?.length) {
return cat.words; return cat.words;
} }
+85 -9
View File
@@ -16,6 +16,37 @@ import drawHeader from './drawHeader';
const LINE_COLOR = '#BCBAB2'; // 打印友好的深灰线色,避免渐变色 const LINE_COLOR = '#BCBAB2'; // 打印友好的深灰线色,避免渐变色
const LINE_WIDTH = 3; const LINE_WIDTH = 3;
/** 品牌水印模式:A 右下角醒目;B 页面中央大号旋转浅水印(默认) */
export type BrandWatermarkMode = 'A' | 'B';
export interface DrawPrintFooterOptions {
/** 默认 B */
mode?: BrandWatermarkMode;
/** 品牌文案,默认「涂鸦丫小程序」 */
text?: string;
opacity?: number;
fontSize?: number;
}
const BRAND_TEXT = '涂鸦丫小程序';
const BRAND_COLOR = '#7C766A';
/** 方案 A:右下角单行品牌水印 */
const FOOTER_MODE_A = {
fontSize: 20,
opacity: 0.12,
marginRight: 24,
marginBottom: 20,
} as const;
/** 方案 B:页面中央大号旋转浅水印,单行居中 */
const FOOTER_MODE_B = {
fontSize: 32,
opacity: 0.12,
rotationDeg: -25,
centerYRatio: 0.52,
} as const;
export class BaseDrawService { export class BaseDrawService {
canvas: WechatMiniprogram.Canvas; canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext; ctx: RenderingContext;
@@ -174,24 +205,69 @@ export class BaseDrawService {
} }
/** /**
* 页脚:页面底部居中绘制品牌文案「涂鸦丫小程序」(粗体)。 * 品牌水印页脚(默认「涂鸦丫小程序」单行)。
* - A:右下角水印,适合有底部页码的页面
* - B:页面中央大号旋转浅水印(默认)
* 请在整页正文绘制完成后调用,避免被内容覆盖。 * 请在整页正文绘制完成后调用,避免被内容覆盖。
*/ */
async drawPrintFooter(): Promise<void> { drawPrintFooter(options?: DrawPrintFooterOptions): void {
const mode = options?.mode ?? 'A';
const text = options?.text ?? BRAND_TEXT;
if (mode === 'A') {
this.drawPrintFooterModeA(text, options);
} else {
this.drawPrintFooterModeB(text, options);
}
}
/** 方案 A:右下角单行品牌水印 */
private drawPrintFooterModeA(
text: string,
options?: DrawPrintFooterOptions,
): void {
const { ctx, canvasWidth, canvasHeight } = this; const { ctx, canvasWidth, canvasHeight } = this;
const text = '涂鸦丫小程序'; const fontSize = options?.fontSize ?? FOOTER_MODE_A.fontSize;
const bottomMargin = 12; const opacity = options?.opacity ?? FOOTER_MODE_A.opacity;
const fontSize = 13; const { marginRight, marginBottom } = FOOTER_MODE_A;
const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`; const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`;
const textColor = '#7C766A';
ctx.save(); ctx.save();
ctx.font = font; ctx.font = font;
ctx.fillStyle = textColor; ctx.fillStyle = BRAND_COLOR;
ctx.globalAlpha = opacity;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText(
text,
canvasWidth - marginRight,
canvasHeight - marginBottom,
);
ctx.restore();
}
/** 方案 B:页面中央大号旋转浅水印,单行居中 */
private drawPrintFooterModeB(
text: string,
options?: DrawPrintFooterOptions,
): void {
const { ctx, canvasWidth, canvasHeight } = this;
const fontSize = options?.fontSize ?? FOOTER_MODE_B.fontSize;
const opacity = options?.opacity ?? FOOTER_MODE_B.opacity;
const { rotationDeg, centerYRatio } = FOOTER_MODE_B;
const font = `bold ${fontSize}px "Microsoft Yahei", sans-serif`;
const centerX = canvasWidth / 2;
const centerY = canvasHeight * centerYRatio;
ctx.save();
ctx.font = font;
ctx.fillStyle = BRAND_COLOR;
ctx.globalAlpha = opacity;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const y = canvasHeight - bottomMargin - fontSize / 2; ctx.translate(centerX, centerY);
ctx.fillText(text, canvasWidth / 2, y); ctx.rotate((rotationDeg * Math.PI) / 180);
ctx.fillText(text, 0, 0);
ctx.restore(); ctx.restore();
} }
@@ -13,4 +13,10 @@ export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
subtitle: '控笔练习', subtitle: '控笔练习',
path: '/chinesePages/penControlSheet/penControlSheet', path: '/chinesePages/penControlSheet/penControlSheet',
}, },
{
id: 'word-test-sheet',
title: '测字表',
subtitle: '测字表',
path: '/chinesePages/wordTestSheet/wordTestSheet',
},
]; ];
+9 -2
View File
@@ -24,12 +24,19 @@
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "chinesePages/penControlSheet/penControlSheet", "name": "chinesePages/wordTestSheet/wordTestSheet",
"pathName": "chinesePages/penControlSheet/penControlSheet", "pathName": "chinesePages/wordTestSheet/wordTestSheet",
"query": "", "query": "",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
}, },
{
"name": "chinesePages/penControlSheet/penControlSheet",
"pathName": "chinesePages/penControlSheet/penControlSheet",
"query": "",
"launchMode": "default",
"scene": null
},
{ {
"name": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix", "name": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix",
"pathName": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix", "pathName": "chinesePages/penControlSheet/penControlSheet?id=pen-control-mix",