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
+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>