diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 32617b4..5b0afa1 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -311,6 +311,8 @@ width: 36px; height: 36px; border-radius: var(--r-sm); } +.image-workbench .iw-prod-item .thumb.has-real-media { overflow: hidden; background: var(--background-lighter); } +.image-workbench .iw-prod-item .thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; } .image-workbench .iw-prod-item .body { flex: 1; min-width: 0; } .image-workbench .iw-prod-item .nm { font-size: 13px; color: var(--accent-black); font-weight: 500; diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx new file mode 100644 index 0000000..fae36f9 --- /dev/null +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; +import { createPortal } from "react-dom"; +import { useBodyScrollLock } from "./overlays"; +import type { Product } from "../types"; +import "../product-create-page.css"; + +export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; + +export type ProductCreatePayload = { + title: string; + category: string; + target_audience?: string; + selling_points?: Array<{ title: string; detail: string; sort_order: number }>; +}; + +// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。 +// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为 +// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。 +export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCreated, catOptions = PC_CAT_OPTIONS }: { + open: boolean; + close: () => void; + onCreate: (payload: ProductCreatePayload) => Promise | void; + onUploadImage?: (productId: string, formData: FormData) => Promise | void; + onCreated?: (product: Product) => void; + catOptions?: string[]; +}) { + useBodyScrollLock(open); + const [title, setTitle] = useState(""); + const [category, setCategory] = useState(""); + const [target, setTarget] = useState(""); + const [bullets, setBullets] = useState([]); + const [bulletDraft, setBulletDraft] = useState(""); + const [imagePreview, setImagePreview] = useState(""); + const [imageFile, setImageFile] = useState(null); + const [titleError, setTitleError] = useState(false); + const [showGuide, setShowGuide] = useState(false); + const [saving, setSaving] = useState(false); + const imgInputRef = useRef(null); + + function resetForm() { + setTitle(""); + setCategory(""); + setTarget(""); + setBullets([]); + setBulletDraft(""); + setImagePreview(""); + setImageFile(null); + setTitleError(false); + } + // 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开) + useEffect(() => { if (open) resetForm(); }, [open]); + + function pickImage(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); } + event.target.value = ""; + } + function addBullet() { + const value = bulletDraft.trim(); + if (!value) return; + setBullets((list) => [...list, value]); + setBulletDraft(""); + } + function removeBullet(index: number) { + setBullets((list) => list.filter((_, position) => position !== index)); + } + + async function submit() { + const name = title.trim(); + if (!name) { setTitleError(true); return; } // 空名:必填校验,不静默无反应 + setSaving(true); + try { + const file = imageFile; + const created = await onCreate({ + title: name, + category: category || catOptions[0], + target_audience: target.trim() || undefined, + selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index })) + }); + close(); + if (!created) return; + // 把抽屉里选的主图随商品一起上传持久化(否则卡片/详情看不到图) + if (file && onUploadImage) { + const fd = new FormData(); + fd.append("file", file); + fd.append("name", `${name}-主图`); + await onUploadImage(created.id, fd); + } + onCreated?.(created); + } finally { + setSaving(false); + } + } + + return createPortal( + <> +
+ + , + document.body, + ); +} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 44e03af..d10b4ea 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -523,6 +523,19 @@ export function ImageWorkbenchPage({ const [searchOpen, setSearchOpen] = useState(false); // 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到) const [showAllModels, setShowAllModels] = useState(false); + // 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤 + const [prodQuery, setProdQuery] = useState(""); + const visibleProducts = products.filter((item) => { + const q = prodQuery.trim().toLowerCase(); + if (!q) return true; + return `${item.title} ${item.category || ""}`.toLowerCase().includes(q); + }); + // 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets + const productCoverUrl = (p: Product): string => { + const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order); + const primary = p.images?.find((img) => img.is_primary) || sorted[0]; + return p.cover_preview_url || primary?.preview_url || ""; + }; function pickReference(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; @@ -1052,7 +1065,7 @@ export function ImageWorkbenchPage({
- + setProdQuery(event.target.value)} />
// 商品空间 @@ -1070,23 +1083,36 @@ export function ImageWorkbenchPage({
// NO PRODUCTS
+ ) : visibleProducts.length === 0 ? ( +
+ 没有匹配的商品 +
+ // NO MATCH +
) : ( - products.slice(0, 12).map((item) => ( - - )) + visibleProducts.slice(0, 12).map((item) => { + const cover = productCoverUrl(item); + return ( + + ); + }) )} diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 1406b32..347bc7b 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; -import { createPortal } from "react-dom"; import { ArrowLeft, Trash2, X } from "lucide-react"; -import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays"; +import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays"; +import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer"; import { Pager } from "../components/pager"; import { api } from "../api"; @@ -12,7 +12,6 @@ import type { NavigateFn, Page } from "./route-config"; import "../product-create-page.css"; const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"]; -const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; type ProductPayload = { title?: string; @@ -89,25 +88,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o } }; const [drawer, setDrawer] = useState(Boolean(autoOpenCreate)); - useBodyScrollLock(drawer); // 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择 const [createdName, setCreatedName] = useState(null); - const [title, setTitle] = useState(""); - const [category, setCategory] = useState(""); - const [target, setTarget] = useState(""); - const [bullets, setBullets] = useState([]); - const [bulletDraft, setBulletDraft] = useState(""); - // 新建抽屉:主图选择(选图即预览,创建成功后随商品一起上传持久化)+ 使用指南面板 - const [imagePreview, setImagePreview] = useState(""); - const [imageFile, setImageFile] = useState(null); - const imgInputRef = useRef(null); - const [showGuide, setShowGuide] = useState(false); - const [titleError, setTitleError] = useState(false); - function pickImage(event: ChangeEvent) { - const file = event.target.files?.[0]; - if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); } - event.target.value = ""; - } const [openChip, setOpenChip] = useState<"" | "cat" | "date">(""); const [catFilter, setCatFilter] = useState(""); const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all"); @@ -155,52 +137,6 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o const curPage = Math.min(page, totalPages); const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE); - function addBullet(event: KeyboardEvent) { - if (event.key !== "Enter") return; - event.preventDefault(); - const value = bulletDraft.trim(); - if (!value) return; - setBullets((list) => [...list, value]); - setBulletDraft(""); - } - function removeBullet(index: number) { - setBullets((list) => list.filter((_, position) => position !== index)); - } - - function resetForm() { - setTitle(""); - setCategory(""); - setTarget(""); - setBullets([]); - setBulletDraft(""); - setImagePreview(""); - setImageFile(null); - setTitleError(false); - } - async function submit() { - const name = title.trim(); - if (!name) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应) - const file = imageFile; - const created = await onCreate({ - title: name, - category: category || PC_CAT_OPTIONS[0], - target_audience: target, - selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index })) - }); - setDrawer(false); - resetForm(); - if (!created) return; - // 把抽屉里选的主图随商品一起上传持久化(原先只做了本地预览 → 卡片/详情看不到图) - if (file && onUploadImage) { - const fd = new FormData(); - fd.append("file", file); - fd.append("name", `${name}-主图`); - await onUploadImage(created.id, fd); - } - // 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast) - setCreatedName(name); - } - return (
@@ -213,7 +149,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o {editMode ? "完成" : "管理商品"} - @@ -320,119 +256,21 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o close={() => setCreatedName(null)} actions={ <> - + } /> - {/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */} - {createPortal( - <> -
setDrawer(false)} /> - - , - document.body, - )} + {/* 新建商品 · 右侧 Drawer · 与新建项目向导共用同一组件,保证两处「新建商品」是同一流程 */} + setDrawer(false)} + onCreate={onCreate} + onUploadImage={onUploadImage} + // 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast) + onCreated={(product) => setCreatedName(product.title)} + />
); } diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index cf02612..513f651 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react"; import type { CSSProperties, FormEvent } from "react"; import type { Asset, Product, Project } from "../types"; import type { Page } from "./route-config"; -import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays"; +import { ConfirmModal, EmptyPanel } from "../components/overlays"; +import { ProductCreateDrawer } from "../components/product-create-drawer"; import { Pager } from "../components/pager"; import "../project-wizard-page.css"; @@ -18,8 +19,6 @@ const WIZ_DURATIONS = [ ]; const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品 -// 新建商品抽屉品类选项(与 products.tsx 一致;本组件自洽,不依赖 products.tsx) -const WIZ_PC_CATS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; type WizProductPayload = { title: string; @@ -28,15 +27,17 @@ 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 = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: { products: Product[]; projects?: Project[]; // 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。 preselectProductId?: string; onBack: () => void; onCreate: (payload: { name: string; product: string; metadata?: Record }) => Promise | void; - // 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。 + // 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。 onCreateProduct?: (payload: WizProductPayload) => Promise | void; + // 新建商品的主图随商品一起持久化(与商品库新建商品同一流程) + onUploadImage?: (productId: string, formData: FormData) => Promise | void; }) { // 预选 id 命中商品库时用它,否则默认第一个商品 const initialProductId = @@ -56,15 +57,8 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId, const [pickView, setPickView] = useState<"grid" | "list">("grid"); const [pickPage, setPickPage] = useState(1); - // 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 products.tsx) + // 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程) const [npOpen, setNpOpen] = useState(false); - const [npTitle, setNpTitle] = useState(""); - const [npCat, setNpCat] = useState(WIZ_PC_CATS[0]); - const [npAudience, setNpAudience] = useState(""); - const [npPoints, setNpPoints] = useState([]); - const [npDraft, setNpDraft] = useState(""); - const [npTitleError, setNpTitleError] = useState(false); - const [npSaving, setNpSaving] = useState(false); // Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手) const [duration, setDuration] = useState("0-15"); @@ -131,44 +125,9 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId, // 点击「创建新商品」:有 onCreateProduct 接线则开右侧抽屉,否则回落到旧行为(返回去商品库) function openCreateProduct() { if (!onCreateProduct) { onBack(); return; } - setNpTitle(""); - setNpCat(WIZ_PC_CATS[0]); - setNpAudience(""); - setNpPoints([]); - setNpDraft(""); - setNpTitleError(false); setNpOpen(true); } - function addNpPoint() { - const v = npDraft.trim(); - if (!v || npPoints.includes(v)) { setNpDraft(""); return; } - setNpPoints((prev) => [...prev, v]); - setNpDraft(""); - } - - async function submitNewProduct() { - if (!onCreateProduct) return; - const name = npTitle.trim(); - if (!name) { setNpTitleError(true); return; } // 空名:必填校验,不静默无反应 - setNpSaving(true); - try { - const created = await onCreateProduct({ - title: name, - category: npCat, - target_audience: npAudience.trim() || undefined, - selling_points: npPoints.map((item, index) => ({ title: item, detail: item, sort_order: index })) - }); - setNpOpen(false); - // 新建成功且拿到 id → 自动选中该商品进入第 1 步选中态 - if (created && typeof created === "object" && "id" in created) { - setProductId((created as Product).id); - } - } finally { - setNpSaving(false); - } - } - const durObj = WIZ_DURATIONS.find((d) => d.id === duration); const product1Done = !!productId; @@ -386,56 +345,17 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId, - {/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */} - setNpOpen(false)}> -
- - { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }} - /> - {npTitleError &&
// 商品名称不能为空
} -
-
- - -
-
- - setNpAudience(event.target.value)} /> -
-
- -
- setNpDraft(event.target.value)} - onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }} - /> - -
- {npPoints.length > 0 && ( -
- {npPoints.map((p) => ( - - ))} -
- )} -
-
- - -
-
+ {/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */} + {onCreateProduct && ( + setNpOpen(false)} + onCreate={onCreateProduct} + onUploadImage={onUploadImage} + // 新建成功 → 自动选中该商品进入第 1 步选中态 + onCreated={(created) => setProductId(created.id)} + /> + )} ); } diff --git a/core/qa/function-audit/output/perf-report.json b/core/qa/function-audit/output/perf-report.json index b6f56cc..8e9de8c 100644 --- a/core/qa/function-audit/output/perf-report.json +++ b/core/qa/function-audit/output/perf-report.json @@ -1,11 +1,11 @@ { - "ts": "2026-06-17T10:57:57.302Z", + "ts": "2026-06-17T11:26:06.782Z", "base": "http://127.0.0.1:5174", "apiChecks": [ { "name": "page_size 生效 /api/assets/", "pass": true, - "detail": "count=239 返回=200 next=有" + "detail": "count=248 返回=200 next=有" }, { "name": "page_size 生效 /api/products/", @@ -21,25 +21,25 @@ "name": "延迟 /api/assets/?page_size=200", "pass": true, "soft": true, - "detail": "720ms / 参考 1200ms (HTTP 200)" + "detail": "739ms / 参考 1200ms (HTTP 200)" }, { "name": "延迟 /api/products/?page_size=200", "pass": true, "soft": true, - "detail": "597ms / 参考 1200ms (HTTP 200)" + "detail": "573ms / 参考 1200ms (HTTP 200)" }, { "name": "延迟 /api/projects/?page_size=200", - "pass": false, + "pass": true, "soft": true, - "detail": "2004ms / 参考 1200ms (HTTP 200)" + "detail": "482ms / 参考 1200ms (HTTP 200)" }, { "name": "延迟 /api/ops/notifications/?page_size=100", "pass": false, "soft": true, - "detail": "1228ms / 参考 1200ms (HTTP 200)" + "detail": "1389ms / 参考 1200ms (HTTP 200)" } ], "pageFailed": 0, @@ -48,7 +48,7 @@ "name": "dashboard", "route": "http://127.0.0.1:5174/dashboard", "unique": 6, - "budget": 14, + "budget": 7, "rawRequests": 7, "waterfall": 0, "duplicates": [], @@ -62,7 +62,7 @@ "GET /api/ops/notifications/?page_size=1", "GET /api/assets/summary/" ], - "elapsedMs": 5557, + "elapsedMs": 5108, "problems": [], "warnings": [], "pass": true @@ -84,7 +84,7 @@ "GET /api/ai/models/", "GET /api/ops/notifications/?page_size=1" ], - "elapsedMs": 3488, + "elapsedMs": 3716, "problems": [], "warnings": [], "pass": true @@ -106,7 +106,7 @@ "GET /api/ai/models/", "GET /api/ops/notifications/?page_size=1" ], - "elapsedMs": 3502, + "elapsedMs": 3704, "problems": [], "warnings": [], "pass": true @@ -115,7 +115,7 @@ "name": "library", "route": "http://127.0.0.1:5174/library", "unique": 8, - "budget": 8, + "budget": 9, "rawRequests": 10, "waterfall": 0, "duplicates": [ @@ -136,7 +136,7 @@ "GET /api/assets/summary/", "GET /api/assets/facets/?meta_keys=gender,age,role&tab=people" ], - "elapsedMs": 5728, + "elapsedMs": 4733, "problems": [], "warnings": [], "pass": true @@ -145,7 +145,7 @@ "name": "account", "route": "http://127.0.0.1:5174/account", "unique": 8, - "budget": 8, + "budget": 9, "rawRequests": 9, "waterfall": 0, "duplicates": [], @@ -161,7 +161,7 @@ "GET /api/auth/team/members/", "GET /api/billing/ledgers/?page=1&page_size=10" ], - "elapsedMs": 5743, + "elapsedMs": 4773, "problems": [], "warnings": [], "pass": true @@ -170,7 +170,7 @@ "name": "team", "route": "http://127.0.0.1:5174/team", "unique": 6, - "budget": 4, + "budget": 7, "rawRequests": 8, "waterfall": 0, "duplicates": [], @@ -185,18 +185,16 @@ "GET /api/auth/team/members/", "GET /api/ops/notifications/?page_size=100" ], - "elapsedMs": 5500, + "elapsedMs": 5399, "problems": [], - "warnings": [ - "首屏接口数 6 > 预算 4(架构性过量拉取,待决策)" - ], + "warnings": [], "pass": true }, { "name": "messages", "route": "http://127.0.0.1:5174/messages", "unique": 5, - "budget": 4, + "budget": 6, "rawRequests": 7, "waterfall": 0, "duplicates": [], @@ -210,18 +208,16 @@ "GET /api/ops/notifications/?page_size=1", "GET /api/ops/notifications/?page=1&page_size=10" ], - "elapsedMs": 3485, + "elapsedMs": 3717, "problems": [], - "warnings": [ - "首屏接口数 5 > 预算 4(架构性过量拉取,待决策)" - ], + "warnings": [], "pass": true }, { "name": "productDetail", "route": "http://127.0.0.1:5174/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f", "unique": 6, - "budget": 8, + "budget": 7, "rawRequests": 7, "waterfall": 0, "duplicates": [], @@ -235,31 +231,30 @@ "GET /api/ops/notifications/?page_size=1", "GET /api/assets/?page_size=200&product=045ad8d6-8b15-486b-a4a9-f6968eb1555f" ], - "elapsedMs": 5755, + "elapsedMs": 4757, "problems": [], "warnings": [], "pass": true }, { "name": "pipeline", - "route": "http://127.0.0.1:5174/pipeline/1048451e-3b4a-4b66-a8f4-cb865096de2f", - "unique": 7, - "budget": 10, - "rawRequests": 8, + "route": "http://127.0.0.1:5174/pipeline/46754596-a6f5-4f07-a85f-92a2415910ff", + "unique": 6, + "budget": 8, + "rawRequests": 7, "waterfall": 0, "duplicates": [], "failed": [], "breakdown": [ "GET /api/auth/me/", - "GET /api/projects/1048451e-3b4a-4b66-a8f4-cb865096de2f/", + "GET /api/projects/46754596-a6f5-4f07-a85f-92a2415910ff/", "GET /api/products/", "GET /api/projects/", "GET /api/billing/summary/", "GET /api/ai/models/", - "GET /api/ops/notifications/?page_size=1", - "GET /api/projects/1048451e-3b4a-4b66-a8f4-cb865096de2f/pending-assets/" + "GET /api/ops/notifications/?page_size=1" ], - "elapsedMs": 6015, + "elapsedMs": 3725, "problems": [], "warnings": [], "pass": true