509 lines
23 KiB
TypeScript
509 lines
23 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react";
|
|
import { api } from "../api";
|
|
import { useFileDrop } from "../components/use-file-drop";
|
|
import { generationErrorText } from "../generation-error";
|
|
import type { ModelEntity } from "../types";
|
|
import { SystemLoading } from "../components/loading";
|
|
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
|
import "../models-page.css";
|
|
|
|
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
|
|
|
type Tab = "all" | "official" | "mine";
|
|
const TABS: { k: Tab; label: string; title: string; note: string }[] = [
|
|
{ k: "all", label: "全部", title: "全部模特", note: "官方模板与团队自建模特都可以在这里复用" },
|
|
{ k: "official", label: "官方模板", title: "官方模特", note: "官方模特均为平台导入的授权素材" },
|
|
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
|
|
];
|
|
|
|
type Preview = { src: string; kind: "image"; name: string };
|
|
|
|
const formatPoints = (value: string) => {
|
|
const points = Number(value);
|
|
return Number.isFinite(points) ? String(points) : value;
|
|
};
|
|
|
|
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
|
|
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + OverlayPortal)。
|
|
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingChanged }: {
|
|
model: ModelEntity | null;
|
|
close: () => void;
|
|
onZoom: (p: Preview) => void;
|
|
onSaved: (model: ModelEntity) => void;
|
|
onNotify?: (type: "success" | "error", text: string) => void;
|
|
onBillingChanged?: () => void;
|
|
}) {
|
|
const open = !!model;
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [pendingPortrait, setPendingPortrait] = useState<File | null>(null);
|
|
const [pendingPortraitUrl, setPendingPortraitUrl] = useState("");
|
|
const [triviewPrice, setTriviewPrice] = useState("");
|
|
const [triviewBusy, setTriviewBusy] = useState(false);
|
|
const [triviewStatus, setTriviewStatus] = useState("");
|
|
const pendingPortraitUrlRef = useRef("");
|
|
const triviewPollRef = useRef(0);
|
|
const portraitFileRef = useRef<HTMLInputElement | null>(null);
|
|
function clearPendingPortrait() {
|
|
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
|
|
pendingPortraitUrlRef.current = "";
|
|
setPendingPortrait(null);
|
|
setPendingPortraitUrl("");
|
|
}
|
|
function dismiss() {
|
|
clearPendingPortrait();
|
|
close();
|
|
}
|
|
useBodyScrollLock(open);
|
|
const { mounted, show } = useOverlayTransition(open, saving ? undefined : dismiss);
|
|
useEffect(() => {
|
|
triviewPollRef.current += 1;
|
|
clearPendingPortrait();
|
|
if (!model) return;
|
|
setName(model.name);
|
|
setDescription(model.description || "");
|
|
setSaving(false);
|
|
setTriviewBusy(false);
|
|
setTriviewStatus("");
|
|
}, [model?.id]);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setTriviewPrice("");
|
|
if (model && !model.is_official) {
|
|
api.modelTriviewQuote(model.id)
|
|
.then((result) => { if (alive) setTriviewPrice(formatPoints(result.points)); })
|
|
.catch(() => { if (alive) setTriviewPrice(""); });
|
|
}
|
|
return () => { alive = false; };
|
|
}, [model?.id, model?.is_official]);
|
|
useEffect(() => () => {
|
|
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
|
|
}, []);
|
|
if (!mounted || !model) return null;
|
|
const currentModel = model;
|
|
const portraitUrl = pendingPortraitUrl || model.portrait;
|
|
const isDirty = Boolean(pendingPortrait) || name.trim() !== model.name || description.trim() !== (model.description || "");
|
|
const canGenerateTriview = Boolean(model.portrait) && Boolean(triviewPrice) && !isDirty && !saving && !triviewBusy;
|
|
const triviewTitle = !model.portrait
|
|
? "请先设置模特形象图"
|
|
: isDirty
|
|
? "请先保存模特修改"
|
|
: !triviewPrice
|
|
? "正在读取当前价格"
|
|
: undefined;
|
|
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
|
|
async function save() {
|
|
if (currentModel.is_official || !name.trim() || saving) return;
|
|
setSaving(true);
|
|
try {
|
|
let updated: ModelEntity;
|
|
if (pendingPortrait) {
|
|
const form = new FormData();
|
|
form.append("file", pendingPortrait);
|
|
form.append("name", name.trim());
|
|
form.append("description", description.trim());
|
|
updated = await api.uploadModelPortrait(currentModel.id, form);
|
|
} else {
|
|
updated = await api.updateLibraryModel(currentModel.id, { name: name.trim(), description: description.trim() });
|
|
}
|
|
onSaved(updated);
|
|
clearPendingPortrait();
|
|
setName(updated.name);
|
|
setDescription(updated.description || "");
|
|
onNotify?.("success", "模特资料已保存");
|
|
} catch (error) {
|
|
onNotify?.("error", error instanceof Error ? error.message : "保存失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
const portraitDrop = useFileDrop(
|
|
(files) => selectPortrait(files[0]),
|
|
{ disabled: saving, accept: (f) => f.type.startsWith("image/") }
|
|
);
|
|
|
|
function selectPortrait(file?: File) {
|
|
if (!file || currentModel.is_official || saving) return;
|
|
clearPendingPortrait();
|
|
const url = URL.createObjectURL(file);
|
|
pendingPortraitUrlRef.current = url;
|
|
setPendingPortrait(file);
|
|
setPendingPortraitUrl(url);
|
|
}
|
|
async function generateTriview() {
|
|
if (!canGenerateTriview || currentModel.is_official) return;
|
|
const pollToken = triviewPollRef.current + 1;
|
|
triviewPollRef.current = pollToken;
|
|
setTriviewBusy(true);
|
|
setTriviewStatus("正在提交生成任务…");
|
|
try {
|
|
const submitted = await api.generateModelTriview(currentModel.id);
|
|
if (triviewPollRef.current !== pollToken) return;
|
|
onBillingChanged?.();
|
|
setTriviewStatus(submitted.reused ? "已有任务生成中…" : "三视图生成中…");
|
|
let transientFailures = 0;
|
|
for (;;) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 1500));
|
|
if (triviewPollRef.current !== pollToken) return;
|
|
try {
|
|
const result = await api.modelTriviewStatus(currentModel.id, submitted.task_id);
|
|
transientFailures = 0;
|
|
if (result.status === "succeeded" && result.model) {
|
|
onSaved(result.model);
|
|
onBillingChanged?.();
|
|
setTriviewStatus("");
|
|
onNotify?.("success", `三视图已生成,扣除 ${formatPoints(result.actual_cost || submitted.estimated_cost)} 积分`);
|
|
return;
|
|
}
|
|
if (["failed", "cancelled"].includes(result.status)) {
|
|
onBillingChanged?.();
|
|
setTriviewStatus("");
|
|
onNotify?.("error", generationErrorText(
|
|
result.error,
|
|
result.error_message || "三视图生成失败,预留积分已释放",
|
|
));
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
transientFailures += 1;
|
|
if (transientFailures >= 3) throw error;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (triviewPollRef.current === pollToken) {
|
|
setTriviewStatus("");
|
|
onNotify?.("error", error instanceof Error ? error.message : "三视图生成失败");
|
|
}
|
|
} finally {
|
|
if (triviewPollRef.current === pollToken) setTriviewBusy(false);
|
|
}
|
|
}
|
|
return createPortal(
|
|
<div className={`modal-bg${show ? " show" : ""}`} onClick={() => { if (!saving) dismiss(); }}>
|
|
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
|
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
|
<div className="modal-h">
|
|
<div className="ic-m"><User size={16} /></div>
|
|
<div className="ti">
|
|
模特详情
|
|
<span>模特 · {model.name}</span>
|
|
</div>
|
|
<button className="x modal-x" type="button" disabled={saving} onClick={dismiss} aria-label="关闭"><X size={14} /></button>
|
|
</div>
|
|
<div className="modal-b model-detail-b">
|
|
<div className="model-detail-left">
|
|
<div className="model-detail-section-h">
|
|
<strong>形象图</strong>
|
|
<div className="model-detail-section-actions">
|
|
<span className="mono">PORTRAIT</span>
|
|
{!model.is_official && (
|
|
<button className="btn btn-sm" type="button" disabled={saving} onClick={() => portraitFileRef.current?.click()}>
|
|
<Upload size={13} />{pendingPortrait ? "重新选择" : model.portrait ? "替换形象图" : "上传形象图"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
|
|
<div
|
|
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}${portraitDrop.dragging ? " is-dragover" : ""}`}
|
|
{...(model.is_official ? {} : portraitDrop.dropProps)}
|
|
style={portraitUrl ? mediaStyle(portraitUrl) : undefined}
|
|
role={portraitUrl ? "button" : undefined}
|
|
tabIndex={portraitUrl ? 0 : undefined}
|
|
onClick={portraitUrl ? () => onZoom({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
|
|
onKeyDown={portraitUrl ? zoomKey({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
|
|
>
|
|
{!portraitUrl && <span className="ph-frame">无图</span>}
|
|
</div>
|
|
<div className="model-detail-tags mono">
|
|
{model.is_official && <span className="pill info">官方模板</span>}
|
|
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
|
{pendingPortrait && <span className="pill info">待保存</span>}
|
|
</div>
|
|
</div>
|
|
<div className="model-detail-right">
|
|
<div>
|
|
<div className="model-detail-section-h">
|
|
<strong>三视图</strong>
|
|
<div className="model-detail-section-actions">
|
|
<span className="mono">可选资产</span>
|
|
{!model.is_official && (
|
|
<button className="btn btn-sm" type="button" disabled={!canGenerateTriview} title={triviewTitle} onClick={() => void generateTriview()}>
|
|
{triviewBusy ? <span className="spinner btn-spin" aria-hidden="true" /> : <RefreshCw size={13} />}
|
|
{triviewBusy ? "三视图生成中…" : `${model.triview ? "重新生成" : "生成三视图"} · ${triviewPrice || "--"} 积分`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
|
|
style={model.triview ? mediaStyle(model.triview) : undefined}
|
|
role={model.triview ? "button" : undefined}
|
|
tabIndex={model.triview ? 0 : undefined}
|
|
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
|
|
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
|
|
>
|
|
{!model.triview && (triviewBusy
|
|
? <span className="ph-frame mono"><span className="spinner" aria-hidden="true" /> 三视图生成中…</span>
|
|
: <span className="ph-frame mono">暂无三视图 · 模特仍可正常使用</span>)}
|
|
</div>
|
|
{triviewStatus && <div className="field-hint mono">{triviewStatus}</div>}
|
|
</div>
|
|
<div className="model-detail-form">
|
|
<div className="field">
|
|
<label className="field-label" htmlFor="model-edit-name">模特名称</label>
|
|
<input id="model-edit-name" className="input" maxLength={255} disabled={model.is_official || saving} value={name} onChange={(event) => setName(event.target.value)} />
|
|
</div>
|
|
<div className="field">
|
|
<label className="field-label" htmlFor="model-edit-description">模特描述 / 提示词</label>
|
|
<textarea id="model-edit-description" className="textarea" rows={4} disabled={model.is_official || saving} placeholder="补充模特特征、风格或生成提示词" value={description} onChange={(event) => setDescription(event.target.value)} />
|
|
</div>
|
|
<div className="field-hint mono">编辑模特资料不会回写已存在的视频项目角色</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="modal-f">
|
|
{model.is_official && <span className="model-detail-readonly mono">官方模板仅供查看</span>}
|
|
<button className="btn" type="button" disabled={saving} onClick={dismiss}>关闭</button>
|
|
{!model.is_official && <button className="btn btn-primary" type="button" disabled={saving || !name.trim() || !isDirty} onClick={() => void save()}>{saving ? "保存中…" : "保存修改"}</button>}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
|
|
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
|
|
export function ModelsPage({ onNotify, onBillingChanged }: {
|
|
onNotify?: (type: "success" | "error", text: string) => void;
|
|
onBillingChanged?: () => void;
|
|
}) {
|
|
const [tab, setTab] = useState<Tab>("all");
|
|
const [items, setItems] = useState<ModelEntity[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
|
|
const [detail, setDetail] = useState<ModelEntity | null>(null);
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
|
|
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
|
const toggleSelect = (id: string) => setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
api
|
|
.listModels({ tab: tab === "all" ? undefined : tab })
|
|
.then((r) => { if (alive) setItems(r.results); })
|
|
.catch(() => { if (alive) setItems([]); })
|
|
.finally(() => { if (alive) setLoading(false); });
|
|
return () => { alive = false; };
|
|
}, [tab]);
|
|
|
|
useEffect(() => { setSelected(new Set()); }, [tab]);
|
|
|
|
const modelDrop = useFileDrop(
|
|
(files) => { void acceptModelFile(files[0]); },
|
|
{ disabled: uploading, accept: (f) => f.type.startsWith("image/") }
|
|
);
|
|
|
|
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = "";
|
|
await acceptModelFile(file);
|
|
}
|
|
|
|
async function acceptModelFile(file?: File | null) {
|
|
if (!file) return;
|
|
setUploading(true);
|
|
try {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("name", file.name.replace(/\.[^.]+$/, ""));
|
|
const created = await api.uploadModel(form);
|
|
setItems((list) => (tab === "official" ? list : [created, ...list]));
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function remove(ids: string[]) {
|
|
const failed = new Set<string>();
|
|
for (const id of ids) {
|
|
try {
|
|
await api.deleteModel(id);
|
|
} catch {
|
|
failed.add(id);
|
|
}
|
|
}
|
|
const removed = ids.filter((id) => !failed.has(id));
|
|
if (removed.length) {
|
|
setItems((list) => list.filter((m) => !removed.includes(m.id)));
|
|
setSelected((prev) => new Set([...prev].filter((id) => !removed.includes(id))));
|
|
onNotify?.("success", removed.length > 1 ? `已移至垃圾桶 · ${removed.length} 个` : "已移至垃圾桶");
|
|
}
|
|
if (failed.size) onNotify?.("error", "部分模特删除失败");
|
|
setConfirmIds(null);
|
|
}
|
|
|
|
return (
|
|
<section className={`models-page${editMode ? " manage-mode" : ""}`}>
|
|
<div className="ml-inner">
|
|
<header className="ml-head">
|
|
<div>
|
|
<h1>模特库</h1>
|
|
<p>维护可复用的品牌模特与人物参考资产</p>
|
|
</div>
|
|
<div className="ml-actions">
|
|
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
|
<Settings2 />
|
|
<span>{editMode ? "完成" : "管理模特"}</span>
|
|
</button>
|
|
<button className="ml-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
|
<UserPlus />
|
|
<span>{uploading ? "上传中…" : modelDrop.dragging ? "松开即可添加" : "添加模特"}</span>
|
|
</button>
|
|
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="ml-toolbar">
|
|
<div className="ml-seg" role="tablist" aria-label="模特来源">
|
|
{TABS.map((t) => (
|
|
<button key={t.k} type="button" role="tab" aria-selected={tab === t.k} className={`ml-seg-btn${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="ml-note">官方模特均为平台导入的授权素材</span>
|
|
</div>
|
|
|
|
<div className="ml-section">
|
|
<h2>{tabCopy.title}</h2>
|
|
<p>{tabCopy.note}</p>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
|
|
) : items.length === 0 ? (
|
|
<div className="ml-empty" role="status">
|
|
<UserPlus />
|
|
<strong>还没有模特</strong>
|
|
<span>点右上「添加模特」上传一张形象图,或在图片/视频流程里生成</span>
|
|
</div>
|
|
) : (
|
|
<div className={`ml-grid${modelDrop.dragging ? " is-dragover" : ""}`} {...modelDrop.dropProps}>
|
|
{items.map((m) => {
|
|
const selectable = !m.is_official;
|
|
const isSelected = selected.has(m.id);
|
|
const onCardClick = () => {
|
|
if (editMode) {
|
|
if (selectable) toggleSelect(m.id);
|
|
return;
|
|
}
|
|
setDetail(m);
|
|
};
|
|
return (
|
|
<article
|
|
className={`ml-card model-card${selectable ? " selectable" : ""}${isSelected ? " selected" : ""}`}
|
|
key={m.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={editMode && selectable ? isSelected : undefined}
|
|
onClick={onCardClick}
|
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}
|
|
>
|
|
{selectable && <span className="ml-check" aria-hidden="true"><Check /></span>}
|
|
{selectable && (
|
|
<button
|
|
className="card-del-btn"
|
|
type="button"
|
|
title="删除模特"
|
|
onClick={(e) => { e.stopPropagation(); setConfirmIds([m.id]); }}
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
|
</button>
|
|
)}
|
|
<div
|
|
className={`placeholder ml-thumb${m.portrait ? " has-mock-media" : ""}`}
|
|
style={m.portrait ? mediaStyle(m.portrait) : undefined}
|
|
>
|
|
{!m.portrait && <span className="ph-frame">无图</span>}
|
|
</div>
|
|
<div className="ml-meta">
|
|
<h3 title={m.name}>{m.name}</h3>
|
|
<p>{m.description?.trim() || (m.source === "upload" ? "真人上传的人物参考" : "AI 生成的人物参考")}</p>
|
|
<div className="ml-line">
|
|
{m.is_official ? (
|
|
<>
|
|
<span className="ml-tag green">平台授权</span>
|
|
<span className="ml-tag">官方</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="ml-tag blue">我添加的</span>
|
|
<span className={`ml-tag${m.triview ? " green" : ""}`}>{m.triview ? "资料完整" : "待补三视图"}</span>
|
|
</>
|
|
)}
|
|
<span className="ml-tag">{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`ml-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
|
|
<div className="ml-sel-copy">
|
|
<strong>已选择 {selected.size} 个模特</strong>
|
|
<span>仅我的模特可删除,确认后移入垃圾桶</span>
|
|
</div>
|
|
<div className="ml-sel-actions">
|
|
<button className="ml-sel-cancel" type="button" onClick={exitEdit}>取消</button>
|
|
<button className="ml-sel-confirm" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
|
|
确认删除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmModal
|
|
open={Boolean(confirmIds && confirmIds.length)}
|
|
title="删除模特"
|
|
|
|
icon={<Trash2 size={16} />}
|
|
detail={`确定删除选中的 ${confirmIds?.length || 0} 个模特?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
|
confirmText="删除"
|
|
onCancel={() => setConfirmIds(null)}
|
|
onConfirm={() => void remove(confirmIds || [])}
|
|
/>
|
|
|
|
<ModelDetailModal
|
|
model={detail}
|
|
close={() => setDetail(null)}
|
|
onZoom={setPreview}
|
|
onSaved={(updated) => {
|
|
setItems((list) => list.map((item) => item.id === updated.id ? updated : item));
|
|
setDetail(updated);
|
|
}}
|
|
onNotify={onNotify}
|
|
onBillingChanged={onBillingChanged}
|
|
/>
|
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
|
</section>
|
|
);
|
|
}
|