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:
zyc
2026-06-17 18:01:29 +08:00
co-authored by Claude Opus 4.8
parent 8ba76e5bb7
commit 200871847a
11 changed files with 367 additions and 65 deletions
+123 -28
View File
@@ -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">