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