// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除; // 选中 active 素材注入输入条(source=library,生成时后端换 asset:// 引用)。 // processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。 import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Check, 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 [creatingGroup, setCreatingGroup] = useState(false); const [newName, setNewName] = useState(""); const [uploading, setUploading] = useState(false); const [quickUploading, setQuickUploading] = useState(false); const [editingAssetId, setEditingAssetId] = useState(null); const [editingName, setEditingName] = useState(""); const [renamingAssetId, setRenamingAssetId] = useState(null); const [confirmDeleteAssetId, setConfirmDeleteAssetId] = useState(null); const [deletingAssetId, setDeletingAssetId] = useState(null); const [editingGroupId, setEditingGroupId] = useState(null); const [editingGroupName, setEditingGroupName] = useState(""); const [renamingGroupId, setRenamingGroupId] = useState(null); const [confirmDeleteGroupId, setConfirmDeleteGroupId] = useState(null); const [deletingGroupId, setDeletingGroupId] = useState(null); 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([]); setEditingAssetId(null); setEditingName(""); setConfirmDeleteAssetId(null); setEditingGroupId(null); setEditingGroupName(""); setConfirmDeleteGroupId(null); 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 || creatingGroup) return; setCreatingGroup(true); 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 : "创建失败"); } finally { setCreatingGroup(false); } }; 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 startRenameAsset = (item: FreeAssetItem) => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); setEditingAssetId(item.id); setEditingName(item.name); }; const cancelRenameAsset = () => { setEditingAssetId(null); setEditingName(""); }; const renameAsset = async (item: FreeAssetItem) => { const name = editingName.trim(); if (!name) { notify("info", "素材名称不能为空"); return; } if (name === item.name) { cancelRenameAsset(); return; } setRenamingAssetId(item.id); try { const data = await api.renameFreeAsset(item.id, name); setAssets((prev) => prev.map((a) => (a.id === item.id ? data.asset : a))); cancelRenameAsset(); } catch (error) { notify("error", error instanceof Error ? error.message : "重命名失败"); } finally { setRenamingAssetId(null); } }; const removeAsset = async (item: FreeAssetItem) => { setDeletingAssetId(item.id); try { await api.deleteFreeAsset(item.id); setAssets((prev) => prev.filter((a) => a.id !== item.id)); setConfirmDeleteAssetId(null); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); } finally { setDeletingAssetId(null); } }; const startRenameGroup = (group: FreeAssetGroup) => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); setEditingGroupId(group.id); setEditingGroupName(group.name); }; const cancelRenameGroup = () => { setEditingGroupId(null); setEditingGroupName(""); }; const renameGroup = async (group: FreeAssetGroup) => { const name = editingGroupName.trim(); if (!name) { notify("info", "素材组名称不能为空"); return; } if (name === group.name) { cancelRenameGroup(); return; } setRenamingGroupId(group.id); try { const data = await api.updateFreeAssetGroup(group.id, { name }); setGroups((prev) => prev.map((g) => (g.id === group.id ? data.group : g))); if (activeGroup?.id === group.id) setActiveGroup(data.group); cancelRenameGroup(); } catch (error) { notify("error", error instanceof Error ? error.message : "重命名失败"); } finally { setRenamingGroupId(null); } }; const removeGroup = async (group: FreeAssetGroup) => { setDeletingGroupId(group.id); try { await api.deleteFreeAssetGroup(group.id); setGroups((prev) => prev.filter((g) => g.id !== group.id)); if (activeGroup?.id === group.id) { setActiveGroup(null); setAssets([]); } setConfirmDeleteGroupId(null); notify("success", "素材组已删除"); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); } finally { setDeletingGroupId(null); } }; 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 ? ( ) : "人物素材库"} // 火山素材登记 · @ 引用生成
{ setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}> {!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" && !creatingGroup) void createGroup(); if (event.key === "Escape" && !creatingGroup) setCreating(false); }} />
) : ( <> // 自动进入默认素材组审核 )}
{groups.length === 0 && !loading ? (

还没有素材组

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

) : (
{groups.map((group) => { const isEditing = editingGroupId === group.id; const isDeleting = confirmDeleteGroupId === group.id; return (
{ if (!editingGroupId) void loadGroupDetail(group); }} onKeyDown={(event) => { if (!editingGroupId && event.key === "Enter") void loadGroupDetail(group); }} >
{group.thumbnail_url ? {group.name} : }
{isEditing ? (
event.stopPropagation()}> setEditingGroupName(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") void renameGroup(group); if (event.key === "Escape") cancelRenameGroup(); }} />
) : (
{group.name}
)}
// {group.asset_count} 个素材
{isDeleting ? (
event.stopPropagation()}>
删除素材组?
// 组内素材也会删除
) : null}
); })}
)} ) : ( <>
{ 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]; const isEditing = editingAssetId === item.id; const isDeleting = confirmDeleteAssetId === item.id; return (
{ if (!editingAssetId) pick(item); }} onKeyDown={(event) => { if (!editingAssetId && event.key === "Enter") pick(item); }} >
{item.thumb_url ? {item.name} : {item.type === "audio" ? "♪" : item.type.toUpperCase()}} {item.duration ? {item.duration}s : null}
{isEditing ? (
event.stopPropagation()}> setEditingName(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") void renameAsset(item); if (event.key === "Escape") cancelRenameAsset(); }} />
) : (
{item.name}
)} {pill.label} {item.status === "failed" && item.error_message &&
// {item.error_message}
}
{isDeleting ? (
event.stopPropagation()}>
删除素材?
) : null}
); })}
)} )}
, document.body ); }