feat: 每日打卡

This commit is contained in:
R524809
2026-04-22 16:49:29 +08:00
parent de74666c9f
commit 2a1dbad164
8 changed files with 541 additions and 34 deletions
+33
View File
@@ -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(
@@ -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();
}
}
}
}
@@ -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);
@@ -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<T>(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',
],
};
}
@@ -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(
@@ -92,6 +92,23 @@
</view>
</view>
<view wx:if="{{showDailyGroupPicker}}" class="lt-section">
<text class="lt-section-title">选择字母组</text>
<view class="lt-chip-row lt-chip-row--wrap lt-chip-row--triple">
<view
wx:for="{{dailyGroups}}"
wx:key="label"
class="lt-chip lt-chip--triple {{selectedDailyGroupIdx === index ? 'lt-chip--active' : ''}}"
hover-class="lt-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
data-idx="{{index}}"
bind:tap="onSelectDailyGroup">
{{item.label}}
</view>
</view>
</view>
<view
wx:if="{{showNextLetter}}"
class="lt-shuffle-btn"
@@ -174,3 +174,43 @@ export function drawFourLineGrid(
ctx.lineTo(x + w, yBot);
ctx.stroke();
}
/**
* 闭合四线三格
* - 外框:闭合矩形
* - 中间两条:虚线
*/
export function drawClosedFourLineGrid(
ctx: RenderingContext,
x: number,
y: number,
w: number,
h: number,
style?: FourLineGridStyle,
) {
const ink = style?.ink ?? '#9AA9A2';
const middleInk = style?.middleInk ?? 'rgba(154, 169, 162, 0.65)';
const lineWidth = style?.lineWidth ?? 1;
const dash = style?.dash ?? [3, 3];
const y1 = y + h / 3;
const y2 = y + (h * 2) / 3;
ctx.save();
ctx.strokeStyle = ink;
ctx.lineWidth = lineWidth;
ctx.setLineDash([]);
ctx.strokeRect(x, y, w, h);
ctx.strokeStyle = middleInk;
ctx.setLineDash(dash);
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x + w, y1);
ctx.moveTo(x, y2);
ctx.lineTo(x + w, y2);
ctx.stroke();
ctx.restore();
}