feat(canvas): 实现画布项目导出为ZIP压缩包功能

- 新增 canvas-export 工具模块,支持将画布项目打包为 ZIP 文件
- 修改导出功能,将原来的 JSON 格式改为 ZIP 压缩包,包含 projects.json 和相关媒体文件
- 添加对 fflate 库的依赖用于 ZIP 文件处理
- 更新画布库页面,支持多选项目批量导出
- 修改导入功能以支持读取新版 ZIP 格式的画布项目
- 新增 zip 处理工具库,封装压缩和解压操作
- 扩展文件存储服务,添加获取和设置 Blob 对象的方法
- 更新 AGENTS.md 文档,添加标准库使用原则
This commit is contained in:
HouYunFei
2026-05-26 13:00:16 +08:00
parent 567ead89b1
commit 1fb782819b
11 changed files with 160 additions and 28 deletions
+1
View File
@@ -6,6 +6,7 @@
- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
+3
View File
@@ -1,5 +1,8 @@
# 待测试
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`
+3
View File
@@ -17,6 +17,7 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fflate": "^0.8.3",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -802,6 +803,8 @@
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+1
View File
@@ -23,6 +23,7 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fflate": "^0.8.3",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -6,6 +6,7 @@ import { Button, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
import { exportCanvasProjects } from "../utils/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter();
@@ -65,7 +66,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
</>
) : (
<>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || "无限画布")} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
</>
@@ -75,13 +76,3 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
</article>
);
}
function exportProject(project: CanvasProject) {
const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project };
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`;
link.click();
URL.revokeObjectURL(url);
}
+20
View File
@@ -0,0 +1,20 @@
import type { CanvasProject } from "./stores/use-canvas-store";
export type CanvasExportFile = {
app: "infinite-canvas";
version: 3;
exportedAt: string;
projects: CanvasProjectExportItem[];
};
export type CanvasProjectExportItem = {
project: CanvasProject;
files: CanvasExportAsset[];
};
export type CanvasExportAsset = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
+30 -14
View File
@@ -3,19 +3,17 @@
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { App, Button } from "antd";
import { FileUp, Plus } from "lucide-react";
import { Download, FileUp, Plus } from "lucide-react";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasDeleteProjectsDialog } from "./components/canvas-delete-projects-dialog";
import { CanvasProjectCard } from "./components/canvas-project-card";
import { useCanvasStore, type CanvasProject } from "./stores/use-canvas-store";
import type { CanvasExportFile } from "./export-types";
import { useCanvasStore } from "./stores/use-canvas-store";
import { useCanvasUiStore } from "./stores/use-canvas-ui-store";
type CanvasExportFile = {
app: "infinite-canvas";
version: number;
exportedAt: string;
project: CanvasProject;
};
import { exportCanvasProjects } from "./utils/canvas-export";
export default function CanvasPage() {
const { message } = App.useApp();
@@ -35,11 +33,24 @@ export default function CanvasPage() {
const importCanvas = async (file?: File) => {
if (!file) return;
try {
const data = JSON.parse(await file.text()) as CanvasExportFile;
enterProject(importProject(data.project));
message.success("画布已导入");
const zip = await readZip(file);
const projectFile = zip.get("projects.json");
if (!projectFile) throw new Error("missing projects.json");
const data = JSON.parse(await projectFile.text()) as CanvasExportFile;
await Promise.all(
data.projects.flatMap((project) =>
project.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
),
);
data.projects.forEach((item) => importProject(item.project));
message.success(`已导入 ${data.projects.length} 个画布`);
} catch {
message.error("导入失败,请选择有效的 JSON 文件");
message.error("导入失败,请选择有效的画布压缩包");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
@@ -55,9 +66,14 @@ export default function CanvasPage() {
</div>
<div className="flex items-center gap-2">
{selectedIds.length ? (
<>
<Button disabled={!hydrated} icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `无限画布-${selectedIds.length}个项目`)}>
</Button>
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
</Button>
</>
) : null}
{projects.length ? (
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
@@ -92,7 +108,7 @@ export default function CanvasPage() {
)}
</div>
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<CanvasDeleteProjectsDialog />
</main>
);
@@ -0,0 +1,54 @@
import { createZip } from "@/lib/zip";
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "../export-types";
import type { CanvasProject } from "../stores/use-canvas-store";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all(
projects.map(async (project) => {
const files: CanvasExportAsset[] = [];
await Promise.all(
collectStorageKeys(project).map(async (storageKey) => {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `projects/${project.id}/files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`;
files.push({ storageKey, path, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
return { project, files };
}),
);
const data: CanvasExportFile = { app: "infinite-canvas", version: 3, exportedAt: new Date().toISOString(), projects: exportedProjects };
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
const url = URL.createObjectURL(zip);
const link = document.createElement("a");
link.href = url;
link.download = `${safeFileName(fileName)}.zip`;
link.click();
URL.revokeObjectURL(url);
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
return [...keys];
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
return storageKey.startsWith("image:") ? "png" : "bin";
}
+21
View File
@@ -0,0 +1,21 @@
import { unzipSync, zipSync } from "fflate";
type ZipFile = {
name: string;
data: BlobPart;
};
export async function createZip(files: ZipFile[]) {
const entries = await Promise.all(
files.map(async (file) => {
const data = new Uint8Array(await new Blob([file.data]).arrayBuffer());
return [file.name, data] as const;
}),
);
return new Blob([zipSync(Object.fromEntries(entries), { level: 0 })], { type: "application/zip" });
}
export async function readZip(file: Blob) {
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
return new Map(Object.entries(entries).map(([name, data]) => [name, new Blob([data])]));
}
+11
View File
@@ -29,6 +29,17 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") {
return url;
}
export async function getMediaBlob(storageKey: string) {
return store.getItem<Blob>(storageKey);
}
export async function setMediaBlob(storageKey: string, blob: Blob) {
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return url;
}
export async function cleanupUnusedMedia(usedData: unknown) {
const usedKeys = collectMediaStorageKeys(usedData);
const unused: string[] = [];
+11
View File
@@ -38,6 +38,17 @@ export async function resolveImageUrl(storageKey?: string, fallback = "") {
return url;
}
export async function getImageBlob(storageKey: string) {
return store.getItem<Blob>(storageKey);
}
export async function setImageBlob(storageKey: string, blob: Blob) {
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return url;
}
export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string }) {
const url = image.dataUrl || (await resolveImageUrl(image.storageKey, image.url || ""));
if (!url || url.startsWith("data:")) return url;