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:
@@ -488,8 +488,11 @@ export function App() {
|
||||
const taskId = submitted?.task?.id;
|
||||
if (!taskId) return null; // 提交失败(余额不足/无 worker 等),错误已由 submit 抛出处理
|
||||
const { assets, error } = await pollAiTasks([taskId]);
|
||||
// 只刷新当前项目详情(新出的图就在 base_asset_groups 里),不再整页全量 loadData ——
|
||||
// 后者十几个 setState 触发全 PipelinePage 大重渲染,所有背景图被重新赋值 → "每次生成全页图重载"。
|
||||
// 余额受出图扣费影响,单独轻量刷新一下即可,不必连带把 products/projects/全部 assets/通知都重拉。
|
||||
await refreshProjectDetail();
|
||||
void loadData();
|
||||
api.billingSummary().then((b) => b && setBilling(b)).catch(() => {});
|
||||
if (assets.length === 0) {
|
||||
setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" });
|
||||
return null;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -694,3 +694,9 @@
|
||||
.actorlib-studio-h { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.actorlib-drop { border: 1.5px dashed var(--border-strong, var(--black-alpha-32)); border-radius: var(--r-md); padding: 40px 20px; text-align: center; color: var(--black-alpha-72); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); }
|
||||
.actorlib-drop:hover { border-color: var(--heat); background: var(--heat-12); }
|
||||
/* 添加人物工作台多步:左 = 生成/上传 + 候选;右 = 选中立绘命名 / 三视图 / 保存 */
|
||||
.actorlib-studio-grid { display: grid; grid-template-columns: 1fr 240px; gap: 18px; align-items: start; }
|
||||
.actorlib-studio-right { border-left: 1px solid var(--border-faint); padding-left: 18px; }
|
||||
.actorlib-studio-detail .actor-thumb { aspect-ratio: 3/4; border-radius: var(--r-md); overflow: hidden; position: relative; border: 1px solid var(--border-faint); }
|
||||
.actor-card .actor-thumb.active { border-color: var(--heat); }
|
||||
@media (max-width: 720px) { .actorlib-studio-grid { grid-template-columns: 1fr; } .actorlib-studio-right { border-left: none; border-top: 1px solid var(--border-faint); padding-left: 0; padding-top: 16px; } }
|
||||
|
||||
@@ -266,6 +266,31 @@ function AddTagInline({ onAdd, placeholder, ariaLabel }: { onAdd?: (value: strin
|
||||
);
|
||||
}
|
||||
|
||||
// 可编辑提示词框(基础资产卡用):**非受控** contentEditable —— 初值只在挂载时灌一次,之后由浏览器维护
|
||||
// DOM,React 不再 reconcile 其子节点。受控写法(把 state 当 children 回灌)在输入触发换行时,浏览器会
|
||||
// 往 contentEditable 里插 <div>/<br>,React 拿单个文本子节点去对账就会 removeChild 崩溃 → 整页白屏。
|
||||
// 读取走 onInput 写回上层 state(供「生成/重跑」带上),与渲染解耦,既能编辑又不崩。
|
||||
function PromptBox({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
// 只挂载时灌初值;后续用户编辑由 DOM 自己维护,不受 value 变化驱动(避免重渲染打断输入/白屏)。
|
||||
// 切换实体/草稿时上层用 key 强制重挂载,会重新走这次初值灌入,所以不需要把 value 进依赖。
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.textContent !== value) ref.current.textContent = value;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="prompt-box"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-stop
|
||||
onInput={(e) => onChange(e.currentTarget.textContent || "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 行34 · 「添加分镜」插入的本地可编辑空白卡片:输入旁白/画面,提交后落库
|
||||
function DraftShotCard({ draft, onCommit, onCancel }: {
|
||||
draft: { id: string; afterId: string | null; narration: string; visual: string };
|
||||
@@ -377,11 +402,15 @@ export function PipelinePage(props: {
|
||||
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
|
||||
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
||||
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
|
||||
onAttachBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
||||
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
|
||||
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
|
||||
onGenerateActor: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||
onUploadActor: (file: File) => Promise<unknown>;
|
||||
// 本地上传演员:返回上传后的 Asset(供添加人物工作台进右侧栏)
|
||||
onUploadActor: (file: File) => Promise<Asset | null>;
|
||||
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
|
||||
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
|
||||
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
|
||||
onRenameActor?: (assetId: string, name: string) => Promise<unknown>;
|
||||
onGenerateStoryboard: (prompt: string) => void;
|
||||
onSkipStoryboard: () => Promise<unknown>;
|
||||
onSubmitVideo: (segmentId: string, prompt: string) => void;
|
||||
@@ -398,7 +427,7 @@ export function PipelinePage(props: {
|
||||
const {
|
||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onGenerateStoryboard, onSkipStoryboard,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
} = props;
|
||||
@@ -488,6 +517,9 @@ export function PipelinePage(props: {
|
||||
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
|
||||
// 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上)
|
||||
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
|
||||
// 新增场景:先落「占位草稿卡」(标题 + 可编辑提示词),用户编辑完点「AI 生成」才真正出图;
|
||||
// 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。
|
||||
const [sceneDrafts, setSceneDrafts] = useState<Array<{ id: string; title: string; prompt: string }>>([]);
|
||||
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
|
||||
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||
@@ -584,13 +616,21 @@ export function PipelinePage(props: {
|
||||
setAdTriId(null); // 跟随当前立绘的最新三视图
|
||||
setAdPrompt(entity.group.prompt ?? "");
|
||||
}
|
||||
// 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体组(选演员后挂为候选并采用)
|
||||
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; groupId?: string } | null>(null);
|
||||
// 流程步骤4 · 演员库覆盖层:replace 带要替换的目标(已生成卡=groupId;seed 占位卡还没组=kind+label);
|
||||
// browse 带 studio 直接进「添加人物工作台」
|
||||
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string } | null>(null);
|
||||
function openActorReplace(entity: AssetEntity) {
|
||||
setActorLib({ mode: "replace", groupId: entity.group.id });
|
||||
}
|
||||
// seed 占位卡「替换」:还没 group,记 kind+label,选演员后由后端按 label 命中/建组再挂
|
||||
function openActorReplaceSeed(kind: "person" | "scene", tag: string) {
|
||||
setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag });
|
||||
}
|
||||
async function pickActor(assetId: string) {
|
||||
if (actorLib?.mode === "replace" && actorLib.groupId) await onAttachBaseAsset(actorLib.groupId, assetId);
|
||||
if (actorLib?.mode === "replace") {
|
||||
if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId);
|
||||
else if (actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: actorLib.seedLabel }, assetId);
|
||||
}
|
||||
setActorLib(null);
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -623,6 +663,10 @@ export function PipelinePage(props: {
|
||||
const sbFrames = [...(displayedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const [sbSelected, setSbSelected] = useState(0);
|
||||
const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null;
|
||||
// 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N),
|
||||
// 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。
|
||||
const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null;
|
||||
const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0;
|
||||
|
||||
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
|
||||
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
@@ -2255,11 +2299,16 @@ export function PipelinePage(props: {
|
||||
<div className="stage-assets">
|
||||
<div className="asset-side">
|
||||
{KIND_ORDER.map((kind) => {
|
||||
const list = groupsByKind(kind);
|
||||
const adopted = list.filter((g) => g.adopted_asset).length;
|
||||
// 计数口径要和下方分区「· N 个」一致:人物/场景按「实体」(buildEntities 已排除自动配套的
|
||||
// 三视图组、并按同名归并),否则人物的三视图组会被算进去 → 显示 5 而真人只有 2。
|
||||
// 商品是「单实体多版本」模型,仍按组计(与商品区一致)。
|
||||
const count = kind === "product" ? groupsByKind(kind).length : buildEntities(kind).length;
|
||||
const adopted = kind === "product"
|
||||
? groupsByKind(kind).filter((g) => g.adopted_asset).length
|
||||
: buildEntities(kind).filter((e) => e.group.adopted_asset).length;
|
||||
return (
|
||||
<div className={`ttab${kind === assetTab ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => jumpAssetSection(kind)}>
|
||||
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
|
||||
<span>{KIND_LABEL[kind]}</span><span className="num">{count ? `${adopted}/${count}` : "0"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -2375,9 +2424,38 @@ export function PipelinePage(props: {
|
||||
<h3>{KIND_LABEL[kind]} · {entities.length} 个</h3>
|
||||
<span className="spacer"></span>
|
||||
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
|
||||
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse" }); else void genBaseAsset("scene", genPrompt, undefined, customBusy); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse", studio: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||
</div>
|
||||
<div className="asset-grid-2">
|
||||
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
|
||||
{kind === "scene" && sceneDrafts.map((draft) => {
|
||||
const dk = `scene-draft:${draft.id}`;
|
||||
const busy = isBusy(dk);
|
||||
return (
|
||||
<div className="asset-card-2 asset-seed" data-asset-kind="scene" key={dk}>
|
||||
<div className="placeholder thumb-2">
|
||||
{busy
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中 · 约 8s</span></div>
|
||||
: <span className="ph-frame">{(draft.title.trim() || "新场景")} · 待生成</span>}
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack">
|
||||
<input type="text" value={draft.title} placeholder="场景名(可选)" aria-label="场景名" disabled={busy} data-stop
|
||||
style={{ flex: 1, minWidth: 0, fontSize: "13.5px", fontWeight: 600, color: "var(--accent-black)", background: "transparent", border: "none", outline: "none", padding: 0 }}
|
||||
onChange={(e) => setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, title: e.target.value } : d))} />
|
||||
<span className="spacer"></span>
|
||||
<span className="pill neutral"><span className="dot"></span>新增</span>
|
||||
</div>
|
||||
<PromptBox key={dk} value={draft.prompt} onChange={(v) => setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, prompt: v } : d))} />
|
||||
<div className="hstack" style={{ marginTop: 10 }}>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => setSceneDrafts((list) => list.filter((d) => d.id !== draft.id))}>移除</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={async () => { const p = draft.prompt.trim() || genPrompt; const res = await genBaseAsset("scene", p, draft.title.trim() || undefined, dk); if (res && (res.id || res.adopted_asset)) setSceneDrafts((list) => list.filter((d) => d.id !== draft.id)); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 脚本提取出但还没生成的人物/场景:seed 卡(可改提示词后生成,进入页面会自动补齐) */}
|
||||
{pendingTags.map((tag) => {
|
||||
const seedKey = `seed:${kind}:${tag}`;
|
||||
@@ -2392,8 +2470,9 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={seedKey} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
|
||||
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
|
||||
<div className="hstack" style={{ marginTop: 10 }}>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => openActorReplaceSeed(kind, tag)}>替换</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||
</div>
|
||||
@@ -2426,7 +2505,7 @@ export function PipelinePage(props: {
|
||||
{rs === "failed" && <span className="pill err" title={`审核未过:${byId.get(grp.adopted_asset!)?.review_error || "建议改提示词重新生成"}`}><span className="dot"></span>审核✗</span>}
|
||||
{rs === "processing" && <span className="pill neutral"><span className="dot"></span>审核中</span>}
|
||||
</div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={entity.key} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
|
||||
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
|
||||
<div className="hstack" style={{ marginTop: 10 }}>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; if (kind === "person") void genPersonWithTri(p, entity.name, entBK); else void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||
<span className="spacer"></span>
|
||||
@@ -2436,7 +2515,23 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{entities.length === 0 && pendingTags.length === 0 && (
|
||||
{/* 人物区末尾「添加人物」卡:点开「添加人物工作台」(studio) */}
|
||||
{kind === "person" && (
|
||||
<div className="asset-card-2" data-asset-kind="person" data-stop role="button" tabIndex={0}
|
||||
title="打开添加人物工作台" style={{ cursor: "pointer" }}
|
||||
onClick={() => setActorLib({ mode: "browse", studio: true })}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setActorLib({ mode: "browse", studio: true }); } }}>
|
||||
<div className="placeholder thumb-2" style={{ flexDirection: "column", gap: 8 }}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<span className="ph-frame">添加人物</span>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>添加人物</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>工作台</span></div>
|
||||
<div className="muted mono" style={{ fontSize: 12, marginTop: 6 }}>// AI 生成 / 本地上传 → 三视图 → 入库</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entities.length === 0 && pendingTags.length === 0 && (kind !== "scene" || sceneDrafts.length === 0) && kind !== "person" && (
|
||||
<div className="placeholder" style={{ gridColumn: "1 / -1", minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
||||
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点右上「新增{KIND_LABEL[kind]}」</span>
|
||||
</div>
|
||||
@@ -2474,13 +2569,22 @@ export function PipelinePage(props: {
|
||||
<div className="sub">#{frame.sort_order + 1}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
|
||||
}) : sbExpectedShots ? (
|
||||
/* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */
|
||||
Array.from({ length: sbExpectedShots }, (_, idx) => (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={`ph-${idx}`} onClick={() => setSbSelected(idx)}>
|
||||
<div className="placeholder"><span className="ph-frame">场 {idx + 1}</span></div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">// 待生成</div>
|
||||
</div>
|
||||
))
|
||||
) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
|
||||
</div>
|
||||
{(() => {
|
||||
const url = frameUrl(sbActiveFrame);
|
||||
return (
|
||||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}>
|
||||
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
|
||||
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : sbExpectedShots ? `场 ${sbSelected + 1} · 待生成` : "// 故事板未生成"}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -2549,7 +2653,7 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 整张输出 · {sbFrames.length} 场 · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="info"><span className="mono">[ image-2 整张输出 · {sbFrames.length ? `${sbFrames.length} 场` : sbExpectedShots ? `待生成 ${sbExpectedShots} 场` : "0 场"} · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
{adoptedStoryboard ? (
|
||||
@@ -3219,7 +3323,7 @@ export function PipelinePage(props: {
|
||||
if (viewPortraitAsset) await onAdoptBaseAsset(grp.id, viewPortraitAsset);
|
||||
if (triGroup && viewTriAsset) await onAdoptBaseAsset(triGroup.id, viewTriAsset);
|
||||
setAdDetail(null);
|
||||
}}>使用该资产</button>
|
||||
}}>应用当前角色</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3229,11 +3333,14 @@ export function PipelinePage(props: {
|
||||
<ActorLibrary
|
||||
open={Boolean(actorLib)}
|
||||
mode={actorLib?.mode || "browse"}
|
||||
initialStudio={actorLib?.studio}
|
||||
assets={assets}
|
||||
onClose={() => setActorLib(null)}
|
||||
onPick={pickActor}
|
||||
onGenerate={onGenerateActor}
|
||||
onUpload={onUploadActor}
|
||||
onGenerateTriview={onGenerateTriview}
|
||||
onRename={onRenameActor}
|
||||
onRefresh={() => void onRefreshProject()}
|
||||
/>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
@@ -787,26 +787,49 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
||||
return videoSortDesc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
|
||||
const realName = product.title || "补水保湿精华液";
|
||||
const realCat = product.category || "美妆个护 / 精华液";
|
||||
const realTarget = product.target_audience || "22-32 岁女性、敏感肌、办公室通勤";
|
||||
const realBullets = product.selling_points.length
|
||||
? product.selling_points.map((point) => point.title)
|
||||
: ["透明质酸 + B5,敷完不黏不闷", "30g 大容量精华液", "0 香精 0 酒精,敏感肌可用"];
|
||||
// 真实字段 · 只用商品真实数据,缺省留空(不再注入设计稿假数据,避免"没填的卖点/人群凭空冒出来")
|
||||
const realName = product.title || "未命名商品";
|
||||
const realCat = product.category || "";
|
||||
const realTarget = product.target_audience || "";
|
||||
const realBullets = product.selling_points.map((point) => point.title);
|
||||
|
||||
const [name, setName] = useState(realName);
|
||||
const [cat, setCat] = useState(realCat);
|
||||
const [target, setTarget] = useState(realTarget);
|
||||
const [points, setPoints] = useState<string[]>(realBullets);
|
||||
const [pointDraft, setPointDraft] = useState("");
|
||||
|
||||
function addPoint(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
const value = pointDraft.trim();
|
||||
if (!value) return;
|
||||
setPoints((list) => [...list, value]);
|
||||
setPointDraft("");
|
||||
}
|
||||
function removePoint(index: number) {
|
||||
setPoints((list) => list.filter((_, position) => position !== index));
|
||||
}
|
||||
function updatePoint(index: number, value: string) {
|
||||
setPoints((list) => list.map((item, position) => (position === index ? value : item)));
|
||||
}
|
||||
|
||||
function save() {
|
||||
void onUpdate({ title: name, category: cat, target_audience: target });
|
||||
void onUpdate({
|
||||
title: name,
|
||||
category: cat,
|
||||
target_audience: target,
|
||||
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id
|
||||
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index })) as Product["selling_points"]
|
||||
});
|
||||
setEditing(false);
|
||||
}
|
||||
function cancel() {
|
||||
setName(realName);
|
||||
setCat(realCat);
|
||||
setTarget(realTarget);
|
||||
setPoints(realBullets);
|
||||
setPointDraft("");
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
@@ -932,12 +955,24 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
||||
<div className="k">核心卖点</div>
|
||||
<div className="v">
|
||||
<div className="v-static">
|
||||
{realBullets.map((bullet, index) => <span className="bullet" key={index}>{bullet}</span>)}
|
||||
{realBullets.length
|
||||
? realBullets.map((bullet, index) => <span className="bullet" key={index}>{bullet}</span>)
|
||||
: <span className="bullet" style={{ opacity: 0.5 }}>未填写</span>}
|
||||
</div>
|
||||
<ul className="v-edit v-bullet-list" id="v-bullets-list">
|
||||
{realBullets.map((bullet, index) => (
|
||||
<li className="bl-item" key={index}><span className="num">{index + 1}</span><span className="bl-text">{bullet}</span></li>
|
||||
{points.map((bullet, index) => (
|
||||
<li className="bl-item" key={index}>
|
||||
<span className="num">{index + 1}</span>
|
||||
<input className="bl-input" value={bullet} onChange={(event) => updatePoint(index, event.target.value)} placeholder="卖点描述" />
|
||||
<button className="bl-x" type="button" onClick={() => removePoint(index)} aria-label="删除卖点">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="bl-add">
|
||||
<span className="num">+</span>
|
||||
<input className="bl-input" value={pointDraft} onChange={(event) => setPointDraft(event.target.value)} onKeyDown={addPoint} placeholder="添加新卖点 · 回车确认" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user