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
+18 -98
View File
@@ -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<string, unknown> }) => Promise<unknown> | void;
// 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | 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<string[]>([]);
const [npDraft, setNpDraft] = useState("");
const [npTitleError, setNpTitleError] = useState(false);
const [npSaving, setNpSaving] = useState(false);
// Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手)
const [duration, setDuration] = useState<string | null>("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,
</div>
</form>
{/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */}
<Drawer title="新建商品" open={npOpen} close={() => setNpOpen(false)}>
<div className="field">
<label className="field-label">商品名称<span className="req">*</span></label>
<input
className={`input${npTitleError ? " error" : ""}`}
value={npTitle}
placeholder="例:南卡 Lite Pro 蓝牙耳机"
onChange={(event) => { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }}
/>
{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>
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
{onCreateProduct && (
<ProductCreateDrawer
open={npOpen}
close={() => setNpOpen(false)}
onCreate={onCreateProduct}
onUploadImage={onUploadImage}
// 新建成功 → 自动选中该商品进入第 1 步选中态
onCreated={(created) => setProductId(created.id)}
/>
)}
</section>
);
}