Polish static UI flows
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Root Next.js Archive · 2026-05-28
|
||||
|
||||
This folder preserves the earlier root-level Next.js scaffold.
|
||||
|
||||
It was archived because the current product surface is the static HTML app in
|
||||
`电商AI平台/*.html`, while this Next.js implementation only contained a partial
|
||||
route set and could confuse future edits.
|
||||
|
||||
Archived paths:
|
||||
- `app/`
|
||||
- `components/`
|
||||
- `k8s/`
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `next.config.mjs`
|
||||
- `next-env.d.ts`
|
||||
- `postcss.config.mjs`
|
||||
- `tsconfig.json`
|
||||
- `deployment-guide.md`
|
||||
|
||||
To restore it later, move these files back to the repository root.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import GridBg from "@/components/GridBg";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Airshelf — AI 短视频生产平台",
|
||||
description:
|
||||
"为抖音 / TikTok 商户打造的 AI 短视频生产流水线 · 脚本 → 基础资产 → 故事板 → 视频片段 → 拼接导出",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" className={inter.variable}>
|
||||
<body>
|
||||
<div className="app">
|
||||
<Sidebar />
|
||||
<main className="main">
|
||||
<GridBg />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Link from "next/link";
|
||||
import Topbar from "@/components/Topbar";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface Recent {
|
||||
name: string;
|
||||
meta: string;
|
||||
prog: ("done" | "cur" | "fail" | "")[];
|
||||
pill: { kind: "info" | "ok" | "err"; label: string };
|
||||
action: { label: string; href: string };
|
||||
}
|
||||
|
||||
const RECENT: Recent[] = [
|
||||
{
|
||||
name: "补水面膜 · 痛点种草",
|
||||
meta: "补水面膜 / AI 全生 / 6 镜",
|
||||
prog: ["done", "done", "cur", "", ""],
|
||||
pill: { kind: "info", label: "故事板 待确认" },
|
||||
action: { label: "继续", href: "/pipeline?stage=3" },
|
||||
},
|
||||
{
|
||||
name: "蓝牙耳机 · 开箱测评",
|
||||
meta: "南卡 Lite Pro / 自带脚本 / 5 镜",
|
||||
prog: ["done", "done", "done", "done", "done"],
|
||||
pill: { kind: "ok", label: "已完成" },
|
||||
action: { label: "打开", href: "/pipeline?stage=5" },
|
||||
},
|
||||
{
|
||||
name: "速食牛肉面 · 一句话主题",
|
||||
meta: "滋啦速食 / 一句话 / 4 镜",
|
||||
prog: ["done", "cur", "", "", ""],
|
||||
pill: { kind: "info", label: "资产生成中" },
|
||||
action: { label: "继续", href: "/pipeline?stage=2" },
|
||||
},
|
||||
{
|
||||
name: "防晒霜 · 对比展示",
|
||||
meta: "透真防晒 / AI 全生 / 6 镜",
|
||||
prog: ["done", "done", "done", "cur", ""],
|
||||
pill: { kind: "info", label: "视频生成 4/6" },
|
||||
action: { label: "继续", href: "/pipeline?stage=4" },
|
||||
},
|
||||
{
|
||||
name: "咖啡冻干粉 · 剧情带货",
|
||||
meta: "三顿半同款 / 一句话 / 5 镜",
|
||||
prog: ["done", "done", "fail", "", ""],
|
||||
pill: { kind: "err", label: "故事板失败" },
|
||||
action: { label: "查看", href: "/pipeline?stage=3" },
|
||||
},
|
||||
];
|
||||
|
||||
export default function WorkspacePage() {
|
||||
return (
|
||||
<>
|
||||
<Topbar />
|
||||
<section className="content">
|
||||
<div className="welcome page-head">
|
||||
<div>
|
||||
<h1>欢迎回来,小李</h1>
|
||||
<div className="sub">
|
||||
<span className="mono-sub">// 05.13 · 周三</span>
|
||||
<span>·</span>
|
||||
你有 <b style={{ color: "var(--ink)" }}>3 个项目</b> 正在进行中
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-create" href="/products">
|
||||
<Icon name="product-plus" size={16} />
|
||||
新建商品
|
||||
</Link>
|
||||
<Link className="btn btn-primary btn-create" href="/projects/new">
|
||||
<Icon name="clapperboard" size={16} />
|
||||
新建项目
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats has-corners" style={{ marginBottom: 36 }}>
|
||||
<span className="corner-tr" aria-hidden />
|
||||
<span className="corner-bl" aria-hidden />
|
||||
<div className="stat">
|
||||
<div className="lbl">总项目 <span className="badge">ALL</span></div>
|
||||
<div className="v">12</div>
|
||||
<div className="delta up">↑ 本月 +3</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">进行中 <span className="badge">WIP</span></div>
|
||||
<div className="v">3</div>
|
||||
<div className="delta">2 个待审核</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">本月成片 <span className="badge">DONE</span></div>
|
||||
<div className="v">8</div>
|
||||
<div className="delta up">↑ 较上月 +33%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="lbl">余额 <span className="badge">¥</span></div>
|
||||
<div className="v">¥327<small>.40</small></div>
|
||||
<div className="usage-bar"><span /></div>
|
||||
<div className="sub-mono">已用 ¥162.60 / ¥500</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid2">
|
||||
<div>
|
||||
<div className="section-h">
|
||||
<h2>最近项目</h2>
|
||||
<Link className="more" href="/projects">[ ALL · 12 ] →</Link>
|
||||
</div>
|
||||
<div className="list-card">
|
||||
{RECENT.map((r) => (
|
||||
<div className="recent-row" key={r.name}>
|
||||
<div className="thumb">9:16</div>
|
||||
<div className="r-meta">
|
||||
<div className="name">{r.name}</div>
|
||||
<div className="sub">{r.meta}</div>
|
||||
</div>
|
||||
<div className="prog">
|
||||
{r.prog.map((p, i) => <span key={i} className={p || undefined} />)}
|
||||
</div>
|
||||
<span className={`pill pill-${r.pill.kind}`}><span className="dot" />{r.pill.label}</span>
|
||||
<Link className="btn btn-sm" href={r.action.href}>{r.action.label}</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<div>
|
||||
<div className="section-h">
|
||||
<h2>快捷入口</h2>
|
||||
<span className="more">[ /shortcuts ]</span>
|
||||
</div>
|
||||
<div className="shortcuts">
|
||||
<Link className="shortcut" href="/products">
|
||||
<div className="ic"><Icon name="package" /></div>
|
||||
<div>
|
||||
<div className="t">商品库</div>
|
||||
<div className="d">12 SKU</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link className="shortcut" href="/library">
|
||||
<div className="ic"><Icon name="images" /></div>
|
||||
<div>
|
||||
<div className="t">资产库</div>
|
||||
<div className="d">人 8 · 景 14 · 片 8</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link className="shortcut" href="/account">
|
||||
<div className="ic"><Icon name="credit-card" /></div>
|
||||
<div>
|
||||
<div className="t">充值</div>
|
||||
<div className="d">¥327.40</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link className="shortcut" href="/projects">
|
||||
<div className="ic"><Icon name="clapperboard" /></div>
|
||||
<div>
|
||||
<div className="t">所有项目</div>
|
||||
<div className="d">12 个</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-h">
|
||||
<h2>提示</h2>
|
||||
<span className="more">[ FAQ ]</span>
|
||||
</div>
|
||||
<div className="tip">
|
||||
<strong>扣费规则</strong>
|
||||
生成失败、超时、用户重跑 — 均不扣费。仅在你点{" "}
|
||||
<span className="mono-pill">[ 确认通过 ]</span> 时按 token 实际结算。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Topbar from "@/components/Topbar";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface Product {
|
||||
name: string;
|
||||
cat: string;
|
||||
imgs: number;
|
||||
tags: string[];
|
||||
thumb: string;
|
||||
}
|
||||
|
||||
const PRODUCTS: Product[] = [
|
||||
{ name: "透真玻尿酸补水面膜", cat: "美妆个护", imgs: 3, tags: ["熬夜党", "敏感肌", "¥39.9/盒"], thumb: "补水面膜 · 1200×800" },
|
||||
{ name: "南卡 Lite Pro 蓝牙耳机", cat: "数码 3C", imgs: 5, tags: ["通勤", "运动", "¥199"], thumb: "蓝牙耳机 · 1200×800" },
|
||||
{ name: "滋啦速食牛肉面 · 6 桶装", cat: "食品饮料", imgs: 4, tags: ["加班", "独居", "¥49.9"], thumb: "速食牛肉面 · 1200×800" },
|
||||
{ name: "透真清透物理防晒霜", cat: "美妆个护", imgs: 4, tags: ["SPF50", "通勤", "¥69"], thumb: "防晒霜 · 1200×800" },
|
||||
{ name: "三顿半同款冻干咖啡粉", cat: "食品饮料", imgs: 6, tags: ["提神", "早八", "¥89/24 颗"], thumb: "咖啡冻干粉 · 1200×800" },
|
||||
{ name: "小熊 4L 可视空气炸锅", cat: "家电", imgs: 5, tags: ["小户型", "健康", "¥159"], thumb: "空气炸锅 · 1200×800" },
|
||||
{ name: "露露同款裸感瑜伽裤", cat: "服饰", imgs: 8, tags: ["健身房", "通勤", "¥119"], thumb: "瑜伽裤 · 1200×800" },
|
||||
];
|
||||
|
||||
const FILTERS = ["全部", "美妆个护", "数码 3C", "食品饮料", "服饰", "家电"];
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<>
|
||||
<Topbar crumbs={[{ label: "工作台", href: "/" }, { label: "商品库" }]} />
|
||||
<section className="content">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>商品库</h1>
|
||||
<div className="sub">
|
||||
<span>12 个商品</span>
|
||||
<span className="mono-sub">· SKU</span>
|
||||
<span>· 商品信息会作为脚本和资产生成的素材</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary btn-create">
|
||||
<Icon name="product-plus" size={16} />
|
||||
新建商品
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input className="input" placeholder="搜索商品名称、品牌" />
|
||||
</div>
|
||||
{FILTERS.map((f, i) => (
|
||||
<button key={f} className={`filter-chip${i === 0 ? " active" : ""}`}>
|
||||
{f}
|
||||
{i === 0 && <span className="count-mini">12</span>}
|
||||
</button>
|
||||
))}
|
||||
<span className="spacer" />
|
||||
<button className="filter-chip">最近添加</button>
|
||||
</div>
|
||||
|
||||
<div className="product-grid">
|
||||
{PRODUCTS.map((p) => (
|
||||
<div className="product-card" key={p.name}>
|
||||
<div className="placeholder product-thumb">
|
||||
<span className="ph-frame">{p.thumb}</span>
|
||||
</div>
|
||||
<div className="product-body">
|
||||
<div className="product-name">{p.name}</div>
|
||||
<div className="product-meta">
|
||||
<span>{p.cat}</span>
|
||||
<span className="dot-sep">·</span>
|
||||
<span>{p.imgs} 张图</span>
|
||||
</div>
|
||||
<div className="product-tags">
|
||||
{p.tags.map((t) => (
|
||||
<span key={t} className="tag-sm">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="product-card add">
|
||||
<div className="plus-circle">
|
||||
<Icon name="plus" size={20} />
|
||||
</div>
|
||||
<div>新建商品</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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={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>
|
||||
);
|
||||
}
|
||||
@@ -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 btn-create" href="/projects/new">
|
||||
<Icon name="clapperboard" size={16} />
|
||||
新建项目
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export default function GridBg() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid-bg" aria-hidden />
|
||||
<pre className="scatter" style={{ top: 96, left: 280 }} aria-hidden>
|
||||
{` · · +
|
||||
· +XX+
|
||||
+XXXX·
|
||||
+X· `}
|
||||
</pre>
|
||||
<pre className="scatter" style={{ top: 340, right: 96 }} aria-hidden>
|
||||
{`+ · ·
|
||||
XX· ·
|
||||
·XXXX·+
|
||||
·++· `}
|
||||
</pre>
|
||||
<pre className="scatter" style={{ bottom: 160, left: "42%" }} aria-hidden>
|
||||
{` · +
|
||||
+·XX·
|
||||
·X+ ·
|
||||
· `}
|
||||
</pre>
|
||||
<pre className="scatter" style={{ top: 580, left: 60 }} aria-hidden>
|
||||
{` +X·
|
||||
·XX·
|
||||
+·X·+`}
|
||||
</pre>
|
||||
<span className="sq" style={{ top: 238, left: 478 }} aria-hidden />
|
||||
<span className="sq" style={{ top: 478, left: 1198 }} aria-hidden />
|
||||
<span className="sq" style={{ bottom: 300, left: 238 }} aria-hidden />
|
||||
<span className="sq" style={{ top: 718, right: 240 }} aria-hidden />
|
||||
<span className="corner-tag" style={{ top: 158, left: 34 }} aria-hidden>[ 200 OK ]</span>
|
||||
<span className="corner-tag" style={{ top: 158, right: 34 }} aria-hidden>[ /v2 ]</span>
|
||||
<span className="corner-tag" style={{ bottom: 36, left: 34 }} aria-hidden>[ .MP4 · 9:16 ]</span>
|
||||
<span className="corner-tag" style={{ bottom: 36, right: 34 }} aria-hidden>[ STUDIO ]</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconName =
|
||||
| "home"
|
||||
| "play"
|
||||
| "folder"
|
||||
| "package"
|
||||
| "boxes"
|
||||
| "images"
|
||||
| "credit-card"
|
||||
| "clapperboard"
|
||||
| "film"
|
||||
| "video"
|
||||
| "tile"
|
||||
| "bars"
|
||||
| "bars2"
|
||||
| "key"
|
||||
| "cog"
|
||||
| "chev-down"
|
||||
| "chev-right"
|
||||
| "search"
|
||||
| "bell"
|
||||
| "help"
|
||||
| "doc"
|
||||
| "up"
|
||||
| "plus"
|
||||
| "product-plus"
|
||||
| "airshelf"
|
||||
| "flame"
|
||||
| "check"
|
||||
| "x"
|
||||
| "play-tri"
|
||||
| "rotate"
|
||||
| "more"
|
||||
| "wallet"
|
||||
| "coin"
|
||||
| "download"
|
||||
| "team"
|
||||
| "lightbulb"
|
||||
| "sparkles"
|
||||
| "info"
|
||||
| "arrow-right";
|
||||
|
||||
const PATHS: Record<IconName, React.ReactNode> = {
|
||||
home: (
|
||||
<>
|
||||
<path d="M3 12 12 3l9 9" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
</>
|
||||
),
|
||||
play: <path d="m6 4 14 8-14 8Z" />,
|
||||
package: (
|
||||
<>
|
||||
<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
<path d="m7.5 4.3 9 5.1" />
|
||||
</>
|
||||
),
|
||||
boxes: (
|
||||
<>
|
||||
<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
<path d="m7.5 4.3 9 5.1" />
|
||||
</>
|
||||
),
|
||||
images: (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
||||
</>
|
||||
),
|
||||
clapperboard: (
|
||||
<>
|
||||
<path d="m12.3 3.5 3 4" />
|
||||
<path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" />
|
||||
<path d="m6.2 5.3 3.1 3.9" />
|
||||
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
</>
|
||||
),
|
||||
film: (
|
||||
<>
|
||||
<path d="m12.3 3.5 3 4" />
|
||||
<path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" />
|
||||
<path d="m6.2 5.3 3.1 3.9" />
|
||||
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
</>
|
||||
),
|
||||
video: (
|
||||
<>
|
||||
<path d="m16 13 5.2 3.1a.5.5 0 0 0 .8-.4V8.3a.5.5 0 0 0-.8-.4L16 11" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</>
|
||||
),
|
||||
folder: (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</>
|
||||
),
|
||||
tile: <path d="M3 6h18M3 12h18M3 18h18" />,
|
||||
bars: (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
||||
</>
|
||||
),
|
||||
bars2: <path d="M3 21V9M9 21V5M15 21v-8M21 21V11" />,
|
||||
key: (
|
||||
<>
|
||||
<circle cx="9" cy="12" r="6" />
|
||||
<path d="m15 12 6 0M19 9v6" />
|
||||
</>
|
||||
),
|
||||
cog: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8 2 2 0 0 1-2.8 2.8 1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5 2 2 0 0 1-4 0 1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3 2 2 0 0 1-2.8-2.8 1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8 2 2 0 0 1 2.8-2.8 1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5 2 2 0 0 1 4 0 1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3 2 2 0 0 1 2.8 2.8 1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1Z" />
|
||||
</>
|
||||
),
|
||||
"chev-down": <path d="m6 9 6 6 6-6" />,
|
||||
"chev-right": <path d="m9 6 6 6-6 6" />,
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</>
|
||||
),
|
||||
bell: <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0" />,
|
||||
help: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.5 9a2.5 2.5 0 1 1 4 2.2c-.7.5-1.5 1-1.5 2v.3M12 17h.01" />
|
||||
</>
|
||||
),
|
||||
doc: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6M9 13h6M9 17h6" />
|
||||
</>
|
||||
),
|
||||
up: <path d="M12 19V5M5 12l7-7 7 7" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
"product-plus": (
|
||||
<>
|
||||
<path d="M12 22V12" />
|
||||
<path d="M16 17h6" />
|
||||
<path d="M19 14v6" />
|
||||
<path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="m7.5 4.3 9 5.1" />
|
||||
</>
|
||||
),
|
||||
airshelf: (
|
||||
<>
|
||||
<path d="M4.5 19 12 4.5 19.5 19" />
|
||||
<path d="M8 14h8" />
|
||||
<path d="M7 17h10" />
|
||||
<path d="M9.5 10.3c1.5-1.2 3.5-1.2 5 0" />
|
||||
</>
|
||||
),
|
||||
flame: (
|
||||
<>
|
||||
<path d="M8.5 14.5c0-2 1.5-3.4 3.5-5.5.4 2.2 2.8 3.1 2.8 5.6a3.3 3.3 0 0 1-6.3-.1Z" />
|
||||
<path d="M12 22c4 0 7-2.8 7-7 0-4.7-3.3-7.3-5-12-2 3.3-7 6-7 12a5 5 0 0 0 5 7Z" />
|
||||
</>
|
||||
),
|
||||
check: <path d="M4 12l5 5L20 6" />,
|
||||
x: <path d="M5 5l14 14M19 5L5 19" />,
|
||||
"play-tri": <path d="m5 4 14 8-14 8Z" />,
|
||||
rotate: (
|
||||
<>
|
||||
<path d="M4 12a8 8 0 0 1 14-5.5L21 9" />
|
||||
<path d="M21 4v5h-5" />
|
||||
<path d="M20 12a8 8 0 0 1-14 5.5L3 15" />
|
||||
<path d="M3 20v-5h5" />
|
||||
</>
|
||||
),
|
||||
more: (
|
||||
<>
|
||||
<circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
wallet: (
|
||||
<>
|
||||
<rect x="3" y="6" width="18" height="13" rx="2" />
|
||||
<path d="M3 10h18M16 14h2" />
|
||||
</>
|
||||
),
|
||||
"credit-card": (
|
||||
<>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
</>
|
||||
),
|
||||
coin: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" />
|
||||
</>
|
||||
),
|
||||
download: <path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" />,
|
||||
team: (
|
||||
<>
|
||||
<circle cx="9" cy="9" r="3" />
|
||||
<path d="M3 20c0-3 3-5 6-5s6 2 6 5" />
|
||||
<circle cx="17" cy="10" r="2.4" />
|
||||
<path d="M21 19c0-2-1.6-4-4-4-.6 0-1.2.2-1.7.4" />
|
||||
</>
|
||||
),
|
||||
lightbulb: (
|
||||
<>
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
|
||||
</>
|
||||
),
|
||||
sparkles: (
|
||||
<>
|
||||
<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" />
|
||||
<path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z" />
|
||||
</>
|
||||
),
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</>
|
||||
),
|
||||
"arrow-right": <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||
};
|
||||
|
||||
interface Props extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export default function Icon({ name, size = 16, strokeWidth = 1.5, ...rest }: Props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...rest}
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const NAV = [
|
||||
{ id: "workspace", label: "工作台", icon: "home" as const, href: "/" },
|
||||
{ id: "play", label: "试拍台", icon: "play" as const, href: "/play" },
|
||||
{
|
||||
id: "projects",
|
||||
label: "项目",
|
||||
icon: "clapperboard" as const,
|
||||
href: "/projects",
|
||||
chev: true,
|
||||
},
|
||||
{ id: "products", label: "商品库", icon: "package" as const, href: "/products" },
|
||||
{ id: "library", label: "资产库", icon: "images" as const, href: "/library" },
|
||||
{ id: "usage", label: "用量", icon: "bars2" as const, href: "/usage" },
|
||||
{ id: "api", label: "API Keys", icon: "key" as const, href: "/api-keys" },
|
||||
{ id: "settings", label: "设置", icon: "cog" as const, href: "/settings" },
|
||||
];
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(href + "/");
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
// /account is reached via the user pill in the topbar — not in nav.
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">
|
||||
<Icon name="airshelf" size={22} />
|
||||
</div>
|
||||
<div className="brand-name">Airshelf</div>
|
||||
<div className="brand-ver">v1</div>
|
||||
</div>
|
||||
|
||||
<div className="search-box">
|
||||
<Icon name="search" />
|
||||
<span>搜索</span>
|
||||
<span className="kbd">⌘K</span>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{NAV.map((n) => {
|
||||
const active = isActive(pathname, n.href);
|
||||
return (
|
||||
<Link key={n.id} href={n.href} className={active ? "active" : ""}>
|
||||
<Icon name={n.icon} />
|
||||
<span className="label">{n.label}</span>
|
||||
{n.chev && <Icon name="chev-down" className="chev" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="nav-section">协作</div>
|
||||
<div className="nav-item disabled" title="V1.5 上线 · 敬请期待">
|
||||
<Icon name="team" />
|
||||
<span className="label">团队</span>
|
||||
<span className="badge-mini">V1.5</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="aside-foot">
|
||||
<Link href="/account" className="aside-user">
|
||||
<div className="av">李</div>
|
||||
<div className="em">li.dao@studio.cn</div>
|
||||
</Link>
|
||||
<div className="aside-collapse">
|
||||
<Icon name="chev-right" size={12} style={{ transform: "rotate(180deg)" }} />
|
||||
收起
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from "next/link";
|
||||
import Icon from "./Icon";
|
||||
|
||||
export interface Crumb {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Pass crumbs to show breadcrumbs (e.g. inner pages). Omit to show the team switcher (workspace). */
|
||||
crumbs?: Crumb[];
|
||||
balance?: string;
|
||||
}
|
||||
|
||||
export default function Topbar({ crumbs, balance = "¥327.40" }: Props) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
{crumbs && crumbs.length > 0 ? (
|
||||
<nav className="crumbs">
|
||||
{crumbs.map((c, i) => {
|
||||
const last = i === crumbs.length - 1;
|
||||
const sep = i > 0 ? <span key={`s-${i}`} className="sep">/</span> : null;
|
||||
return last ? (
|
||||
<span key={c.label}>{sep}<span className="here">{c.label}</span></span>
|
||||
) : (
|
||||
<span key={c.label}>
|
||||
{sep}
|
||||
{c.href ? <Link href={c.href}>{c.label}</Link> : <span>{c.label}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : (
|
||||
<div className="team-switcher" role="button">
|
||||
<div className="p">个</div>
|
||||
个人工作室
|
||||
<Icon name="chev-down" size={12} className="chev" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="top-r">
|
||||
<span className="balance-chip">
|
||||
<Icon name="credit-card" size={13} />
|
||||
余额 <strong>{balance}</strong>
|
||||
</span>
|
||||
<button className="icon-btn" aria-label="通知">
|
||||
<Icon name="bell" />
|
||||
<span className="dot" />
|
||||
</button>
|
||||
<button className="pill-btn">
|
||||
<Icon name="help" />
|
||||
帮助
|
||||
</button>
|
||||
<button className="pill-btn">
|
||||
<Icon name="doc" />
|
||||
文档
|
||||
</button>
|
||||
<button className="pill-btn upgrade">
|
||||
<Icon name="up" />
|
||||
升级
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# 部署操作手册
|
||||
|
||||
> 本文档说明如何将代码推送到测试环境和生产环境。
|
||||
> 日常开发在 `dev` 分支,生产发布通过合并到 `master` 分支触发。
|
||||
|
||||
---
|
||||
|
||||
## 环境说明
|
||||
|
||||
| 环境 | 触发分支 | 镜像仓库 | K3s 集群 | 域名 |
|
||||
|------|---------|---------|---------|------|
|
||||
| 测试(development) | `dev` | `cr.volces.com/zyc/...` | `192.168.0.129:6443` | `airflow-studio.test.airlabs.art` |
|
||||
| 生产(production) | `master` | `gitea-prod-cn-shanghai.cr.volces.com/prod/...` | `192.168.0.130:6443` | `airflow-studio.airlabs.art` |
|
||||
|
||||
---
|
||||
|
||||
## 推送到测试环境
|
||||
|
||||
只需要把代码推到 `dev` 分支,CI/CD 自动触发。
|
||||
|
||||
```bash
|
||||
# 确认当前在 dev 分支
|
||||
git checkout dev
|
||||
|
||||
# 提交代码
|
||||
git add .
|
||||
git commit -m "feat: 你的改动描述"
|
||||
|
||||
# 推送触发构建
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
构建完成后在 Gitea Actions 查看进度:
|
||||
- Build and Push Backend ✅
|
||||
- Build and Push Web ✅
|
||||
- Setup Kubectl ✅
|
||||
- Deploy to K3s ✅
|
||||
|
||||
---
|
||||
|
||||
## 推送到生产环境
|
||||
|
||||
> ⚠️ **注意**:操作完成后必须切回 `dev` 分支,不要在 `master` 上继续开发。
|
||||
|
||||
### 完整流程
|
||||
|
||||
```bash
|
||||
# 1. 确保 dev 分支代码是最新的
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
# 2. 切换到 master 分支
|
||||
git checkout master
|
||||
|
||||
# 3. 合并 dev 的代码
|
||||
git merge dev
|
||||
|
||||
# 4. 推送到远程,触发生产构建
|
||||
git push origin master
|
||||
|
||||
# 5. ⚠️ 立刻切回 dev,不要停留在 master
|
||||
git checkout dev
|
||||
```
|
||||
|
||||
### 如果有合并冲突
|
||||
|
||||
```bash
|
||||
# 解决冲突后
|
||||
git add .
|
||||
git commit -m "merge: dev into master"
|
||||
git push origin master
|
||||
git checkout dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建失败排查
|
||||
|
||||
### Build and Push 失败(docker pull 超时)
|
||||
Docker 镜像拉取超时,CI 会自动重试 3 次。如仍失败,检查构建机网络。
|
||||
|
||||
### Setup Kubectl 失败(command not found)
|
||||
kubectl 未安装或下载失败,CI 会自动从 daocloud 镜像安装。
|
||||
|
||||
### Deploy to K3s 失败(i/o timeout)
|
||||
K3s API Server 连接超时,CI 会自动重试 3 次(每次间隔 10 秒)。
|
||||
- 若持续失败,检查 K3s 节点状态:`kubectl get nodes`
|
||||
- 确认 kubeconfig secret(`VOLCANO_TEST_KUBE_CONFIG` / `VOLCANO_PROD_KUBE_CONFIG`)有值
|
||||
|
||||
---
|
||||
|
||||
## 快速检查部署状态
|
||||
|
||||
```bash
|
||||
# 测试环境
|
||||
ssh root@14.103.63.199
|
||||
kubectl get pods -n default
|
||||
|
||||
# 生产环境
|
||||
ssh root@118.196.0.100
|
||||
kubectl get pods -n default
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Celery Worker 监控
|
||||
|
||||
Celery worker 负责轮询火山 API 的视频生成状态。
|
||||
|
||||
```bash
|
||||
# 查看 worker 日志(测试环境)
|
||||
kubectl logs -f deployment/celery-worker -n default
|
||||
|
||||
# 查看队列积压(测试环境 Redis)
|
||||
redis-cli -h redis-shzlsczo52dft8mia.redis.ivolces.com -p 6379 -a Zyc188208 llen celery
|
||||
```
|
||||
|
||||
`recover_stuck_tasks` 定时任务每 3 分钟自动扫描卡住的任务并重新入队,无需手动干预。
|
||||
@@ -0,0 +1,15 @@
|
||||
# ClusterIssuer for Let's Encrypt automatic certificate generation & renewal
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: airlabsv001@gmail.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: airshelf-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- airshelf.airlabs.art
|
||||
secretName: airshelf-tls
|
||||
rules:
|
||||
- host: airshelf.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: airshelf-web
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-https
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
@@ -0,0 +1,59 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airshelf-web
|
||||
labels:
|
||||
app: airshelf-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airshelf-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airshelf-web
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: cr-pull-secret
|
||||
containers:
|
||||
- name: airshelf-web
|
||||
image: ${CI_REGISTRY_IMAGE}/airshelf-web:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
cpu: "20m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "150m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airshelf-web
|
||||
spec:
|
||||
selector:
|
||||
app: airshelf-web
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+1744
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "airshelf",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.5.18",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.0.0",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"postcss": "8.5.1",
|
||||
"tailwindcss": "4.0.0",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "_design_src"]
|
||||
}
|
||||
Reference in New Issue
Block a user