大量修改UI

This commit is contained in:
Azmat@qq.com
2026-08-19 17:44:56 +08:00
parent 18b3927817
commit a343f47f0c
76 changed files with 11655 additions and 6104 deletions
+154 -79
View File
@@ -1,21 +1,21 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { RefreshCw, Upload, User, X } from "lucide-react";
import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react";
import { api } from "../api";
import { generationErrorText } from "../generation-error";
import type { ModelEntity } from "../types";
import { SystemLoading } from "../components/loading";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
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 }[] = [
{ k: "all", label: "全部" },
{ k: "official", label: "官方模板" },
{ k: "mine", label: "我的模特" }
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 };
@@ -193,7 +193,7 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
<div className="model-detail-section-h">
<strong>形象图</strong>
<div className="model-detail-section-actions">
<span className="mono">// PORTRAIT</span>
<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 ? "替换形象图" : "上传形象图"}
@@ -223,7 +223,7 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
<div className="model-detail-section-h">
<strong>三视图</strong>
<div className="model-detail-section-actions">
<span className="mono">// 可选资产</span>
<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} />}
@@ -242,9 +242,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
>
{!model.triview && (triviewBusy
? <span className="ph-frame mono"><span className="spinner" aria-hidden="true" /> 三视图生成中…</span>
: <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>)}
: <span className="ph-frame mono">暂无三视图 · 模特仍可正常使用</span>)}
</div>
{triviewStatus && <div className="field-hint mono">// {triviewStatus}</div>}
{triviewStatus && <div className="field-hint mono">{triviewStatus}</div>}
</div>
<div className="model-detail-form">
<div className="field">
@@ -255,12 +255,12 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
<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 className="field-hint mono">编辑模特资料不会回写已存在的视频项目角色</div>
</div>
</div>
</div>
<div className="modal-f">
{model.is_official && <span className="model-detail-readonly mono">// 官方模板仅供查看</span>}
{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>
@@ -280,10 +280,19 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
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;
@@ -296,6 +305,8 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
return () => { alive = false; };
}, [tab]);
useEffect(() => { setSelected(new Set()); }, [tab]);
async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
@@ -312,98 +323,162 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
}
}
async function remove(id: string) {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
onNotify?.("success", "已移至垃圾桶");
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "删除失败");
} finally {
setConfirmId(null);
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 (
<div className="models-page">
<div className="page-head">
<section className={`models-page${editMode ? " manage-mode" : ""}`}>
<header className="ml-head">
<div>
<h1>模特库</h1>
<div className="sub">
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
<span>·</span>
<span>图片创作与视频项目都能引用</span>
</div>
<p>维护可复用的品牌模特与人物参考资产</p>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? "上传中…" : "真人上传"}
<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 ? "上传中…" : "添加模特"}</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="tabs">
{TABS.map((t) => (
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
<div className="ml-section">
<h2>{tabCopy.title}</h2>
<p>{tabCopy.note}</p>
</div>
{loading ? (
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
<span className="models-empty-hint mono">点右上「真人上传」加一个,或在图片/视频流程里生成</span>
<div className="ml-empty" role="status">
<UserPlus />
<strong>还没有模特</strong>
<span>点右上「添加模特」上传一张形象图,或在图片/视频流程里生成</span>
</div>
) : (
<div className="models-grid">
{items.map((m) => (
<div
className="model-card"
key={m.id}
role="button"
tabIndex={0}
onClick={() => setDetail(m)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
<div className="ml-grid">
{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(); } }}
>
{!m.portrait && <span className="ph-frame">无图</span>}
{m.is_official && <span className="pill info model-official">官方模板</span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}>删除</button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}>取消</button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={(e) => { e.stopPropagation(); setConfirmId(m.id); }}>×</button>
))}
</div>
<div className="model-body">
<div className="model-name" title={m.name}>{m.name}</div>
{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 model-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
className={`placeholder ml-thumb${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
>
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
{!m.portrait && <span className="ph-frame">无图</span>}
</div>
<div className="model-tags mono">
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span>·</span>
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
<div className="ml-meta">
<h3 title={m.name}>{m.name}</h3>
<p>{m.description?.trim() || (m.source === "upload" ? "真人上传的人物参考" : "AI 生成的人物参考")}</p>
<div
className={`placeholder ml-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame">待补三视图</span>}
</div>
<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>
</div>
</div>
))}
</article>
);
})}
</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)}
@@ -416,6 +491,6 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
onBillingChanged={onBillingChanged}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
</section>
);
}