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,7 @@
{
"component": true,
"styleIsolation": "isolated",
"usingComponents": {
"van-popup": "@vant/weapp/popup/index"
}
}
@@ -0,0 +1,196 @@
.debug-publish-tools {
.debug-publish-tools__fab {
position: fixed;
right: 24rpx;
bottom: calc(136rpx + env(safe-area-inset-bottom));
z-index: 40;
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 20rpx 28rpx;
border-radius: 999rpx;
background: rgba(50, 46, 37, 0.92);
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.18);
}
.debug-publish-tools__fab--hover {
transform: scale(0.98);
opacity: 0.92;
}
.debug-publish-tools__fab-icon {
font-size: 28rpx;
line-height: 1;
}
.debug-publish-tools__fab-text {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.debug-publish-tools__popup {
width: 680rpx;
max-height: 82vh;
overflow: hidden;
}
.debug-publish-tools__content {
max-height: 82vh;
padding: 56rpx 32rpx 32rpx;
box-sizing: border-box;
overflow-y: auto;
}
.debug-publish-tools__title {
display: block;
margin-bottom: 28rpx;
font-size: 36rpx;
font-weight: 800;
color: #322e25;
text-align: center;
}
.debug-publish-tools__section + .debug-publish-tools__section {
margin-top: 28rpx;
}
.debug-publish-tools__section-title {
display: block;
margin-bottom: 18rpx;
font-size: 26rpx;
font-weight: 700;
color: #7c766a;
}
.debug-publish-tools__meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
}
.debug-publish-tools__meta-item,
.debug-publish-tools__path-box,
.debug-publish-tools__field {
padding: 18rpx 20rpx;
border-radius: 20rpx;
background: #f7f1e6;
}
.debug-publish-tools__path-box {
margin-top: 16rpx;
}
.debug-publish-tools__meta-label,
.debug-publish-tools__field-label {
display: block;
font-size: 22rpx;
font-weight: 700;
color: #8a8478;
}
.debug-publish-tools__meta-value,
.debug-publish-tools__path {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #322e25;
line-height: 1.5;
word-break: break-all;
}
.debug-publish-tools__field + .debug-publish-tools__field {
margin-top: 16rpx;
}
.debug-publish-tools__input {
width: 100%;
margin-top: 12rpx;
min-height: 44rpx;
font-size: 26rpx;
color: #322e25;
}
.debug-publish-tools__chip-row {
display: flex;
gap: 16rpx;
margin-top: 14rpx;
}
.debug-publish-tools__chip-row--wrap {
flex-wrap: wrap;
}
.debug-publish-tools__chip {
min-width: 120rpx;
padding: 16rpx 22rpx;
border-radius: 999rpx;
background: #ebe3d4;
font-size: 24rpx;
font-weight: 700;
color: #605b50;
text-align: center;
box-sizing: border-box;
}
.debug-publish-tools__chip--active {
background: #f7ce00;
color: #453900;
}
.debug-publish-tools__hint {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.6;
color: #8a8478;
}
.debug-publish-tools__actions {
display: flex;
gap: 20rpx;
margin-top: 32rpx;
}
.debug-publish-tools__btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
border: none;
padding: 0;
}
.debug-publish-tools__btn::after {
border: none;
}
.debug-publish-tools__btn--ghost {
background: #f0eadf;
color: #605b50;
}
.debug-publish-tools__btn--primary {
background: linear-gradient(135deg, #f7ee47 0%, #f0ca08 100%);
color: #453900;
}
.debug-publish-tools__processor {
position: fixed;
left: -9999px;
top: -9999px;
width: 2px;
height: 2px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.debug-publish-tools__processor-canvas {
width: 2px;
height: 2px;
}
}
@@ -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),
};
},
},
});
@@ -0,0 +1,183 @@
<view class="debug-publish-tools">
<view
class="debug-publish-tools__fab"
hover-class="debug-publish-tools__fab--hover"
hover-start-time="0"
hover-stay-time="70"
catchtap="onOpen">
<text class="debug-publish-tools__fab-icon">📤</text>
<text class="debug-publish-tools__fab-text">发布到云端</text>
</view>
<van-popup
show="{{visible}}"
position="center"
round
closeable="{{!loading}}"
close-on-click-overlay="{{!loading}}"
bind:close="onClose"
custom-class="debug-publish-tools__popup">
<view class="debug-publish-tools__content">
<text class="debug-publish-tools__title">Debug 发布</text>
<view wx:if="{{meta}}" class="debug-publish-tools__section">
<text class="debug-publish-tools__section-title">基础信息</text>
<view class="debug-publish-tools__meta-grid">
<view class="debug-publish-tools__meta-item">
<text class="debug-publish-tools__meta-label">ID</text>
<text class="debug-publish-tools__meta-value">{{meta.id}}</text>
</view>
<view class="debug-publish-tools__meta-item">
<text class="debug-publish-tools__meta-label">分类</text>
<text class="debug-publish-tools__meta-value">{{meta.category}}</text>
</view>
<view class="debug-publish-tools__meta-item">
<text class="debug-publish-tools__meta-label">年龄</text>
<text class="debug-publish-tools__meta-value">
{{meta.ageMin}}-{{meta.ageMax}} 岁
</text>
</view>
<view class="debug-publish-tools__meta-item">
<text class="debug-publish-tools__meta-label">难度</text>
<text class="debug-publish-tools__meta-value">{{meta.difficulty}}</text>
</view>
</view>
<view class="debug-publish-tools__path-box">
<text class="debug-publish-tools__meta-label">路径</text>
<text class="debug-publish-tools__path">{{meta.path}}</text>
</view>
</view>
<view class="debug-publish-tools__section">
<text class="debug-publish-tools__section-title">资料信息</text>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">标题</text>
<input
class="debug-publish-tools__input"
value="{{formTitle}}"
maxlength="20"
bindinput="onTitleInput" />
</view>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">副标题</text>
<input
class="debug-publish-tools__input"
value="{{formSubtitle}}"
maxlength="40"
bindinput="onSubtitleInput" />
</view>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">标签</text>
<input
class="debug-publish-tools__input"
value="{{formTags}}"
placeholder="多个标签用逗号分隔"
maxlength="80"
bindinput="onTagsInput" />
</view>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">状态</text>
<view class="debug-publish-tools__chip-row">
<view
class="debug-publish-tools__chip {{formStatus === 'draft' ? 'debug-publish-tools__chip--active' : ''}}"
data-value="draft"
bindtap="onSelectStatus">
draft
</view>
<view
class="debug-publish-tools__chip {{formStatus === 'active' ? 'debug-publish-tools__chip--active' : ''}}"
data-value="active"
bindtap="onSelectStatus">
active
</view>
<view
class="debug-publish-tools__chip {{formStatus === 'hidden' ? 'debug-publish-tools__chip--active' : ''}}"
data-value="hidden"
bindtap="onSelectStatus">
hidden
</view>
</view>
</view>
</view>
<view class="debug-publish-tools__section">
<text class="debug-publish-tools__section-title">图片处理</text>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">裁剪模式</text>
<view class="debug-publish-tools__chip-row debug-publish-tools__chip-row--wrap">
<view
class="debug-publish-tools__chip {{formCropMode === 'header-footer' ? 'debug-publish-tools__chip--active' : ''}}"
data-value="header-footer"
bindtap="onSelectCropMode">
裁剪头尾
</view>
<view
class="debug-publish-tools__chip {{formCropMode === 'header-only' ? 'debug-publish-tools__chip--active' : ''}}"
data-value="header-only"
bindtap="onSelectCropMode">
仅裁头部
</view>
<view
class="debug-publish-tools__chip {{formCropMode === 'none' ? 'debug-publish-tools__chip--active' : ''}}"
data-value="none"
bindtap="onSelectCropMode">
不裁剪
</view>
</view>
</view>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">
压缩质量 {{formQuality}}
</text>
<slider
min="40"
max="95"
step="5"
value="{{formQuality}}"
activeColor="#f7ce00"
backgroundColor="#ece4d2"
bindchange="onQualityChange" />
</view>
<view class="debug-publish-tools__field">
<text class="debug-publish-tools__field-label">
输出宽度 {{formWidth}}px
</text>
<slider
min="400"
max="1000"
step="20"
value="{{formWidth}}"
activeColor="#f7ce00"
backgroundColor="#ece4d2"
bindchange="onWidthChange" />
</view>
<text class="debug-publish-tools__hint">
默认按文档规则裁掉页眉和页脚,并尽量压缩到 {{maxSizeKB}}KB 内。
</text>
</view>
<view class="debug-publish-tools__actions">
<button
class="debug-publish-tools__btn debug-publish-tools__btn--ghost"
disabled="{{loading}}"
bindtap="onClose">
取消
</button>
<button
class="debug-publish-tools__btn debug-publish-tools__btn--primary"
loading="{{loading}}"
disabled="{{loading}}"
bindtap="onConfirm">
确认发布
</button>
</view>
</view>
</van-popup>
<view class="debug-publish-tools__processor">
<canvas
id="processorCanvas"
type="2d"
class="debug-publish-tools__processor-canvas" />
</view>
</view>