feat(asset): 添加素材导入导出功能

- 实现了素材批量导出为包含 assets.json 和媒体文件的压缩包
- 实现了从压缩包导入素材到本地存储的功能
- 在素材页面添加了导出素材和导入素材的按钮入口
- 创建了 asset-transfer 模块处理素材打包和解包逻辑
- 支持图片和视频文件的存储路径映射和恢复
- 添加了文件安全命名和 MIME 类型识别功能
This commit is contained in:
HouYunFei
2026-05-26 13:13:05 +08:00
parent 1fb782819b
commit 9311b8a92b
3 changed files with 130 additions and 7 deletions
+1
View File
@@ -3,6 +3,7 @@
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`
@@ -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";
}
+54 -7
View File
@@ -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<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const assetInputRef = useRef<HTMLInputElement>(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<string, unknown>;
delete payload.id;
delete payload.createdAt;
delete payload.updatedAt;
addAsset(payload as Parameters<typeof addAsset>[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() {
))}
</div>
</div>
<button
type="button"
className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300"
onClick={openCreate}
>
</button>
<div className="flex flex-wrap gap-4">
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => void exportAllAssets()}
>
</button>
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => assetInputRef.current?.click()}
>
</button>
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={openCreate}
>
</button>
</div>
</div>
</div>
</div>
@@ -352,6 +397,8 @@ export default function AssetsPage() {
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>