大量修改UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user