From 1fb782819b29625c2a0fc0cc7631d86816594a43 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Tue, 26 May 2026 13:00:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=AE=9E=E7=8E=B0=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E9=A1=B9=E7=9B=AE=E5=AF=BC=E5=87=BA=E4=B8=BAZIP?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E5=8C=85=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 canvas-export 工具模块,支持将画布项目打包为 ZIP 文件 - 修改导出功能,将原来的 JSON 格式改为 ZIP 压缩包,包含 projects.json 和相关媒体文件 - 添加对 fflate 库的依赖用于 ZIP 文件处理 - 更新画布库页面,支持多选项目批量导出 - 修改导入功能以支持读取新版 ZIP 格式的画布项目 - 新增 zip 处理工具库,封装压缩和解压操作 - 扩展文件存储服务,添加获取和设置 Blob 对象的方法 - 更新 AGENTS.md 文档,添加标准库使用原则 --- AGENTS.md | 1 + docs/pending-test.md | 3 ++ web/bun.lock | 3 ++ web/package.json | 1 + .../canvas/components/canvas-project-card.tsx | 13 +---- web/src/app/(user)/canvas/export-types.ts | 20 +++++++ web/src/app/(user)/canvas/page.tsx | 50 +++++++++++------ .../app/(user)/canvas/utils/canvas-export.ts | 54 +++++++++++++++++++ web/src/lib/zip.ts | 21 ++++++++ web/src/services/file-storage.ts | 11 ++++ web/src/services/image-storage.ts | 11 ++++ 11 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 web/src/app/(user)/canvas/export-types.ts create mode 100644 web/src/app/(user)/canvas/utils/canvas-export.ts create mode 100644 web/src/lib/zip.ts diff --git a/AGENTS.md b/AGENTS.md index ee7e276..242e3ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - 先读现有代码,再动手修改,优先沿用项目已有结构和写法。 - 写代码保持最少行数,能简单实现就不要引入复杂抽象。 +- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。 - 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。 - 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。 - 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。 diff --git a/docs/pending-test.md b/docs/pending-test.md index ba8bf91..18f1096 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,8 @@ # 待测试 +- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。 +- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。 +- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。 - 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。 - 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。 - 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。 diff --git a/web/bun.lock b/web/bun.lock index d14f1aa..2f470d9 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/package.json b/web/package.json index 6eaca4f..20b1015 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app/(user)/canvas/components/canvas-project-card.tsx b/web/src/app/(user)/canvas/components/canvas-project-card.tsx index e667b5c..b432bde 100644 --- a/web/src/app/(user)/canvas/components/canvas-project-card.tsx +++ b/web/src/app/(user)/canvas/components/canvas-project-card.tsx @@ -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 }) { > ) : ( <> - } onClick={() => exportProject(project)} aria-label="导出" /> + } onClick={() => void exportCanvasProjects([project], project.title || "无限画布")} aria-label="导出" /> } onClick={() => startEditing(project.id, project.title)} aria-label="重命名" /> } onClick={() => setDeleteIds([project.id])} aria-label="删除" /> > @@ -75,13 +76,3 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) { ); } - -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); -} diff --git a/web/src/app/(user)/canvas/export-types.ts b/web/src/app/(user)/canvas/export-types.ts new file mode 100644 index 0000000..e48596e --- /dev/null +++ b/web/src/app/(user)/canvas/export-types.ts @@ -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; +}; diff --git a/web/src/app/(user)/canvas/page.tsx b/web/src/app/(user)/canvas/page.tsx index 9e93c32..bd65239 100644 --- a/web/src/app/(user)/canvas/page.tsx +++ b/web/src/app/(user)/canvas/page.tsx @@ -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() {