Files
doodle-mini/miniprogram/components3.0/debug-publish-tools/debug-publish-tools.ts
T
2026-05-20 10:18:25 +08:00

351 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>(
(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<WechatMiniprogram.Image> {
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<string> {
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<number> {
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<DebugProcessImageResult> {
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),
};
},
},
});