feat: 开发练习纸

This commit is contained in:
R524809
2026-08-25 15:40:38 +08:00
parent c0b8666257
commit f82ac4d89d
13 changed files with 1238 additions and 1 deletions
@@ -0,0 +1,682 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { PaperType } from '../paperSheet.config';
/** 页面内容区边距(逻辑像素) */
const LAYOUT = {
topGap: 14,
leftMargin: 36,
rightMargin: 36,
bottomMargin: 36,
} as const;
interface ContentRect {
top: number;
left: number;
width: number;
height: number;
right: number;
bottom: number;
}
/** 将 #RRGGBB 转为带透明度的 rgba 字符串 */
function withAlpha(hex: string, alpha: number): string {
const normalized = hex.replace('#', '');
const full =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export default class PaperDrawService extends BaseDrawService {
/** 主线条颜色 */
private color: string = '#333333';
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '作业纸',
subTitle: '多种作业纸一键打印',
...options,
});
}
/**
* 绘制指定类型 + 颜色的作业纸
*/
async draw(paperType: PaperType, color: string) {
this.color = color;
this.options.title = this.optionTitleForType(paperType);
this.prepareDraw();
if (PaperDrawService.HEADER_TYPES.has(paperType)) {
// 听写、作业登记表:需要标注是谁的、哪天的,保留精简页眉
this.drawCompactHeader();
} else {
// 纯书写纸(田字格 / 米字格 / 方格 / 四线三格 / 信纸 / 横线 / 竖线)
// 不画页眉,最大化书写区,仅留顶部打印留白
this.currentY = 20;
}
const rect = this.getContentRect();
switch (paperType) {
case 'tian':
this.drawGridCells(rect, { diagonal: false });
break;
case 'mi':
this.drawGridCells(rect, { diagonal: true });
break;
case 'square':
this.drawSquareGrid(rect);
break;
case 'four-line':
this.drawFourLine(rect);
break;
case 'letter':
this.drawLetterPaper(rect);
break;
case 'horizontal':
this.drawHorizontalLines(rect);
break;
case 'vertical':
this.drawVerticalLines(rect);
break;
case 'dictation-hanzi':
this.drawDictation(rect, 'hanzi');
break;
case 'dictation-pinyin':
this.drawDictation(rect, 'pinyin');
break;
case 'homework-log':
this.drawHomeworkLog(rect);
break;
}
this.drawCenteredFooter();
}
/** 需要保留精简页眉(标题 + 姓名/日期)的纸张类型 */
private static readonly HEADER_TYPES = new Set<PaperType>([
'dictation-hanzi',
'dictation-pinyin',
'homework-log',
]);
/**
* 自定义精简页眉:无 Logo、无分割线,标题字体较小、整体高度更矮,
* 保留 姓名 / 日期 两个填写项(不含得分)。
* 绘制完成后设置 currentY 供正文使用。
*/
private drawCompactHeader() {
const { ctx, canvasWidth } = this;
const marginX = 24;
// 标题(较小字号,居中)
const titleY = 12;
ctx.fillStyle = '#322e25';
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(
String(this.options.title || '作业纸'),
canvasWidth / 2,
titleY,
);
// 姓名 / 日期(与标题拉开间距)
const metaY = 48;
const metaFontPx = 11;
ctx.font = `${metaFontPx}px "Microsoft Yahei"`;
ctx.fillStyle = '#7c766a';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const innerLeft = marginX + 6;
const innerRight = canvasWidth - marginX - 6;
const innerW = innerRight - innerLeft;
const colW = innerW / 2;
const drawMetaField = (label: string, x: number, right: number) => {
ctx.fillText(label, x, metaY);
const labelW = ctx.measureText(label).width;
const lineY = metaY + metaFontPx + 3;
ctx.strokeStyle = '#E5DCC9';
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x + labelW + 2, lineY);
ctx.lineTo(Math.max(x + labelW + 2, right), lineY);
ctx.stroke();
};
drawMetaField('姓名:', innerLeft, innerLeft + colW - 24);
drawMetaField('日期:', innerLeft + colW, innerRight);
// 正文起始 Y(不画分割线)
this.currentY = metaY + metaFontPx + 12;
}
/** 底部品牌水印:居中展示 */
private drawCenteredFooter() {
const { ctx, canvasWidth, canvasHeight } = this;
ctx.save();
ctx.font = 'bold 13px "Microsoft Yahei"';
ctx.fillStyle = '#7c766a';
ctx.globalAlpha = 0.35;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('涂鸦丫小程序', canvasWidth / 2, canvasHeight - 16);
ctx.restore();
}
private optionTitleForType(paperType: PaperType): string {
const map: Record<PaperType, string> = {
tian: '田字格作业纸',
mi: '米字格作业纸',
square: '方格作业纸',
'four-line': '四线三格作业纸',
letter: '信纸',
horizontal: '横线本',
vertical: '竖格作业纸',
'dictation-hanzi': '汉字听写',
'dictation-pinyin': '拼音 / 英语听写',
'homework-log': '作业登记表',
};
return map[paperType];
}
private getContentRect(): ContentRect {
const top = this.currentY + LAYOUT.topGap;
const left = LAYOUT.leftMargin;
const width = this.canvasWidth - LAYOUT.leftMargin - LAYOUT.rightMargin;
const height = this.canvasHeight - top - LAYOUT.bottomMargin;
return {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
};
}
/** 主色 / 辅助色 */
private get borderColor(): string {
return this.color;
}
private get midColor(): string {
return withAlpha(this.color, 0.55);
}
private get diagColor(): string {
return withAlpha(this.color, 0.4);
}
// ─────────────────────────── 田字格 / 米字格 ───────────────────────────
private drawGridCells(rect: ContentRect, opts: { diagonal: boolean }) {
const { ctx } = this;
const targetCell = 46;
const gap = 8;
const cols = Math.max(
1,
Math.floor((rect.width + gap) / (targetCell + gap)),
);
const rows = Math.max(
1,
Math.floor((rect.height + gap) / (targetCell + gap)),
);
const cell = Math.min(
(rect.width - (cols - 1) * gap) / cols,
(rect.height - (rows - 1) * gap) / rows,
);
// 居中排布
const gridW = cols * cell + (cols - 1) * gap;
const startX = rect.left + (rect.width - gridW) / 2;
const startY = rect.top;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = startX + c * (cell + gap);
const y = startY + r * (cell + gap);
this.drawSingleGridCell(ctx, x, y, cell, opts.diagonal);
}
}
}
private drawSingleGridCell(
ctx: RenderingContext,
x: number,
y: number,
size: number,
diagonal: boolean,
) {
// 外框实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(x, y, size, size);
const cx = x + size / 2;
const cy = y + size / 2;
// 十字虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(cx, y);
ctx.lineTo(cx, y + size);
ctx.moveTo(x, cy);
ctx.lineTo(x + size, cy);
ctx.stroke();
// 对角虚线(米字格)
if (diagonal) {
ctx.strokeStyle = this.diagColor;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y);
ctx.lineTo(x, y + size);
ctx.stroke();
}
ctx.setLineDash([]);
}
// ─────────────────────────── 方格 ───────────────────────────
private drawSquareGrid(rect: ContentRect) {
const { ctx } = this;
const targetCell = 27;
const cols = Math.max(1, Math.round(rect.width / targetCell));
const cell = rect.width / cols;
const rows = Math.max(1, Math.floor(rect.height / cell));
const gridW = cols * cell;
const gridH = rows * cell;
const startX = rect.left;
const startY = rect.top;
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
for (let c = 0; c <= cols; c++) {
const x = startX + c * cell;
ctx.moveTo(x, startY);
ctx.lineTo(x, startY + gridH);
}
for (let r = 0; r <= rows; r++) {
const y = startY + r * cell;
ctx.moveTo(startX, y);
ctx.lineTo(startX + gridW, y);
}
ctx.stroke();
}
// ─────────────────────────── 四线三格 ───────────────────────────
private drawFourLine(rect: ContentRect) {
const { ctx } = this;
const groupHeight = 34; // 一组四线三格的总高
const groupGap = 24; // 组间距
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.floor((rect.height + groupGap) / unit));
for (let i = 0; i < count; i++) {
const topY = rect.top + i * unit;
const l1 = topY;
const l2 = topY + groupHeight / 3;
const l3 = topY + (groupHeight * 2) / 3;
const l4 = topY + groupHeight;
// 上下两条实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, l1);
this.strokeHLine(rect.left, rect.right, l4);
// 中间两条虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
this.strokeHLine(rect.left, rect.right, l2);
this.strokeHLine(rect.left, rect.right, l3);
ctx.setLineDash([]);
}
}
// ─────────────────────────── 信纸 ───────────────────────────
private drawLetterPaper(rect: ContentRect) {
const { ctx } = this;
const lineGap = 34;
const innerTop = rect.top + 10;
const innerBottom = rect.bottom - 24;
const lineCount = Math.floor((innerBottom - innerTop) / lineGap);
// 顶部双线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.6;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, rect.top);
this.strokeHLine(rect.left, rect.right, rect.top + 4);
// 中间横线
ctx.lineWidth = 1;
ctx.strokeStyle = withAlpha(this.color, 0.75);
for (let i = 1; i <= lineCount; i++) {
const y = innerTop + i * lineGap;
if (y >= innerBottom) break;
this.strokeHLine(rect.left, rect.right, y);
}
// 底部双线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.6;
this.strokeHLine(rect.left, rect.right, innerBottom);
this.strokeHLine(rect.left, rect.right, innerBottom + 4);
// 右下角「第 页」
ctx.fillStyle = this.borderColor;
ctx.font = '13px "Microsoft Yahei"';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText('第 页', rect.right, innerBottom + 10);
}
// ─────────────────────────── 横线 ───────────────────────────
private drawHorizontalLines(rect: ContentRect) {
const { ctx } = this;
const lineGap = 34;
const count = Math.floor(rect.height / lineGap);
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
for (let i = 0; i <= count; i++) {
const y = rect.top + i * lineGap;
if (y > rect.bottom) break;
this.strokeHLine(rect.left, rect.right, y);
}
}
// ─────────────────────────── 竖线 ───────────────────────────
private drawVerticalLines(rect: ContentRect) {
const { ctx } = this;
const targetGap = 34;
const count = Math.max(1, Math.round(rect.width / targetGap));
const gap = rect.width / count;
// 上下边界实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, rect.top);
this.strokeHLine(rect.left, rect.right, rect.bottom);
// 竖线
ctx.beginPath();
for (let c = 0; c <= count; c++) {
const x = rect.left + c * gap;
ctx.moveTo(x, rect.top);
ctx.lineTo(x, rect.bottom);
}
ctx.stroke();
}
// ─────────────────────────── 听写本(汉字 / 拼音英语) ───────────────────────────
/** 汉字听写:田字格行布局参数 */
private static readonly DICT_HANZI = { cell: 40, rowGap: 20 };
/** 拼音 / 英语听写:四线三格行布局参数 */
private static readonly DICT_PINYIN = { groupHeight: 34, groupGap: 22 };
private drawDictation(rect: ContentRect, variant: 'hanzi' | 'pinyin') {
// 先按行布局算出左侧书写区实际占用高度,使右侧订正栏与其等高
const usedHeight = this.dictationUsedHeight(rect.height, variant);
const alignedRect: ContentRect = {
...rect,
height: usedHeight,
bottom: rect.top + usedHeight,
};
// 右侧订正栏(与左侧等高),返回左侧书写区右边界
const leftAreaRight = this.drawCorrectionColumn(alignedRect);
const leftRect: ContentRect = {
...alignedRect,
width: leftAreaRight - rect.left,
right: leftAreaRight,
};
if (variant === 'hanzi') {
this.drawDictationHanzi(leftRect);
} else {
this.drawDictationPinyin(leftRect);
}
}
/** 计算听写本左侧行区实际占用高度(末行不含尾部间距) */
private dictationUsedHeight(
available: number,
variant: 'hanzi' | 'pinyin',
): number {
if (variant === 'hanzi') {
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
const unit = cell + rowGap;
const rows = Math.max(1, Math.floor((available + rowGap) / unit));
return rows * unit - rowGap;
}
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.floor((available + groupGap) / unit));
return count * unit - groupGap;
}
/** 绘制右侧订正栏,返回左侧书写区的右边界 x */
private drawCorrectionColumn(rect: ContentRect): number {
const { ctx } = this;
const correctW = 120;
const gapToCorrect = 16;
const correctX = rect.right - correctW;
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
ctx.setLineDash([]);
ctx.strokeRect(correctX, rect.top, correctW, rect.height);
ctx.fillStyle = this.borderColor;
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('订正栏', correctX + correctW / 2, rect.top + 14);
this.strokeHLine(correctX, correctX + correctW, rect.top + 40);
return correctX - gapToCorrect;
}
/** 汉字听写:整齐的田字格行(无上方提示线) */
private drawDictationHanzi(rect: ContentRect) {
const { ctx } = this;
const { cell, rowGap } = PaperDrawService.DICT_HANZI;
const unit = cell + rowGap;
const rowCount = Math.max(1, Math.round((rect.height + rowGap) / unit));
const cols = Math.max(1, Math.floor(rect.width / cell));
for (let r = 0; r < rowCount; r++) {
const rowTop = rect.top + r * unit;
for (let c = 0; c < cols; c++) {
const x = rect.left + c * cell;
this.drawSingleGridCell(ctx, x, rowTop, cell, false);
}
}
}
/** 拼音 / 英语听写:四线三格行 */
private drawDictationPinyin(rect: ContentRect) {
const { ctx } = this;
const { groupHeight, groupGap } = PaperDrawService.DICT_PINYIN;
const unit = groupHeight + groupGap;
const count = Math.max(1, Math.round((rect.height + groupGap) / unit));
for (let i = 0; i < count; i++) {
const topY = rect.top + i * unit;
const l1 = topY;
const l2 = topY + groupHeight / 3;
const l3 = topY + (groupHeight * 2) / 3;
const l4 = topY + groupHeight;
// 上下实线
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
this.strokeHLine(rect.left, rect.right, l1);
this.strokeHLine(rect.left, rect.right, l4);
// 中间两条虚线
ctx.strokeStyle = this.midColor;
ctx.setLineDash([4, 3]);
this.strokeHLine(rect.left, rect.right, l2);
this.strokeHLine(rect.left, rect.right, l3);
ctx.setLineDash([]);
}
}
// ─────────────────────────── 作业登记表 ───────────────────────────
private drawHomeworkLog(rect: ContentRect) {
const { ctx } = this;
const subjects = ['语文', '数学', '英语', '其他'];
const rowsPerSubject = 5;
const totalRows = subjects.length * rowsPerSubject;
const subjectColW = 60; // 左侧科目列
const numColW = 44; // 序号列
const checkColW = 56; // 右侧完成勾选列
const contentColX = rect.left + subjectColW + numColW;
const checkColX = rect.right - checkColW;
const rowHeight = rect.height / totalRows;
const tableTop = rect.top;
const tableBottom = rect.top + rowHeight * totalRows;
ctx.setLineDash([]);
// 外框
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
ctx.strokeRect(rect.left, tableTop, rect.width, tableBottom - tableTop);
// 竖分割线
ctx.lineWidth = 1;
this.strokeVLine(rect.left + subjectColW, tableTop, tableBottom);
this.strokeVLine(contentColX, tableTop, tableBottom);
this.strokeVLine(checkColX, tableTop, tableBottom);
ctx.font = '13px "Microsoft Yahei"';
for (let s = 0; s < subjects.length; s++) {
const blockTop = tableTop + s * rowsPerSubject * rowHeight;
const blockBottom = blockTop + rowsPerSubject * rowHeight;
// 科目分组分隔(实线)
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1.2;
this.strokeHLine(rect.left, rect.right, blockBottom);
// 科目名称(竖排居中)
ctx.fillStyle = this.borderColor;
ctx.font = 'bold 15px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const subjectCx = rect.left + subjectColW / 2;
const subjectCy = (blockTop + blockBottom) / 2;
this.drawVerticalText(subjects[s], subjectCx, subjectCy, 18);
for (let r = 0; r < rowsPerSubject; r++) {
const rowTop = blockTop + r * rowHeight;
const rowCy = rowTop + rowHeight / 2;
// 序号
ctx.fillStyle = this.borderColor;
ctx.font = '13px "Microsoft Yahei"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
String(r + 1),
rect.left + subjectColW + numColW / 2,
rowCy,
);
// 内容区书写虚线
ctx.strokeStyle = this.midColor;
ctx.lineWidth = 0.8;
ctx.setLineDash([3, 3]);
this.strokeHLine(
contentColX + 8,
checkColX - 8,
rowCy + rowHeight / 2 - 4,
);
ctx.setLineDash([]);
// 勾选方框
const boxSize = Math.min(18, rowHeight - 10);
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = 1;
ctx.strokeRect(
checkColX + (checkColW - boxSize) / 2,
rowCy - boxSize / 2,
boxSize,
boxSize,
);
}
}
}
// ─────────────────────────── 工具方法 ───────────────────────────
private strokeHLine(x1: number, x2: number, y: number) {
const { ctx } = this;
ctx.beginPath();
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.stroke();
}
private strokeVLine(x: number, y1: number, y2: number) {
const { ctx } = this;
ctx.beginPath();
ctx.moveTo(x, y1);
ctx.lineTo(x, y2);
ctx.stroke();
}
/** 竖排文字(逐字向下) */
private drawVerticalText(
text: string,
cx: number,
cy: number,
lineHeight: number,
) {
const { ctx } = this;
const chars = text.split('');
const totalH = chars.length * lineHeight;
let y = cy - totalH / 2 + lineHeight / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const ch of chars) {
ctx.fillText(ch, cx, y);
y += lineHeight;
}
}
}
@@ -0,0 +1,107 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { PAPER_SHEET_WORKSHEET_DEFINITIONS } from '../../config/worksheets/papers';
type PaperSheetWorksheetRow =
(typeof PAPER_SHEET_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
PAPER_SHEET_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, PaperSheetWorksheetRow>;
export const PAPER_SHEET_WORKSHEET_ID = PAPER_SHEET_WORKSHEET_DEFINITIONS[0].id;
/** 纸张类型 id */
export type PaperType =
| 'tian'
| 'mi'
| 'square'
| 'four-line'
| 'letter'
| 'horizontal'
| 'vertical'
| 'dictation-hanzi'
| 'dictation-pinyin'
| 'homework-log';
export interface PaperTypeOption {
id: PaperType;
name: string;
/** 次要说明 */
desc: string;
}
/** 纸张类型选项(展示顺序与用户需求一致) */
export const PAPER_TYPE_OPTIONS: PaperTypeOption[] = [
{ id: 'tian', name: '田字格', desc: '汉字练习' },
{ id: 'mi', name: '米字格', desc: '书法练习' },
{ id: 'square', name: '方格', desc: '书法练字纸' },
{ id: 'four-line', name: '四线三格', desc: '拼音 / 字母' },
{ id: 'letter', name: '信纸', desc: '横线信笺' },
{ id: 'horizontal', name: '横线', desc: '横线本' },
{ id: 'vertical', name: '竖线', desc: '竖排书写' },
{ id: 'dictation-hanzi', name: '汉字听写', desc: '田字格 + 订正栏' },
{
id: 'dictation-pinyin',
name: '拼音/英语听写',
desc: '四线三格 + 订正栏',
},
{ id: 'homework-log', name: '作业登记表', desc: '分科登记' },
];
export interface PaperColorOption {
id: string;
name: string;
/** 主线条颜色 */
value: string;
}
/** 颜色选项:浅红、浅绿、黑色 */
export const PAPER_COLOR_OPTIONS: PaperColorOption[] = [
{ id: 'red', name: '浅红', value: '#E48A8A' },
{ id: 'green', name: '浅绿', value: '#7FB069' },
{ id: 'black', name: '黑色', value: '#333333' },
];
export const DEFAULT_PAPER_TYPE: PaperType = 'tian';
export const DEFAULT_PAPER_COLOR = PAPER_COLOR_OPTIONS[0].value;
export function getPaperTypeName(id: string): string {
return PAPER_TYPE_OPTIONS.find((p) => p.id === id)?.name || '作业纸';
}
export function isValidPaperType(id: string): id is PaperType {
return PAPER_TYPE_OPTIONS.some((p) => p.id === 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: 'papers',
subcategory: 'paper-sheet',
path: `/papersPages/paperSheet/paperSheet?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,133 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.ps-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.ps-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.ps-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.ps-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.ps-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
// ─────────── 纸张类型 ───────────
.ps-type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.ps-type-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;
}
.ps-type-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.ps-type-card--pressed {
transform: scale(0.95);
}
.ps-type-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
}
.ps-type-card__desc {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
}
// ─────────── 颜色 ───────────
.ps-color-list {
display: flex;
gap: 24rpx;
}
.ps-color-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
padding: 24rpx 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,
border-color 0.12s,
background 0.12s;
}
.ps-color-item--active {
background: #ffffff;
border-color: @brand;
}
.ps-color-item--pressed {
transform: scale(0.95);
}
.ps-color-dot {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
box-shadow: inset 0 0 0 2rpx rgba(0, 0, 0, 0.08);
}
.ps-color-item__name {
font-size: 26rpx;
font-weight: 700;
color: @text-title;
}
@@ -0,0 +1,165 @@
import PaperDrawService from './draw/paperDrawService';
import {
PAPER_SHEET_WORKSHEET_ID,
PAPER_TYPE_OPTIONS,
PAPER_COLOR_OPTIONS,
DEFAULT_PAPER_TYPE,
DEFAULT_PAPER_COLOR,
getModeInfo,
getPublishMetaByMode,
isValidPaperType,
type PaperType,
} from './paperSheet.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;
type PageData = CanvasDataState & {
worksheetId: string;
paperTypeOptions: typeof PAPER_TYPE_OPTIONS;
colorOptions: typeof PAPER_COLOR_OPTIONS;
selectedPaperType: PaperType;
selectedColor: string;
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 PaperDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '作业纸',
functionId: PAPER_SHEET_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: PAPER_SHEET_WORKSHEET_ID,
paperTypeOptions: PAPER_TYPE_OPTIONS,
colorOptions: PAPER_COLOR_OPTIONS,
selectedPaperType: DEFAULT_PAPER_TYPE,
selectedColor: DEFAULT_PAPER_COLOR,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string; type?: string }) {
this.syncDebugPublishEnv();
const initialType =
options.type && isValidPaperType(options.type)
? (options.type as PaperType)
: DEFAULT_PAPER_TYPE;
this.setData({
selectedPaperType: initialType,
});
this.initPageInfo(PAPER_SHEET_WORKSHEET_ID, '作业纸');
this.loadFavoritedMap();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new PaperDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
async drawCanvas() {
if (!this.drawService) return;
try {
await (this.drawService as PaperDrawService).draw(
this.data.selectedPaperType,
this.data.selectedColor,
);
this.setData({ hasContent: true });
} catch (err) {
console.error('paperSheet draw failed', err);
this.setData({ hasContent: false });
}
},
onSelectPaperType(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as PaperType | undefined;
if (!id || id === this.data.selectedPaperType) return;
this.setData({ selectedPaperType: id }, () => this.drawCanvas());
},
onSelectColor(e: WechatMiniprogram.TouchEvent) {
const value = e.currentTarget.dataset.value as string | undefined;
if (!value || value === this.data.selectedColor) return;
this.setData({ selectedColor: value }, () => this.drawCanvas());
},
onPreviewRefresh() {
this.drawCanvas();
},
onShare() {},
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 = [PAPER_SHEET_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,76 @@
<nav-bar title="作业纸" />
<view class="ps-page">
<view class="ps-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="ps-section">
<view class="ps-section-header">
<text class="ps-section-title">纸张类型</text>
</view>
<view class="ps-type-grid">
<view
wx:for="{{paperTypeOptions}}"
wx:key="id"
class="ps-type-card {{selectedPaperType === item.id ? 'ps-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="ps-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectPaperType">
<text class="ps-type-card__name">{{item.name}}</text>
<text class="ps-type-card__desc">{{item.desc}}</text>
</view>
</view>
</view>
<view class="ps-section">
<view class="ps-section-header">
<text class="ps-section-title">颜色</text>
</view>
<view class="ps-color-list">
<view
wx:for="{{colorOptions}}"
wx:key="id"
class="ps-color-item {{selectedColor === item.value ? 'ps-color-item--active' : ''}}"
data-value="{{item.value}}"
hover-class="ps-color-item--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectColor">
<view
class="ps-color-dot"
style="background:{{item.value}}" />
<text class="ps-color-item__name">{{item.name}}</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" />