feat(io): 导出 WebP/PNG 下载,模板存 localStorage 可加载复用

- export.ts:toBlob 参数纯函数 resolveExportRequest——multiplier 恒为 1,
  画布 backstore 本就是逻辑尺寸,不再乘预览缩放;WebP 带质量、PNG 无损不带
- templates.ts:模板 = canvas.toJSON() + 尺寸,存 localStorage(key 单键数组);
  save/list/delete 可注入 store 便于 Node 侧自检;collectUsedFonts 扫文本对象
  fontFamily(instanceof FabricText + type 名兜底),missingFonts 查缺字
- ExportPanel.vue:格式/质量/文件名/下载;模板保存命名、列表、加载
  (loadFromJSON + setSize 恢复尺寸)、删除(二次确认);加载时若字体列表
  已就绪且模板用了本机没有的字体则提示;字体列表为空(T2 未实现)跳过检查
- selfcheck.ts:bun 直接跑的 Node 断言,覆盖 multiplier、storage 往返、
  usedFonts 收集、缺字体与文件名清洗

浏览器下载、质量滑杆体积、刷新后加载恢复需主控用 Chrome 验
This commit is contained in:
2026-09-04 16:02:28 +08:00
parent 95eeb69ff8
commit fa4a7e92f4
5 changed files with 721 additions and 30 deletions
+313 -8
View File
@@ -1,19 +1,324 @@
<script setup lang="ts">
/**
* 【T5 待实现 — 导出与模板面板】
*
* 导出:格式(WebP / PNG)、质量滑杆(只对 WebP 生效)、文件名、下载按钮。
* 模板:保存当前画布为模板、列出已存模板、加载、删除。
*/
import { computed, onMounted, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { fonts } from "@/fonts/useFonts";
import type { ExportFormat, Template } from "@/types";
import {
collectUsedFonts,
deleteTemplate,
exportCanvas,
listTemplates,
missingFonts,
normalizeFilename,
saveTemplate,
} from "@/io";
const { getCanvas, size, setSize } = useEditor();
/* ---------------------------------------------------------------- 导出 */
const format = ref<ExportFormat>("webp");
const quality = ref(0.85);
const filename = ref("");
const exporting = ref(false);
const exportError = ref("");
const qualityPercent = computed(() => Math.round(quality.value * 100));
const defaultFilename = computed(() => `cover-${size.value.width}x${size.value.height}`);
const exportTarget = computed(
() => `${normalizeFilename(filename.value || defaultFilename.value)}.${format.value}`,
);
function pickFormat(next: string) {
if (next === "webp" || next === "png") format.value = next;
}
async function doExport() {
if (exporting.value) return;
exportError.value = "";
exporting.value = true;
try {
await exportCanvas(
getCanvas(),
{ width: size.value.width, height: size.value.height },
{
format: format.value,
quality: quality.value,
filename: filename.value || defaultFilename.value,
},
);
} catch (err) {
exportError.value = err instanceof Error ? err.message : String(err);
} finally {
exporting.value = false;
}
}
/* ---------------------------------------------------------------- 模板 */
const templates = ref<Template[]>([]);
const templateName = ref("");
const saving = ref(false);
const templateMsg = ref("");
const missingFontsNow = ref<string[]>([]);
const confirmingDeleteId = ref<string | null>(null);
function refreshTemplates() {
templates.value = listTemplates();
}
onMounted(refreshTemplates);
function shortDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function saveCurrentTemplate() {
if (saving.value) return;
saving.value = true;
templateMsg.value = "";
missingFontsNow.value = [];
try {
const canvas = getCanvas();
// 存之前扫一遍文本对象的 fontFamily——换台机器复现不出来的正是这些本机字体。
const used = collectUsedFonts(canvas.getObjects());
const saved = saveTemplate({
name: templateName.value,
size: { width: size.value.width, height: size.value.height },
canvasJSON: canvas.toJSON(),
usedFonts: used,
});
refreshTemplates();
templateName.value = "";
templateMsg.value = `已保存模板「${saved.name}」`;
} catch (err) {
templateMsg.value = `保存失败:${err instanceof Error ? err.message : String(err)}`;
} finally {
saving.value = false;
}
}
async function loadTemplateIntoCanvas(t: Template) {
templateMsg.value = "";
missingFontsNow.value = [];
try {
const canvas = getCanvas();
// 先按模板尺寸恢复画布,再灌对象(loadFromJSON 不改变画布尺寸)。
setSize({ width: t.size.width, height: t.size.height });
await canvas.loadFromJSON(t.canvasJSON as Record<string, unknown>);
canvas.requestRenderAll();
// 字体提醒。T2 的字体列表还没拉出来(空数组)就跳过——
// 别让缺字体检查把模板加载一起拖垮。
if (fonts.value.length > 0) {
missingFontsNow.value = missingFonts(
t.usedFonts,
fonts.value.map((f) => f.family),
);
}
templateMsg.value = `已加载「${t.name}」`;
} catch (err) {
templateMsg.value = `加载「${t.name}」失败:${err instanceof Error ? err.message : String(err)}`;
}
}
function askDelete(t: Template) {
confirmingDeleteId.value = t.id;
}
function cancelDelete() {
confirmingDeleteId.value = null;
}
function doDelete(t: Template) {
try {
deleteTemplate(t.id);
refreshTemplates();
confirmingDeleteId.value = null;
templateMsg.value = `已删除「${t.name}」`;
} catch (err) {
templateMsg.value = `删除失败:${err instanceof Error ? err.message : String(err)}`;
}
}
</script>
<template>
<p class="todo">T5 待实现:WebP / PNG 导出,模板保存与加载</p>
<!-- ==================== 导出 ==================== -->
<div class="field">
<label>格式</label>
<select :value="format" @change="pickFormat(($event.target as HTMLSelectElement).value)">
<option value="webp">WebP(推荐,体积小)</option>
<option value="png">PNG(无损,一般用不上)</option>
</select>
</div>
<div class="field">
<label>质量 {{ qualityPercent }}%(PNG 无损时忽略)</label>
<input
type="range"
v-model.number="quality"
min="0.1"
max="1"
step="0.05"
:disabled="format === 'png'"
/>
</div>
<div class="field">
<label>文件名</label>
<input
v-model="filename"
:placeholder="defaultFilename"
@keyup.enter="doExport"
/>
</div>
<p class="meta">
导出 {{ size.width }}×{{ size.height }}px 全分辨率(预览缩放不影响),
下载为 <code>{{ exportTarget }}</code>。
</p>
<button :disabled="exporting" @click="doExport">
{{ exporting ? "导出中…" : `下载 ${format.toUpperCase()}` }}
</button>
<p v-if="exportError" class="msg">⚠ {{ exportError }}</p>
<!-- ==================== 模板 ==================== -->
<div class="sep"></div>
<div class="field">
<label>保存当前画布为模板</label>
<div class="row">
<input
v-model="templateName"
class="grow"
placeholder="模板名(可留空自动起名)"
@keyup.enter="saveCurrentTemplate"
/>
<button :disabled="saving" @click="saveCurrentTemplate">保存</button>
</div>
</div>
<p v-if="templateMsg" class="msg">{{ templateMsg }}</p>
<p v-if="missingFontsNow.length" class="msg">
⚠ 该模板用了本机字体:{{ missingFontsNow.join("、") }}。当前机器没有这些字体,
会退回系统字体显示,文字排版可能对不上。
</p>
<h3 class="sub">已存模板({{ templates.length }})</h3>
<p v-if="templates.length === 0" class="empty">
还没有模板。调好一张封面后点上面的「保存」,之后就能一键加载复用。
</p>
<ul v-else class="tpl-list">
<li v-for="t in templates" :key="t.id" class="tpl">
<div class="tpl-head">
<span class="name" :title="`id: ${t.id}`">{{ t.name }}</span>
<span class="dim">{{ t.size.width }}×{{ t.size.height }} · {{ shortDate(t.createdAt) }}</span>
</div>
<div class="tpl-actions row">
<button @click="loadTemplateIntoCanvas(t)">加载</button>
<template v-if="confirmingDeleteId === t.id">
<span class="dim">确认删除?</span>
<button @click="doDelete(t)">删除</button>
<button @click="cancelDelete">取消</button>
</template>
<button v-else @click="askDelete(t)">删除</button>
<span v-if="t.usedFonts.length" class="dim fonts">含 {{ t.usedFonts.length }} 个字体</span>
</div>
</li>
</ul>
</template>
<style scoped>
.todo {
.grow {
flex: 1;
min-width: 0;
}
.meta {
color: var(--fg-dim);
margin: 0 0 10px;
line-height: 1.6;
}
.meta code {
color: var(--fg);
}
.msg {
color: var(--fg-dim);
margin: 8px 0 0;
line-height: 1.6;
word-break: break-all;
}
.sep {
border-top: 1px solid var(--line);
margin: 14px 0 12px;
}
.sub {
margin: 10px 0 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--fg-dim);
}
.empty {
color: var(--fg-dim);
margin: 0;
line-height: 1.6;
}
.tpl-list {
list-style: none;
margin: 0;
padding: 0;
}
.tpl {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--bg);
padding: 8px 10px;
margin-bottom: 8px;
}
.tpl-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.tpl-head .name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim {
color: var(--fg-dim);
font-size: 12px;
white-space: nowrap;
}
.tpl-actions {
flex-wrap: wrap;
row-gap: 6px;
}
.tpl-actions .fonts {
margin-left: auto;
}
</style>