diff --git a/miniprogram/core/data/worksheets.ts b/miniprogram/core/data/worksheets.ts index 47c1bcc..01703af 100644 --- a/miniprogram/core/data/worksheets.ts +++ b/miniprogram/core/data/worksheets.ts @@ -453,6 +453,39 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ }, }, ), + sheet( + { + id: 'letter-tracing-daily-checkin', + title: '每日打卡', + desc: '四宫格每日字母打卡,闭合四线三格练习', + category: 'english', + page: 'letterTracing', + subpackage: 'englishPages', + icon: '🗓️', + img: '', + ageRange: [4, 7], + difficulty: 'basic', + previewBg: '#e8f4ff', + previewText: 'ABCD', + }, + { + subcategory: 'letter-tracing', + template: 'tracing-writing', + generator: 'letter-tracing', + sortOrder: 48, + layoutConfig: { + ...L.trace, + instructionText: '四宫格每日打卡字母描红', + }, + generatorConfig: { + mode: 'letter-tracing-daily-checkin', + letterCase: 'both', + repetitions: 5, + fadePattern: 'first-only', + dailyLetters: ['A', 'B', 'C', 'D'], + }, + }, + ), ]; export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS.filter( diff --git a/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts b/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts new file mode 100644 index 0000000..57fb620 --- /dev/null +++ b/miniprogram/englishPages/letterTracing/draw/dailyCheckinDraw.ts @@ -0,0 +1,317 @@ +import { BaseDrawService } from '../../../core/draw/baseDraw'; +import type { + DailyCheckinRow, + DailyCheckinSection, + LetterTracingData, +} from '../generators/letter-tracing-generator'; +import { + PRINT_CLEARLY_DASHED, + PRINT_CLEARLY_REGULAR, + fontFamilyOf, +} from '../../shared/data/fontProfiles'; +import { + calcLetterFontSizes, + drawClosedFourLineGrid, + drawLetterInFourLineGrid, + loadLetterFont, + type LetterFontSizes, +} from '../../shared/draw/drawTools'; + +type DailyCheckinData = Extract< + LetterTracingData, + { mode: 'letter-tracing-daily-checkin' } +>; + +// 页面左右边距(px) +const PAGE_MARGIN_X = 14; +// 页面上下边距(px) +const PAGE_MARGIN_Y = 14; +// 每个分区内容区左右内边距(px) +const SECTION_PADDING_X = 18; +// 每个分区内容区顶部内边距(px) +const SECTION_PADDING_TOP = 12; +// 每个分区内容区底部内边距(px) +const SECTION_PADDING_BOTTOM = 10; + +// 标题与上一元素的垂直间距(px) +const HEADER_TITLE_GAP = 24; +// 标题与元信息(如日期、学号等)的间距(px) +const HEADER_META_GAP = 22; +// 正文内容距标题/元信息顶部的间距(px) +const CONTENT_TOP_GAP = 14; + +// 页脚短语与内容之间的间距(px) +const FOOTER_PHRASE_GAP = 26; +// 页脚索引与短语之间的间距(px) +const FOOTER_INDEX_GAP = 22; + +// 单个格子区列数 +const GRID_COLS = 5; +// 单个格子区行数 +const GRID_ROWS = 7; +// 格子列间距(px) +const GRID_COL_GAP = 6; +// 格子行间距(px) +const GRID_ROW_GAP = 8; + +// 网格描边主色 +const GRID_BORDER = '#9AA9A2'; +// 网格中间线虚线颜色 +const GRID_MIDDLE = 'rgba(154, 169, 162, 0.65)'; + +// 黑色文本主色 +const COLOR_BLACK = '#1a1a1a'; +// 数据红色(例:未签到状态) +const COLOR_LIGHT_RED = '#FF8E8E'; + +export default class DailyCheckinDraw extends BaseDrawService { + async draw(data: DailyCheckinData) { + this.prepareDraw(); + await Promise.all([ + loadLetterFont(PRINT_CLEARLY_REGULAR), + loadLetterFont(PRINT_CLEARLY_DASHED), + ]); + + this.drawPageDividers(); + + const sections = this.getSectionRects(); + for (let i = 0; i < sections.length; i++) { + this.drawSection(sections[i], data.sections[i] ?? null); + } + } + + private drawPageDividers() { + const verticalX = this.canvasWidth / 2; + const horizontalY = this.canvasHeight / 2; + + this.drawLine( + verticalX, + PAGE_MARGIN_Y, + verticalX, + this.canvasHeight - PAGE_MARGIN_Y, + { + isDashed: true, + dashPattern: [5, 5], + color: '#D6D2CC', + lineWidth: 1, + }, + ); + + this.drawLine( + PAGE_MARGIN_X, + horizontalY, + this.canvasWidth - PAGE_MARGIN_X, + horizontalY, + { + isDashed: true, + dashPattern: [5, 5], + color: '#D6D2CC', + lineWidth: 1, + }, + ); + } + + private getSectionRects() { + const halfW = this.canvasWidth / 2; + const halfH = this.canvasHeight / 2; + + return [ + { + x: PAGE_MARGIN_X, + y: PAGE_MARGIN_Y, + w: halfW - PAGE_MARGIN_X, + h: halfH - PAGE_MARGIN_Y, + }, + { + x: halfW, + y: PAGE_MARGIN_Y, + w: this.canvasWidth - PAGE_MARGIN_X - halfW, + h: halfH - PAGE_MARGIN_Y, + }, + { + x: PAGE_MARGIN_X, + y: halfH, + w: halfW - PAGE_MARGIN_X, + h: this.canvasHeight - PAGE_MARGIN_Y - halfH, + }, + { + x: halfW, + y: halfH, + w: this.canvasWidth - PAGE_MARGIN_X - halfW, + h: this.canvasHeight - PAGE_MARGIN_Y - halfH, + }, + ]; + } + + private drawSection( + rect: { x: number; y: number; w: number; h: number }, + section: DailyCheckinSection | null, + ) { + if (!section) return; + + this.drawSectionHeader(rect); + this.drawSectionFooter(rect, section.pageNumber); + this.drawSectionGrid(rect, section.rows); + } + + private drawSectionHeader(rect: { + x: number; + y: number; + w: number; + h: number; + }) { + const cx = rect.x + rect.w / 2; + const titleY = rect.y + SECTION_PADDING_TOP + 12; + const metaY = titleY + HEADER_TITLE_GAP; + + this.ctx.save(); + this.ctx.fillStyle = '#322E25'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + + this.ctx.font = '20px serif'; + this.ctx.fillText('每日打卡', cx, titleY); + + this.ctx.font = '14px serif'; + this.ctx.fillText('月', rect.x + rect.w * 0.34, metaY); + this.ctx.fillText('日', rect.x + rect.w * 0.48, metaY); + this.ctx.fillText('姓名:', rect.x + rect.w * 0.7, metaY); + + this.ctx.strokeStyle = '#8E897E'; + this.ctx.lineWidth = 1; + this.ctx.setLineDash([]); + this.ctx.beginPath(); + this.ctx.moveTo(rect.x + rect.w * 0.14, metaY + 4); + this.ctx.lineTo(rect.x + rect.w * 0.3, metaY + 4); + this.ctx.moveTo(rect.x + rect.w * 0.38, metaY + 4); + this.ctx.lineTo(rect.x + rect.w * 0.44, metaY + 4); + this.ctx.moveTo(rect.x + rect.w * 0.52, metaY + 4); + this.ctx.lineTo(rect.x + rect.w * 0.58, metaY + 4); + this.ctx.moveTo(rect.x + rect.w * 0.78, metaY + 4); + this.ctx.lineTo(rect.x + rect.w * 0.94, metaY + 4); + this.ctx.stroke(); + + this.ctx.restore(); + } + + private drawSectionFooter( + rect: { x: number; y: number; w: number; h: number }, + pageNumber: number, + ) { + const cx = rect.x + rect.w / 2; + const phraseY = + rect.y + rect.h - SECTION_PADDING_BOTTOM - FOOTER_PHRASE_GAP; + const indexY = rect.y + rect.h - SECTION_PADDING_BOTTOM - 6; + + this.ctx.save(); + this.ctx.fillStyle = '#3E3A33'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.font = '14px serif'; + this.ctx.fillText('日拱一卒 不期而至', cx, phraseY); + this.ctx.font = '12px serif'; + this.ctx.fillText(`- ${pageNumber} -`, cx, indexY); + this.ctx.restore(); + } + + private drawSectionGrid( + rect: { x: number; y: number; w: number; h: number }, + rows: DailyCheckinRow[], + ) { + const contentTop = + rect.y + + SECTION_PADDING_TOP + + HEADER_TITLE_GAP + + HEADER_META_GAP + + CONTENT_TOP_GAP; + const contentBottom = + rect.y + + rect.h - + SECTION_PADDING_BOTTOM - + FOOTER_PHRASE_GAP - + FOOTER_INDEX_GAP; + const contentHeight = contentBottom - contentTop; + const contentWidth = rect.w - SECTION_PADDING_X * 2; + + const cellW = + (contentWidth - GRID_COL_GAP * (GRID_COLS - 1)) / GRID_COLS; + const cellH = + (contentHeight - GRID_ROW_GAP * (GRID_ROWS - 1)) / GRID_ROWS; + const pairGap = Math.min(16, cellW * 0.28); + const regularSizes = calcLetterFontSizes( + cellH * 0.86, + PRINT_CLEARLY_REGULAR, + ); + const dashedSizes = calcLetterFontSizes( + cellH * 0.86, + PRINT_CLEARLY_DASHED, + ); + + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + const row = rows[rowIdx]; + const y = contentTop + rowIdx * (cellH + GRID_ROW_GAP); + + for (let colIdx = 0; colIdx < GRID_COLS; colIdx++) { + const x = + rect.x + + SECTION_PADDING_X + + colIdx * (cellW + GRID_COL_GAP); + drawClosedFourLineGrid(this.ctx, x, y, cellW, cellH, { + ink: GRID_BORDER, + middleInk: GRID_MIDDLE, + lineWidth: 1, + dash: [3, 3], + }); + + const cell = row.cells[colIdx]; + if (!cell) continue; + + const sizes = + row.style === 'dashed' ? dashedSizes : regularSizes; + const fontFamily = fontFamilyOf( + row.style === 'dashed' + ? PRINT_CLEARLY_DASHED + : PRINT_CLEARLY_REGULAR, + ); + const centerX = x + cellW / 2; + + this.ctx.save(); + this.ctx.globalAlpha = cell.alpha; + + drawLetterInFourLineGrid( + this.ctx, + cell.upper, + centerX - pairGap / 2, + y, + cellH, + sizes, + { + fontFamily, + color: + cell.color === COLOR_BLACK + ? COLOR_BLACK + : COLOR_LIGHT_RED, + }, + ); + + drawLetterInFourLineGrid( + this.ctx, + cell.lower, + centerX + pairGap / 2, + y, + cellH, + sizes, + { + fontFamily, + color: + cell.color === COLOR_BLACK + ? COLOR_BLACK + : COLOR_LIGHT_RED, + }, + ); + + this.ctx.restore(); + } + } + } +} diff --git a/miniprogram/englishPages/letterTracing/draw/letterTracingDraw.ts b/miniprogram/englishPages/letterTracing/draw/letterTracingDraw.ts index 5029235..869c95f 100644 --- a/miniprogram/englishPages/letterTracing/draw/letterTracingDraw.ts +++ b/miniprogram/englishPages/letterTracing/draw/letterTracingDraw.ts @@ -6,6 +6,7 @@ import TwoColumnDraw from './twoColumnDraw'; import UpperLowerDraw from './upperLowerDraw'; import SingleLineDraw from './singleLineDraw'; import TripleDraw from './tripleDraw'; +import DailyCheckinDraw from './dailyCheckinDraw'; /** * 字母描红绘制分发层。 @@ -20,6 +21,7 @@ export default class LetterTracingDraw extends BaseDrawService { 'letter-tracing-two-column': TwoColumnDraw, 'letter-tracing-half': SingleLineDraw, 'letter-tracing-three': TripleDraw, + 'letter-tracing-daily-checkin': DailyCheckinDraw, }[data.mode]; const delegate = new DrawClass(this.canvas, this.ctx, this.options); diff --git a/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts b/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts index cd56452..464e394 100644 --- a/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts +++ b/miniprogram/englishPages/letterTracing/generators/letter-tracing-generator.ts @@ -13,7 +13,9 @@ export type LetterTracingMode = /** 5 三字母精练:每页聚焦 3 个字母,每字母 4 行(2 行示范 + 2 行简练) */ | 'letter-tracing-three' /** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */ - | 'letter-tracing-half'; + | 'letter-tracing-half' + /** 7 每日打卡:整页四宫格,每宫一个字母,7 行 5 列闭合四线三格 */ + | 'letter-tracing-daily-checkin'; /** 字母大小写模式(部分模式不使用) */ export type LetterCaseMode = 'upper' | 'lower' | 'both'; @@ -32,6 +34,8 @@ export interface LetterTracingGeneratorConfig { alphabetHalf?: AlphabetHalf; /** 三字母精练:当前页聚焦的 3 个字母(如 ['A','B','C']) */ tripleLetters?: string[]; + /** 每日打卡:当前页聚焦的 4 个字母(如 ['A','B','C','D']) */ + dailyLetters?: string[]; } /** 单个描红格子的数据 */ @@ -71,6 +75,19 @@ export interface TripleSection { rows: SingleLineRow[]; } +/** 每日打卡:单行(5 列),支持实线/虚线/留空三种样式 */ +export interface DailyCheckinRow { + style: 'solid' | 'dashed' | 'blank'; + cells: (SingleLineCell | null)[]; +} + +/** 每日打卡:单个字母对应一个宫格 */ +export interface DailyCheckinSection { + letter: string; + pageNumber: number; + rows: DailyCheckinRow[]; +} + /** Highlight It 格子数据(3×6 网格,混入目标字母) */ export interface HighlightGrid { rows: string[][]; @@ -118,6 +135,10 @@ export type LetterTracingData = mode: 'letter-tracing-three'; sections: TripleSection[]; } + | { + mode: 'letter-tracing-daily-checkin'; + sections: DailyCheckinSection[]; + } | { mode: 'letter-tracing-upper-lower'; upperRows: string[][]; @@ -196,30 +217,6 @@ function rowForChar( }; } -/** 一行内多个字母,各自占一段描红格(用于分栏对照等) */ -function rowForManyChars( - chars: string[], - repetitions: number, - fadePattern: 'gradient' | 'first-only', -): TracingRow { - const cells: TracingCell[] = []; - for (const ch of chars) { - cells.push(...buildCells(ch, repetitions, fadePattern)); - } - return { - displayChar: chars.join(''), - cells, - }; -} - -function chunk(arr: T[], size: number): T[][] { - const out: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - out.push(arr.slice(i, i + size)); - } - return out; -} - /** 总览区 26 字母拆成 7 + 7 + 6 + 6 四行(前两行 7 个、后两行 6 个,与四线三格排版一致) */ function overviewRowsFrom(letters: string[]): string[][] { return [ @@ -271,6 +268,37 @@ function buildSimpleCells(L: string): (SingleLineCell | null)[] { ]; } +function buildDailyFilledCells( + L: string, + style: 'solid' | 'dashed', + firstBlack: boolean = false, +): (SingleLineCell | null)[] { + const lower = L.toLowerCase(); + return Array.from({ length: 5 }, (_, idx) => ({ + upper: L, + lower, + style, + color: idx === 0 && firstBlack ? _COLOR_BLACK : _COLOR_LIGHT_RED, + alpha: 1, + })); +} + +function buildDailyBlankCells(): (SingleLineCell | null)[] { + return Array.from({ length: 5 }, () => null); +} + +function buildDailyCheckinRows(L: string): DailyCheckinRow[] { + return [ + { style: 'solid', cells: buildDailyFilledCells(L, 'solid', true) }, + { style: 'solid', cells: buildDailyFilledCells(L, 'solid') }, + { style: 'blank', cells: buildDailyBlankCells() }, + { style: 'dashed', cells: buildDailyFilledCells(L, 'dashed') }, + { style: 'blank', cells: buildDailyBlankCells() }, + { style: 'dashed', cells: buildDailyFilledCells(L, 'dashed') }, + { style: 'blank', cells: buildDailyBlankCells() }, + ]; +} + /** * 根据配置生成字母描红数据 * - picture-tracing:教学区 + 6 行描红 @@ -377,6 +405,18 @@ export function generateLetterTracing( return { mode: 'letter-tracing-half', rows }; } + if (mode === 'letter-tracing-daily-checkin') { + const letters = (config.dailyLetters ?? ['A', 'B', 'C', 'D']).map( + normalizeLetter, + ); + const sections: DailyCheckinSection[] = letters.map((L) => ({ + letter: L, + pageNumber: LETTERS_UPPER.indexOf(L) + 1, + rows: buildDailyCheckinRows(L), + })); + return { mode: 'letter-tracing-daily-checkin', sections }; + } + // letter-tracing-upper-lower return { mode: 'letter-tracing-upper-lower', @@ -410,5 +450,11 @@ export function mergeLetterTracingConfig( 'A-M') as AlphabetHalf, tripleLetters: overrides.tripleLetters ?? base.tripleLetters ?? ['A', 'B', 'C'], + dailyLetters: overrides.dailyLetters ?? base.dailyLetters ?? [ + 'A', + 'B', + 'C', + 'D', + ], }; } diff --git a/miniprogram/englishPages/letterTracing/letterTracing.ts b/miniprogram/englishPages/letterTracing/letterTracing.ts index c3e23c9..b04ff85 100644 --- a/miniprogram/englishPages/letterTracing/letterTracing.ts +++ b/miniprogram/englishPages/letterTracing/letterTracing.ts @@ -56,6 +56,12 @@ const MODES = [ label: '三字母精练', desc: '每页聚焦 3 个字母深度书写', }, + { + id: 'letter-tracing-daily-checkin', + icon: 'draw-o', + label: '每日打卡', + desc: '四宫格每日字母打卡练习', + }, ] as const; const MODE_MAP = Object.fromEntries(MODES.map((m) => [m.id, m])) as Record< @@ -72,6 +78,21 @@ for (let i = 0; i < LETTERS_UPPER.length; i += 3) { TRIPLE_GROUPS.push({ label, letters: [...group] }); } +/** 每日打卡分组:4 个字母一组 */ +const DAILY_GROUPS: { label: string; letters: string[] }[] = []; +for (let i = 0; i < LETTERS_UPPER.length; i += 4) { + const group = LETTERS_UPPER.slice(i, i + 4); + const label = + group.length > 1 ? `${group[0]}–${group[group.length - 1]}` : group[0]; + DAILY_GROUPS.push({ label, letters: [...group] }); +} + +function getDailyGroupIndex(letters?: string[]): number { + const first = normalizeLetter(letters?.[0] ?? 'A'); + const idx = DAILY_GROUPS.findIndex((group) => group.letters[0] === first); + return idx >= 0 ? idx : 0; +} + /** 根据 worksheetId 查找页面元信息 */ function pageInfoLookup(id: string) { const m = MODE_MAP[id]; @@ -88,8 +109,11 @@ type PageData = CanvasDataState & { showCaseToggle: boolean; showNextLetter: boolean; showTripleGroupPicker: boolean; + showDailyGroupPicker: boolean; tripleGroups: { label: string; letters: string[] }[]; selectedTripleGroupIdx: number; + dailyGroups: { label: string; letters: string[] }[]; + selectedDailyGroupIdx: number; letterGrid: string[]; modeOptions: ReadonlyArray<{ id: string; label: string; desc: string }>; isPreviewFavorite: boolean; @@ -118,8 +142,11 @@ createPage( showCaseToggle: false, showNextLetter: true, showTripleGroupPicker: false, + showDailyGroupPicker: false, tripleGroups: TRIPLE_GROUPS, selectedTripleGroupIdx: 0, + dailyGroups: DAILY_GROUPS, + selectedDailyGroupIdx: 0, letterGrid: LETTERS_PAIRS, modeOptions: [...MODES], isPreviewFavorite: false, @@ -204,6 +231,8 @@ createPage( this.data.traceMode === 'letter-tracing-case-pairing' || this.data.traceMode === 'letter-tracing-half'; const isTriple = this.data.traceMode === 'letter-tracing-three'; + const isDaily = + this.data.traceMode === 'letter-tracing-daily-checkin'; return mergeLetterTracingConfig(base, { selectedLetter: this.data.selectedLetter, letterCase, @@ -221,6 +250,13 @@ createPage( ?.letters, } : {}), + ...(isDaily + ? { + dailyLetters: + DAILY_GROUPS[this.data.selectedDailyGroupIdx] + ?.letters, + } + : {}), }); }, @@ -293,6 +329,14 @@ createPage( this.drawCanvas(); }, + /** 用户在每日打卡分组选择器中选择了一组 */ + onSelectDailyGroup(e: WechatMiniprogram.TouchEvent) { + const idx = Number(e.currentTarget.dataset.idx); + if (isNaN(idx) || idx === this.data.selectedDailyGroupIdx) return; + this.setData({ selectedDailyGroupIdx: idx }); + this.drawCanvas(); + }, + /** * 应用指定的 worksheet 配置到页面状态 * 根据 worksheet 的 generatorConfig 决定 UI 控件的显隐(字母选择器、大小写切换等) @@ -320,9 +364,15 @@ createPage( traceMode === 'letter-tracing-half'; const showNextLetter = traceMode === 'letter-tracing-single'; const showTripleGroupPicker = traceMode === 'letter-tracing-three'; + const showDailyGroupPicker = + traceMode === 'letter-tracing-daily-checkin'; const selectedLetter = options?.preserveLetter ? (this.data.selectedLetter ?? 'A') : normalizeLetter(merged.selectedLetter ?? ''); + const selectedDailyGroupIdx = + options?.preserveLetter && showDailyGroupPicker + ? (this.data.selectedDailyGroupIdx ?? 0) + : getDailyGroupIndex(merged.dailyLetters); const letterCaseLower = traceMode === 'letter-tracing-case-pairing' || traceMode === 'letter-tracing-half' @@ -343,6 +393,8 @@ createPage( showCaseToggle, showNextLetter, showTripleGroupPicker, + showDailyGroupPicker, + selectedDailyGroupIdx, }, () => { this.initPageInfo( diff --git a/miniprogram/englishPages/letterTracing/letterTracing.wxml b/miniprogram/englishPages/letterTracing/letterTracing.wxml index 890d9e0..9da66ef 100644 --- a/miniprogram/englishPages/letterTracing/letterTracing.wxml +++ b/miniprogram/englishPages/letterTracing/letterTracing.wxml @@ -92,6 +92,23 @@ + + 选择字母组 + + + {{item.label}} + + + +