878 lines
41 KiB
TypeScript
878 lines
41 KiB
TypeScript
"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={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">推荐 5–30 字。这句话会作为 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>
|
||
);
|
||
}
|