iye 086d92991e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
统一 Airshelf 界面组件与图标
2026-05-27 12:29:41 +08:00

878 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
/* ============================================================
Data
============================================================ */
interface Product {
id: string;
name: string;
cat: string;
price: number;
imgs: number;
points: string[];
tags: string[];
thumb: string;
}
const PRODUCTS: Product[] = [
{ id: "mask", name: "透真玻尿酸补水面膜", cat: "美妆个护", price: 39.9, imgs: 3, points: ["透明质酸 + B5", "30g 大容量精华", "0 香精 0 酒精"], tags: ["熬夜党", "敏感肌"], thumb: "补水面膜" },
{ id: "earphone",name: "南卡 Lite Pro 蓝牙耳机", cat: "数码 3C", price: 199, imgs: 5, points: ["主动降噪", "32 小时续航", "IP55 防水"], tags: ["通勤", "运动"], thumb: "蓝牙耳机" },
{ id: "noodle", name: "滋啦速食牛肉面 · 6 桶装", cat: "食品饮料", price: 49.9, imgs: 4, points: ["3 分钟出餐", "真材实料牛肉", "0 防腐剂"], tags: ["加班", "独居"], thumb: "速食牛肉面" },
{ id: "sun", name: "透真清透物理防晒霜", cat: "美妆个护", price: 69, imgs: 4, points: ["SPF50 PA+++", "纯物理防晒", "不泛白不假面"], tags: ["SPF50", "通勤"], thumb: "防晒霜" },
{ id: "coffee", name: "三顿半同款冻干咖啡粉", cat: "食品饮料", price: 89, imgs: 6, points: ["冷热水秒溶", "意式深烘", "24 颗轻便装"], tags: ["提神", "早八"], thumb: "咖啡冻干粉" },
{ id: "fryer", name: "小熊 4L 可视空气炸锅", cat: "家电", price: 159, imgs: 5, points: ["可视化窗口", "4L 大容量", "低脂少油"], tags: ["小户型", "健康"], thumb: "空气炸锅" },
{ id: "yoga", name: "露露同款裸感瑜伽裤", cat: "服饰", price: 119, imgs: 8, points: ["裸感面料", "高弹回弹", "随心动随心穿"], tags: ["健身房", "通勤"], thumb: "瑜伽裤" },
];
const RECENT_IDS = ["mask", "sun", "coffee", "earphone"];
const CATS = ["全部", "美妆个护", "数码 3C", "食品饮料", "服饰", "家电"];
type SourceId = "ai" | "theme" | "manual";
const SOURCES: Array<{ id: SourceId; name: string; icon: "sparkles" | "lightbulb" | "doc"; tag: string; desc: string }> = [
{ id: "ai", name: "AI 全生", icon: "sparkles", tag: "最常用", desc: "LLM 全权决定脚本走向,最省事。后续仍可在故事板阶段微调。" },
{ id: "theme", name: "一句话主题", icon: "lightbulb", tag: "轻引导", desc: "你给一句切入主题AI 按此扩写。推荐 530 字。" },
{ id: "manual", name: "自带脚本", icon: "doc", tag: "我已有稿", desc: "粘贴或上传完整脚本,系统按镜头自动切分并适配商品。" },
];
type DurationId = "0-10" | "0-15" | "0-30" | "0-60";
const DURATIONS: Array<{ id: DurationId; label: string; shotsRange: [number, number]; tag: string; completion: number; conversion: number; }> = [
{ id: "0-10", label: "0-10 秒", shotsRange: [3, 4], tag: "黄金完播", completion: 52, conversion: 1.6 },
{ id: "0-15", label: "0-15 秒", shotsRange: [4, 5], tag: "完播率最佳", completion: 42, conversion: 1.8 },
{ id: "0-30", label: "0-30 秒", shotsRange: [6, 8], tag: "卖点详解", completion: 32, conversion: 2.1 },
{ id: "0-60", label: "0-60 秒", shotsRange: [10, 12], tag: "故事化", completion: 26, conversion: 2.4 },
];
type StyleId = "pain" | "review" | "compare";
const STYLES: Array<{ id: StyleId; name: string; note: string; tag?: string; flow: string[] }> = [
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用", flow: ["痛点", "共鸣", "产品", "效果", "引导"] },
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", flow: ["开箱", "首印象", "试用", "对比", "结论"] },
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", flow: ["对照", "差距", "本品", "数据", "购买"] },
];
type PersonaId = "urban" | "bestie" | "ceo" | "reviewer" | "mom" | "genz";
const PERSONAS: Array<{ id: PersonaId; name: string; sub: string; metric: string; defaults: { duration: DurationId; style: StyleId } }> = [
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", defaults: { duration: "0-15", style: "pain" } },
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", defaults: { duration: "0-15", style: "pain" } },
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", defaults: { duration: "0-30", style: "pain" } },
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", defaults: { duration: "0-30", style: "review" } },
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", defaults: { duration: "0-30", style: "pain" } },
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", defaults: { duration: "0-10", style: "compare" } },
];
/* ============================================================
Helpers
============================================================ */
const USER_EMAIL = "airlabsv001@gmail.com";
const ACCOUNT_BALANCE = 327.4;
function avg([a, b]: [number, number]) { return (a + b) / 2; }
/* ============================================================
Component
============================================================ */
type StepNum = 1 | 2 | 3 | 4;
export default function NewProjectPage() {
const router = useRouter();
const [step, setStep] = useState<StepNum>(1);
// Step 1
const [productId, setProductId] = useState<string | null>(null);
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
// Step 2
const [sourceId, setSourceId] = useState<SourceId | null>(null);
const [themeText, setThemeText] = useState("");
const [manualScript, setManualScript] = useState("");
// Step 3
const [projectName, setProjectName] = useState("");
const [duration, setDuration] = useState<DurationId>("0-15");
const [scriptStyle, setScriptStyle] = useState<StyleId>("pain");
const [persona, setPersona] = useState<PersonaId>("urban");
const [recoDismissed, setRecoDismissed] = useState(false);
const [points, setPoints] = useState<Record<string, boolean>>({});
// Step 4
const [notifyEmail, setNotifyEmail] = useState(true);
const [notifyWeChat, setNotifyWeChat] = useState(false);
const [agreed, setAgreed] = useState(false);
/* ---- derived ---- */
const product = useMemo(() => PRODUCTS.find((p) => p.id === productId) ?? null, [productId]);
const source = useMemo(() => SOURCES.find((s) => s.id === sourceId) ?? null, [sourceId]);
const personaObj = useMemo(() => PERSONAS.find((p) => p.id === persona)!, [persona]);
const durationObj = useMemo(() => DURATIONS.find((d) => d.id === duration)!, [duration]);
const styleObj = useMemo(() => STYLES.find((s) => s.id === scriptStyle)!, [scriptStyle]);
const shots = avg(durationObj.shotsRange);
const completion = durationObj.completion;
const conversion = durationObj.conversion;
// Live cost: roughly 4 line items
const cost = useMemo(() => {
const script = 0.20;
const storyboard = 0.40;
const assets = product ? product.imgs * 0.30 : 0;
const render = shots * 0.30;
const subtotal = script + storyboard + assets + render;
const fee = +(subtotal * 0.05).toFixed(2);
return { script, storyboard, assets, render, subtotal: +subtotal.toFixed(2), fee, total: +(subtotal + fee).toFixed(2) };
}, [product, shots]);
const balanceAfter = +(ACCOUNT_BALANCE - cost.total).toFixed(2);
const lowBalance = balanceAfter < 5;
const etaMinutes = Math.max(3, Math.round(2 + shots * 0.4 + (product?.imgs ?? 0) * 0.2));
// Reco bubble (Step 3)
const recoMismatch =
personaObj.defaults.duration !== duration || personaObj.defaults.style !== scriptStyle;
const showReco = step === 3 && recoMismatch && !recoDismissed;
const recoDuration = DURATIONS.find((d) => d.id === personaObj.defaults.duration)!;
const recoStyle = STYLES.find((s) => s.id === personaObj.defaults.style)!;
/* ---- validation gates ---- */
const canPass1 = !!product;
const canPass2 =
!!source &&
(source.id !== "theme" || themeText.trim().length >= 4) &&
(source.id !== "manual" || manualScript.trim().length >= 20);
const canPass3 = projectName.trim().length >= 2;
const canFinish = agreed && !lowBalance;
/* ---- actions ---- */
function selectProduct(p: Product) {
setProductId(p.id);
// seed defaults derived from product
if (!projectName) setProjectName(`${p.name.split(" ")[0]} · 痛点种草 · v1`);
const seeded: Record<string, boolean> = {};
p.points.forEach((pt, i) => { seeded[pt] = i < 2; });
setPoints(seeded);
}
function applyPreset() {
setDuration(personaObj.defaults.duration);
setScriptStyle(personaObj.defaults.style);
setRecoDismissed(false);
}
function pickPersona(id: PersonaId) {
setPersona(id);
setRecoDismissed(false);
}
function togglePoint(k: string) { setPoints((p) => ({ ...p, [k]: !p[k] })); }
function goPrev() { setStep((s) => (s > 1 ? ((s - 1) as StepNum) : s)); }
function goNext() {
if (step === 1 && !canPass1) return;
if (step === 2 && !canPass2) return;
if (step === 3 && !canPass3) return;
setStep((s) => (s < 4 ? ((s + 1) as StepNum) : s));
}
function startGenerate() {
if (!canFinish) return;
router.push("/pipeline?stage=1");
}
function jumpTo(target: StepNum) {
// only allow going to a completed step or current
if (target < step) setStep(target);
}
/* ---- step rail config ---- */
const stepConfig: Array<{ n: StepNum; label: string; desc: string }> = [
{ n: 1, label: "选择商品", desc: product ? product.name : "未选择" },
{ n: 2, label: "脚本来源", desc: source ? source.name + (source.id === "theme" && themeText ? " · 有主题" : "") : "未选择" },
{ n: 3, label: "项目配置", desc: step >= 3 ? `${durationObj.label} · ${styleObj.name}` : "时长 · 风格 · 人设" },
{ n: 4, label: "确认与计费", desc: `预估 ¥${cost.total.toFixed(2)}` },
];
/* ---- filtered products for Step 1 ---- */
const filteredProducts = useMemo(() => {
return PRODUCTS.filter((p) => {
if (pickCat !== "全部" && p.cat !== pickCat) return false;
if (pickSearch && !p.name.includes(pickSearch)) return false;
return true;
});
}, [pickCat, pickSearch]);
const recentProducts = useMemo(
() => RECENT_IDS.map((id) => PRODUCTS.find((p) => p.id === id)!).filter(Boolean),
[]
);
return (
<>
<Topbar
crumbs={[
{ label: "工作台", href: "/" },
{ label: "视频项目", href: "/projects" },
{ label: "新建项目" },
]}
/>
<section className="content wizard-content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono-sub">// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成</span>
</div>
</div>
<div className="actions">
<Link className="btn btn-ghost" href="/projects">退</Link>
</div>
</div>
<div className="wizard-shell">
{/* ── Steps rail ─────────────────────────── */}
<nav className="steps">
{stepConfig.map((s, i) => {
const state: "done" | "active" | "pending" =
s.n < step ? "done" : s.n === step ? "active" : "pending";
const clickable = s.n < step;
return (
<div
key={s.n}
className={`step ${state}${clickable ? " clickable" : ""}${i === stepConfig.length - 1 ? " last" : ""}`}
onClick={() => clickable && jumpTo(s.n)}
>
<div className="num">
{state === "done"
? <Icon name="check" size={12} strokeWidth={1.5} />
: s.n}
</div>
<div>
<div className="step-label">{s.label}</div>
<div className="step-desc">{s.desc}</div>
</div>
</div>
);
})}
</nav>
{/* ── Wiz main ───────────────────────────── */}
<div className="wiz-main">
{/* Step 1 · 选择商品 ───────────────── */}
{step === 1 && (
<section className="card active-step">
<div className="step-h">
<h2> 1 · </h2>
<p> SKU LLM /</p>
</div>
<div className="pick-toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input
className="input"
placeholder="搜索商品名称、品牌"
value={pickSearch}
onChange={(e) => setPickSearch(e.target.value)}
/>
</div>
{CATS.map((c) => (
<button
key={c}
className={`filter-chip${pickCat === c ? " active" : ""}`}
onClick={() => setPickCat(c)}
>
{c}
</button>
))}
</div>
{pickCat === "全部" && !pickSearch && (
<>
<div className="pick-section-h">
<span>使</span>
<span className="count">{recentProducts.length}</span>
</div>
<div className="product-pick-grid">
{recentProducts.map((p) => (
<ProductPickCard key={p.id} p={p} selected={productId === p.id} onSelect={() => selectProduct(p)} />
))}
</div>
</>
)}
<div className="pick-section-h">
<span>{pickCat === "全部" && !pickSearch ? "全部商品" : "搜索结果"}</span>
<span className="count">{filteredProducts.length}</span>
</div>
<div className="product-pick-grid">
{filteredProducts.map((p) => (
<ProductPickCard key={p.id} p={p} selected={productId === p.id} onSelect={() => selectProduct(p)} />
))}
<div className="product-pick add">
<div className="pc"><Icon name="plus" size={16} /></div>
<div></div>
</div>
</div>
</section>
)}
{/* Step 2 · 脚本来源 ───────────────── */}
{step === 2 && (
<>
<CollapsedStep
title="第 1 步 · 选择商品"
onEdit={() => setStep(1)}
body={product && <ProductSummary p={product} />}
/>
<section className="card active-step">
<div className="step-h">
<h2> 2 · </h2>
<p> LLM 稿</p>
</div>
<div className="source-row">
{SOURCES.map((s) => (
<div
key={s.id}
className={`source-card${sourceId === s.id ? " selected" : ""}`}
onClick={() => setSourceId(s.id)}
>
<span className="src-ic"><Icon name={s.icon} size={16} /></span>
<h4>{s.name}</h4>
<span className="src-tag">[ {s.tag} ]</span>
<p className="src-desc">{s.desc}</p>
</div>
))}
</div>
{source && (
<div className="source-detail">
<div className="sd-h">
// 已选 · <b>{source.name}</b>
</div>
{source.id === "ai" && (
<div className="field-hint" style={{ fontSize: 12.5, color: "var(--ink-2)" }}>
AI / / LLM
</div>
)}
{source.id === "theme" && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<input
className="input"
placeholder="例:熬夜党的急救面膜 / 加班吃啥不内疚"
value={themeText}
onChange={(e) => setThemeText(e.target.value)}
/>
<div className="field-hint"> 530 LLM </div>
</div>
)}
{source.id === "manual" && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<textarea
className="input textarea"
style={{ minHeight: 140 }}
placeholder="粘贴你的脚本内容(旁白 / 镜头描述均可,系统会自动切分镜头)"
value={manualScript}
onChange={(e) => setManualScript(e.target.value)}
/>
<div className="field-hint">
20 /
</div>
</div>
)}
</div>
)}
</section>
</>
)}
{/* Step 3 · 项目配置 ───────────────── */}
{step === 3 && (
<>
<CollapsedStep
title="第 1 步 · 选择商品"
onEdit={() => setStep(1)}
body={product && <ProductSummary p={product} />}
/>
<CollapsedStep
title="第 2 步 · 脚本来源"
onEdit={() => setStep(2)}
body={source && <SourceSummary source={source} themeText={themeText} manualScript={manualScript} />}
/>
<section className="card active-step">
<div className="step-h">
<h2> 3 · </h2>
<p> LLM 线 1 </p>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={projectName} onChange={(e) => setProjectName(e.target.value)} />
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="option-row cols-4">
{DURATIONS.map((d) => (
<div
key={d.id}
className={`option-card${duration === d.id ? " selected" : ""}`}
onClick={() => setDuration(d.id)}
>
<h4>{d.label}</h4>
<div className="sub">{d.shotsRange[0]}-{d.shotsRange[1]} </div>
<div className="note">{d.tag}</div>
<div className="metric"> <span className="val">{d.completion}%</span></div>
</div>
))}
</div>
<div className="field-hint"> TOP · LLM </div>
</div>
<div className="field">
<label className="field-label"></label>
<div className="option-row">
{STYLES.map((s) => (
<div
key={s.id}
className={`option-card${scriptStyle === s.id ? " selected" : ""}`}
onClick={() => setScriptStyle(s.id)}
>
<h4>{s.name}</h4>
<div className="note">{s.note}</div>
{s.tag && <span className="tag-mono">[ {s.tag} ]</span>}
</div>
))}
</div>
</div>
<div className="field">
<label className="field-label"></label>
<div className="option-row cols-6">
{PERSONAS.map((p) => (
<div
key={p.id}
className={`option-card${persona === p.id ? " selected" : ""}`}
onClick={() => pickPersona(p.id)}
>
<h4>{p.name}</h4>
<div className="sub">{p.sub}</div>
<div className="metric"><span className="val">{p.metric}</span></div>
</div>
))}
</div>
{showReco && (
<div className="reco-bubble">
<span className="ic"><Icon name="lightbulb" size={14} /></span>
<div className="txt">
<span>
TOP <strong>{recoDuration.label}</strong> + <strong>{recoStyle.name}</strong>
</span>
<span className="meta">
{durationObj.label} · {styleObj.name}
</span>
</div>
<button onClick={applyPreset}></button>
<button className="dismiss" onClick={() => setRecoDismissed(true)} aria-label="忽略">
<Icon name="x" size={14} />
</button>
</div>
)}
</div>
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="hstack" style={{ gap: 6, flexWrap: "wrap" }}>
{Object.entries(points).map(([k, v]) => (
<span key={k} className={`theme-pill${v ? " on" : ""}`} onClick={() => togglePoint(k)}>
{v ? "✓" : "+"} {k}
</span>
))}
</div>
</div>
)}
</section>
</>
)}
{/* Step 4 · 确认与计费 ───────────── */}
{step === 4 && (
<section className="card active-step">
<div className="step-h">
<h2> 4 · </h2>
<p> 3 + 线</p>
</div>
<div className="confirm-grid">
<div className="confirm-card">
<div className="cc-h">
<span>// 商品</span>
<button className="cc-edit" onClick={() => setStep(1)}></button>
</div>
{product && (
<div className="hstack" style={{ gap: 12, alignItems: "flex-start" }}>
<div className="placeholder" style={{ width: 44, height: 56, flexShrink: 0 }}>
<span className="ph-frame">9:16</span>
</div>
<div className="cc-body" style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{product.name}</div>
<div className="ln">
<span>{product.cat}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<b>¥{product.price}</b>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span>{product.imgs} </span>
</div>
</div>
</div>
)}
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 脚本来源</span>
<button className="cc-edit" onClick={() => setStep(2)}></button>
</div>
{source && (
<div className="cc-body">
<div style={{ fontWeight: 600, fontSize: 13 }}>{source.name}</div>
<div className="ln">
{source.id === "ai" && <span>LLM · Step 3 </span>}
{source.id === "theme" && <span><b>{themeText || "(未填)"}</b></span>}
{source.id === "manual" && <span><b>{manualScript.length}</b> · </span>}
</div>
</div>
)}
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 项目配置</span>
<button className="cc-edit" onClick={() => setStep(3)}></button>
</div>
<div className="cc-body">
<div style={{ fontWeight: 600, fontSize: 13 }}>{projectName}</div>
<div className="ln"><b>{styleObj.name}</b> · {personaObj.name} · {personaObj.sub}</div>
<div className="ln" style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--ink-3)" }}>
{Object.entries(points).filter(([, v]) => v).map(([k]) => k).join(" / ") || "未选"}
</div>
</div>
</div>
<div className="confirm-card">
<div className="cc-h">
<span>// 输出参数</span>
</div>
<div className="cc-body">
<div className="ln"><b>{durationObj.label}</b> · <b>{durationObj.shotsRange[0]}-{durationObj.shotsRange[1]} </b> · 9:16</div>
<div className="ln"> <b>{completion}%</b> · <b>{conversion}%</b></div>
<div className="ln" style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--ink-3)" }}>
// 数据来源:抖音同品类 TOP 均值
</div>
</div>
</div>
</div>
<div className="section-sub"> · </div>
<div className="bill-list">
<div className="bill-row">
<div className="l"> <span className="l-sub">LLM · 1 稿</span></div>
<div className="qty">× 1</div>
<div className="amt">¥{cost.script.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"></span></div>
<div className="qty">× 1</div>
<div className="amt">¥{cost.storyboard.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"> </span></div>
<div className="qty">× {product?.imgs ?? 0} </div>
<div className="amt">¥{cost.assets.toFixed(2)}</div>
</div>
<div className="bill-row">
<div className="l"> <span className="l-sub"> · · </span></div>
<div className="qty">× {shots} </div>
<div className="amt">¥{cost.render.toFixed(2)}</div>
</div>
<div className="bill-row subtotal">
<div className="l"></div>
<div className="qty" />
<div className="amt">¥{cost.subtotal.toFixed(2)}</div>
</div>
<div className="bill-row subtotal">
<div className="l"> <span className="l-sub">5%</span></div>
<div className="qty" />
<div className="amt">¥{cost.fee.toFixed(2)}</div>
</div>
<div className="bill-row total">
<div className="l"></div>
<div className="qty" />
<div className="amt">¥{Math.floor(cost.total)}<small>.{cost.total.toFixed(2).split(".")[1]}</small></div>
</div>
</div>
<div className={`balance-row${lowBalance ? " low" : ""}`}>
<div className="bl">
<Icon name="wallet" size={14} />
<span className="lbl"></span>
<span className="val">¥{ACCOUNT_BALANCE.toFixed(2)}</span>
<span className="arrow"></span>
<span className="lbl"></span>
<span className="val after">¥{balanceAfter.toFixed(2)}</span>
</div>
{lowBalance ? (
<span className="pill pill-err"><span className="dot" /> · <a style={{ marginLeft: 4, textDecoration: "underline" }}></a></span>
) : (
<span className="pill pill-ok"><span className="dot" /></span>
)}
</div>
<div className="section-sub"> · </div>
<div className="eta-block">
<div className="eta-tile">
<div className="lbl"></div>
<div className="v">~ {etaMinutes}<small></small></div>
<div className="desc">// pipeline 5 阶段累计 · 不含人工审核</div>
</div>
<div className="eta-tile">
<div className="lbl"></div>
<div
className={`check-row${notifyEmail ? " on" : ""}`}
style={{ padding: "4px 0" }}
onClick={() => setNotifyEmail((v) => !v)}
>
<span className="check-box" />
<span className="lab"> <span className="mono">{USER_EMAIL}</span></span>
</div>
<div
className={`check-row${notifyWeChat ? " on" : ""}`}
style={{ padding: "4px 0" }}
onClick={() => setNotifyWeChat((v) => !v)}
>
<span className="check-box" />
<span className="lab"> <span className="mono"> · </span></span>
</div>
</div>
</div>
<div className={`tos-row${agreed ? " on" : ""}`} onClick={() => setAgreed((v) => !v)}>
<span className="check-box" />
<span className="lab">
<a></a> <a>使</a>
</span>
</div>
</section>
)}
{/* ── Wiz foot ──────────────────────── */}
<div className="wiz-foot">
<button className="btn btn-ghost" onClick={goPrev} disabled={step === 1}>
</button>
<div className="hstack" style={{ gap: 12 }}>
{step < 4 ? (
<>
<span className="muted-2" style={{ fontSize: 12.5, fontFamily: "var(--mono)", letterSpacing: ".02em" }}>
// 下一步:{stepConfig[step].label}
</span>
<button
className="btn btn-primary btn-lg"
disabled={
(step === 1 && !canPass1) ||
(step === 2 && !canPass2) ||
(step === 3 && !canPass3)
}
onClick={goNext}
>
</button>
</>
) : (
<>
<span className="muted-2" style={{ fontSize: 12.5, fontFamily: "var(--mono)", letterSpacing: ".02em" }}>
// 扣款 ¥{cost.total.toFixed(2)} · 进入 pipeline
</span>
<button className="btn btn-primary btn-lg" disabled={!canFinish} onClick={startGenerate}>
</button>
</>
)}
</div>
</div>
</div>
{/* ── Live preview panel ─────────────────── */}
<aside className="wiz-preview">
<div className="pv-h">
<span></span>
<span className="live-dot">LIVE</span>
</div>
<div className="pv-title">
{projectName || (product ? `${product.name} · 待命名` : "未命名项目")}
</div>
<div className="pv-metrics">
<div className="pv-metric">
<div className="l"></div>
<div className="v">{shots}<small></small></div>
</div>
<div className="pv-metric accent">
<div className="l"></div>
<div className="v">{completion}<small>%</small></div>
</div>
<div className="pv-metric">
<div className="l"></div>
<div className="v">{conversion}<small>%</small></div>
</div>
<div className="pv-metric">
<div className="l"></div>
<div className="v">¥{cost.total.toFixed(2)}</div>
</div>
</div>
{product ? (
<>
<div className="pv-section">
<div className="lbl">// 商品</div>
<ul className="pv-list">
<li>{product.name}</li>
<li>{product.cat} · ¥{product.price}</li>
</ul>
</div>
<div className="pv-section">
<div className="lbl">// 人设 · 风格</div>
<ul className="pv-list">
<li>{personaObj.name} · {personaObj.sub}</li>
<li>{styleObj.name} · {durationObj.tag}</li>
</ul>
</div>
<div className="pv-section">
<div className="lbl">// 脚本走向</div>
<div className="pv-flow">
{styleObj.flow.map((n, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center" }}>
<span className="node">{n}</span>
{i < styleObj.flow.length - 1 && <span className="arrow"></span>}
</span>
))}
</div>
</div>
<div className="pv-section">
<div className="lbl">// 突出卖点</div>
<ul className="pv-list">
{Object.entries(points).filter(([, v]) => v).map(([k]) => <li key={k}>{k}</li>)}
{Object.values(points).every((v) => !v) && (
<li style={{ color: "var(--ink-3)" }}> · LLM </li>
)}
</ul>
</div>
</>
) : (
<div className="pv-section">
<div className="lbl">// 待选择</div>
<ul className="pv-list" style={{ opacity: 0.6 }}>
<li style={{ color: "var(--ink-3)" }}></li>
<li style={{ color: "var(--ink-3)" }}></li>
</ul>
</div>
)}
<div className="pv-foot">
<span>Step {step} / 4 · Restraint</span>
<strong>
{step < 4 ? "进行中" : canFinish ? "就绪" : (lowBalance ? "余额不足" : "待确认")}
</strong>
</div>
</aside>
</div>
</section>
</>
);
}
/* ============================================================
Sub-components
============================================================ */
function ProductPickCard({ p, selected, onSelect }: { p: Product; selected: boolean; onSelect: () => void }) {
return (
<div className={`product-pick${selected ? " selected" : ""}`} onClick={onSelect}>
<div className="placeholder thumb"><span className="ph-frame">9:16</span></div>
<div className="body">
<div className="name">{p.name}</div>
<div className="meta">
{p.cat} · <b>¥{p.price}</b> · {p.imgs}
</div>
<div className="tags">
{p.tags.map((t) => <span key={t} className="tag-s">{t}</span>)}
</div>
</div>
</div>
);
}
function CollapsedStep({ title, onEdit, body }: { title: string; onEdit: () => void; body: React.ReactNode }) {
return (
<section className="card collapsed-step">
<div className="hstack">
<h3>{title}</h3>
<span className="spacer" />
<button className="btn btn-ghost btn-sm" onClick={onEdit}></button>
</div>
<div style={{ marginTop: 10 }}>{body}</div>
</section>
);
}
function ProductSummary({ p }: { p: Product }) {
return (
<div className="hstack" style={{ gap: 12, alignItems: "flex-start" }}>
<div className="placeholder" style={{ width: 44, height: 56, flexShrink: 0 }}>
<span className="ph-frame">9:16</span>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{p.name}</div>
<div className="muted-2 mono" style={{ fontSize: 11.5, marginTop: 3, letterSpacing: ".02em" }}>
{p.cat} · ¥{p.price} · {p.imgs} · {p.points.length}
</div>
</div>
</div>
);
}
function SourceSummary({ source, themeText, manualScript }: { source: { id: SourceId; name: string }; themeText: string; manualScript: string }) {
return (
<div className="hstack" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="pill pill-info"><span className="dot" />{source.name}</span>
{source.id === "theme" && themeText && (
<>
<span className="muted"></span>
<span style={{ fontSize: 13 }}>{themeText}</span>
</>
)}
{source.id === "manual" && (
<>
<span className="muted"></span>
<span style={{ fontSize: 13 }}>{manualScript.length} </span>
</>
)}
{source.id === "ai" && (
<span className="muted-2 mono" style={{ fontSize: 11.5, letterSpacing: ".02em" }}>
// 走向由 Step 3 决定
</span>
)}
</div>
);
}