feat(core/frontend): P1 pixel restoration (settings/messages/wizard/product-create faithful; ai-tools draft)

- settings.tsx + settings-page.css: restore to settings.html (left-nav + sections), real user/team data
- messages.tsx + messages-page.css: rich inbox restore (filters/detail/props grid) on real notifications
- projects.tsx ProjectWizardPage + project-wizard-page.css: restore to projects-new.html
- products.tsx ProductCreateUploadPage + product-create-page.css: restore to product-create-v2 baseline
- ai-tools.tsx (AssetFactory/ImageWorkbench) + ai-tools-page.css: DRAFT unified studio shell;
  deviates from per-page baselines (image-optimize should be chat-stream; model-photo product+person picker)
  -> pending rework alongside P3 standalone image-gen decision
- shot-p1.mjs: playwright visual-parity capture (react vs exact baseline; uses 127.0.0.1 not localhost)
- verified: tsc --noEmit clean; screenshots confirm settings/wizard/product-create/messages faithful

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-05 14:25:19 +08:00
co-authored by Claude Opus 4.8
parent 25bf3293df
commit 78fd7ee13d
12 changed files with 3316 additions and 101 deletions
+321 -12
View File
@@ -1,9 +1,33 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import "../project-wizard-page.css";
// 时长 / 脚本风格 / 人设 — 与 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" }
];
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
export function ProjectWizardPage({ products, onBack, onCreate }: {
products: Product[];
@@ -12,26 +36,311 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
}) {
const [productId, setProductId] = useState(products[0]?.id || "");
const product = products.find((item) => item.id === productId) || products[0];
const [name, setName] = useState(() => `${products[0]?.title || "商品"} · 短视频 · ${new Date().toLocaleDateString("zh-CN")}`);
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
// Step 1 · 商品选择器本地交互态
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
const [catOpen, setCatOpen] = useState(false);
const [pickView, setPickView] = useState<"grid" | "list">("grid");
const [pickPage, setPickPage] = useState(1);
// Step 2 · 配置(视觉选择,详见 Stage 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]);
function submit(event: FormEvent) {
event.preventDefault();
if (product) void onCreate({ name: name || `${product.title} · 短视频`, product: product.id });
// 分类清单
const cats = useMemo(
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
[products]
);
// 筛选 + 排序后的商品
const filtered = useMemo(() => {
const q = pickSearch.trim().toLowerCase();
return products.filter((p) => {
const cat = p.category || "未分类";
if (pickCat !== "全部" && cat !== pickCat) return false;
if (q) {
const blob = `${p.title} ${cat} ${(p.selling_points || []).map((s) => s.title).join(" ")}`.toLowerCase();
if (!blob.includes(q)) return false;
}
return true;
});
}, [products, pickSearch, pickCat]);
const hasFilter = !!pickSearch || pickCat !== "全部";
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / WIZ_PAGE_SIZE));
const cur = Math.min(pickPage, totalPages);
const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE);
function selectProduct(id: string) {
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() {
setPickSearch("");
setPickCat("全部");
setPickPage(1);
}
function applyPreset() {
const p = WIZ_PERSONAS.find((item) => item.id === persona);
if (!p) return;
setDuration(p.dur);
setScriptStyle(p.style);
setRecoDismissed(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;
const canStart = product1Done && config2Done;
function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product) return;
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
}
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;
};
return (
<>
<div className="page-head"><div><h1>新建视频项目</h1><div className="sub"><span className="mono">// 选择商品 → 创建项目 → 进入 Stage 1 脚本</span></div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}>退出</button></div></div>
<section className="project-wizard-page">
<div className="page-head">
<div>
<h1>新建项目</h1>
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
</div>
<div className="actions">
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
</div>
</div>
<form className="wizard" onSubmit={submit}>
<nav className="steps" aria-label="新建项目步骤"><div className="step active"><div className="num">1</div><div><div className="label">选择商品</div><div className="desc">{product?.title || "未选择"}</div></div></div><div className="step"><div className="num">2</div><div><div className="label">项目名称</div><div className="desc">自动生成可修改</div></div></div><div className="step"><div className="num">3</div><div><div className="label">进入脚本</div><div className="desc">Stage 1 配置脚本</div></div></div></nav>
<div><section className="wiz-pane active"><div className="wiz-step-h"><h2>为哪个商品创建视频?</h2><p>按定稿,创建页只绑定商品和项目名称,脚本风格、卖点重点放到 Stage 1。</p></div><div className="product-select-grid">{products.map((item) => <button className={`product-pick ${productId === item.id ? "selected" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title}</span></div><strong>{item.title}</strong><span>{item.category || "未分类"} · {item.selling_points.length} 个卖点</span></button>)}</div>{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}<div className="field" style={{ marginTop: 18 }}><label className="field-label">项目名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="wiz-foot"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回</button><div className="hstack"><span className="muted-2 mono">// 下一步:流水线 Stage 1</span><button className="btn btn-primary btn-lg" type="submit" disabled={!product}><ArrowRight size={13} />创建并进入脚本</button></div></div></section></div>
<aside className="wiz-preview"><div className="pv-h"><span>实时预估</span><span className="live">LIVE</span></div><div className="pv-title">{name || product?.title || "未命名项目"}</div><div className="pv-metrics"><div className="pv-metric"><div className="l">片段</div><div className="v">4<small>段</small></div></div><div className="pv-metric accent"><div className="l">总时长</div><div className="v">60<small>s</small></div></div><div className="pv-metric"><div className="l">阶段</div><div className="v">5</div></div></div></aside>
{/* ── 左侧步骤轨 ── */}
<nav className="steps" aria-label="新建项目步骤">
<div className={`step ${product1Done ? "done" : "active"}`}>
<div className="num">{product1Done ? (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
) : "1"}</div>
<div>
<div className="label">选择商品</div>
<div className="desc">{product?.title || "未选择"}</div>
</div>
</div>
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
<div className="num">{config2Done ? (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
) : "2"}</div>
<div>
<div className="label">项目配置</div>
<div className="desc">{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}</div>
</div>
</div>
</nav>
{/* ── 主体 ── */}
<div className="wiz-body">
{/* Step 1 · 商品选择 */}
<section className="step-pane-wrap">
<div className="wiz-pane">
<div className="wiz-step-h">
<h2>第 1 步 · 选择商品</h2>
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
</div>
<div className="pp-toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
</div>
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="pp-menu">
{cats.map((c) => (
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
<span>{c}</span>
</div>
))}
</div>
</div>
{hasFilter && (
<button className="pp-clear" type="button" onClick={clearPickFilters}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
清空筛选
</button>
)}
</div>
<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="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>
</div>
{total === 0 ? (
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
) : (
pageList.map((p) => (
<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>
<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>
{total > WIZ_PAGE_SIZE && (
<div className="pp-pager">
<span className="total">共 {total} 条</span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}>‹</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
))}
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}>›</button>
</div>
<span className="page-size">每页 {WIZ_PAGE_SIZE} 条</span>
</div>
)}
<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>
</div>
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}
</div>
</section>
{/* Step 2 · 项目配置 */}
<section className="step-pane-wrap">
<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)} />
</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>
</div>
<div className="field">
<label className="field-label">脚本风格</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>}
</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>
<div className="theme-pill-row">
{Object.entries(points).map(([k, v]) => (
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
{v && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>}
<span>{k}</span>
</button>
))}
</div>
</div>
)}
</div>
</section>
{/* ── 底部「开始」CTA ── */}
<div className="wiz-start-bar">
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
<span>开始</span>
</button>
</div>
</div>
</form>
</>
</section>
);
}