feat: 开发练习纸
This commit is contained in:
@@ -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" />
|
||||
Reference in New Issue
Block a user