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
@@ -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();
}
}
}
}