"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(1); // Step 1 const [productId, setProductId] = useState(null); const [pickSearch, setPickSearch] = useState(""); const [pickCat, setPickCat] = useState("全部"); // Step 2 const [sourceId, setSourceId] = useState(null); const [themeText, setThemeText] = useState(""); const [manualScript, setManualScript] = useState(""); // Step 3 const [projectName, setProjectName] = useState(""); const [duration, setDuration] = useState("0-15"); const [scriptStyle, setScriptStyle] = useState("pain"); const [persona, setPersona] = useState("urban"); const [recoDismissed, setRecoDismissed] = useState(false); const [points, setPoints] = useState>({}); // 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 = {}; 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 ( <>

新建项目

// 商品 → 脚本来源 → 配置 → 确认 · 4 步开始生成
退出
{/* ── Steps rail ─────────────────────────── */} {/* ── Wiz main ───────────────────────────── */}
{/* Step 1 · 选择商品 ───────────────── */} {step === 1 && (

第 1 步 · 选择商品

从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。

setPickSearch(e.target.value)} />
{CATS.map((c) => ( ))}
{pickCat === "全部" && !pickSearch && ( <>
最近使用 {recentProducts.length}
{recentProducts.map((p) => ( selectProduct(p)} /> ))}
)}
{pickCat === "全部" && !pickSearch ? "全部商品" : "搜索结果"} {filteredProducts.length}
{filteredProducts.map((p) => ( selectProduct(p)} /> ))}
新建商品
)} {/* Step 2 · 脚本来源 ───────────────── */} {step === 2 && ( <> setStep(1)} body={product && } />

第 2 步 · 脚本来源

决定 LLM 如何获得初稿脚本。三种方式由「最省事」到「最保真原意」。

{SOURCES.map((s) => (
setSourceId(s.id)} >

{s.name}

[ {s.tag} ]

{s.desc}

))}
{source && (
// 已选 · {source.name}
{source.id === "ai" && (
AI 全生模式无需额外输入。下一步选定时长 / 风格 / 人设后,LLM 会自动决定切入点和卖点权重。
)} {source.id === "theme" && (
setThemeText(e.target.value)} />
推荐 5–30 字。这句话会作为 LLM 扩写的锚点,越具体越聚焦。
)} {source.id === "manual" && (