Files
yingqing/core/frontend/src/components/free-create/asset-library-modal.tsx
T
Azmat@qq.com a84431c9c5
Deploy dev / deploy (push) Successful in 1m2s
添加审核 优化
2026-09-24 18:25:15 +08:00

525 lines
23 KiB
TypeScript

// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除;
// 选中 active 素材注入输入条(source=library,生成时后端换 asset:// 引用)。
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, CheckCircle2, ChevronLeft, CircleX, Clock3, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
import { api } from "../../api";
import { useFileDrop } from "../use-file-drop";
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
import { useBodyScrollLock } from "../overlays";
const STATUS_ICON: Record<FreeAssetItem["status"], { cls: string; label: string; Icon: typeof CheckCircle2 }> = {
processing: { cls: "is-processing", label: "审核中,暂不可引用", Icon: Clock3 },
active: { cls: "is-active", label: "审核通过,可被引用", Icon: CheckCircle2 },
failed: { cls: "is-failed", label: "审核未通过,无法引用", Icon: CircleX }
};
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<FreeAssetGroup[]>([]);
const [activeGroup, setActiveGroup] = useState<FreeAssetGroup | null>(null);
const [assets, setAssets] = useState<FreeAssetItem[]>([]);
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<string | null>(null);
const [editingName, setEditingName] = useState("");
const [renamingAssetId, setRenamingAssetId] = useState<string | null>(null);
const [confirmDeleteAssetId, setConfirmDeleteAssetId] = useState<string | null>(null);
const [deletingAssetId, setDeletingAssetId] = useState<string | null>(null);
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editingGroupName, setEditingGroupName] = useState("");
const [renamingGroupId, setRenamingGroupId] = useState<string | null>(null);
const [confirmDeleteGroupId, setConfirmDeleteGroupId] = useState<string | null>(null);
const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const quickFileRef = useRef<HTMLInputElement>(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]);
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("info", "素材已提交审核,通过后即可引用");
} catch (error) {
notify("error", error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
};
// 素材库弹窗:整块 body 都能接住拖进来的文件。在组内传素材,在组外快速建图。
const libDrop = useFileDrop(
(files) => {
const file = files[0];
if (!file) return;
if (activeGroup) void uploadAsset(file);
else void quickUploadImage(file);
},
{ disabled: uploading }
);
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("info", "图片已提交审核,通过后即可引用");
void loadGroupDetail(data.group);
} catch (error) {
notify("error", error instanceof Error ? error.message : "上传失败");
} finally {
setQuickUploading(false);
}
};
// 所有 Hook 必须在关闭态也保持同一调用顺序;不能在此之前提前 return。
if (!open) return null;
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(
<div className="modal-bg show" onClick={onClose}>
<div className="modal fc-lib-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Users size={16} /></div>
<div className="ti">
{activeGroup ? (
<button type="button" className="fc-lib-back" onClick={() => { setActiveGroup(null); setAssets([]); void loadGroups(); }}>
<ChevronLeft size={14} /> {activeGroup.name}
</button>
) : "人物素材库"}
<span>火山素材登记 · @ 引用生成</span>
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
<div
className={`modal-b fc-lib-body${libDrop.dragging ? " is-dragover" : ""}`}
{...libDrop.dropProps}
onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}
>
{!activeGroup ? (
<>
<div className="fc-lib-toolbar">
<input
ref={quickFileRef}
type="file"
hidden
accept="image/jpeg,image/png,image/webp"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void quickUploadImage(file);
}}
/>
{creating ? (
<div className="fc-lib-create">
<input
className="input"
autoFocus
placeholder="素材组名称(如:碧碧)"
value={newName}
disabled={creatingGroup}
onChange={(event) => setNewName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !creatingGroup) void createGroup();
if (event.key === "Escape" && !creatingGroup) setCreating(false);
}}
/>
<button type="button" className="btn btn-sm btn-primary" disabled={creatingGroup || !newName.trim()} onClick={() => void createGroup()}>
{creatingGroup ? "创建中…" : "创建"}
</button>
<button type="button" className="btn btn-sm btn-ghost" disabled={creatingGroup} onClick={() => setCreating(false)}>取消</button>
</div>
) : (
<>
<button type="button" className="btn btn-sm btn-primary" disabled={quickUploading} onClick={() => quickFileRef.current?.click()}>
<Upload size={13} /> {quickUploading ? "上传中…" : "上传图片"}
</button>
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> 新建素材组</button>
<span className="mono fc-lib-hint">自动进入默认素材组审核</span>
</>
)}
</div>
{groups.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3>还没有素材组</h3>
<p>一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截</p>
</div>
) : (
<div className="fc-lib-groups">
{groups.map((group) => {
const isEditing = editingGroupId === group.id;
const isDeleting = confirmDeleteGroupId === group.id;
return (
<div
key={group.id}
className="fc-lib-group"
role="button"
tabIndex={0}
onClick={() => { if (!editingGroupId) void loadGroupDetail(group); }}
onKeyDown={(event) => { if (!editingGroupId && event.key === "Enter") void loadGroupDetail(group); }}
>
<div className="fc-lib-thumb">
{group.thumbnail_url ? <img src={group.thumbnail_url} alt={group.name} /> : <Users size={20} />}
</div>
{isEditing ? (
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
<input
className="input fc-lib-name-input"
autoFocus
value={editingGroupName}
disabled={renamingGroupId === group.id}
onChange={(event) => setEditingGroupName(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") void renameGroup(group);
if (event.key === "Escape") cancelRenameGroup();
}}
/>
<button
type="button"
className="fc-lib-rename-btn ok"
title="保存"
disabled={renamingGroupId === group.id || !editingGroupName.trim()}
onClick={() => void renameGroup(group)}
>
<Check size={12} />
</button>
<button type="button" className="fc-lib-rename-btn" title="取消" onClick={cancelRenameGroup}>
<X size={12} />
</button>
</div>
) : (
<div className="fc-lib-name" title={group.name}>{group.name}</div>
)}
<div className="fc-lib-count mono">{group.asset_count} 个素材</div>
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameGroup(group); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); setEditingGroupId(null); setConfirmDeleteGroupId(group.id); }}><Trash2 size={12} /></button>
</div>
{isDeleting ? (
<div className="fc-lib-delete-pop" onClick={(event) => event.stopPropagation()}>
<div className="fc-lib-delete-title">删除素材组?</div>
<div className="fc-lib-delete-note mono">组内素材也会删除</div>
<div className="fc-lib-delete-actions">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setConfirmDeleteGroupId(null)}>取消</button>
<button type="button" className="btn btn-sm fc-lib-delete-danger" disabled={deletingGroupId === group.id} onClick={() => void removeGroup(group)}>
{deletingGroupId === group.id ? "删除中…" : "删除"}
</button>
</div>
</div>
) : null}
</div>
);
})}
</div>
)}
</>
) : (
<>
<div className="fc-lib-toolbar">
<input
ref={fileRef}
type="file"
hidden
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void uploadAsset(file);
}}
/>
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => fileRef.current?.click()}>
<Upload size={13} /> {uploading ? "上传中…" : "上传素材"}
</button>
<span className="mono fc-lib-hint">审核通过(可用)后才能被生成引用</span>
</div>
{assets.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Upload size={22} strokeWidth={1.5} /></span>
<h3>组内还没有素材</h3>
<p>上传该角色的图片/视频/音频</p>
</div>
) : (
<div className="fc-lib-assets">
{assets.map((item) => {
const statusIcon = STATUS_ICON[item.status];
const StatusIcon = statusIcon.Icon;
const isEditing = editingAssetId === item.id;
const isDeleting = confirmDeleteAssetId === item.id;
return (
<div
key={item.id}
className={`fc-lib-asset${item.status === "active" ? " pickable" : ""}`}
role="button"
tabIndex={0}
onClick={() => { if (!editingAssetId) pick(item); }}
onKeyDown={(event) => { if (!editingAssetId && event.key === "Enter") pick(item); }}
>
<div className="fc-lib-thumb">
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
<span className={`fc-lib-status-icon ${statusIcon.cls}`} title={statusIcon.label} aria-label={statusIcon.label}>
<StatusIcon size={14} strokeWidth={1.8} />
</span>
</div>
{isEditing ? (
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
<input
className="input fc-lib-name-input"
autoFocus
value={editingName}
disabled={renamingAssetId === item.id}
onChange={(event) => setEditingName(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") void renameAsset(item);
if (event.key === "Escape") cancelRenameAsset();
}}
/>
<button
type="button"
className="fc-lib-rename-btn ok"
title="保存"
disabled={renamingAssetId === item.id || !editingName.trim()}
onClick={() => void renameAsset(item)}
>
<Check size={12} />
</button>
<button type="button" className="fc-lib-rename-btn" title="取消" onClick={cancelRenameAsset}>
<X size={12} />
</button>
</div>
) : (
<div className="fc-lib-name" title={item.name}>{item.name}</div>
)}
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>{item.error_message}</div>}
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); setEditingAssetId(null); setConfirmDeleteAssetId(item.id); }}><Trash2 size={12} /></button>
</div>
{isDeleting ? (
<div className="fc-lib-delete-pop" onClick={(event) => event.stopPropagation()}>
<div className="fc-lib-delete-title">删除素材?</div>
<div className="fc-lib-delete-actions">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setConfirmDeleteAssetId(null)}>取消</button>
<button type="button" className="btn btn-sm fc-lib-delete-danger" disabled={deletingAssetId === item.id} onClick={() => void removeAsset(item)}>
{deletingAssetId === item.id ? "删除中…" : "删除"}
</button>
</div>
</div>
) : null}
</div>
);
})}
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body
);
}