feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { ArrowLeft, ArrowRight, Grid2X2, List, Plus, Search, Trash2 } from "lucide-react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { stageMeta, stageOrder, statusPill } from "./stage-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { Progress } from "../components/pipeline-stage";
export function ProjectWizardPage({ products, onBack, onCreate }: {
products: Product[];
onBack: () => void;
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
}) {
const [productId, setProductId] = useState(products[0]?.id || "");
const product = products.find((item) => item.id === productId) || products[0];
const [name, setName] = useState(() => `${products[0]?.title || "商品"} · 短视频 · ${new Date().toLocaleDateString("zh-CN")}`);
useEffect(() => {
if (!productId && products[0]) setProductId(products[0].id);
}, [productId, products]);
function submit(event: FormEvent) {
event.preventDefault();
if (product) void onCreate({ name: name || `${product.title} · 短视频`, product: product.id });
}
return (
<>
<div className="page-head"><div><h1>新建视频项目</h1><div className="sub"><span className="mono">// 选择商品 → 创建项目 → 进入 Stage 1 脚本</span></div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}>退出</button></div></div>
<form className="wizard" onSubmit={submit}>
<nav className="steps" aria-label="新建项目步骤"><div className="step active"><div className="num">1</div><div><div className="label">选择商品</div><div className="desc">{product?.title || "未选择"}</div></div></div><div className="step"><div className="num">2</div><div><div className="label">项目名称</div><div className="desc">自动生成可修改</div></div></div><div className="step"><div className="num">3</div><div><div className="label">进入脚本</div><div className="desc">Stage 1 配置脚本</div></div></div></nav>
<div><section className="wiz-pane active"><div className="wiz-step-h"><h2>为哪个商品创建视频?</h2><p>按定稿,创建页只绑定商品和项目名称,脚本风格、卖点重点放到 Stage 1。</p></div><div className="product-select-grid">{products.map((item) => <button className={`product-pick ${productId === item.id ? "selected" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title}</span></div><strong>{item.title}</strong><span>{item.category || "未分类"} · {item.selling_points.length} 个卖点</span></button>)}</div>{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}<div className="field" style={{ marginTop: 18 }}><label className="field-label">项目名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="wiz-foot"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回</button><div className="hstack"><span className="muted-2 mono">// 下一步:流水线 Stage 1</span><button className="btn btn-primary btn-lg" type="submit" disabled={!product}><ArrowRight size={13} />创建并进入脚本</button></div></div></section></div>
<aside className="wiz-preview"><div className="pv-h"><span>实时预估</span><span className="live">LIVE</span></div><div className="pv-title">{name || product?.title || "未命名项目"}</div><div className="pv-metrics"><div className="pv-metric"><div className="l">片段</div><div className="v">4<small>段</small></div></div><div className="pv-metric accent"><div className="l">总时长</div><div className="v">60<small>s</small></div></div><div className="pv-metric"><div className="l">阶段</div><div className="v">5</div></div></div></aside>
</form>
</>
);
}
export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: {
products: Product[];
projects: Project[];
navigate: (page: Page) => void;
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
openPipeline: (projectId: string) => void;
onDelete: (projectId: string) => Promise<unknown> | void;
}) {
const [view, setView] = useState<"list" | "grid">("list");
const [query, setQuery] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const filtered = projects.filter((project) => `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase()));
async function confirmDelete() {
if (!deleteTarget) return;
await onDelete(deleteTarget.id);
setDeleteTarget(null);
}
return (
<>
<div className="page-head"><div><h1>视频项目</h1><div className="sub"><span className="mono">// {projects.length} 个 · {projects.filter((p) => p.status !== "completed").length} 进行中</span></div></div><div className="actions"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建视频项目</button></div></div>
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} /></div><span className="spacer" /><div className="view-toggle"><button className={view === "list" ? "active" : ""} type="button" onClick={() => setView("list")}><List size={13} />列表</button><button className={view === "grid" ? "active" : ""} type="button" onClick={() => setView("grid")}><Grid2X2 size={13} />网格</button></div></div>
{view === "list" ? <table className="t"><thead><tr><th>项目</th><th>商品</th><th>当前阶段</th><th>5 段进度</th><th>状态</th><th /></tr></thead><tbody>{filtered.map((project) => <tr key={project.id} onClick={() => openPipeline(project.id)}><td><div className="proj-name-cell"><div className="placeholder proj-thumb"><span className="ph-frame">9:16</span></div><div><div className="proj-name">{project.name}</div><div className="proj-sub">4 段 · 60s · AI 全生</div></div></div></td><td>{productTitle(project.product)}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><div className="hstack"><Progress status={project.current_stage} /><span className="muted-2 mono">{Math.max(stageOrder.indexOf(project.current_stage as never) + 1, 1)}/5</span></div></td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td><button className="icon-btn-sm" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><Trash2 size={13} /></button></td></tr>)}</tbody></table> : <div className="project-card-grid">{filtered.map((project) => <article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}><div className="placeholder proj-card-thumb"><span className="ph-frame">9:16</span></div><div className="proj-card-body"><strong>{project.name}</strong><div className="proj-sub">{productTitle(project.product)}</div><Progress status={project.current_stage} /></div></article>)}</div>}
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
</>
);
}