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>
This commit is contained in:
iye
2026-05-21 21:16:46 +08:00
co-authored by Claude Opus 4.7
parent 8a783ca36f
commit f420af2069
82 changed files with 15367 additions and 418 deletions
+877
View File
@@ -0,0 +1,877 @@
"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>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Link from "next/link";
import Topbar from "@/components/Topbar";
import Icon from "@/components/Icon";
interface Project {
name: string;
sub: string;
product: string;
source: string;
prog: ("done" | "cur" | "fail" | "")[];
step: string;
pill: { kind: "info" | "ok" | "err" | "neutral"; label: string };
updated: string;
}
const PROJECTS: Project[] = [
{
name: "补水面膜 · 痛点种草 · v3",
sub: "6 镜 · 0-15s",
product: "透真补水面膜",
source: "AI 全生",
prog: ["done", "done", "cur", "", ""],
step: "3/5",
pill: { kind: "info", label: "故事板 待确认" },
updated: "12 分钟前",
},
{
name: "速食牛肉面 · 加班治愈",
sub: "4 镜 · 0-12s",
product: "滋啦速食 · 6 桶装",
source: "一句话主题",
prog: ["done", "cur", "", "", ""],
step: "2/5",
pill: { kind: "info", label: "资产生成中" },
updated: "37 分钟前",
},
{
name: "透真防晒 · 通勤对比",
sub: "6 镜 · 0-18s",
product: "透真清透防晒霜",
source: "AI 全生",
prog: ["done", "done", "done", "cur", ""],
step: "4/5",
pill: { kind: "info", label: "视频生成 4/6" },
updated: "2 小时前",
},
{
name: "咖啡冻干 · 早八剧情",
sub: "5 镜 · 0-15s",
product: "三顿半同款冻干",
source: "一句话主题",
prog: ["done", "done", "fail", "", ""],
step: "3/5",
pill: { kind: "err", label: "故事板生成失败" },
updated: "昨天 18:42",
},
{
name: "蓝牙耳机 · 开箱测评",
sub: "5 镜 · 0-15s",
product: "南卡 Lite Pro",
source: "自带脚本",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 7 日",
},
{
name: "瑜伽裤 · 通勤穿搭",
sub: "5 镜 · 0-15s",
product: "露露同款瑜伽裤",
source: "AI 全生",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 6 日",
},
{
name: "空气炸锅 · 小户型",
sub: "4 镜 · 0-12s",
product: "小熊 4L 空气炸锅",
source: "一句话主题",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "ok", label: "已完成" },
updated: "5 月 4 日",
},
{
name: "补水面膜 · 痛点种草 · v1",
sub: "6 镜 · 0-15s",
product: "透真补水面膜",
source: "AI 全生",
prog: ["done", "done", "done", "done", "done"],
step: "5/5",
pill: { kind: "neutral", label: "已归档" },
updated: "4 月 28 日",
},
];
const TABS = [
{ label: "全部", count: 12, active: true },
{ label: "进行中", count: 3 },
{ label: "待审核", count: 2 },
{ label: "已完成", count: 8 },
{ label: "失败", count: 1 },
];
export default function ProjectsPage() {
return (
<>
<Topbar crumbs={[{ label: "工作台", href: "/" }, { label: "视频项目" }]} />
<section className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span>12 </span>
<span>·</span>
<span>3 </span>
<span>·</span>
<span>8 </span>
</div>
</div>
<div className="actions">
<Link className="btn btn-primary btn-lg" href="/projects/new">
<Icon name="plus" size={14} />
</Link>
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<div key={t.label} className={`tab${t.active ? " active" : ""}`}>
{t.label}
<span className="count">{t.count}</span>
</div>
))}
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input className="input" placeholder="搜索项目名称、商品" />
</div>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<button className="filter-chip">
<Icon name="chev-down" size={12} />
</button>
<span className="spacer" />
<div className="view-toggle">
<button></button>
<button className="active"></button>
</div>
</div>
<table className="proj-table">
<thead>
<tr>
<th style={{ width: "32%" }}></th>
<th></th>
<th></th>
<th style={{ width: 200 }}></th>
<th></th>
<th style={{ width: 120 }}></th>
<th style={{ width: 60 }} />
</tr>
</thead>
<tbody>
{PROJECTS.map((p) => (
<tr key={p.name}>
<td>
<div className="proj-row-cell">
<div className="placeholder proj-thumb">
<span className="ph-frame">9:16</span>
</div>
<div>
<div className="proj-name">{p.name}</div>
<div className="proj-sub">{p.sub}</div>
</div>
</div>
</td>
<td>{p.product}</td>
<td><span className="muted">{p.source}</span></td>
<td>
<div className="hstack" style={{ gap: 8 }}>
<div className="prog">
{p.prog.map((s, i) => <span key={i} className={s || undefined} />)}
</div>
<span className="muted-2 mono" style={{ fontSize: 11 }}>{p.step}</span>
</div>
</td>
<td>
<span className={`pill pill-${p.pill.kind}`}>
<span className="dot" />
{p.pill.label}
</span>
</td>
<td className="muted-2">{p.updated}</td>
<td>
<div className="row-actions">
<Link className="icon-btn-sm" href="/pipeline?stage=3" title="继续">
<Icon name="play-tri" size={12} />
</Link>
<button className="icon-btn-sm" title="更多">
<Icon name="more" size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
</>
);
}