From 200871847a99c8735cb6a36dbe91d3e834a6b344 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 18:01:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E5=95=86=E5=93=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=8D=96=E7=82=B9=E5=8F=AF=E7=BC=96=E8=BE=91+?= =?UTF-8?q?=E5=8E=BB=E5=81=87=E6=95=B0=E6=8D=AE=20/=20=E5=87=BA=E5=9B=BE?= =?UTF-8?q?=E5=90=8E=E4=B8=8D=E5=86=8D=E6=95=B4=E9=A1=B5=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=20/=20=E5=9B=BE=E7=89=87=E9=95=BF=E6=95=88=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 商品详情页核心卖点:编辑态原是纯静态文本、且 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 --- core/backend/apps/assets/storage.py | 4 +- core/backend/apps/products/serializers.py | 24 ++- core/backend/apps/projects/views.py | 29 +++- core/frontend/src/App.tsx | 5 +- .../frontend/src/components/actor-library.tsx | 151 ++++++++++++++---- core/frontend/src/pipeline-page.css | 6 + core/frontend/src/routes/pipeline.tsx | 141 ++++++++++++++-- core/frontend/src/routes/products.tsx | 57 +++++-- skills/ecommerce-video-script/SKILL.md | 11 +- .../references/checklist.md | 1 + .../references/methodology.md | 3 +- 11 files changed, 367 insertions(+), 65 deletions(-) diff --git a/core/backend/apps/assets/storage.py b/core/backend/apps/assets/storage.py index f3351e2..6dcee36 100644 --- a/core/backend/apps/assets/storage.py +++ b/core/backend/apps/assets/storage.py @@ -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, diff --git a/core/backend/apps/products/serializers.py b/core/backend/apps/products/serializers.py index c701037..722124e 100644 --- a/core/backend/apps/products/serializers.py +++ b/core/backend/apps/products/serializers.py @@ -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", []) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index b21076f..0942446 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 6119723..f978a04 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -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; diff --git a/core/frontend/src/components/actor-library.tsx b/core/frontend/src/components/actor-library.tsx index eeb2800..ed75727 100644 --- a/core/frontend/src/components/actor-library.tsx +++ b/core/frontend/src/components/actor-library.tsx @@ -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; onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>; - onUpload: (file: File) => Promise; + onUpload: (file: File) => Promise; + // 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group) + onGenerateTriview?: (portraitAssetId: string) => Promise<{ id: string } | null>; + onRename?: (assetId: string, name: string) => Promise; // 给人物命名(写回资产 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([]); // AI 生成/上传得到的立绘候选 + const [picked, setPicked] = useState(null); // 进右侧栏的那张立绘 + const [actorName, setActorName] = useState(""); + const [triReady, setTriReady] = useState(false); // 已据选中立绘生成过三视图 const fileRef = useRef(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, 添加人物工作台
- - + +
- {studioMode === "ai" ? ( -
-
// 描述演员形象(年龄 / 风格 / 妆造 / 背景)
-