fix(core): 商品详情卖点可编辑+去假数据 / 出图后不再整页重载 / 图片长效缓存
- 商品详情页核心卖点:编辑态原是纯静态文本、且 save 不提交 selling_points, 既改不了也存不了;改为可改/删/回车增,save 带 selling_points 一起 PATCH。 - 去掉详情页 name/cat/target/卖点 的「设计稿假数据」fallback —— 商品没卖点 时不再凭空显示护肤品卖点,空值显示「未填写」。 - 出图成功后 submitAndPollAsset 不再整页全量 loadData(十几个 setState 触发 全页大重渲染),只刷当前项目详情 + 轻量刷余额。 - TOS 上传对象加 Cache-Control: immutable 长效强缓存,重渲染不再重拉图。 - products 序列化器内嵌商品图/封面 preview_url(并修回错位的 read_only_fields)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,22 +11,31 @@ const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.prev
|
||||
// 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」
|
||||
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated";
|
||||
|
||||
export function ActorLibrary({ open, mode, assets, onClose, onPick, onGenerate, onUpload, onRefresh }: {
|
||||
export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: {
|
||||
open: boolean;
|
||||
mode: "browse" | "replace";
|
||||
initialStudio?: boolean; // 打开即进「添加人物工作台」
|
||||
assets: Asset[];
|
||||
onClose: () => void;
|
||||
onPick?: (assetId: string) => void | Promise<unknown>;
|
||||
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||
onUpload: (file: File) => Promise<unknown>;
|
||||
onUpload: (file: File) => Promise<Asset | null>;
|
||||
// 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group)
|
||||
onGenerateTriview?: (portraitAssetId: string) => Promise<{ id: string } | null>;
|
||||
onRename?: (assetId: string, name: string) => Promise<unknown>; // 给人物命名(写回资产 name)
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"preset" | "mine">("preset");
|
||||
const [studio, setStudio] = useState(false); // 添加演员工作台
|
||||
const [studio, setStudio] = useState(Boolean(initialStudio)); // 添加演员工作台
|
||||
const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
// 添加人物工作台多步态:候选立绘 + 选中那张 + 命名 + 三视图就绪标志
|
||||
const [candidates, setCandidates] = useState<Asset[]>([]); // AI 生成/上传得到的立绘候选
|
||||
const [picked, setPicked] = useState<Asset | null>(null); // 进右侧栏的那张立绘
|
||||
const [actorName, setActorName] = useState("");
|
||||
const [triReady, setTriReady] = useState(false); // 已据选中立绘生成过三视图
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
useBodyScrollLock(open);
|
||||
useEffect(() => {
|
||||
@@ -35,28 +44,63 @@ export function ActorLibrary({ open, mode, assets, onClose, onPick, onGenerate,
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
// 每次打开按 initialStudio 重置 studio,并清空工作台多步态(避免上次残留)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStudio(Boolean(initialStudio));
|
||||
setStudioMode("ai");
|
||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
||||
}, [open, initialStudio]);
|
||||
|
||||
// person 类资产 → 演员;按 preset / mine 分两 tab
|
||||
const people = useMemo(() => assets.filter((a) => a.category === "person" && previewOf(a)), [assets]);
|
||||
const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a)));
|
||||
|
||||
async function genActor() {
|
||||
// 选某张候选立绘 → 进右侧栏(预填名字,清三视图标志)
|
||||
function pickCandidate(a: Asset) {
|
||||
setPicked(a);
|
||||
setActorName(a.name || "");
|
||||
setTriReady(false);
|
||||
}
|
||||
// AI 生成立绘候选(不直接关 studio,等用户选一张进下一步)
|
||||
async function genCandidates() {
|
||||
const p = prompt.trim() || "电商真人模特,自然光,干净背景,9:16 竖屏,正面半身";
|
||||
setBusy(true);
|
||||
try {
|
||||
await onGenerate(p);
|
||||
onRefresh();
|
||||
setStudio(false);
|
||||
setTab("mine");
|
||||
const res = await onGenerate(p);
|
||||
const got = res?.assets?.filter((a) => previewOf(a)) ?? [];
|
||||
setCandidates(got);
|
||||
if (got.length === 1) pickCandidate(got[0]); // 只有一张就直接进右侧栏
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
async function pickFile(file?: File | null) {
|
||||
// 本地上传 → 直接作为选中立绘进右侧栏
|
||||
async function uploadCandidate(file?: File | null) {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
const asset = await onUpload(file);
|
||||
if (asset && previewOf(asset)) { setCandidates([asset]); pickCandidate(asset); }
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
// 据选中立绘生成三视图(后端异步出图,完成后刷新即见;本地无 worker 时点了不报错)
|
||||
async function genTriview() {
|
||||
if (!picked || !onGenerateTriview) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await onGenerateTriview(picked.id);
|
||||
if (r) setTriReady(true);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
// 保存人物:写名字(若有改名能力)→ 刷新 → 关工作台 → 切「我的演员」tab
|
||||
async function saveActor() {
|
||||
if (!picked) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const name = actorName.trim();
|
||||
if (name && name !== picked.name && onRename) await onRename(picked.id, name);
|
||||
onRefresh();
|
||||
setStudio(false);
|
||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
||||
setTab("mine");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
@@ -83,27 +127,78 @@ export function ActorLibrary({ open, mode, assets, onClose, onPick, onGenerate,
|
||||
<strong style={{ fontSize: 14 }}>添加人物工作台</strong>
|
||||
</div>
|
||||
<div className="actorlib-tabs" style={{ marginBottom: 14 }}>
|
||||
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => setStudioMode("ai")}>AI 生成</button>
|
||||
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => setStudioMode("upload")}>本地上传</button>
|
||||
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("ai"); setCandidates([]); setPicked(null); }}>AI 生成</button>
|
||||
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("upload"); setCandidates([]); setPicked(null); }}>本地上传</button>
|
||||
</div>
|
||||
{studioMode === "ai" ? (
|
||||
<div className="actorlib-studio-ai">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 描述演员形象(年龄 / 风格 / 妆造 / 背景)</div>
|
||||
<textarea className="asset-prompt-edit" rows={4} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genActor()}>
|
||||
{busy ? "生成中…" : "AI 生成演员并保存"}
|
||||
</button>
|
||||
{/* 多步工作台:左 = 生成/上传 + 立绘候选;右 = 选中立绘的命名/三视图/保存 */}
|
||||
<div className="actorlib-studio-grid">
|
||||
<div className="actorlib-studio-left">
|
||||
{studioMode === "ai" ? (
|
||||
<div className="actorlib-studio-ai">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 描述演员形象(年龄 / 风格 / 妆造 / 背景)→ 生成立绘候选</div>
|
||||
<textarea className="asset-prompt-edit" rows={4} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genCandidates()}>
|
||||
{busy ? "生成中…" : "生成立绘"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-studio-upload">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地人物图片 → 进右侧栏生成三视图 / 命名</div>
|
||||
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地人物图片上传"}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||
</div>
|
||||
)}
|
||||
{candidates.length > 0 && (
|
||||
<>
|
||||
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张立绘 → 右侧命名 / 生成三视图 / 保存</div>
|
||||
<div className="actorlib-grid">
|
||||
{candidates.map((a) => {
|
||||
const url = previewOf(a);
|
||||
return (
|
||||
<div className="actor-card" key={a.id}>
|
||||
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}${picked?.id === a.id ? " active" : ""}`} style={url ? mediaStyle(url) : undefined}
|
||||
role="button" tabIndex={0} title="选用这张立绘"
|
||||
onClick={() => pickCandidate(a)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pickCandidate(a); } }}>
|
||||
{!url && <span className="ph-frame">{a.name}</span>}
|
||||
<span className="actor-pick">{picked?.id === a.id ? "已选" : "选用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-studio-upload">
|
||||
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地人物图片上传保存"}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void pickFile(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||
<div className="actorlib-studio-right">
|
||||
{picked ? (
|
||||
<div className="actorlib-studio-detail">
|
||||
<div className={`placeholder actor-thumb${previewOf(picked) ? " has-mock-media" : ""}`} style={previewOf(picked) ? mediaStyle(previewOf(picked)) : undefined}>
|
||||
{!previewOf(picked) && <span className="ph-frame">{picked.name}</span>}
|
||||
</div>
|
||||
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>// 3 给人物命名</label>
|
||||
<input className="asset-prompt-edit" style={{ minHeight: 0, height: 36 }} placeholder="如:都市白领 · 小林" value={actorName} onChange={(e) => setActorName(e.target.value)} />
|
||||
{onGenerateTriview && (
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genTriview()}>
|
||||
{busy ? "提交中…" : triReady ? "已提交三视图 · 再生成一版" : "生成三视图"}
|
||||
</button>
|
||||
)}
|
||||
{triReady && <div className="muted mono" style={{ fontSize: 12, marginTop: 6 }}>// 三视图出图在后台进行,保存后回人物卡详情可查看</div>}
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveActor()}>
|
||||
{busy ? "保存中…" : "保存人物"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 220, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// {studioMode === "ai" ? "生成立绘后,选一张进入这里" : "上传图片后,在这里命名 / 生成三视图"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-body">
|
||||
|
||||
Reference in New Issue
Block a user