fix(core): 测试清单 行16-33 — 商品详情/视频项目/新建向导/图片生成 批量修复

商品库+详情(行16-23):AI素材空状态缺省页;三视图16:9+版本历史切换+采用按钮;
详情「生成视频」预勾选商品、快捷生成图带入商品;管理商品编辑态保留底部统计;
删除商品 icon+DELETE ITEM 文案、乐观删除防重复点击;卡片图铺满占位区。

视频项目+新建向导(行24-28):筛选脚本来源按真实数据渲染;新建项目内「创建新商品」
右侧抽屉+创建后自动选中;开始栏 sticky 常驻;默认选中商品即显示卖点、未填完禁用开始;
去掉脚本风格/人物设定区块。

图片生成工作台(行29-33):模特单选;生成中改 spinner 动态图标+按钮间距;多任务并行;
重跑追加批次而非覆盖+更多按钮 hover 气泡菜单+「加入资产库/取消」切换与提示弹窗;
多批次本地持久化恢复。

收尾:App.tsx 接 initialProductId/preselectProductId/onCreateProduct/onAdoptTriView;
全局 toast 自动消失(成功3s/错误5s)修复行17③/22「右上角提示一直挂着」。

tsc -b && vite build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-16 15:10:26 +08:00
co-authored by Claude Opus 4.8
parent 57018fb101
commit f7a403ead3
9 changed files with 733 additions and 241 deletions
+158 -107
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays";
import { Pager } from "../components/pager";
import "../project-wizard-page.css";
@@ -11,34 +11,38 @@ const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
const WIZ_DURATIONS = [
{ id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "黄金完播" },
{ id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "完播率最佳" },
{ id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点详解" },
{ id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化" }
];
const WIZ_STYLES = [
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用" },
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", tag: "" },
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", tag: "" }
];
const WIZ_PERSONAS = [
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", dur: "0-15", style: "pain" },
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", dur: "0-15", style: "pain" },
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", dur: "0-30", style: "pain" },
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", dur: "0-30", style: "review" },
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", dur: "0-30", style: "pain" },
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", dur: "0-10", style: "compare" }
{ id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "快速种草", note: "适合快速种草" },
{ id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "精准短片", note: "适合短平快投放" },
{ id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点展开", note: "适合卖点展开" },
{ id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化", note: "适合故事化表达" }
];
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
// 新建商品抽屉品类选项(与 products.tsx 一致;本组件自洽,不依赖 products.tsx)
const WIZ_PC_CATS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: {
type WizProductPayload = {
title: string;
category: string;
target_audience?: string;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
};
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
products: Product[];
projects?: Project[];
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
preselectProductId?: string;
onBack: () => void;
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
// 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
}) {
const [productId, setProductId] = useState(products[0]?.id || "");
// 预选 id 命中商品库时用它,否则默认第一个商品
const initialProductId =
(preselectProductId && products.some((p) => p.id === preselectProductId) && preselectProductId) ||
products[0]?.id || "";
const [productId, setProductId] = useState(initialProductId);
const product = products.find((item) => item.id === productId) || products[0];
// 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1);
// 用户手动改过名字后不再覆盖。
@@ -52,25 +56,40 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
const [pickView, setPickView] = useState<"grid" | "list">("grid");
const [pickPage, setPickPage] = useState(1);
// Step 2 · 配置(视觉选择,详见 Stage 1)
// 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 products.tsx)
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");
const [scriptStyle, setScriptStyle] = useState<string | null>("pain");
const [persona, setPersona] = useState<string | null>(null);
const [recoDismissed, setRecoDismissed] = useState(false);
const [points, setPoints] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!productId && products[0]) setProductId(products[0].id);
}, [productId, products]);
// 卖点胶囊跟随当前选中商品初始化(默认全不勾选)。初始挂载时(含预选商品)即填充,
// 修复「默认选中商品却不显示卖点,要切走再切回才出现」的初始化时机 bug。
useEffect(() => {
const p = products.find((item) => item.id === productId);
const seed: Record<string, boolean> = {};
(p?.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
setPoints(seed);
}, [productId, products]);
useEffect(() => {
if (nameTouched) return;
const styleName = WIZ_STYLES.find((s) => s.id === scriptStyle)?.name || "痛点种草";
const base = `${(product?.title || "商品").split(" ")[0]} · ${styleName}`;
const base = `${(product?.title || "商品").split(" ")[0]} · 短视频`;
let version = 1;
while (projects.some((p) => p.name === `${base} · v${version}`)) version += 1;
setName(`${base} · v${version}`);
}, [nameTouched, product, scriptStyle, projects]);
}, [nameTouched, product, projects]);
// 分类清单
const cats = useMemo(
@@ -99,13 +118,8 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE);
function selectProduct(id: string) {
// 卖点种子由 productId useEffect 统一初始化,这里只切换选中商品
setProductId(id);
const p = products.find((item) => item.id === id);
if (p) {
const seed: Record<string, boolean> = {};
(p.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
setPoints(seed);
}
}
function clearPickFilters() {
@@ -114,25 +128,52 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
setPickPage(1);
}
function applyPreset() {
const p = WIZ_PERSONAS.find((item) => item.id === persona);
if (!p) return;
setDuration(p.dur);
setScriptStyle(p.style);
setRecoDismissed(false);
// 点击「创建新商品」:有 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 personaObj = WIZ_PERSONAS.find((p) => p.id === persona);
const durObj = WIZ_DURATIONS.find((d) => d.id === duration);
const styleObj = WIZ_STYLES.find((s) => s.id === scriptStyle);
const showReco =
!!personaObj && !!duration && !!scriptStyle && !recoDismissed &&
(personaObj.dur !== duration || personaObj.style !== scriptStyle);
const recoDur = personaObj && WIZ_DURATIONS.find((d) => d.id === personaObj.dur);
const recoStyle = personaObj && WIZ_STYLES.find((s) => s.id === personaObj.style);
const product1Done = !!productId;
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
// 信息没填完(项目名 < 2 字 / 未选时长)时「开始」禁用
const config2Done = !!durObj && name.trim().length >= 2;
const canStart = product1Done && config2Done;
// 提交中状态:创建项目后 App 端全量刷新需数秒,按钮要给「创建中…」反馈,避免用户以为没点上
@@ -140,7 +181,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
async function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product || starting) return;
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
// 向导选项(成片时长档 / 选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
setStarting(true);
try {
@@ -150,8 +191,6 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
metadata: {
wizard: {
duration,
script_style: scriptStyle,
persona,
selling_point_ids: selectedPoints
}
}
@@ -196,7 +235,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
) : "2"}</div>
<div>
<div className="label">项目配置</div>
<div className="desc">{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}</div>
<div className="desc">{durObj ? `${durObj.label} · ${name.trim() || "未命名"}` : "项目名 · 成片时长"}</div>
</div>
</div>
</nav>
@@ -241,7 +280,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
<div className="pp-create-card" onClick={onBack}>
<div className="pp-create-card" onClick={openCreateProduct}>
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
<div className="pc-t">创建新商品</div>
<div className="pc-d">// 在此添加一个新商品</div>
@@ -278,10 +317,10 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
<div className="pp-bottom-tip">
<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="M12 8v5M12 16h.01" /></svg>
<span>找不到想要的商品?可<a onClick={onBack}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
<span>找不到想要的商品?可<a onClick={openCreateProduct}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
</div>
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={openCreateProduct} />}
</div>
</section>
@@ -290,67 +329,28 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
<div className="wiz-pane">
<div className="wiz-step-h">
<h2>第 2 步 · 项目配置</h2>
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
</div>
<div className="config-row">
<div className="field">
<label className="field-label">项目名称<span className="req">*</span></label>
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
<div className="field">
<label className="field-label">视频时长<span className="req">*</span></label>
<select className="select duration-select" value={duration || ""} onChange={(event) => setDuration(event.target.value || null)}>
<option value="" disabled>选择时长</option>
{WIZ_DURATIONS.map((d) => (
<option value={d.id} key={d.id}>{d.label} · {d.shots[0]}-{d.shots[1]} 镜</option>
))}
</select>
</div>
<p>基础配置只保留项目名和成片时长。脚本来源、风格和人物设定已移到流水线第 1 步由脚本助手引导。</p>
</div>
<div className="field">
<label className="field-label">脚本风格</label>
<label className="field-label">项目名称<span className="req">*</span></label>
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
<div className="field">
<label className="field-label">视频时长<span className="req">*</span></label>
<div className="opt-row cols-4">
{WIZ_STYLES.map((s) => (
<div className={`opt-card${scriptStyle === s.id ? " selected" : ""}`} key={s.id} onClick={() => setScriptStyle(s.id)}>
<h4>{s.name}</h4>
<div className="note">{s.note}</div>
{s.tag && <span className="badge">[ {s.tag} ]</span>}
{WIZ_DURATIONS.map((d) => (
<div className={`opt-card${duration === d.id ? " selected" : ""}`} key={d.id} onClick={() => setDuration(d.id)}>
<span className="badge">[ {d.tag} ]</span>
<h4>{d.label}</h4>
<div className="sub">{d.shots[0]}-{d.shots[1]} 镜 · 9:16</div>
<div className="note">{d.note}</div>
</div>
))}
</div>
</div>
<div className="field">
<label className="field-label">人物设定</label>
<div className="opt-row cols-6">
{WIZ_PERSONAS.map((p) => (
<div className={`opt-card${persona === p.id ? " selected" : ""}`} key={p.id} onClick={() => { setPersona(p.id); setRecoDismissed(false); }}>
<h4>{p.name}</h4>
<div className="sub">{p.sub}</div>
<div className="metric"><span className="val">{p.metric}</span></div>
</div>
))}
</div>
{showReco && recoDur && recoStyle && durObj && styleObj && (
<div className="reco-bubble">
<span className="ic">
<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="M12 16v-4M12 8h.01" /></svg>
</span>
<div className="txt">
<span>抖音同人设 TOP 视频更常用 <strong>{recoDur.label}</strong> + <strong>{recoStyle.name}</strong></span>
<span className="meta">当前 {durObj.label} · {styleObj.name} → 推荐换为同人设最优组合</span>
</div>
<button className="btn-apply" type="button" onClick={applyPreset}>一键套用</button>
<button className="dismiss" type="button" onClick={() => setRecoDismissed(true)} 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>
</div>
)}
</div>
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
@@ -376,6 +376,57 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
</div>
</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>
</section>
);
}
@@ -609,7 +660,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</div>
</td>
<td>{productTitle(project.product)}</td>
<td><span className="muted">AI 全生</span></td>
<td><span className="muted">{(project.script_versions?.length || 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
<td>
<div className="hstack">
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>