From cbf07faf2746e08c629031c35b61a335e216ad2d Mon Sep 17 00:00:00 2001 From: R524809 Date: Wed, 11 Feb 2026 16:03:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AD=97=E5=B8=96?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/base/pageMixin.ts | 34 +- .../components/word-input/word-input.ts | 25 +- .../components/word-input/word-input.wxml | 1 + miniprogram/focusPages/shape/shape.ts | 21 +- miniprogram/pages/copyBook/copyBook.ts | 37 +-- miniprogram/pages/copyBook/copyBook.wxml | 2 +- miniprogram/pages/index/index.ts | 32 +- miniprogram/service/wordDrawService.ts | 300 ++++++++---------- miniprogram/utils/downloadPrint.ts | 254 +++++++++++++++ miniprogram/utils/getWordsSvgJson.ts | 43 ++- miniprogram/utils/saveImage.ts | 5 +- 11 files changed, 474 insertions(+), 280 deletions(-) create mode 100644 miniprogram/utils/downloadPrint.ts diff --git a/miniprogram/base/pageMixin.ts b/miniprogram/base/pageMixin.ts index 5ef61e0..f5c0647 100644 --- a/miniprogram/base/pageMixin.ts +++ b/miniprogram/base/pageMixin.ts @@ -1,6 +1,5 @@ import { PAPER_SIZE } from '../constants/colors'; -import { checkAndSaveImage } from '../utils/saveImage'; -import { shouldShowShareGuide } from '../utils/shareGuide'; +import { downloadPrint } from '../utils/downloadPrint'; import { BaseDrawService } from '../service/baseDraw'; import tracker from '../utils/tracker'; import { defaultShareConfig } from '../config/config'; @@ -172,20 +171,12 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) { /** * 导出打印 */ - exportToPrint(this: PageCanvasInstance) { - if (!this.canvas || !this.data.hasContent) { - return; - } - - if (shouldShowShareGuide()) { - this.setData({ showShareDialog: true }); - return; - } - - // 上报下载埋点 - tracker.reportDownload(this.data.pageTitle, this.data.currentMode); - - checkAndSaveImage(this.canvas); + async exportToPrint(this: PageCanvasInstance) { + await downloadPrint(this.canvas, { + errorToast: '请先生成内容', + trackerName: this.data.pageTitle, + trackerMode: this.data.currentMode, + }); }, getShareOptions(this: PageCanvasInstance): ShareOptions { @@ -231,12 +222,15 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) { /** * 分享成功回调 */ - onShareSuccess(this: PageCanvasInstance) { + async onShareSuccess(this: PageCanvasInstance) { this.setData({ showShareDialog: false }); if (this.canvas) { - // 上报下载埋点(分享成功后下载) - tracker.reportDownload(this.data.pageTitle); - checkAndSaveImage(this.canvas); + // 分享成功后下载(不限制次数) + await downloadPrint(this.canvas, { + errorToast: '请先生成内容', + trackerName: this.data.pageTitle, + trackerMode: this.data.currentMode, + }); } }, diff --git a/miniprogram/components/word-input/word-input.ts b/miniprogram/components/word-input/word-input.ts index 419fc6b..1e2e69f 100644 --- a/miniprogram/components/word-input/word-input.ts +++ b/miniprogram/components/word-input/word-input.ts @@ -14,15 +14,27 @@ Component({ type: String, value: '请输入文字', }, + value: { + type: String, + value: '', // 输入框的值(支持外部控制) + }, }, /** * 组件的初始数据 */ data: { + innerValue: '', // 内部维护的输入值 + }, + observers: { + // 监听外部传入的 value 变化 + value(newVal: string) { + this.setData({ innerValue: newVal }); + }, }, lifetimes: { attached() { - (this as any).value = ''; + // 初始化时同步外部传入的 value + this.setData({ innerValue: this.properties.value || '' }); }, }, /** @@ -32,17 +44,14 @@ Component({ onInput(e: WechatMiniprogram.Input) { let { value } = e.detail; value = value.replace(/\s/g, ''); - if (value.length > 0) { - (this as any).value = value; - } + this.setData({ innerValue: value }); }, onConfirm() { - const value = (this as any).value; + const value = this.data.innerValue; - if (value.length > 0) { - this.triggerEvent('onConfirm', { value }); - } + // 触发确认事件(即使值为空也触发,让父组件处理清空逻辑) + this.triggerEvent('onConfirm', { value }); }, // onTap() { diff --git a/miniprogram/components/word-input/word-input.wxml b/miniprogram/components/word-input/word-input.wxml index 404fd10..1d048a9 100644 --- a/miniprogram/components/word-input/word-input.wxml +++ b/miniprogram/components/word-input/word-input.wxml @@ -11,6 +11,7 @@ diff --git a/miniprogram/focusPages/shape/shape.ts b/miniprogram/focusPages/shape/shape.ts index cbf2107..6fc4ca6 100644 --- a/miniprogram/focusPages/shape/shape.ts +++ b/miniprogram/focusPages/shape/shape.ts @@ -1,9 +1,7 @@ import { SHAPES, ShapeCard } from '../../constants/shapes'; import { WATER_COLORS, PAPER_SIZE } from '../../constants/colors'; import ShapeDrawService from '../../service/shapeDrawService'; -import { checkAndSaveImage } from '../../utils/saveImage'; -import { shouldShowShareGuide } from '../../utils/shareGuide'; -import tracker from '../../utils/tracker'; +import { downloadPrint } from '../../utils/downloadPrint'; Page({ canvas: null as Canvas | null, @@ -286,22 +284,15 @@ Page({ }, /** 下载打印 */ - exportToPrint() { + async exportToPrint() { if (!this.canvas || this.data.shapeList.length <= 0) { return; } - // 检查是否需要显示分享引导 - if (shouldShowShareGuide()) { - this.setData({ showShareDialog: true }); - return; - } - - // 上报下载埋点 - tracker.reportDownload('图形涂色'); - - // 直接下载 - checkAndSaveImage(this.canvas); + await downloadPrint(this.canvas, { + errorToast: '请先生成图形', + trackerName: '图形涂色', + }); }, /** 关闭分享引导弹窗 */ diff --git a/miniprogram/pages/copyBook/copyBook.ts b/miniprogram/pages/copyBook/copyBook.ts index f7a7b9c..c849e7f 100644 --- a/miniprogram/pages/copyBook/copyBook.ts +++ b/miniprogram/pages/copyBook/copyBook.ts @@ -1,6 +1,5 @@ import WordDrawService from '../../service/wordDrawService'; -import { checkAndSaveImage } from '../../utils/saveImage'; -import { shouldShowShareGuide } from '../../utils/shareGuide'; +import { downloadPrint } from '../../utils/downloadPrint'; import { PAPER_SIZE } from '../../constants/colors'; import { getWordsSvgData } from '../../utils/getWordsSvgJson'; import { WORDS } from '../../constants/words'; @@ -108,6 +107,7 @@ Page({ { inputWords: [], words: updatedWords, + inputValue: '', // 同步清空输入框值 }, () => { this.renderPracticeContent().catch(console.error); @@ -143,11 +143,12 @@ Page({ // 3. 替换输入汉字(不是追加),合并已选择的汉字 const updatedWords = [...newInputChars, ...selectedWords]; - // 4. 更新数据并提示 + // 4. 更新数据并提示(同步更新 inputValue 保持一致) this.setData( { inputWords: newInputChars, words: updatedWords, + inputValue: text, // 同步输入框值 }, () => { this.renderPracticeContent().catch(console.error); @@ -436,32 +437,12 @@ Page({ return characterData; }, - /** 记录待下载状态(当需要分享引导时) */ - pendingDownload: false, - // 下载打印练字贴 - exportToPrint() { - if (!this.canvas) { - wx.showToast({ title: '请先生成练字贴', icon: 'none' }); - return; - } - - // 检查是否需要显示分享引导 - if (shouldShowShareGuide()) { - this.setData({ showShareDialog: true }); - return; - } - - // 上报下载埋点 - tracker.reportDownload('练字贴'); - - // 直接下载 - checkAndSaveImage(this.canvas); - }, - - /** 关闭分享引导弹窗 */ - onCloseShareDialog() { - this.setData({ showShareDialog: false }); + async exportToPrint() { + await downloadPrint(this.canvas, { + errorToast: '请先生成练字贴', + trackerName: '练字贴', + }); }, // 分享功能 diff --git a/miniprogram/pages/copyBook/copyBook.wxml b/miniprogram/pages/copyBook/copyBook.wxml index 4e3465e..6e0bc9a 100644 --- a/miniprogram/pages/copyBook/copyBook.wxml +++ b/miniprogram/pages/copyBook/copyBook.wxml @@ -2,7 +2,7 @@ 自定义练字贴 - + diff --git a/miniprogram/pages/index/index.ts b/miniprogram/pages/index/index.ts index 9f46829..324f7a2 100644 --- a/miniprogram/pages/index/index.ts +++ b/miniprogram/pages/index/index.ts @@ -5,8 +5,7 @@ import { IDrawService, TemplateType, } from '../../service/drawServiceFactory'; -import { checkAndSaveImage } from '../../utils/saveImage'; -import { shouldShowShareGuide } from '../../utils/shareGuide'; +import { downloadPrint } from '../../utils/downloadPrint'; import tracker from '../../utils/tracker'; import { defaultShareConfig } from '../../config/config'; @@ -120,7 +119,7 @@ Page({ if (currentCardList.length > 0) { this.drawService .draw(currentCardList) - .catch((err) => { + .catch((err: any) => { console.error('绘制失败:', err); }); } @@ -168,7 +167,7 @@ Page({ } // 如果canvas已初始化且有内容,则绘制 if (this.drawService && updatedCardList!.length > 0) { - this.drawService.draw(updatedCardList!).catch((err) => { + this.drawService.draw(updatedCardList!).catch((err: any) => { console.error('绘制失败:', err); }); } @@ -186,7 +185,7 @@ Page({ } // 如果canvas已初始化且有内容,则绘制 if (this.drawService && currentCardList.length > 0) { - this.drawService.draw(currentCardList).catch((err) => { + this.drawService.draw(currentCardList).catch((err: any) => { console.error('绘制失败:', err); }); } @@ -288,27 +287,16 @@ Page({ }, /** 下载打印 */ - exportToPrint() { + async exportToPrint() { if (!this.canvas || this.data.cardList.length <= 0) { return; } - // 检查是否需要显示分享引导 - if (shouldShowShareGuide()) { - this.setData({ showShareDialog: true }); - return; - } - - // 上报下载埋点 - tracker.reportDownload('涂色识字', this.data.selectedTemplate); - - // 直接下载 - checkAndSaveImage(this.canvas); - }, - - /** 关闭分享引导弹窗 */ - onCloseShareDialog() { - this.setData({ showShareDialog: false }); + await downloadPrint(this.canvas, { + errorToast: '请先生成涂色卡', + trackerName: '涂色识字', + trackerMode: this.data.selectedTemplate, + }); }, /** 分享成功回调(由组件触发) */ diff --git a/miniprogram/service/wordDrawService.ts b/miniprogram/service/wordDrawService.ts index 215f26a..54b1603 100644 --- a/miniprogram/service/wordDrawService.ts +++ b/miniprogram/service/wordDrawService.ts @@ -2,120 +2,118 @@ import { BaseDrawService } from './baseDraw'; import { CharacterItem } from '../types/characterType'; /** - * 仅支持 M/L/Z 的简单 SVG 路径解析与绘制 + * cnchar-data 坐标系配置 + * 参考 hanzi-writer 的实现逻辑 + * 字符数据的边界框:左上角 (0, -124),右下角 (1024, 900) */ -interface DrawSvgPathCommandsParams { - ctx: RenderingContext; - pathD: string; - offsetX: number; - offsetY: number; +const CHAR_BOUNDS = { + minX: 0, + minY: -124, + maxX: 1024, + maxY: 900, +}; +const CHAR_WIDTH = CHAR_BOUNDS.maxX - CHAR_BOUNDS.minX; // 1024 +const CHAR_HEIGHT = CHAR_BOUNDS.maxY - CHAR_BOUNDS.minY; // 1024 + +/** + * 计算缩放变换参数(模仿 cnchar.draw 的 getScalingTransform 函数) + * 用于将 1024x1024 坐标系的字符数据变换到目标画布尺寸 + */ +interface ScalingTransform { + xOffset: number; + yOffset: number; scale: number; } -function drawSvgPathCommands({ - ctx, - pathD, - offsetX, - offsetY, - scale, -}: DrawSvgPathCommandsParams) { - // console.log('drawSvgPathCommands 参数:', { pathD, offsetX, offsetY, scale }); +function getScalingTransform( + width: number, + height: number, + padding: number, +): ScalingTransform { + // 计算可用空间 + const availableWidth = width - 2 * padding; + const availableHeight = height - 2 * padding; - // 精度处理函数:避免浮点数精度问题(提高到6位小数精度,确保高精度) - const roundToPrecision = (num: number, precision: number = 6): number => { - return ( - Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision) - ); - }; + // 计算缩放比例(取较小的比例以确保字符完整显示) + const scaleX = availableWidth / CHAR_WIDTH; + const scaleY = availableHeight / CHAR_HEIGHT; + const scale = Math.min(scaleX, scaleY); - // 像素对齐函数:优化对齐策略,提升平滑度 - // 对于Fill+Stroke模式,使用更精细的对齐策略,避免过度对齐导致的锯齿 - // 对齐到0.5像素可以获得更好的平滑效果 - const alignToPixel = (num: number): number => { - // 对齐到0.5像素,而不是整数像素,可以获得更平滑的线条 - return Math.round(num * 2) / 2; - }; + // 计算偏移量(居中显示) + const scaledWidth = CHAR_WIDTH * scale; + const scaledHeight = CHAR_HEIGHT * scale; + const centerX = padding + (availableWidth - scaledWidth) / 2; + const centerY = padding + (availableHeight - scaledHeight) / 2; - // 新的解析方法:按命令片段解析 - // 使用正则表达式匹配从字母开头到下一个字母(或结尾)的片段 - const commandRegex = /([MLZ])([^MLZ]*?)(?=[MLZ]|$)/g; + // 计算 xOffset 和 yOffset(考虑字符边界框的偏移) + const xOffset = -CHAR_BOUNDS.minX * scale + centerX; + const yOffset = -CHAR_BOUNDS.minY * scale + centerY; + + return { xOffset, yOffset, scale }; +} + +/** + * SVG 路径解析与绘制(支持 M/L/Q/Z 命令) + * 参考 cnchar.draw 的实现,使用 Canvas transform 进行坐标变换 + */ +interface DrawSvgPathParams { + ctx: RenderingContext; + pathD: string; +} + +function drawSvgPath({ ctx, pathD }: DrawSvgPathParams) { + // 使用正则表达式匹配 SVG 路径命令 + // 支持 M(moveTo)、L(lineTo)、Q(二次贝塞尔曲线)、Z(closePath) + const commandRegex = /([MLQZ])([^MLQZ]*?)(?=[MLQZ]|$)/gi; const commands: Array<{ cmd: string; coords: string }> = []; let match; while ((match = commandRegex.exec(pathD)) !== null) { - const cmd = match[1]; + const cmd = match[1].toUpperCase(); const coords = match[2].trim(); commands.push({ cmd, coords }); } - // console.log('解析的命令片段:', commands); - - let commandCount = 0; - - // 优化的坐标解析和绘制函数 - const parseAndDrawCoords = ( - coords: string, - cmd: string, - drawFunction: (x: number, y: number) => void, - ) => { - // 更严格的坐标解析:支持负数和小数 - const coordParts = coords - .split(/\s+/) - .filter((part) => part.trim() !== ''); - - if (coordParts.length >= 2) { - const x = parseFloat(coordParts[0]); - const y = parseFloat(coordParts[1]); - - if (!isNaN(x) && !isNaN(y) && isFinite(x) && isFinite(y)) { - // 计算最终坐标:先进行高精度计算,再对齐到整数像素 - // 使用高精度计算确保路径的准确性,然后对齐到像素避免亚像素渲染 - const calculatedX = offsetX + x * scale; - const calculatedY = offsetY + y * scale; - const preciseX = roundToPrecision(calculatedX); - const preciseY = roundToPrecision(calculatedY); - const finalX = alignToPixel(preciseX); - const finalY = alignToPixel(preciseY); - - // console.log(`${cmd}: 绘制到 (${finalX}, ${finalY}) [原始: (${x}, ${y})]`); - drawFunction(finalX, finalY); - } else { - console.warn( - `${cmd}: 坐标解析失败 - x=${x}, y=${y}, 原始坐标: "${coords}"`, - ); - } - } else { - console.warn( - `${cmd}: 坐标数量不足,需要2个,实际${coordParts.length}个,原始: "${coords}"`, - ); - } - }; - - // 命令处理映射表,提高可读性和可扩展性 - const commandHandlers: Record void> = { - M: (coords) => - parseAndDrawCoords(coords, 'M', (x, y) => ctx.moveTo(x, y)), - L: (coords) => - parseAndDrawCoords(coords, 'L', (x, y) => ctx.lineTo(x, y)), - Z: () => { - // 关键修复:处理Z命令,闭合路径 - ctx.closePath(); - }, + // 解析坐标的辅助函数 + const parseCoords = (coords: string): number[] => { + return coords + .split(/[\s,]+/) + .filter((part) => part.trim() !== '') + .map((part) => parseFloat(part)); }; for (const { cmd, coords } of commands) { - commandCount++; - // console.log(`命令 ${commandCount}: ${cmd}, 坐标: "${coords}"`); + const coordValues = parseCoords(coords); - const handler = commandHandlers[cmd]; - if (handler) { - handler(coords); - } else { - console.warn(`未知命令: ${cmd}`); + switch (cmd) { + case 'M': + if (coordValues.length >= 2) { + ctx.moveTo(coordValues[0], coordValues[1]); + } + break; + case 'L': + if (coordValues.length >= 2) { + ctx.lineTo(coordValues[0], coordValues[1]); + } + break; + case 'Q': + // 二次贝塞尔曲线:Q cpx cpy x y + if (coordValues.length >= 4) { + ctx.quadraticCurveTo( + coordValues[0], + coordValues[1], + coordValues[2], + coordValues[3], + ); + } + break; + case 'Z': + ctx.closePath(); + break; + default: + console.warn(`未知的 SVG 路径命令: ${cmd}`); } } - - // console.log(`总共处理了 ${commandCount} 个命令`); } interface DrawStrokesParams { @@ -130,6 +128,10 @@ interface DrawStrokesParams { lineWidth: number; } +/** + * 绘制笔画(参考 cnchar.draw 的实现方式) + * 使用 Canvas transform 进行坐标变换,支持 1024x1024 坐标系 + */ function drawStrokes({ ctx, strokes, @@ -137,93 +139,51 @@ function drawStrokes({ offsetY, size, uptoInclusive, - fillStyle, // 填充颜色,用于 Fill + Stroke 模式 - strokeStyle, // 描边颜色,用于绘制笔画 - lineWidth, // 线条宽度,控制笔画粗细 + fillStyle, + strokeStyle: _strokeStyle, // 保留接口兼容性,cnchar 模式只使用 fill + lineWidth: _lineWidth, // 保留接口兼容性,cnchar 模式只使用 fill }: DrawStrokesParams) { - // 设置 Canvas 绘制质量(关键优化:提升线条流畅度) + // 抑制未使用变量警告 + void _strokeStyle; + void _lineWidth; + // 设置 Canvas 绘制质量 ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; // 高质量平滑 - - // 设置路径绘制属性(关键:这些设置直接影响笔画粗细和流畅度) - // 注意:不使用 save/restore,与测试页面保持一致,避免状态管理问题 - ctx.lineCap = 'round'; // 圆角端点,使笔画末端更自然 - ctx.lineJoin = 'round'; // 圆角连接,使转折处更平滑 - ctx.miterLimit = 10; // 斜接限制 - - // 优化渲染策略:使用 Fill + Stroke 模式 - // 先 fill 填充内部,再 stroke 描边,stroke 宽度略小,避免双重渲染导致的毛糙 - // 这样可以获得自然的笔画粗细,同时保持流畅度 + ctx.imageSmoothingQuality = 'high'; - // 模板中使用 54x54 的视窗尺寸 - const viewBoxSize = 54; + // 设置路径绘制属性 + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.miterLimit = 10; // 添加内边距:在田字格四周预留空间,避免笔画贴边 - const padding = size * 0.1; // 内边距为田字格大小的10% - const contentSize = size - padding * 2; // 实际绘制区域大小 - - // 优化缩放比例:使用更精确的缩放,不进行四舍五入,保持原始精度 - const contentScale = contentSize / viewBoxSize; + const padding = size * 0.1; - // 计算内容区域的起始位置(居中),优化对齐策略提升平滑度 - const rawOffsetX = offsetX + padding; - const rawOffsetY = offsetY + padding; - // 对齐到0.5像素,而不是整数像素,可以获得更平滑的渲染效果 - const contentOffsetX = Math.round(rawOffsetX * 2) / 2; - const contentOffsetY = Math.round(rawOffsetY * 2) / 2; - - // console.log('drawStrokes 参数:', { - // strokesCount: strokes.length, - // uptoInclusive, - // offsetX, - // offsetY, - // size, - // padding, - // contentSize, - // contentOffsetX, - // contentOffsetY, - // originalScale: scale, - // contentScale, - // fillStyle, - // strokeStyle, - // lineWidth - // }); + // 计算缩放变换参数(参考 cnchar.draw 的 getScalingTransform) + const transform = getScalingTransform(size, size, padding); for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) { - // console.log(`绘制第 ${s} 个笔画:`, strokes[s]); + ctx.save(); + + // 应用变换矩阵(参考 cnchar.draw 的方式) + // 1. 先平移到目标位置 + // 2. 再应用 Y 轴翻转和缩放 + // transform: translate(xOffset, height - yOffset) scale(scale, -scale) + ctx.translate( + offsetX + transform.xOffset, + offsetY + size - transform.yOffset, + ); + ctx.scale(transform.scale, -transform.scale); + + // 绘制路径 ctx.beginPath(); - - // 记录路径是否包含Z命令(已闭合) - const hasClosePath = strokes[s].includes('Z'); - - drawSvgPathCommands({ - ctx, - pathD: strokes[s], - offsetX: contentOffsetX, - offsetY: contentOffsetY, - scale: contentScale, - }); - - // 优化渲染策略:使用 Fill + Stroke 模式 - // 先 fill 填充内部,再 stroke 描边,stroke 宽度略小,避免双重渲染导致的毛糙 - // 如果路径没有Z命令,手动闭合(fill需要闭合路径) - if (!hasClosePath) { - ctx.closePath(); - } - - // 1. 先填充内部 + drawSvgPath({ ctx, pathD: strokes[s] }); + + // 使用 fill 绘制(cnchar 只使用 fill) ctx.fillStyle = fillStyle; ctx.fill(); - - // 2. 再描边(stroke宽度设为fill视觉宽度的70%,避免明显的双重渲染) - // 与测试页面保持一致:使用相同的stroke宽度计算方式 - ctx.strokeStyle = strokeStyle; - const strokeWidth = Math.max(1.5, lineWidth * 0.7); - ctx.lineWidth = strokeWidth; - ctx.stroke(); + + ctx.restore(); } - - // 注意:不使用 restore,与测试页面保持一致 } interface DrawTianZiGridParams { @@ -580,7 +540,7 @@ class WordDrawService extends BaseDrawService { uptoInclusive: strokes.length - 1, fillStyle: 'rgb(85,85,85)', // 深灰色填充(#555555),比#666666更深一点 strokeStyle: 'rgb(85,85,85)', // 深灰色描边 - lineWidth: 0.6, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) + lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) }); } @@ -626,7 +586,7 @@ class WordDrawService extends BaseDrawService { uptoInclusive: strokeIndex, fillStyle: 'rgb(170,170,170)', // 浅灰色填充(#aaaaaa),参考练字贴颜色 strokeStyle: 'rgb(170,170,170)', // 浅灰色描边 - lineWidth: 0.6, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) + lineWidth: 1.2, // Fill + Stroke 模式,线条宽度调小一点(逻辑像素) }); } diff --git a/miniprogram/utils/downloadPrint.ts b/miniprogram/utils/downloadPrint.ts new file mode 100644 index 0000000..08f652f --- /dev/null +++ b/miniprogram/utils/downloadPrint.ts @@ -0,0 +1,254 @@ +/** + * 下载打印通用工具 + * 支持下载次数限制和激励广告 + */ + +import { checkAndSaveImage } from './saveImage'; +import tracker from './tracker'; + +// 存储键名 +const STORAGE_KEY_DOWNLOAD_COUNT = 'downloadCount'; +const STORAGE_KEY_DOWNLOAD_DATE = 'downloadDate'; +const STORAGE_KEY_WATCHED_AD_TODAY = 'watchedAdToday'; + +// 免费下载次数限制 +const FREE_DOWNLOAD_LIMIT = 5; + +// 激励视频广告单元ID +const AD_UNIT_ID = 'adunit-eff5626f8b68c2e0'; + +/** + * 获取今天的日期字符串(YYYY-MM-DD格式) + */ +function getTodayDateString(): string { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * 获取今日下载次数 + */ +function getTodayDownloadCount(): number { + const today = getTodayDateString(); + const downloadDate = wx.getStorageSync(STORAGE_KEY_DOWNLOAD_DATE); + + // 如果不是今天,重置计数 + if (downloadDate !== today) { + wx.setStorageSync(STORAGE_KEY_DOWNLOAD_COUNT, 0); + wx.setStorageSync(STORAGE_KEY_DOWNLOAD_DATE, today); + wx.setStorageSync(STORAGE_KEY_WATCHED_AD_TODAY, false); + return 0; + } + + return wx.getStorageSync(STORAGE_KEY_DOWNLOAD_COUNT) || 0; +} + +/** + * 增加今日下载次数 + */ +function incrementDownloadCount(): void { + const today = getTodayDateString(); + const count = getTodayDownloadCount(); + wx.setStorageSync(STORAGE_KEY_DOWNLOAD_COUNT, count + 1); + wx.setStorageSync(STORAGE_KEY_DOWNLOAD_DATE, today); +} + +/** + * 检查今日是否已观看广告 + */ +function hasWatchedAdToday(): boolean { + const today = getTodayDateString(); + const watchedDate = wx.getStorageSync(STORAGE_KEY_WATCHED_AD_TODAY); + return watchedDate === today; +} + +/** + * 标记今日已观看广告 + */ +function markWatchedAdToday(): void { + const today = getTodayDateString(); + wx.setStorageSync(STORAGE_KEY_WATCHED_AD_TODAY, today); +} + +/** + * 创建激励视频广告实例 + */ +function createVideoAd() { + if (!wx.createRewardedVideoAd) { + return null; + } + + const videoAd = wx.createRewardedVideoAd({ + adUnitId: AD_UNIT_ID, + }); + + videoAd.onLoad(() => { + console.log('激励视频广告加载成功'); + }); + + videoAd.onError((err) => { + console.error('激励视频广告加载失败', err); + }); + + return videoAd; +} + +/** + * 显示激励视频广告 + */ +function showVideoAd(): Promise { + return new Promise((resolve) => { + const videoAd = createVideoAd(); + + if (!videoAd) { + wx.showToast({ + title: '当前环境不支持激励广告', + icon: 'none', + }); + resolve(false); + return; + } + + // 显示 loading + wx.showLoading({ + title: '加载中...', + mask: true, + }); + + // 监听广告关闭事件 + videoAd.onClose((res) => { + // 隐藏 loading(如果还在显示) + wx.hideLoading(); + + if (res && res.isEnded) { + // 用户完整观看了广告 + markWatchedAdToday(); + resolve(true); + } else { + // 用户提前关闭了广告 + resolve(false); + } + }); + + // 显示广告 + videoAd + .show() + .then(() => { + // 广告显示成功,隐藏 loading + wx.hideLoading(); + }) + .catch(() => { + // 失败重试,保持 loading 显示 + videoAd + .load() + .then(() => { + // 加载成功,继续显示广告 + return videoAd.show(); + }) + .then(() => { + // 广告显示成功,隐藏 loading + wx.hideLoading(); + }) + .catch((err) => { + // 最终失败,隐藏 loading 并提示 + wx.hideLoading(); + console.error('激励视频广告显示失败', err); + wx.showToast({ + title: '广告加载失败,请稍后重试', + icon: 'none', + }); + resolve(false); + }); + }); + }); +} + +/** + * 执行下载 + */ +function doDownload( + canvas: Canvas, + options: { + successToast?: string; + errorToast?: string; + trackerName?: string; + trackerMode?: string; + }, +) { + // 上报下载埋点 + if (options.trackerName) { + if (options.trackerMode) { + tracker.reportDownload(options.trackerName, options.trackerMode); + } else { + tracker.reportDownload(options.trackerName); + } + } + + // 增加下载次数 + incrementDownloadCount(); + + // 执行下载(checkAndSaveImage 内部已有成功提示,这里不需要额外处理) + checkAndSaveImage(canvas); +} + +/** + * 下载打印(通用方法) + * @param canvas Canvas 实例 + * @param options 配置选项 + * @param options.successToast 成功提示(可选,默认:'保存成功,请在相册中查看') + * @param options.errorToast 错误提示(可选,默认:'请先生成内容') + * @param options.trackerName 埋点名称(可选) + * @param options.trackerMode 埋点模式(可选) + */ +export async function downloadPrint( + canvas: Canvas | null, + options: { + successToast?: string; + errorToast?: string; + trackerName?: string; + trackerMode?: string; + } = {}, +): Promise { + // 检查 canvas 是否存在 + if (!canvas) { + wx.showToast({ + title: options.errorToast || '请先生成内容', + icon: 'none', + }); + return; + } + + // 获取今日下载次数 + const downloadCount = getTodayDownloadCount(); + const hasWatchedAd = hasWatchedAdToday(); + + // 如果已观看广告,直接下载 + if (hasWatchedAd) { + doDownload(canvas, options); + return; + } + + // 如果未超过免费次数,直接下载 + if (downloadCount < FREE_DOWNLOAD_LIMIT) { + doDownload(canvas, options); + return; + } + + // 超过免费次数,需要观看广告 + const watched = await showVideoAd(); + if (watched) { + // 观看完成,可以下载 + doDownload(canvas, options); + } + /* else { + // 未观看完成,提示用户 + wx.showToast({ + title: '需要完整观看广告才能下载', + icon: 'none', + duration: 2000, + }); + } */ +} diff --git a/miniprogram/utils/getWordsSvgJson.ts b/miniprogram/utils/getWordsSvgJson.ts index fc28f96..9d5accd 100644 --- a/miniprogram/utils/getWordsSvgJson.ts +++ b/miniprogram/utils/getWordsSvgJson.ts @@ -8,7 +8,8 @@ const CACHE_CONFIG = { expireDays: 7, // 缓存过期天数 // url: 'https://cdn.joeyone.cn/doodle/words-svg-all.json' // 远程数据地址 // url: 'https://cdn.joeyone.cn/doodle/words-svg-3000.json' // 远程数据地址 - url: 'https://cdn.joeyone.cn/doodle/char_svg_3500.json' // 远程数据地址 + // url: 'https://cdn.joeyone.cn/doodle/char_svg_3500.json' // 远程数据地址 + url: 'https://cdn.joeyone.cn/doodle/char_common_stroke.json', // 远程数据地址 }; /** @@ -28,7 +29,7 @@ interface CacheData { function isCacheExpired(timestamp: number): boolean { const now = Date.now(); const expireTime = CACHE_CONFIG.expireDays * 24 * 60 * 60 * 1000; // 7天的毫秒数 - return (now - timestamp) > expireTime; + return now - timestamp > expireTime; } /** @@ -52,7 +53,10 @@ function getCacheData(): CacheData | null { return null; } - console.log('使用本地缓存的SVG汉字数据,缓存时间:', new Date(cacheData.timestamp).toLocaleString()); + console.log( + '使用本地缓存的SVG汉字数据,缓存时间:', + new Date(cacheData.timestamp).toLocaleString(), + ); return cacheData; } catch (error) { console.error('读取SVG汉字数据缓存失败:', error); @@ -69,7 +73,7 @@ function saveCacheData(data: Record): void { const cacheData: CacheData = { data, timestamp: Date.now(), - version: '1.0' // 可以用于后续版本控制 + version: '1.0', // 可以用于后续版本控制 }; wx.setStorageSync(CACHE_CONFIG.key, JSON.stringify(cacheData)); @@ -91,13 +95,17 @@ async function fetchRemoteData(): Promise> { { timeout: 15000, // 15秒超时 header: { - 'Accept': 'application/json', - 'Cache-Control': 'no-cache' - } - } + Accept: 'application/json', + 'Cache-Control': 'no-cache', + }, + }, ); - console.log('远程SVG汉字数据获取成功,共', Object.keys(svgWordsData).length, '个汉字'); + console.log( + '远程SVG汉字数据获取成功,共', + Object.keys(svgWordsData).length, + '个汉字', + ); return svgWordsData; } @@ -143,7 +151,12 @@ export function clearWordsSvgCache(): void { * 获取缓存信息 * @returns 缓存信息对象 */ -export function getCacheInfo(): { exists: boolean; timestamp?: number; isExpired?: boolean; daysLeft?: number } { +export function getCacheInfo(): { + exists: boolean; + timestamp?: number; + isExpired?: boolean; + daysLeft?: number; +} { try { const cacheStr = wx.getStorageSync(CACHE_CONFIG.key); if (!cacheStr) { @@ -152,13 +165,19 @@ export function getCacheInfo(): { exists: boolean; timestamp?: number; isExpired const cacheData: CacheData = JSON.parse(cacheStr); const isExpired = isCacheExpired(cacheData.timestamp); - const daysLeft = isExpired ? 0 : Math.ceil((CACHE_CONFIG.expireDays * 24 * 60 * 60 * 1000 - (Date.now() - cacheData.timestamp)) / (24 * 60 * 60 * 1000)); + const daysLeft = isExpired + ? 0 + : Math.ceil( + (CACHE_CONFIG.expireDays * 24 * 60 * 60 * 1000 - + (Date.now() - cacheData.timestamp)) / + (24 * 60 * 60 * 1000), + ); return { exists: true, timestamp: cacheData.timestamp, isExpired, - daysLeft + daysLeft, }; } catch (error) { console.error('获取缓存信息失败:', error); diff --git a/miniprogram/utils/saveImage.ts b/miniprogram/utils/saveImage.ts index 9dd0a37..f773c47 100644 --- a/miniprogram/utils/saveImage.ts +++ b/miniprogram/utils/saveImage.ts @@ -1,5 +1,3 @@ -import { recordDownloadSuccess } from './shareGuide'; - const doSaveImage = (canvas: Canvas) => { wx.canvasToTempFilePath({ canvas: canvas, @@ -9,8 +7,7 @@ const doSaveImage = (canvas: Canvas) => { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => { - // 记录下载成功 - recordDownloadSuccess(); + // 下载成功提示(下载次数记录在 downloadPrint.ts 中处理) wx.showToast({ title: '保存成功,请在相册中查看', icon: 'success',