feat(core): 基础资产 Agent 化 — 商品三视图(image_edit 参考主图)+ 人物立绘/三视图异步生成 + 演员库/详情

- 商品三视图:有真实商品主图时走 image_edit,以主图为参考锁包装一致(品牌字/配色/外形/Logo)
- 人物:据「某一版立绘」异步生成配套三视图(image_edit,worker 内跑),立绘/三视图各存版本可切换
- 基础资产出图改异步(run_base_asset_task / generate_triview_task),Web 层不被慢出图占住
- 前端:演员库浏览/新增、人物详情(立绘+三视图+版本切换/下载/查看大图)、按 busyKey 的单卡并发 loading(genBusy,替代全局 genBusyKey,各按钮互不阻塞)
- dev:本地连云端 MySQL 复用连接(CONN_MAX_AGE/health check/connect_timeout),仅 development 生效
- 含 projects 测试补充

tsc + py_compile + 26 后端测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 15:10:22 +08:00
co-authored by Claude Opus 4.8
parent da0c7edfe2
commit 296888f0bf
11 changed files with 361 additions and 59 deletions
+26 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import type { Product, Project } from "../types";
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays";
import { Pager } from "../components/pager";
@@ -28,9 +28,11 @@ type WizProductPayload = {
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
};
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
export function ProjectWizardPage({ products, projects = [], assets = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
products: Product[];
projects?: Project[];
// 团队 assets:商品的 cover_asset/images[].asset 是资产 id,需在此反查真 preview_url
assets?: Asset[];
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
preselectProductId?: string;
onBack: () => void;
@@ -200,9 +202,18 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
}
}
const productCover = (p: Product): CSSProperties | undefined => {
const file = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || p.images?.[0]?.asset;
return file ? ({ ["--mock-media-url"]: `url(${file})` } as CSSProperties) : undefined;
// cover_asset / images[].asset 是资产 id,在团队 assets 里反查真 preview_url(对齐 products.tsx)
const assetById = useMemo(() => {
const map = new Map<string, Asset>();
assets.forEach((a) => map.set(a.id, a));
return map;
}, [assets]);
const productCoverUrl = (p: Product): string => {
const firstImage = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
const id = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || firstImage?.asset;
if (!id) return "";
const asset = assetById.get(id);
return asset?.files?.find((f) => f.is_primary)?.preview_url || asset?.files?.[0]?.preview_url || "";
};
return (
@@ -288,16 +299,23 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
{total === 0 ? (
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
) : (
pageList.map((p) => (
pageList.map((p) => {
const coverUrl = productCoverUrl(p);
return (
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
<div className={`placeholder product-thumb${productCover(p) ? " has-mock-media" : ""}`} style={productCover(p)}><span className="ph-frame">{p.title} · 1200×800</span></div>
{coverUrl ? (
<div className="product-thumb has-real-media"><img src={coverUrl} alt={p.title} loading="lazy" /></div>
) : (
<div className="placeholder product-thumb"><span className="ph-frame">{p.title} · 1200×800</span></div>
)}
<div className="product-body">
<div className="product-name">{p.title}</div>
<div className="product-cat">{p.category || "未分类"}</div>
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
</div>
</div>
))
);
})
)}
</div>