import { DEBUG_PUBLISH_DEFAULT_QUALITY, DEBUG_PUBLISH_DEFAULT_WIDTH, DEBUG_PUBLISH_MAX_SIZE_KB, clampPublishQuality, clampPublishWidth, getCropRatios, normalizeTagsInput, type DebugCropMode, type DebugProcessImageParams, type DebugProcessImageResult, type DebugPublishConfirmDetail, type DebugPublishMeta, } from '../../utils/debugPublish'; /** * Debug 发布工具组件。 * * 组件只负责两件事: * 1. 展示调试发布的 UI(悬浮按钮 + 弹窗表单) * 2. 用隐藏 canvas 处理预览图的裁剪与压缩 * * 真正的上传云存储、调用云函数写库仍由页面层处理, * 这样同一套组件可以复用到多个绘制页面。 */ Component({ properties: { enabled: { type: Boolean, value: false, }, visible: { type: Boolean, value: false, }, loading: { type: Boolean, value: false, }, meta: { type: Object, value: {}, }, }, data: { formTitle: '', formSubtitle: '', formTags: '', formStatus: 'draft', formCropMode: 'header-only', // header-only | header-footer | none formQuality: DEBUG_PUBLISH_DEFAULT_QUALITY, formWidth: DEBUG_PUBLISH_DEFAULT_WIDTH, maxSizeKB: DEBUG_PUBLISH_MAX_SIZE_KB, }, observers: { visible(visible: boolean) { if (visible) { this.syncFormState( (this.properties as any).meta as DebugPublishMeta | null, ); } }, meta(meta: DebugPublishMeta | null) { if ((this.properties as any).visible) { this.syncFormState(meta); } }, }, lifetimes: { ready() { this.initProcessorCanvas(); }, }, methods: { /** 打开弹窗时,用当前题型元数据重置表单。 */ syncFormState(meta: DebugPublishMeta | null) { this.setData({ formTitle: meta?.title ?? '', formSubtitle: meta?.subtitle ?? '', formTags: meta?.tags?.join(', ') ?? '', formStatus: meta?.status ?? 'draft', formCropMode: 'header-only', // 默认裁剪头部 formQuality: DEBUG_PUBLISH_DEFAULT_QUALITY, formWidth: DEBUG_PUBLISH_DEFAULT_WIDTH, }); }, /** 点击悬浮发布按钮。挂载时机已由页面层控制,这里无需再二次判断 enabled。 */ onOpen() { this.triggerEvent('open'); }, /** 关闭发布弹窗。 */ onClose() { this.triggerEvent('close'); }, onTitleInput(e: WechatMiniprogram.Input) { this.setData({ formTitle: (e.detail.value ?? '').trim() }); }, onSubtitleInput(e: WechatMiniprogram.Input) { this.setData({ formSubtitle: (e.detail.value ?? '').trim() }); }, onTagsInput(e: WechatMiniprogram.Input) { this.setData({ formTags: e.detail.value ?? '' }); }, onSelectStatus(e: WechatMiniprogram.TouchEvent) { const value = e.currentTarget.dataset.value as string; if (!value) return; this.setData({ formStatus: value }); }, onSelectCropMode(e: WechatMiniprogram.TouchEvent) { const value = e.currentTarget.dataset.value as DebugCropMode; if (!value) return; this.setData({ formCropMode: value }); }, onQualityChange(e: WechatMiniprogram.CustomEvent) { this.setData({ formQuality: clampPublishQuality(Number(e.detail.value)), }); }, onWidthChange(e: WechatMiniprogram.CustomEvent) { this.setData({ formWidth: clampPublishWidth(Number(e.detail.value)), }); }, /** 校验弹窗输入,并把结果回传给页面层。 */ onConfirm() { const meta = (this.properties as any) .meta as DebugPublishMeta | null; if (!meta) { wx.showToast({ title: '缺少发布元数据', icon: 'none', }); return; } const title = this.data.formTitle.trim(); const subtitle = this.data.formSubtitle.trim(); if (!title || !subtitle) { wx.showToast({ title: '标题和副标题不能为空', icon: 'none', }); return; } const detail: DebugPublishConfirmDetail = { meta: { title, subtitle, tags: normalizeTagsInput(this.data.formTags), status: this.data.formStatus as DebugPublishMeta['status'], }, settings: { cropMode: this.data.formCropMode as DebugCropMode, quality: clampPublishQuality(this.data.formQuality), width: clampPublishWidth(this.data.formWidth), maxSizeKB: this.data.maxSizeKB, }, }; this.triggerEvent('confirm', detail); }, /** 初始化隐藏处理 canvas,后续所有裁剪压缩都在这里完成。 */ initProcessorCanvas() { if ((this as any)._processorCanvasReadyPromise) return; (this as any)._processorCanvasReadyPromise = new Promise( (resolve, reject) => { this.createSelectorQuery() .select('#processorCanvas') .fields({ node: true, size: true }) .exec((res) => { if (!res[0] || !res[0].node) { reject(new Error('处理画布初始化失败')); return; } (this as any)._processorCanvas = res[0].node; (this as any)._processorCtx = ( this as any )._processorCanvas.getContext('2d'); resolve(); }); }, ); }, /** 确保隐藏 canvas 已经 ready。 */ async ensureProcessorCanvasReady() { if (!(this as any)._processorCanvasReadyPromise) { this.initProcessorCanvas(); } await (this as any)._processorCanvasReadyPromise; }, /** 用当前 canvas 上下文加载临时图片文件。 */ loadImage( canvas: WechatMiniprogram.Canvas, src: string, ): Promise { return new Promise((resolve, reject) => { const image = canvas.createImage(); image.onload = () => resolve(image); image.onerror = reject; image.src = src; }); }, /** 将处理后的 canvas 导出成 jpg 临时文件。 */ exportCanvasToTempFile( canvas: WechatMiniprogram.Canvas, width: number, height: number, quality: number, ): Promise { return new Promise((resolve, reject) => { wx.canvasToTempFilePath({ canvas, fileType: 'jpg', quality, destWidth: width, destHeight: height, success: (res) => resolve(res.tempFilePath), fail: (err) => reject(err), }); }); }, /** * 读取导出文件大小,用于控制最大体积。 * 开发者工具中 canvasToTempFilePath 可能返回 HTTP URL, * wx.getFileSystemManager().getFileInfo() 不支持 HTTP 路径, * 此时返回 -1 表示无法获取大小,调用方应跳过体积检测。 */ getFileSize(filePath: string): Promise { return new Promise((resolve) => { wx.getFileSystemManager().getFileInfo({ filePath, success: (res) => resolve(res.size), fail: () => resolve(-1), }); }); }, /** * 裁剪并压缩入口图。 * * 流程: * 1. 加载 preview-card 导出的完整 A4 图片 * 2. 按裁剪模式裁掉页眉 / 页脚 * 3. 按目标宽度重绘到隐藏 canvas * 4. 若体积超出阈值,则逐步降低 jpg quality */ async processImage( params: DebugProcessImageParams, ): Promise { await this.ensureProcessorCanvasReady(); const canvas = (this as any) ._processorCanvas as WechatMiniprogram.Canvas; const ctx = (this as any)._processorCtx as RenderingContext; if (!canvas || !ctx) { throw new Error('处理画布不可用'); } const image = await this.loadImage(canvas, params.sourcePath); const { topRatio, bottomRatio } = getCropRatios( params.settings.cropMode, ); const sourceWidth = image.width; const sourceHeight = image.height; const cropTop = Math.round(sourceHeight * topRatio); const cropBottom = Math.round(sourceHeight * bottomRatio); const extractHeight = sourceHeight - cropTop - cropBottom; if (extractHeight <= 0) { throw new Error('裁剪区域无效'); } const targetWidth = clampPublishWidth(params.settings.width); const targetHeight = Math.round( (extractHeight * targetWidth) / sourceWidth, ); canvas.width = targetWidth; canvas.height = targetHeight; ctx.clearRect(0, 0, targetWidth, targetHeight); ctx.drawImage( image, 0, cropTop, sourceWidth, extractHeight, 0, 0, targetWidth, targetHeight, ); let currentQuality = clampPublishQuality(params.settings.quality) / 100; let outputPath = ''; let outputSize = 0; while (currentQuality >= 0.35) { outputPath = await this.exportCanvasToTempFile( canvas, targetWidth, targetHeight, currentQuality, ); outputSize = await this.getFileSize(outputPath); // -1 表示无法获取大小(开发者工具 HTTP 路径),直接跳过体积循环 if ( outputSize < 0 || outputSize <= params.settings.maxSizeKB * 1024 ) { break; } currentQuality = Math.round((currentQuality - 0.05) * 100) / 100; } return { tempFilePath: outputPath, size: outputSize, width: targetWidth, height: targetHeight, quality: Math.round(currentQuality * 100), }; }, }, });