351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
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-footer',
|
||
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-footer',
|
||
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),
|
||
};
|
||
},
|
||
},
|
||
});
|