Files
yingqing/core/frontend/src/routes/models.tsx
T

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>
);
}