// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除; // 选中 active 素材注入输入条(source=library,生成时后端换 asset:// 引用)。 // processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。 import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react"; import { api } from "../../api"; import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types"; import { useBodyScrollLock } from "../overlays"; const STATUS_PILL: Record = { processing: { cls: "pill-info", label: "审核中" }, active: { cls: "pill-ok", label: "可用" }, failed: { cls: "pill-err", label: "未通过" } }; export function AssetLibraryModal({ open, onClose, onPick, notify }: { open: boolean; onClose: () => void; onPick: (ref: FreeVideoRef) => void; notify: (type: "success" | "error" | "info", text: string) => void; }) { const [groups, setGroups] = useState([]); const [activeGroup, setActiveGroup] = useState(null); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const [uploading, setUploading] = useState(false); const [quickUploading, setQuickUploading] = useState(false); const fileRef = useRef(null); const quickFileRef = useRef(null); useBodyScrollLock(open); const loadGroups = useCallback(async () => { setLoading(true); try { const data = await api.freeAssetGroups(); setGroups(data.results); } catch (error) { notify("error", error instanceof Error ? error.message : "素材组加载失败"); } finally { setLoading(false); } }, [notify]); const loadGroupDetail = useCallback(async (group: FreeAssetGroup) => { setLoading(true); try { const data = await api.freeAssetGroup(group.id); setActiveGroup(data.group); setAssets(data.assets); } catch (error) { notify("error", error instanceof Error ? error.message : "素材加载失败"); } finally { setLoading(false); } }, [notify]); useEffect(() => { if (!open) return; setActiveGroup(null); setAssets([]); void loadGroups(); }, [open, loadGroups]); // processing 素材每 8s 轮询刷新 useEffect(() => { if (!open || !activeGroup) return; const pending = assets.filter((a) => a.status === "processing"); if (pending.length === 0) return; const timer = window.setInterval(() => { pending.forEach((item) => { void api.pollFreeAsset(item.id).then((data) => { setAssets((prev) => prev.map((a) => (a.id === data.asset.id ? data.asset : a))); }).catch(() => undefined); }); }, 8000); return () => window.clearInterval(timer); }, [open, activeGroup, assets]); if (!open) return null; const createGroup = async () => { const name = newName.trim(); if (!name) return; try { const data = await api.createFreeAssetGroup({ name }); setGroups((prev) => [data.group, ...prev]); setCreating(false); setNewName(""); notify("success", `素材组「${name}」已创建`); } catch (error) { notify("error", error instanceof Error ? error.message : "创建失败"); } }; const uploadAsset = async (file: File) => { if (!activeGroup) return; setUploading(true); try { const form = new FormData(); form.append("file", file); const data = await api.uploadFreeAsset(activeGroup.id, form); setAssets((prev) => [data.asset, ...prev]); notify("success", "素材已上传,审核中"); } catch (error) { notify("error", error instanceof Error ? error.message : "上传失败"); } finally { setUploading(false); } }; const quickUploadImage = async (file: File) => { setQuickUploading(true); try { const form = new FormData(); form.append("file", file); const data = await api.quickUploadFreeAsset(form); setActiveGroup(data.group); setAssets([data.asset]); notify("success", "图片已上传到默认素材组,审核中"); void loadGroupDetail(data.group); } catch (error) { notify("error", error instanceof Error ? error.message : "上传失败"); } finally { setQuickUploading(false); } }; const renameAsset = async (item: FreeAssetItem) => { const name = window.prompt("素材名称(用于 @ 引用)", item.name); if (!name || name.trim() === item.name) return; try { const data = await api.renameFreeAsset(item.id, name.trim()); setAssets((prev) => prev.map((a) => (a.id === item.id ? data.asset : a))); } catch (error) { notify("error", error instanceof Error ? error.message : "重命名失败"); } }; const removeAsset = async (item: FreeAssetItem) => { if (!window.confirm(`删除素材「${item.name}」?`)) return; try { await api.deleteFreeAsset(item.id); setAssets((prev) => prev.filter((a) => a.id !== item.id)); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); } }; const renameGroup = async (group: FreeAssetGroup) => { const name = window.prompt("素材组名称", group.name); if (!name || name.trim() === group.name) return; try { const data = await api.updateFreeAssetGroup(group.id, { name: name.trim() }); setGroups((prev) => prev.map((g) => (g.id === group.id ? data.group : g))); if (activeGroup?.id === group.id) setActiveGroup(data.group); } catch (error) { notify("error", error instanceof Error ? error.message : "重命名失败"); } }; const removeGroup = async (group: FreeAssetGroup) => { if (!window.confirm(`删除素材组「${group.name}」及其全部素材?此操作不可恢复。`)) return; try { await api.deleteFreeAssetGroup(group.id); setGroups((prev) => prev.filter((g) => g.id !== group.id)); if (activeGroup?.id === group.id) { setActiveGroup(null); setAssets([]); } notify("success", "素材组已删除"); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); } }; const pick = (item: FreeAssetItem) => { if (item.status !== "active") { notify("info", item.status === "processing" ? "素材还在审核中,请稍候" : "素材未通过审核,无法引用"); return; } onPick({ url: item.url, type: item.type, label: item.name, thumb_url: item.thumb_url, duration: item.duration || undefined, asset_id: item.id, source: "library" }); onClose(); }; return createPortal(
event.stopPropagation()}> ++
{activeGroup ? ( ) : "人物素材库"} // 火山素材登记 · @ 引用生成
{!activeGroup ? ( <>
{ const file = event.target.files?.[0]; event.target.value = ""; if (file) void quickUploadImage(file); }} /> {creating ? (
setNewName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void createGroup(); if (event.key === "Escape") setCreating(false); }} />
) : ( <> // 自动进入默认素材组审核 )}
{groups.length === 0 && !loading ? (

还没有素材组

// 一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截

) : (
{groups.map((group) => (
void loadGroupDetail(group)} onKeyDown={(event) => { if (event.key === "Enter") void loadGroupDetail(group); }}>
{group.thumbnail_url ? {group.name} : }
{group.name}
// {group.asset_count} 个素材
))}
)} ) : ( <>
{ const file = event.target.files?.[0]; event.target.value = ""; if (file) void uploadAsset(file); }} /> // 审核通过(可用)后才能被生成引用
{assets.length === 0 && !loading ? (

组内还没有素材

// 上传该角色的图片/视频/音频

) : (
{assets.map((item) => { const pill = STATUS_PILL[item.status]; return (
pick(item)} onKeyDown={(event) => { if (event.key === "Enter") pick(item); }}>
{item.thumb_url ? {item.name} : {item.type === "audio" ? "♪" : item.type.toUpperCase()}} {item.duration ? {item.duration}s : null}
{item.name}
{pill.label} {item.status === "failed" && item.error_message &&
// {item.error_message}
}
); })}
)} )}
, document.body ); }