feat: worksheet发布功能开发

This commit is contained in:
R524809
2026-04-24 17:49:11 +08:00
parent 5106dbbd7c
commit bb69d5c49d
24 changed files with 2540 additions and 1014 deletions
@@ -0,0 +1,350 @@
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),
};
},
},
});