diff --git a/docs/pending-test.md b/docs/pending-test.md index 18f1096..dd0254a 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -3,6 +3,7 @@ - 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。 - 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。 - 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。 +- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。 - 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。 - 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。 - 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。 diff --git a/web/src/app/(user)/assets/asset-transfer.ts b/web/src/app/(user)/assets/asset-transfer.ts new file mode 100644 index 0000000..7c39b03 --- /dev/null +++ b/web/src/app/(user)/assets/asset-transfer.ts @@ -0,0 +1,75 @@ +import { createZip, readZip } from "@/lib/zip"; +import { getMediaBlob, setMediaBlob } from "@/services/file-storage"; +import { getImageBlob, setImageBlob } from "@/services/image-storage"; +import type { Asset } from "@/stores/use-asset-store"; + +type AssetExportFile = { + app: "infinite-canvas"; + version: 1; + exportedAt: string; + assets: Asset[]; + files: AssetExportItem[]; +}; + +type AssetExportItem = { + storageKey: string; + path: string; + mimeType: string; + bytes: number; +}; + +export async function exportAssets(assets: Asset[]) { + const files: AssetExportItem[] = []; + const zipFiles: { name: string; data: BlobPart }[] = []; + + await Promise.all( + assets.map(async (asset) => { + const storageKey = asset.kind === "image" || asset.kind === "video" ? asset.data.storageKey : undefined; + if (!storageKey) return; + const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); + if (!blob) return; + const path = `files/${safeFileName(storageKey)}.${fileExtension(blob.type, asset.kind)}`; + files.push({ storageKey, path, mimeType: blob.type || asset.data.mimeType, bytes: blob.size }); + zipFiles.push({ name: path, data: blob }); + }), + ); + + const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files }; + const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]); + const url = URL.createObjectURL(zip); + const link = document.createElement("a"); + link.href = url; + link.download = "我的素材.zip"; + link.click(); + URL.revokeObjectURL(url); +} + +export async function readAssetPackage(file: File) { + const zip = await readZip(file); + const assetFile = zip.get("assets.json"); + if (!assetFile) throw new Error("missing assets.json"); + const data = JSON.parse(await assetFile.text()) as AssetExportFile; + await Promise.all( + data.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)); + }), + ); + return data.assets; +} + +function safeFileName(value: string) { + return value.replace(/[\\/:*?"<>|]/g, "_"); +} + +function fileExtension(mimeType: string, kind: Asset["kind"]) { + 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 kind === "image" ? "png" : "bin"; +} diff --git a/web/src/app/(user)/assets/page.tsx b/web/src/app/(user)/assets/page.tsx index d92d37d..32ddbd7 100644 --- a/web/src/app/(user)/assets/page.tsx +++ b/web/src/app/(user)/assets/page.tsx @@ -9,6 +9,7 @@ import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils"; import { uploadImage } from "@/services/image-storage"; import { cn } from "@/lib/utils"; import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store"; +import { exportAssets, readAssetPackage } from "./asset-transfer"; type AssetFormValues = { kind: AssetKind; @@ -35,6 +36,7 @@ export default function AssetsPage() { const [form] = Form.useForm(); const coverInputRef = useRef(null); const imageInputRef = useRef(null); + const assetInputRef = useRef(null); const assets = useAssetStore((state) => state.assets); const addAsset = useAssetStore((state) => state.addAsset); const updateAsset = useAssetStore((state) => state.updateAsset); @@ -153,6 +155,33 @@ export default function AssetsPage() { link.click(); }; + const exportAllAssets = async () => { + if (!validAssets.length) { + message.warning("暂无素材可导出"); + return; + } + await exportAssets(validAssets); + }; + + const importAssetZip = async (file?: File) => { + if (!file) return; + try { + const importedAssets = await readAssetPackage(file); + importedAssets.forEach((asset) => { + const payload = { ...asset } as Record; + delete payload.id; + delete payload.createdAt; + delete payload.updatedAt; + addAsset(payload as Parameters[0]); + }); + message.success(`已导入 ${importedAssets.length} 个素材`); + } catch { + message.error("导入失败,请选择有效的素材压缩包"); + } finally { + if (assetInputRef.current) assetInputRef.current.value = ""; + } + }; + const confirmDelete = () => { if (!deletingAsset) return; removeAsset(deletingAsset.id); @@ -208,13 +237,29 @@ export default function AssetsPage() { ))} - +
+ + + +
@@ -352,6 +397,8 @@ export default function AssetsPage() { setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} /> + void importAssetZip(event.target.files?.[0])} /> + setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。