326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除;
|
|
// 选中 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<FreeAssetItem["status"], { cls: string; label: string }> = {
|
|
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<FreeAssetGroup[]>([]);
|
|
const [activeGroup, setActiveGroup] = useState<FreeAssetGroup | null>(null);
|
|
const [assets, setAssets] = useState<FreeAssetItem[]>([]);
|
|
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<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([]);
|
|
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(
|
|
<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">
|
|
{!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}
|
|
onChange={(event) => setNewName(event.target.value)}
|
|
onKeyDown={(event) => { if (event.key === "Enter") void createGroup(); if (event.key === "Escape") setCreating(false); }}
|
|
/>
|
|
<button type="button" className="btn btn-sm btn-primary" onClick={() => void createGroup()}>创建</button>
|
|
<button type="button" className="btn btn-sm btn-ghost" 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) => (
|
|
<div key={group.id} className="fc-lib-group" role="button" tabIndex={0} onClick={() => void loadGroupDetail(group)} onKeyDown={(event) => { if (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>
|
|
<div className="fc-lib-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(); void renameGroup(group); }}><Pencil size={12} /></button>
|
|
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); void removeGroup(group); }}><Trash2 size={12} /></button>
|
|
</div>
|
|
</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 pill = STATUS_PILL[item.status];
|
|
return (
|
|
<div key={item.id} className={`fc-lib-asset${item.status === "active" ? " pickable" : ""}`} role="button" tabIndex={0} onClick={() => pick(item)} onKeyDown={(event) => { if (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}
|
|
</div>
|
|
<div className="fc-lib-name" title={item.name}>{item.name}</div>
|
|
<span className={`pill pill-l3 ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
|
{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(); void renameAsset(item); }}><Pencil size={12} /></button>
|
|
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); void removeAsset(item); }}><Trash2 size={12} /></button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|