diff --git a/miniprogram/app.json b/miniprogram/app.json index 9bbafe0..2d3720a 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -58,6 +58,12 @@ "wordTestSheet/wordTestSheet" ], "independent": false + }, + { + "root": "papersPages", + "name": "papersPages", + "pages": ["paperSheet/paperSheet"], + "independent": false } ], "preloadRule": { diff --git a/miniprogram/config/worksheets/index.ts b/miniprogram/config/worksheets/index.ts index a79284e..9a9af13 100644 --- a/miniprogram/config/worksheets/index.ts +++ b/miniprogram/config/worksheets/index.ts @@ -10,3 +10,4 @@ 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'; +export { PAPER_SHEET_WORKSHEET_DEFINITIONS } from './papers'; diff --git a/miniprogram/config/worksheets/papers.ts b/miniprogram/config/worksheets/papers.ts new file mode 100644 index 0000000..37d9b52 --- /dev/null +++ b/miniprogram/config/worksheets/papers.ts @@ -0,0 +1,26 @@ +import type { WorksheetDefinition } from './types'; + +export const PAPER_SHEET_WORKSHEET_DEFINITIONS = [ + { + id: 'paper-sheet', + icon: 'draw-o', + title: '作业纸', + subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印', + ageMin: 3, + ageMax: 12, + difficulty: 1, + tags: [ + '作业纸', + '田字格', + '米字格', + '方格', + '四线三格', + '信纸', + '横线', + '竖线', + '听写', + '作业登记表', + ], + sortOrder: 50, + }, +] as const satisfies ReadonlyArray; diff --git a/miniprogram/core/data/categories.ts b/miniprogram/core/data/categories.ts index a81f627..e0186d4 100644 --- a/miniprogram/core/data/categories.ts +++ b/miniprogram/core/data/categories.ts @@ -38,6 +38,12 @@ export const CATEGORY_LIST: CategoryType[] = [ icon: '🔤', path: '/pages/category/category?id=english', }, + { + id: 'papers', + name: '作业纸', + icon: '📄', + path: '/pages/category/category?id=papers', + }, // { // id: 'craft', // name: '创意手工', diff --git a/miniprogram/core/models/category.ts b/miniprogram/core/models/category.ts index 864d473..be2566e 100644 --- a/miniprogram/core/models/category.ts +++ b/miniprogram/core/models/category.ts @@ -4,7 +4,8 @@ export type CategoryId = | 'english' | 'puzzle' | 'craft' - | 'pinyin'; + | 'pinyin' + | 'papers'; /** Tab / 首页用的分类展示模型 */ export interface CategoryType { diff --git a/miniprogram/pages/category/category.data.ts b/miniprogram/pages/category/category.data.ts index 766d036..3bd7be8 100644 --- a/miniprogram/pages/category/category.data.ts +++ b/miniprogram/pages/category/category.data.ts @@ -687,6 +687,18 @@ const CATEGORY_ITEMS_BY_ID: Record = { available: true, }), ], + papers: [ + item({ + id: 'paper-sheet', + title: '作业纸', + subtitle: '田字格 / 方格 / 信纸等多种作业纸一键打印', + icon: '📄', + ageBand: age(3, 12), + difficulty: 1, + path: '/papersPages/paperSheet/paperSheet?id=paper-sheet', + available: true, + }), + ], craft: [ item({ id: 'craft-coloring', diff --git a/miniprogram/papersPages/paperSheet/draw/paperDrawService.ts b/miniprogram/papersPages/paperSheet/draw/paperDrawService.ts new file mode 100644 index 0000000..5a46862 --- /dev/null +++ b/miniprogram/papersPages/paperSheet/draw/paperDrawService.ts @@ -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, + ) { + 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([ + '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 = { + 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; + } + } +} diff --git a/miniprogram/papersPages/paperSheet/paperSheet.config.ts b/miniprogram/papersPages/paperSheet/paperSheet.config.ts new file mode 100644 index 0000000..89cdb7a --- /dev/null +++ b/miniprogram/papersPages/paperSheet/paperSheet.config.ts @@ -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; + +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', + }; +} diff --git a/miniprogram/papersPages/paperSheet/paperSheet.json b/miniprogram/papersPages/paperSheet/paperSheet.json new file mode 100644 index 0000000..20adc28 --- /dev/null +++ b/miniprogram/papersPages/paperSheet/paperSheet.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/papersPages/paperSheet/paperSheet.less b/miniprogram/papersPages/paperSheet/paperSheet.less new file mode 100644 index 0000000..e42a7b5 --- /dev/null +++ b/miniprogram/papersPages/paperSheet/paperSheet.less @@ -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; +} diff --git a/miniprogram/papersPages/paperSheet/paperSheet.ts b/miniprogram/papersPages/paperSheet/paperSheet.ts new file mode 100644 index 0000000..9397448 --- /dev/null +++ b/miniprogram/papersPages/paperSheet/paperSheet.ts @@ -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, + + 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, + ) => 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, + }, +); diff --git a/miniprogram/papersPages/paperSheet/paperSheet.wxml b/miniprogram/papersPages/paperSheet/paperSheet.wxml new file mode 100644 index 0000000..c3fe428 --- /dev/null +++ b/miniprogram/papersPages/paperSheet/paperSheet.wxml @@ -0,0 +1,76 @@ + + + + + + + + + 纸张类型 + + + + {{item.name}} + {{item.desc}} + + + + + + + 颜色 + + + + + {{item.name}} + + + + + + + + + + + diff --git a/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts b/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts index 3e58b37..dc6715e 100644 --- a/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts +++ b/miniprogram/supportPages/unreleasedDebug/unreleasedDebug.config.ts @@ -7,6 +7,12 @@ export interface UnreleasedPageEntry { export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [ // 在这里手动添加未上线页面 + { + id: 'papers', + title: '作业纸页面', + subtitle: '作业纸页面', + path: '/papersPages/paperSheet/paperSheet', + }, { id: 'clock-reading', title: '认识时钟',