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
+539 -25
View File
@@ -1,52 +1,566 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, Check, Grid2X2, List, RefreshCw, Search, WandSparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
ArrowRight,
Check,
Download,
Grid2X2,
List,
MoreHorizontal,
Quote,
RefreshCw,
Search,
WandSparkles
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
const TASK_TYPE_LABEL: Record<string, string> = {
model: "模特上身图",
platform: "平台套图",
image: "图片创作",
model_photo: "模特上身图",
platform_cover: "平台套图",
image_optimize: "图片创作"
};
const STATUS_LABEL: Record<string, string> = {
succeeded: "已完成",
completed: "已完成",
done: "已完成",
ok: "已完成",
skipped: "已跳过",
failed: "失败",
error: "失败",
running: "生成中",
queued: "排队中",
polling: "生成中",
needs_review: "待确认"
};
function statusText(status: string) {
return STATUS_LABEL[status] || status;
}
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
const cards = [
{ page: "modelPhoto" as Page, tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", cost: "≈ ¥0.30 / 张" },
{ page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", cost: "≈ ¥0.50 / 张" },
{ page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", cost: "≈ ¥0.40 / 组" }
{
page: "modelPhoto" as Page,
tag: "[ MODEL · TRY-ON ]",
title: "模特上身图",
desc: "选择模特,AI 生成商品模特上身效果图",
cost: "≈ ¥0.30 / 张"
},
{
page: "platformCover" as Page,
tag: "[ PLATFORM · KIT ]",
title: "平台套图",
desc: "选择平台模板,AI 生成电商平台套图",
cost: "≈ ¥0.50 / 张"
},
{
page: "imageOptimize" as Page,
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
cost: "≈ ¥0.40 / 组"
}
];
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const task of aiTasks) {
const pill = statusPill(task.status);
if (pill === "ok") acc.ok += 1;
else if (pill === "err") acc.err += 1;
else if (pill === "info") acc.gen += 1;
}
return acc;
}, [aiTasks]);
const visible = aiTasks.slice(0, 8);
return (
<>
<div className="page-head"><div><h1>图片生成</h1><div className="sub"><span className="mono">// 一键生成</span><span>·</span><span>电商视觉素材,提升内容制作效率</span></div></div></div>
<div className="factory-hero">{cards.map((card) => <article className="factory-card with-corners" key={card.title}><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="factory-body"><div className="factory-text"><span className="factory-tag">{card.tag}</span><div className="factory-title">{card.title}</div><div className="factory-desc">{card.desc}</div><div className="factory-cta"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>开始生成<ArrowRight size={13} /></button><span className="cost">[ {card.cost} ]</span></div></div><div className="factory-visual"><div className="placeholder main"><span className="ph-frame">{card.title}</span></div></div></div></article>)}</div>
<div className="section-h"><h2>任务中心</h2><span className="more">// {aiTasks.length} 个真实 AI 任务</span></div>
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索任务名" /></div><span className="spacer" /><div className="view-toggle"><button type="button" className="active"><List size={13} />列表</button><button type="button"><Grid2X2 size={13} />网格</button></div></div>
<table className="t"><thead><tr><th>任务</th><th>类型</th><th>状态</th><th /></tr></thead><tbody>{aiTasks.slice(0, 8).map((task) => <tr key={task.id}><td>{task.id.slice(0, 8)}</td><td>{task.task_type}</td><td><span className={`pill ${statusPill(task.status)}`}><span className="dot" />{task.status}</span></td><td /></tr>)}</tbody></table>
</>
<div className="asset-factory">
<div className="page-head">
<div>
<h1>图片生成</h1>
<div className="sub">
<span className="mono">// 一键生成</span>
<span>·</span>
<span>电商视觉素材,提升内容制作效率</span>
</div>
</div>
</div>
<div className="factory-hero">
{cards.map((card) => (
<article className="factory-card with-corners" key={card.title}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="factory-body">
<div className="factory-text">
<span className="factory-tag">{card.tag}</span>
<div className="factory-title">{card.title}</div>
<div className="factory-desc">{card.desc}</div>
</div>
<div className="factory-cta">
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
开始生成
<ArrowRight size={13} />
</button>
<span className="cost">[ {card.cost} ]</span>
</div>
</div>
</article>
))}
</div>
<div className="section-h">
<h2>任务中心</h2>
<span className="sub-mono">
// {aiTasks.length} 个 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
</span>
</div>
<div className="toolbar">
<div className="search-inline">
<Search size={14} />
<input className="input" placeholder="搜索任务名" />
</div>
<span className="spacer" />
<div className="view-toggle">
<button type="button" className="active">
<List size={13} />
列表
</button>
<button type="button">
<Grid2X2 size={13} />
网格
</button>
</div>
</div>
<div className="result-meta">
// 显示 {visible.length} / {aiTasks.length} 个任务
</div>
{aiTasks.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO TASKS YET</div>
<div>还没有任务,去上方选一个工序开始生成吧</div>
</div>
) : (
<div className="task-list-view">
<table className="t">
<thead>
<tr>
<th style={{ width: "42%" }}>任务</th>
<th style={{ width: 160 }}>进度</th>
<th>状态</th>
<th style={{ width: 140 }}>任务 ID</th>
</tr>
</thead>
<tbody>
{visible.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
return (
<tr key={task.id}>
<td>
<div className="task-name-cell">
<div className="placeholder task-thumb">
<span className="ph-frame">{task.id.slice(0, 4)}</span>
</div>
<div>
<div className="task-name">{typeLabel}</div>
<div className="task-sub">// {task.task_type}</div>
</div>
</div>
</td>
<td>
{pill === "info" ? (
<div className="task-list-prog">
<div className="bar">
<span style={{ width: "60%" }} />
</div>
<span className="pct">60%</span>
</div>
) : (
<span className="muted-2 mono" style={{ fontSize: 11 }}>
{pill === "ok" ? "已完成" : "—"}
</span>
)}
</td>
<td>
<span className={`pill ${pill}`}>
<span className="dot" />
{statusText(task.status)}
</span>
</td>
<td className="muted-2">{task.id.slice(0, 8)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export function ImageWorkbenchPage({ mode, products, assets, modelConfigs, onBack, navigate }: {
mode: "image" | "model" | "cover";
type WorkMode = "image" | "model" | "cover";
const MODE_META: Record<
WorkMode,
{
title: string;
tag: string;
desc: string;
ratio: string;
ratioVar: string;
pickStep?: { num: string; title: string; sub: string; kind: "model" | "platform" };
promptTemplate: (productTitle: string) => string;
}
> = {
image: {
title: "图片创作",
tag: "[ IMAGE · STUDIO ]",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。",
ratio: "1:1",
ratioVar: "1 / 1",
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
},
model: {
title: "模特上身图",
tag: "[ MODEL · TRY-ON ]",
desc: "选择模特和商品,生成电商模特上身图。",
ratio: "3:4",
ratioVar: "3 / 4",
pickStep: { num: "2", title: "选择模特", sub: "// 可多选 · 一次生成多套", kind: "model" },
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。",
ratio: "4:5",
ratioVar: "4 / 5",
pickStep: { num: "2", title: "选择平台", sub: "// 多选平台 · 自动套版", kind: "platform" },
promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版`
}
};
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
const COUNT_OPTIONS = ["1", "2", "4"];
const PLATFORM_OPTIONS = [
{ id: "tb", name: "淘宝" },
{ id: "dy", name: "抖音" },
{ id: "xhs", name: "小红书" },
{ id: "pdd", name: "拼多多" },
{ id: "jd", name: "京东" },
{ id: "ks", name: "快手" }
];
export function ImageWorkbenchPage({
mode,
products,
assets,
modelConfigs,
onBack,
navigate
}: {
mode: WorkMode;
products: Product[];
assets: Asset[];
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
}) {
const meta = {
image: { title: "图片创作", tag: "[ IMAGE · STUDIO ]", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。", ratio: "1:1" },
model: { title: "模特上身图", tag: "[ MODEL · TRY-ON ]", desc: "选择模特和商品,生成电商模特上身图。", ratio: "3:4" },
cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", ratio: "4:5" }
}[mode];
const meta = MODE_META[mode];
const [productId, setProductId] = useState(products[0]?.id || "");
const [prompt, setPrompt] = useState(`${products[0]?.title || "商品"},电商高转化视觉,干净背景,商品主体清晰`);
const product = products.find((item) => item.id === productId) || products[0];
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
const [count, setCount] = useState("4");
const [pickedIds, setPickedIds] = useState<string[]>([]);
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
const modelOptions = useMemo(() => modelConfigs.slice(0, 6), [modelConfigs]);
useEffect(() => {
if (product) setPrompt(`${product.title},电商高转化视觉,干净背景,商品主体清晰`);
}, [productId]);
if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置 prompt 与默认比例
setRatio(meta.ratio);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
function togglePick(id: string) {
setPickedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
}
const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4);
return (
<div className="tool-shell">
<div className="tool-topbar"><button className="back-pill" type="button" onClick={onBack}><ArrowLeft size={14} />返回</button><div><h1>{meta.title}</h1><div className="sub"><span className="mono">{meta.tag}</span> {meta.desc}</div></div><span className="spacer" />{mode === "model" && navigate && <button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}>方案 A</button>}<button className="btn btn-primary" type="button"><WandSparkles size={13} />生成图片</button></div>
<div className="tool-layout"><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 8).map((item) => <button className={`rail-product ${productId === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title.slice(0, 4)}</span></div><span>{item.title}</span></button>)}</aside><section className="tool-canvas"><div className="tool-toolbar"><button className="chip active">全部</button><span className="spacer" /><span className="mono muted-2">{imageModels[0]?.display_name || "Volcano Image"}</span></div><div className="result-board">{Array.from({ length: 4 }).map((_, index) => <button className="result-tile" type="button" key={index}><div className="placeholder"><span className="ph-frame">{meta.ratio} · v{index + 1}</span></div><div className="result-meta-row"><span>候选 {index + 1}</span><span className="pill info"><span className="dot" />待采用</span></div></button>)}</div><div className="candidate-actions"><button className="btn" type="button"><RefreshCw size={13} />重跑</button><button className="btn btn-primary" type="button"><Check size={13} />采用并入资产库</button></div></section><aside className="tool-side"><div className="pane"><h3>生成参数</h3><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /></div><div className="pane"><h3>资产库</h3><div className="asset-mini-grid">{assets.slice(0, 6).map((asset) => <div className="placeholder" key={asset.id}><span className="ph-frame">{asset.asset_type}</span></div>)}</div></div></aside></div>
<div className="image-workbench">
{/* 顶栏 · 返回 + mode 标题 + 主操作 */}
<div className="iw-topbar">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回
</button>
<div className="iw-title">
<h1>{meta.title}</h1>
<div className="sub">
<span className="mono">{meta.tag}</span>
<span>{meta.desc}</span>
</div>
</div>
<span className="spacer" />
{mode === "model" && navigate && (
<button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}>
方案 A
</button>
)}
<button className="btn btn-primary" type="button">
<WandSparkles size={13} />
生成图片
</button>
</div>
<div className="iw-layout">
{/* 最左 · 商品空间 */}
<aside className="iw-prod-space">
<div className="iw-ps-h">
<span className="mono">商品空间</span>
</div>
<div className="iw-ps-search">
<Search size={13} />
<input placeholder="搜索商品" />
</div>
<div className="iw-ps-list">
{products.length === 0 ? (
<div className="iw-ps-empty">
还没有商品
<br />
// NO PRODUCTS
</div>
) : (
products.slice(0, 12).map((item) => (
<button
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
type="button"
key={item.id}
onClick={() => setProductId(item.id)}
>
<div className="placeholder thumb">
<span className="ph-frame">{item.title.slice(0, 2)}</span>
</div>
<div className="body">
<div className="nm">{item.title}</div>
<div className="sub">// {item.category || "未分类"}</div>
</div>
</button>
))
)}
</div>
</aside>
{/* 中 · 参数表单 */}
<section className="iw-form">
<div className="iw-step">
<div className="iw-step-h">
<span className="num">1</span>
<span className="title">商品与提示词</span>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 当前商品</div>
<div className="field" style={{ marginBottom: 0 }}>
<div className="iw-pv-line" style={{ fontSize: 13, color: "var(--accent-black)" }}>
{product?.title || "未选择商品"}
</div>
</div>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 提示词</div>
<textarea
className="textarea"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="描述你想要的画面…"
/>
</div>
</div>
{meta.pickStep && (
<div className="iw-step">
<div className="iw-step-h">
<span className="num">{meta.pickStep.num}</span>
<span className="title">{meta.pickStep.title}</span>
</div>
<div className="iw-sub-h">{meta.pickStep.sub}</div>
{meta.pickStep.kind === "model" ? (
<div className="iw-pick-grid">
{(modelOptions.length ? modelOptions : products.slice(0, 4)).map((item) => {
const id = "id" in item ? item.id : "";
const label = "display_name" in item ? item.display_name : (item as Product).title;
return (
<button
type="button"
key={id}
className={`iw-pick-card ${pickedIds.includes(id) ? "selected" : ""}`}
onClick={() => togglePick(id)}
>
<div className="placeholder m-thumb">
<span className="ph-frame">3:4</span>
</div>
<div className="m-name">{label}</div>
<div className="m-meta">// 模特</div>
<span className="m-check">
<Check size={11} />
</span>
</button>
);
})}
</div>
) : (
<div className="iw-pick-grid platforms">
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`iw-pick-card platforms-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id)}
>
<div className="m-name">{item.name}</div>
<span className="m-check">
<Check size={11} />
</span>
</button>
))}
</div>
)}
</div>
)}
<div className="iw-step">
<div className="iw-step-h">
<span className="num">{meta.pickStep ? "3" : "2"}</span>
<span className="title">生成参数</span>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 比例</div>
<div className="pill-row">
{RATIO_OPTIONS.map((value) => (
<button
type="button"
key={value}
className={`opt ${ratio === value ? "active" : ""}`}
onClick={() => setRatio(value)}
>
{value}
</button>
))}
</div>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 张数</div>
<div className="pill-row">
{COUNT_OPTIONS.map((value) => (
<button
type="button"
key={value}
className={`opt ${count === value ? "active" : ""}`}
onClick={() => setCount(value)}
>
{value}
</button>
))}
</div>
</div>
</div>
<div className="iw-cta">
<button className="btn btn-primary" type="button">
<WandSparkles size={13} />
立即生成
</button>
<div className="iw-cta-hint">
// {imageModels[0]?.display_name || "Volcano Image"} · 预估 {meta.title}
</div>
</div>
</section>
{/* 右 · 结果预览 */}
<section className="iw-preview">
<div className="iw-pv-h">
<Quote className="quote-icon" />
<div className="pv-meta">
<b>{ratio}</b> · {count} 张 · {imageModels[0]?.display_name || "Volcano Image"}
</div>
<div className="pv-line">{prompt}</div>
</div>
<div className="gen-card">
<div className="gen-meta">
<span>Airshelf v2</span>
<span className="m-sep">|</span>
<span>{ratio}</span>
<span className="m-sep">|</span>
<span>{product?.title || meta.title}</span>
</div>
<div className="gen-images" style={{ "--cols": candidateCount >= 4 ? 4 : 2, "--ratio": ratioVar } as React.CSSProperties}>
{Array.from({ length: candidateCount }).map((_, index) => (
<div className="gen-image" key={index}>
<div className="placeholder">
<span className="ph-frame">
{ratio} · #{index + 1}
</span>
</div>
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="重跑">
<RefreshCw size={14} />
</button>
<button className="gen-img-btn" type="button" title="下载">
<Download size={14} />
</button>
<button className="gen-img-btn" type="button" title="更多">
<MoreHorizontal size={14} />
</button>
</div>
</div>
))}
</div>
<div className="gen-card-actions">
<button className="btn btn-sm" type="button">
<RefreshCw size={13} />
重跑
</button>
<button className="btn btn-sm" type="button">
<Check size={13} />
采用并入资产库
</button>
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
</div>
</div>
{assets.length === 0 && (
<div className="iw-pv-empty">
<div className="mono">// NO RESULT YET</div>
<div className="title">还没有生成结果</div>
<div className="hint">
选择商品、填写提示词后点击 <b>立即生成</b>,结果会以候选卡片形式展示在这里。
</div>
</div>
)}
</section>
</div>
</div>
);
}