Files
yingqing/app/projects/new/page.tsx
T
iyeandClaude Opus 4.7 f420af2069 chore: 全量推送 · 累积页面改动 + Next.js 工程骨架 + v1/ 归档 + 文档
页面 (电商AI平台/)
- account / team / settings / index / products / projects: 累积迭代
- restraint.css: 设计 token 补充
- login.html / register.html: 新增登录注册页
- _ARCHIVE.md: 归档说明

Next.js 工程骨架
- app/ + components/: 新一代 SPA 雏形 (page / layout / sidebar / topbar / GridBg / Icon)
- package.json / package-lock.json / next.config.mjs / tsconfig.json / postcss.config.mjs / next-env.d.ts

历史归档 / 文档
- v1/: 原 V1 静态稿镜像 (含 mockup-A/B/C)
- PRD.md / deployment-guide.md / _check.html
- ui参考/ / screenshots/

杂项
- .gitignore 调整
- 删除根 README.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:16:46 +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 按此扩写。推荐 5–30 字。" },
{ 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={3} />
: 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>
);
}