feat(canvas): 实现画布项目导出为ZIP压缩包功能
- 新增 canvas-export 工具模块,支持将画布项目打包为 ZIP 文件 - 修改导出功能,将原来的 JSON 格式改为 ZIP 压缩包,包含 projects.json 和相关媒体文件 - 添加对 fflate 库的依赖用于 ZIP 文件处理 - 更新画布库页面,支持多选项目批量导出 - 修改导入功能以支持读取新版 ZIP 格式的画布项目 - 新增 zip 处理工具库,封装压缩和解压操作 - 扩展文件存储服务,添加获取和设置 Blob 对象的方法 - 更新 AGENTS.md 文档,添加标准库使用原则
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。
|
||||
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
|
||||
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
|
||||
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
|
||||
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
|
||||
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 待测试
|
||||
|
||||
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
|
||||
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
|
||||
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
|
||||
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
||||
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
||||
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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} onClick={() => setDeleteIds(selectedIds)}>
|
||||
删除选中
|
||||
</Button>
|
||||
<>
|
||||
<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";
|
||||
}
|
||||
@@ -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])]));
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user