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
+3 -1
View File
@@ -36,7 +36,9 @@ class TosStorage:
fileobj,
self.bucket,
object_key,
ExtraArgs={"ContentType": content_type},
# object_key 按 asset id 唯一、内容不可变 → 长效强缓存 + immutable:
# 浏览器/CDN 永不重拉,前端重渲染(背景图重新赋值)也命中缓存,不再"每次生成全页图闪一下重载"。
ExtraArgs={"ContentType": content_type, "CacheControl": "public, max-age=31536000, immutable"},
)
return StoredObject(
bucket=self.bucket,
+23 -1
View File
@@ -1,12 +1,29 @@
from rest_framework import serializers
from apps.assets.serializers import AssetFileSerializer
from .models import Product, ProductImage, ProductSellingPoint
def _asset_preview_url(asset) -> str:
"""资产主文件的可显示 URL,内嵌进商品序列化,让前端缩略图不再依赖(分页的)团队 assets 全量列表解析。
与 projects.serializers._asset_preview_url 同套路。"""
if asset is None:
return ""
files = list(asset.files.all())
primary = next((f for f in files if f.is_primary), files[0] if files else None)
return AssetFileSerializer().get_preview_url(primary) if primary else ""
class ProductImageSerializer(serializers.ModelSerializer):
preview_url = serializers.SerializerMethodField()
class Meta:
model = ProductImage
fields = ["id", "asset", "sort_order", "is_primary"]
fields = ["id", "asset", "preview_url", "sort_order", "is_primary"]
def get_preview_url(self, obj) -> str:
return _asset_preview_url(obj.asset)
class ProductSellingPointSerializer(serializers.ModelSerializer):
@@ -18,6 +35,7 @@ class ProductSellingPointSerializer(serializers.ModelSerializer):
class ProductSerializer(serializers.ModelSerializer):
images = ProductImageSerializer(many=True, required=False)
selling_points = ProductSellingPointSerializer(many=True, required=False)
cover_preview_url = serializers.SerializerMethodField()
class Meta:
model = Product
@@ -31,6 +49,7 @@ class ProductSerializer(serializers.ModelSerializer):
"description",
"status",
"cover_asset",
"cover_preview_url",
"images",
"selling_points",
"created_at",
@@ -38,6 +57,9 @@ class ProductSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "created_at", "updated_at"]
def get_cover_preview_url(self, obj) -> str:
return _asset_preview_url(obj.cover_asset)
def create(self, validated_data):
images = validated_data.pop("images", [])
selling_points = validated_data.pop("selling_points", [])
+25 -4
View File
@@ -282,11 +282,32 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="attach-base-asset")
@transaction.atomic
def attach_base_asset(self, request, pk=None):
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。"""
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。
seed 占位卡还没有 group,此时不传 group_id,改传 kind+label:按 label 命中同实体组,没有则据 label 建组再挂(不出图)。"""
project = self.get_object()
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=request.data.get("group_id")).first()
if group is None:
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
group = None
group_id = request.data.get("group_id")
if group_id:
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=group_id).first()
if group is None:
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
else:
# 无 group(seed 占位卡):据 kind+label 复用/新建实体组,把所选演员资产挂成该 tag 的立绘
kind = request.data.get("kind")
if kind not in BaseAssetGroup.Kind.values:
return Response({"detail": "group_id or valid kind is required"}, status=status.HTTP_400_BAD_REQUEST)
label = str(request.data.get("label") or "").strip()
# 按 label 命中同实体组(排除三视图组);商品=该项目唯一非三视图组;都没有则据 label 新建
candidates = [g for g in project.base_asset_groups.filter(kind=kind).order_by("created_at")
if not (g.metadata or {}).get("triview_of")]
if kind == BaseAssetGroup.Kind.PRODUCT:
group = candidates[0] if candidates else None
elif label:
group = next((g for g in candidates if (g.metadata or {}).get("label") == label), None)
if group is None:
group_meta = {"label": label} if label else {}
group = BaseAssetGroup.objects.create(project=project, kind=kind, metadata=group_meta)
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
asset = Asset.objects.filter(team=project.team, id=request.data.get("asset_id")).first()
if asset is None:
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
+4 -1
View File
@@ -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;
+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">
+6
View File
@@ -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; } }
+124 -17
View File
@@ -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)} />
+46 -11
View File
@@ -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>