refactor(core): 抽出共享 ProductCreateDrawer · 商品/项目新建复用同一流程

- 新增 components/product-create-drawer.tsx,products/projects 改用同一右侧 Drawer 组件(products -188 / projects -116)
- ai-tools 配套调整;附 perf-report 产物

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 20:13:40 +08:00
co-authored by Claude Opus 4.8
parent b714976d38
commit c9fb7139ac
6 changed files with 308 additions and 325 deletions
+2
View File
@@ -311,6 +311,8 @@
width: 36px; height: 36px; width: 36px; height: 36px;
border-radius: var(--r-sm); 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 .body { flex: 1; min-width: 0; }
.image-workbench .iw-prod-item .nm { .image-workbench .iw-prod-item .nm {
font-size: 13px; color: var(--accent-black); font-weight: 500; font-size: 13px; color: var(--accent-black); font-weight: 500;
@@ -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<Product | null | undefined> | void;
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | 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<string[]>([]);
const [bulletDraft, setBulletDraft] = useState("");
const [imagePreview, setImagePreview] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [titleError, setTitleError] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [saving, setSaving] = useState(false);
const imgInputRef = useRef<HTMLInputElement | null>(null);
function resetForm() {
setTitle("");
setCategory("");
setTarget("");
setBullets([]);
setBulletDraft("");
setImagePreview("");
setImageFile(null);
setTitleError(false);
}
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
useEffect(() => { if (open) resetForm(); }, [open]);
function pickImage(event: ChangeEvent<HTMLInputElement>) {
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(
<>
<div className={`drawer-bg${open ? " show" : ""}`} onClick={close} />
<aside className={`drawer pc-drawer${open ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!open}>
<div className="drawer-h">
<h3></h3>
<button className="x" type="button" onClick={close} aria-label="关闭">
<svg width="14" height="14" 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>
</div>
<div className="drawer-b">
<div className="form-card">
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}></div>}
</div>
<div className="field-row">
<div>
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
{catOptions.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
<div>
<label className="field-label"><span className="opt">()</span></label>
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
</div>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="pf-upload-row">
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
{imagePreview ? (
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
) : (
<>
<div className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</div>
<div className="uz-t"><strong></strong></div>
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
</>
)}
</div>
<div className="pf-example">
<div className="ex-h"></div>
<div className="ex-grid">
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
</div>
<div className="ex-d"></div>
</div>
</div>
<div className="pf-grid" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<ul className="bullet-list">
{bullets.map((bullet, index) => (
<li className="bl-item" key={`${bullet}-${index}`}>
<span className="num">{index + 1}</span>
<span className="bl-text">{bullet}</span>
<button className="bl-x" type="button" onClick={() => removeBullet(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={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }} placeholder="添加新卖点 · 回车确认" />
</li>
</ul>
</div>
</div>
</div>
{showGuide && (
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 13, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
<strong>// 建好商品的 3 步</strong><br />
+ (,/)<br />
(800×800 ),便 AI <br />
24 ,
</div>
)}
<div className="drawer-f">
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
使
</button>
<button className="btn" type="button" onClick={close}></button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={submit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
{saving ? "创建中…" : "创建商品"}
</button>
</div>
</aside>
</>,
document.body,
);
}
+29 -3
View File
@@ -523,6 +523,19 @@ export function ImageWorkbenchPage({
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
// 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到) // 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到)
const [showAllModels, setShowAllModels] = useState(false); 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<HTMLInputElement>) { function pickReference(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -1052,7 +1065,7 @@ export function ImageWorkbenchPage({
</div> </div>
<div className="iw-ps-search"> <div className="iw-ps-search">
<Search size={13} /> <Search size={13} />
<input placeholder="搜索商品 / 分类" /> <input placeholder="搜索商品 / 分类" value={prodQuery} onChange={(event) => setProdQuery(event.target.value)} />
</div> </div>
<div className="iw-list-h"> <div className="iw-list-h">
<span className="mono">// 商品空间</span> <span className="mono">// 商品空间</span>
@@ -1070,23 +1083,36 @@ export function ImageWorkbenchPage({
<br /> <br />
// NO PRODUCTS // NO PRODUCTS
</div> </div>
) : visibleProducts.length === 0 ? (
<div className="iw-ps-empty">
<br />
// NO MATCH
</div>
) : ( ) : (
products.slice(0, 12).map((item) => ( visibleProducts.slice(0, 12).map((item) => {
const cover = productCoverUrl(item);
return (
<button <button
className={`iw-prod-item ${productId === item.id ? "active" : ""}`} className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
type="button" type="button"
key={item.id} key={item.id}
onClick={() => setProductId(item.id)} onClick={() => setProductId(item.id)}
> >
{cover ? (
<div className="thumb has-real-media"><img src={cover} alt={item.title} loading="lazy" /></div>
) : (
<div className="placeholder thumb"> <div className="placeholder thumb">
<span className="ph-frame">{item.title.slice(0, 2)}</span> <span className="ph-frame">{item.title.slice(0, 2)}</span>
</div> </div>
)}
<div className="body"> <div className="body">
<div className="nm">{item.title}</div> <div className="nm">{item.title}</div>
<div className="sub">// {item.category || "未分类"}</div> <div className="sub">// {item.category || "未分类"}</div>
</div> </div>
</button> </button>
)) );
})
)} )}
</div> </div>
</aside> </aside>
+13 -175
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft, Trash2, X } from "lucide-react"; 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 { Pager } from "../components/pager";
import { api } from "../api"; import { api } from "../api";
@@ -12,7 +12,6 @@ import type { NavigateFn, Page } from "./route-config";
import "../product-create-page.css"; import "../product-create-page.css";
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"]; const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
type ProductPayload = { type ProductPayload = {
title?: string; title?: string;
@@ -89,25 +88,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
} }
}; };
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate)); const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
useBodyScrollLock(drawer);
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择 // 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
const [createdName, setCreatedName] = useState<string | null>(null); const [createdName, setCreatedName] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
const [bullets, setBullets] = useState<string[]>([]);
const [bulletDraft, setBulletDraft] = useState("");
// 新建抽屉:主图选择(选图即预览,创建成功后随商品一起上传持久化)+ 使用指南面板
const [imagePreview, setImagePreview] = useState<string>("");
const [imageFile, setImageFile] = useState<File | null>(null);
const imgInputRef = useRef<HTMLInputElement | null>(null);
const [showGuide, setShowGuide] = useState(false);
const [titleError, setTitleError] = useState(false);
function pickImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); }
event.target.value = "";
}
const [openChip, setOpenChip] = useState<"" | "cat" | "date">(""); const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
const [catFilter, setCatFilter] = useState(""); const [catFilter, setCatFilter] = useState("");
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all"); 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 curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE); const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
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 ( return (
<section className="products-page"> <section className="products-page">
<div className="page-head"> <div className="page-head">
@@ -213,7 +149,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span> <span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
</button> </button>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => { resetForm(); setDrawer(true); }}> <button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
</button> </button>
@@ -320,119 +256,21 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
close={() => setCreatedName(null)} close={() => setCreatedName(null)}
actions={ actions={
<> <>
<button className="btn" type="button" onClick={() => { setCreatedName(null); resetForm(); setDrawer(true); }}></button> <button className="btn" type="button" onClick={() => { setCreatedName(null); setDrawer(true); }}></button>
<button className="btn btn-primary" type="button" onClick={() => { setCreatedName(null); navigate("projectWizard"); }}></button> <button className="btn btn-primary" type="button" onClick={() => { setCreatedName(null); navigate("projectWizard"); }}></button>
</> </>
} }
/> />
{/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */} {/* 新建商品 · 右侧 Drawer · 与新建项目向导共用同一组件,保证两处「新建商品」是同一流程 */}
{createPortal( <ProductCreateDrawer
<> open={drawer}
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} /> close={() => setDrawer(false)}
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}> onCreate={onCreate}
<div className="drawer-h"> onUploadImage={onUploadImage}
<h3></h3> // 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast)
<button className="x" type="button" onClick={() => setDrawer(false)} aria-label="关闭"> onCreated={(product) => setCreatedName(product.title)}
<svg width="14" height="14" 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>
</div>
<div className="drawer-b">
<div className="form-card">
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}></div>}
</div>
<div className="field-row">
<div>
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
<div>
<label className="field-label"><span className="opt">()</span></label>
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
</div>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="pf-upload-row">
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
{imagePreview ? (
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
) : (
<>
<div className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</div>
<div className="uz-t"><strong></strong></div>
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
</>
)}
</div>
<div className="pf-example">
<div className="ex-h"></div>
<div className="ex-grid">
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
</div>
<div className="ex-d"></div>
</div>
</div>
<div className="pf-grid" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<ul className="bullet-list">
{bullets.map((bullet, index) => (
<li className="bl-item" key={`${bullet}-${index}`}>
<span className="num">{index + 1}</span>
<span className="bl-text">{bullet}</span>
<button className="bl-x" type="button" onClick={() => removeBullet(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={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={addBullet} placeholder="添加新卖点 · 回车确认" />
</li>
</ul>
</div>
</div>
</div>
{showGuide && (
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 13, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
<strong>// 建好商品的 3 步</strong><br />
+ (,/)<br />
(800×800 ),便 AI <br />
24 ,
</div>
)}
<div className="drawer-f">
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
使
</button>
<button className="btn" type="button" onClick={() => setDrawer(false)}></button>
<button className="btn btn-primary" type="button" onClick={submit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
</button>
</div>
</aside>
</>,
document.body,
)}
</section> </section>
); );
} }
+16 -96
View File
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react"; import type { CSSProperties, FormEvent } from "react";
import type { Asset, Product, Project } from "../types"; import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config"; 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 { Pager } from "../components/pager";
import "../project-wizard-page.css"; import "../project-wizard-page.css";
@@ -18,8 +19,6 @@ const WIZ_DURATIONS = [
]; ];
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品 const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
// 新建商品抽屉品类选项(与 products.tsx 一致;本组件自洽,不依赖 products.tsx)
const WIZ_PC_CATS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
type WizProductPayload = { type WizProductPayload = {
title: string; title: string;
@@ -28,15 +27,17 @@ type WizProductPayload = {
selling_points?: Array<{ title: string; detail: string; sort_order: number }>; 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[]; products: Product[];
projects?: Project[]; projects?: Project[];
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。 // 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
preselectProductId?: string; preselectProductId?: string;
onBack: () => void; onBack: () => void;
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void; onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
// 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。 // 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void; onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
}) { }) {
// 预选 id 命中商品库时用它,否则默认第一个商品 // 预选 id 命中商品库时用它,否则默认第一个商品
const initialProductId = const initialProductId =
@@ -56,15 +57,8 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
const [pickView, setPickView] = useState<"grid" | "list">("grid"); const [pickView, setPickView] = useState<"grid" | "list">("grid");
const [pickPage, setPickPage] = useState(1); const [pickPage, setPickPage] = useState(1);
// 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 products.tsx) // 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程)
const [npOpen, setNpOpen] = useState(false); 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<string[]>([]);
const [npDraft, setNpDraft] = useState("");
const [npTitleError, setNpTitleError] = useState(false);
const [npSaving, setNpSaving] = useState(false);
// Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手) // Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手)
const [duration, setDuration] = useState<string | null>("0-15"); const [duration, setDuration] = useState<string | null>("0-15");
@@ -131,44 +125,9 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
// 点击「创建新商品」:有 onCreateProduct 接线则开右侧抽屉,否则回落到旧行为(返回去商品库) // 点击「创建新商品」:有 onCreateProduct 接线则开右侧抽屉,否则回落到旧行为(返回去商品库)
function openCreateProduct() { function openCreateProduct() {
if (!onCreateProduct) { onBack(); return; } if (!onCreateProduct) { onBack(); return; }
setNpTitle("");
setNpCat(WIZ_PC_CATS[0]);
setNpAudience("");
setNpPoints([]);
setNpDraft("");
setNpTitleError(false);
setNpOpen(true); 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 durObj = WIZ_DURATIONS.find((d) => d.id === duration);
const product1Done = !!productId; const product1Done = !!productId;
@@ -386,56 +345,17 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
</div> </div>
</form> </form>
{/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */} {/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
<Drawer title="新建商品" open={npOpen} close={() => setNpOpen(false)}> {onCreateProduct && (
<div className="field"> <ProductCreateDrawer
<label className="field-label"><span className="req">*</span></label> open={npOpen}
<input close={() => setNpOpen(false)}
className={`input${npTitleError ? " error" : ""}`} onCreate={onCreateProduct}
value={npTitle} onUploadImage={onUploadImage}
placeholder="例:南卡 Lite Pro 蓝牙耳机" // 新建成功 → 自动选中该商品进入第 1 步选中态
onChange={(event) => { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }} onCreated={(created) => setProductId(created.id)}
/> />
{npTitleError && <div className="field-hint err">// 商品名称不能为空</div>}
</div>
<div className="field">
<label className="field-label"></label>
<select className="select" value={npCat} onChange={(event) => setNpCat(event.target.value)}>
{WIZ_PC_CATS.map((c) => <option value={c} key={c}>{c}</option>)}
</select>
</div>
<div className="field">
<label className="field-label"></label>
<input className="input" value={npAudience} placeholder="例:25-30 岁都市白领" onChange={(event) => setNpAudience(event.target.value)} />
</div>
<div className="field">
<label className="field-label"></label>
<div className="np-point-add">
<input
className="input"
value={npDraft}
placeholder="输入一个卖点后回车添加"
onChange={(event) => setNpDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }}
/>
<button className="btn btn-sm" type="button" onClick={addNpPoint}></button>
</div>
{npPoints.length > 0 && (
<div className="theme-pill-row" style={{ marginTop: 10 }}>
{npPoints.map((p) => (
<button className="theme-pill active" type="button" key={p} onClick={() => setNpPoints((prev) => prev.filter((x) => x !== p))}>
<span>{p}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
))}
</div>
)} )}
</div>
<div className="np-drawer-foot">
<button className="btn" type="button" onClick={() => setNpOpen(false)}></button>
<button className="btn btn-primary" type="button" disabled={npSaving} onClick={submitNewProduct}>{npSaving ? "创建中…" : "创建商品"}</button>
</div>
</Drawer>
</section> </section>
); );
} }
+30 -35
View File
@@ -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", "base": "http://127.0.0.1:5174",
"apiChecks": [ "apiChecks": [
{ {
"name": "page_size 生效 /api/assets/", "name": "page_size 生效 /api/assets/",
"pass": true, "pass": true,
"detail": "count=239 返回=200 next=有" "detail": "count=248 返回=200 next=有"
}, },
{ {
"name": "page_size 生效 /api/products/", "name": "page_size 生效 /api/products/",
@@ -21,25 +21,25 @@
"name": "延迟 /api/assets/?page_size=200", "name": "延迟 /api/assets/?page_size=200",
"pass": true, "pass": true,
"soft": true, "soft": true,
"detail": "720ms / 参考 1200ms (HTTP 200)" "detail": "739ms / 参考 1200ms (HTTP 200)"
}, },
{ {
"name": "延迟 /api/products/?page_size=200", "name": "延迟 /api/products/?page_size=200",
"pass": true, "pass": true,
"soft": true, "soft": true,
"detail": "597ms / 参考 1200ms (HTTP 200)" "detail": "573ms / 参考 1200ms (HTTP 200)"
}, },
{ {
"name": "延迟 /api/projects/?page_size=200", "name": "延迟 /api/projects/?page_size=200",
"pass": false, "pass": true,
"soft": true, "soft": true,
"detail": "2004ms / 参考 1200ms (HTTP 200)" "detail": "482ms / 参考 1200ms (HTTP 200)"
}, },
{ {
"name": "延迟 /api/ops/notifications/?page_size=100", "name": "延迟 /api/ops/notifications/?page_size=100",
"pass": false, "pass": false,
"soft": true, "soft": true,
"detail": "1228ms / 参考 1200ms (HTTP 200)" "detail": "1389ms / 参考 1200ms (HTTP 200)"
} }
], ],
"pageFailed": 0, "pageFailed": 0,
@@ -48,7 +48,7 @@
"name": "dashboard", "name": "dashboard",
"route": "http://127.0.0.1:5174/dashboard", "route": "http://127.0.0.1:5174/dashboard",
"unique": 6, "unique": 6,
"budget": 14, "budget": 7,
"rawRequests": 7, "rawRequests": 7,
"waterfall": 0, "waterfall": 0,
"duplicates": [], "duplicates": [],
@@ -62,7 +62,7 @@
"GET /api/ops/notifications/?page_size=1", "GET /api/ops/notifications/?page_size=1",
"GET /api/assets/summary/" "GET /api/assets/summary/"
], ],
"elapsedMs": 5557, "elapsedMs": 5108,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true
@@ -84,7 +84,7 @@
"GET /api/ai/models/", "GET /api/ai/models/",
"GET /api/ops/notifications/?page_size=1" "GET /api/ops/notifications/?page_size=1"
], ],
"elapsedMs": 3488, "elapsedMs": 3716,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true
@@ -106,7 +106,7 @@
"GET /api/ai/models/", "GET /api/ai/models/",
"GET /api/ops/notifications/?page_size=1" "GET /api/ops/notifications/?page_size=1"
], ],
"elapsedMs": 3502, "elapsedMs": 3704,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true
@@ -115,7 +115,7 @@
"name": "library", "name": "library",
"route": "http://127.0.0.1:5174/library", "route": "http://127.0.0.1:5174/library",
"unique": 8, "unique": 8,
"budget": 8, "budget": 9,
"rawRequests": 10, "rawRequests": 10,
"waterfall": 0, "waterfall": 0,
"duplicates": [ "duplicates": [
@@ -136,7 +136,7 @@
"GET /api/assets/summary/", "GET /api/assets/summary/",
"GET /api/assets/facets/?meta_keys=gender,age,role&tab=people" "GET /api/assets/facets/?meta_keys=gender,age,role&tab=people"
], ],
"elapsedMs": 5728, "elapsedMs": 4733,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true
@@ -145,7 +145,7 @@
"name": "account", "name": "account",
"route": "http://127.0.0.1:5174/account", "route": "http://127.0.0.1:5174/account",
"unique": 8, "unique": 8,
"budget": 8, "budget": 9,
"rawRequests": 9, "rawRequests": 9,
"waterfall": 0, "waterfall": 0,
"duplicates": [], "duplicates": [],
@@ -161,7 +161,7 @@
"GET /api/auth/team/members/", "GET /api/auth/team/members/",
"GET /api/billing/ledgers/?page=1&page_size=10" "GET /api/billing/ledgers/?page=1&page_size=10"
], ],
"elapsedMs": 5743, "elapsedMs": 4773,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true
@@ -170,7 +170,7 @@
"name": "team", "name": "team",
"route": "http://127.0.0.1:5174/team", "route": "http://127.0.0.1:5174/team",
"unique": 6, "unique": 6,
"budget": 4, "budget": 7,
"rawRequests": 8, "rawRequests": 8,
"waterfall": 0, "waterfall": 0,
"duplicates": [], "duplicates": [],
@@ -185,18 +185,16 @@
"GET /api/auth/team/members/", "GET /api/auth/team/members/",
"GET /api/ops/notifications/?page_size=100" "GET /api/ops/notifications/?page_size=100"
], ],
"elapsedMs": 5500, "elapsedMs": 5399,
"problems": [], "problems": [],
"warnings": [ "warnings": [],
"首屏接口数 6 > 预算 4(架构性过量拉取,待决策)"
],
"pass": true "pass": true
}, },
{ {
"name": "messages", "name": "messages",
"route": "http://127.0.0.1:5174/messages", "route": "http://127.0.0.1:5174/messages",
"unique": 5, "unique": 5,
"budget": 4, "budget": 6,
"rawRequests": 7, "rawRequests": 7,
"waterfall": 0, "waterfall": 0,
"duplicates": [], "duplicates": [],
@@ -210,18 +208,16 @@
"GET /api/ops/notifications/?page_size=1", "GET /api/ops/notifications/?page_size=1",
"GET /api/ops/notifications/?page=1&page_size=10" "GET /api/ops/notifications/?page=1&page_size=10"
], ],
"elapsedMs": 3485, "elapsedMs": 3717,
"problems": [], "problems": [],
"warnings": [ "warnings": [],
"首屏接口数 5 > 预算 4(架构性过量拉取,待决策)"
],
"pass": true "pass": true
}, },
{ {
"name": "productDetail", "name": "productDetail",
"route": "http://127.0.0.1:5174/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f", "route": "http://127.0.0.1:5174/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f",
"unique": 6, "unique": 6,
"budget": 8, "budget": 7,
"rawRequests": 7, "rawRequests": 7,
"waterfall": 0, "waterfall": 0,
"duplicates": [], "duplicates": [],
@@ -235,31 +231,30 @@
"GET /api/ops/notifications/?page_size=1", "GET /api/ops/notifications/?page_size=1",
"GET /api/assets/?page_size=200&product=045ad8d6-8b15-486b-a4a9-f6968eb1555f" "GET /api/assets/?page_size=200&product=045ad8d6-8b15-486b-a4a9-f6968eb1555f"
], ],
"elapsedMs": 5755, "elapsedMs": 4757,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true
}, },
{ {
"name": "pipeline", "name": "pipeline",
"route": "http://127.0.0.1:5174/pipeline/1048451e-3b4a-4b66-a8f4-cb865096de2f", "route": "http://127.0.0.1:5174/pipeline/46754596-a6f5-4f07-a85f-92a2415910ff",
"unique": 7, "unique": 6,
"budget": 10, "budget": 8,
"rawRequests": 8, "rawRequests": 7,
"waterfall": 0, "waterfall": 0,
"duplicates": [], "duplicates": [],
"failed": [], "failed": [],
"breakdown": [ "breakdown": [
"GET /api/auth/me/", "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/products/",
"GET /api/projects/", "GET /api/projects/",
"GET /api/billing/summary/", "GET /api/billing/summary/",
"GET /api/ai/models/", "GET /api/ai/models/",
"GET /api/ops/notifications/?page_size=1", "GET /api/ops/notifications/?page_size=1"
"GET /api/projects/1048451e-3b4a-4b66-a8f4-cb865096de2f/pending-assets/"
], ],
"elapsedMs": 6015, "elapsedMs": 3725,
"problems": [], "problems": [],
"warnings": [], "warnings": [],
"pass": true "pass": true