seaislee1209 acbd2e30ad Initial commit: Air Spark project
- frontend/: Next.js 16 app (App Router, React 19, Tailwind v4)
- skills/: project skills (seedance, automation, trae-agents, etc.)
- Docs: PRD, UI-Design-System, DEV-LOG, seedance integration notes
- skills-lock.json: skills version lock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:08:49 +08:00

537 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Plus,
Search,
FolderOpen,
Film,
Clock,
MoreHorizontal,
Zap,
ChevronRight,
Loader2,
Settings,
Copy,
Archive,
Trash2,
X,
Sparkles,
} from "lucide-react";
/* ─────────────────────────────────────────────
Mock Data
───────────────────────────────────────────── */
interface Project {
id: string;
name: string;
type: "original" | "adaptation" | "short" | "custom";
episodes: number;
currentEpisode: number;
currentStage: number;
status: "idle" | "running" | "completed" | "failed";
updatedAt: string;
thumbnail?: string;
}
const TYPE_LABELS: Record<Project["type"], string> = {
original: "原创动画",
adaptation: "网文改编",
short: "短片 / PV",
custom: "自定义",
};
const STATUS_CONFIG: Record<
Project["status"],
{ label: string; dot: string; text: string }
> = {
idle: { label: "空闲", dot: "bg-gray-500", text: "text-text-secondary" },
running: {
label: "运行中",
dot: "bg-blue-500 animate-pulse",
text: "text-blue-400",
},
completed: { label: "已完成", dot: "bg-emerald-500", text: "text-emerald-400" },
failed: { label: "失败", dot: "bg-red-500", text: "text-red-400" },
};
const STAGE_NAMES = [
"剧本对话",
"规划",
"参考图生成",
"宫格生成",
"切分",
"视频生成",
"剪辑导出",
];
const MOCK_PROJECTS: Project[] = [
{
id: "proj_001",
name: "T仔的上班日记",
type: "original",
episodes: 12,
currentEpisode: 1,
currentStage: 6,
status: "running",
updatedAt: "2 小时前",
},
{
id: "proj_002",
name: "星际萌宠大冒险",
type: "original",
episodes: 8,
currentEpisode: 3,
currentStage: 7,
status: "completed",
updatedAt: "昨天",
},
{
id: "proj_003",
name: "凡人修仙传",
type: "adaptation",
episodes: 24,
currentEpisode: 1,
currentStage: 2,
status: "running",
updatedAt: "5 小时前",
},
{
id: "proj_004",
name: "产品宣传 PV",
type: "short",
episodes: 1,
currentEpisode: 1,
currentStage: 4,
status: "failed",
updatedAt: "3 天前",
},
];
/* ─────────────────────────────────────────────
CreateProjectModal
───────────────────────────────────────────── */
const PROJECT_TYPES = [
{ key: "original", label: "原创动画", desc: "从零开始创作原创动画剧本" },
{ key: "adaptation", label: "网文改编", desc: "将现有小说/文本改编为动画" },
{ key: "short", label: "短片 / PV", desc: "产品宣传、MV 等单集短片" },
{ key: "custom", label: "自定义", desc: "自由配置技能和流水线" },
] as const;
function CreateProjectModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const router = useRouter();
const [step, setStep] = useState(1);
const [name, setName] = useState("");
const [type, setType] = useState<string>("original");
const [episodes, setEpisodes] = useState("12");
if (!open) return null;
const handleCreate = () => {
// Mock: just navigate to new project detail
router.push("/dashboard/proj_new");
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div
className="relative w-full max-w-lg mx-4 rounded-2xl border border-white/10 p-6 shadow-2xl"
style={{
background: "rgba(15, 15, 25, 0.95)",
backdropFilter: "blur(20px)",
}}
>
{/* Close */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1.5 rounded-lg text-text-muted hover:text-text-secondary hover:bg-white/[0.06] cursor-pointer"
>
<X className="w-4 h-4" />
</button>
{/* Header */}
<div className="mb-6">
<h2 className="font-[family-name:var(--font-heading)] text-xl font-bold text-text-primary mb-1">
</h2>
<p className="text-sm text-text-secondary">
{step === 1 ? "填写项目基本信息" : "选择项目类型和规模"}
</p>
</div>
{step === 1 ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2"></label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例:恐龙也是打工龙"
className="w-full bg-white/[0.04] border border-white/10 rounded-lg px-4 py-3 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/50 focus:ring-3 focus:ring-accent/15 motion-safe:transition-all"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2"></label>
<div className="grid grid-cols-2 gap-2">
{PROJECT_TYPES.map((t) => (
<button
key={t.key}
onClick={() => setType(t.key)}
className={`p-3 rounded-xl text-left cursor-pointer motion-safe:transition-colors border
${
type === t.key
? "bg-accent/15 border-accent/20 text-accent"
: "bg-white/[0.04] border-white/[0.06] text-text-secondary hover:bg-white/[0.08]"
}`}
>
<p className="text-sm font-medium">{t.label}</p>
<p className="text-[10px] mt-0.5 text-text-muted">{t.desc}</p>
</button>
))}
</div>
</div>
<button
onClick={() => setStep(2)}
disabled={!name.trim()}
className={`w-full py-3 rounded-xl text-sm font-semibold motion-safe:transition-all cursor-pointer
${
name.trim()
? "bg-gradient-to-br from-violet-500 to-violet-700 text-white shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:shadow-[0_0_30px_rgba(139,92,246,0.5)]"
: "bg-white/[0.06] text-text-muted cursor-not-allowed"
}`}
>
</button>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2"></label>
<input
type="number"
value={episodes}
onChange={(e) => setEpisodes(e.target.value)}
min={1}
max={100}
className="w-full bg-white/[0.04] border border-white/10 rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-accent/50 focus:ring-3 focus:ring-accent/15 motion-safe:transition-all"
/>
</div>
{/* Skills preview */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2"></label>
<div className="space-y-2">
{["screenplay-skill", "storyboard-video-skill", "script-segmentation-skill"].map((s) => (
<div key={s} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/[0.04] border border-white/[0.06]">
<Sparkles className="w-3.5 h-3.5 text-accent" />
<span className="text-xs text-text-secondary">{s}</span>
</div>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep(1)}
className="flex-1 py-3 rounded-xl text-sm font-medium text-text-secondary bg-white/[0.06] border border-white/10 hover:bg-white/[0.1] cursor-pointer motion-safe:transition-colors"
>
</button>
<button
onClick={handleCreate}
className="flex-1 py-3 rounded-xl text-sm font-semibold text-white bg-gradient-to-br from-violet-500 to-violet-700 shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:shadow-[0_0_30px_rgba(139,92,246,0.5)] cursor-pointer motion-safe:transition-all flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
);
}
/* ─────────────────────────────────────────────
Components
───────────────────────────────────────────── */
function StageProgress({
currentStage,
status,
}: {
currentStage: number;
status: Project["status"];
}) {
return (
<div className="flex items-center gap-1">
{STAGE_NAMES.map((name, i) => {
const stageNum = i + 1;
const isCompleted = stageNum < currentStage;
const isCurrent = stageNum === currentStage;
const isFailed = isCurrent && status === "failed";
let bgColor = "bg-white/[0.06]"; // pending
if (isCompleted) bgColor = "bg-emerald-500/40";
if (isCurrent && status === "running") bgColor = "bg-blue-500/50 motion-safe:animate-pulse";
if (isCurrent && status === "completed") bgColor = "bg-emerald-500/40";
if (isFailed) bgColor = "bg-red-500/40";
return (
<div
key={stageNum}
className={`h-1.5 flex-1 rounded-full ${bgColor} motion-safe:transition-colors`}
title={`${stageNum}. ${name}`}
/>
);
})}
</div>
);
}
function ProjectCardMenu({ project }: { project: Project }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const MENU_ITEMS = [
{ icon: Settings, label: "项目设置", action: () => {} },
{ icon: Copy, label: "复制项目", action: () => {} },
{ icon: Archive, label: "归档", action: () => {} },
{ icon: Trash2, label: "删除", action: () => {}, danger: true },
];
return (
<div ref={menuRef} className="relative">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-text-secondary hover:bg-white/[0.06] motion-safe:transition-colors cursor-pointer"
aria-label="更多操作"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 w-40 py-1 rounded-xl border border-white/10 shadow-xl z-50"
style={{
background: "rgba(15, 15, 25, 0.95)",
backdropFilter: "blur(20px)",
}}
>
{MENU_ITEMS.map((item) => (
<button
key={item.label}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
setOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs cursor-pointer motion-safe:transition-colors
${
item.danger
? "text-red-400 hover:bg-red-500/10"
: "text-text-secondary hover:bg-white/[0.06] hover:text-text-primary"
}`}
>
<item.icon className="w-3.5 h-3.5" />
{item.label}
</button>
))}
</div>
)}
</div>
);
}
function ProjectCard({ project }: { project: Project }) {
const statusConf = STATUS_CONFIG[project.status];
const stageName = STAGE_NAMES[project.currentStage - 1] || "";
return (
<Link href={`/dashboard/${project.id}`} className="glass-card p-5 hover:bg-white/[0.08] motion-safe:transition-all motion-safe:duration-200 cursor-pointer group block">
{/* Header: type badge + menu */}
<div className="flex items-center justify-between mb-4">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-accent/10 text-accent border border-accent/15">
{TYPE_LABELS[project.type]}
</span>
<ProjectCardMenu project={project} />
</div>
{/* Title */}
<h3 className="font-[family-name:var(--font-heading)] text-lg font-semibold text-text-primary mb-1 truncate">
{project.name}
</h3>
{/* Meta: episodes + status */}
<div className="flex items-center gap-4 mb-4 text-sm">
<span className="flex items-center gap-1.5 text-text-secondary">
<Film className="w-3.5 h-3.5" />
{project.episodes}
</span>
<span className={`flex items-center gap-1.5 ${statusConf.text}`}>
<span className={`w-2 h-2 rounded-full ${statusConf.dot}`} />
{statusConf.label}
</span>
</div>
{/* Stage progress bar */}
<div className="mb-3">
<StageProgress
currentStage={project.currentStage}
status={project.status}
/>
</div>
{/* Current stage label + timestamp */}
<div className="flex items-center justify-between text-xs">
<span className="text-text-secondary">
EP{String(project.currentEpisode).padStart(2, "0")} ·{" "}
{project.currentStage > 0
? `${project.currentStage}阶段 — ${stageName}`
: "未开始"}
</span>
<span className="flex items-center gap-1 text-text-muted">
<Clock className="w-3 h-3" />
{project.updatedAt}
</span>
</div>
</Link>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-14 h-14 rounded-2xl bg-white/[0.04] flex items-center justify-center mb-5">
<FolderOpen className="w-7 h-7 text-text-muted" />
</div>
<h3 className="font-[family-name:var(--font-heading)] text-xl font-semibold text-text-primary mb-2">
</h3>
<p className="text-sm text-text-secondary mb-8 max-w-xs">
AI
</p>
<button
onClick={onCreate}
className="bg-gradient-to-br from-violet-500 to-violet-700 shadow-[0_0_20px_rgba(139,92,246,0.4),0_4px_12px_rgba(0,0,0,0.3)] border border-white/15 rounded-xl px-6 py-3 text-sm text-white font-semibold hover:shadow-[0_0_30px_rgba(139,92,246,0.6)] motion-safe:transition-all motion-safe:duration-200 active:scale-[0.98] cursor-pointer flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
</div>
);
}
/* ─────────────────────────────────────────────
Page
───────────────────────────────────────────── */
export default function DashboardPage() {
const [searchQuery, setSearchQuery] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const projects = MOCK_PROJECTS;
const filtered = projects.filter((p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="relative z-10 min-h-screen px-8 py-8">
{/* ─── Page Header ─── */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-[family-name:var(--font-heading)] text-3xl font-bold text-text-primary mb-1">
</h1>
<p className="text-sm text-text-secondary">
线
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-gradient-to-br from-violet-500 to-violet-700 shadow-[0_0_20px_rgba(139,92,246,0.4),0_4px_12px_rgba(0,0,0,0.3)] border border-white/15 rounded-xl px-5 py-2.5 text-sm text-white font-semibold hover:shadow-[0_0_30px_rgba(139,92,246,0.6)] motion-safe:transition-all motion-safe:duration-200 active:scale-[0.98] cursor-pointer flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* ─── Search & Stats ─── */}
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="搜索项目..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white/[0.04] border border-white/10 rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/50 focus:ring-3 focus:ring-accent/15 motion-safe:transition-all motion-safe:duration-200"
/>
</div>
<div className="flex items-center gap-6 text-sm text-text-secondary">
<span>
<span className="text-text-primary font-medium">{projects.length}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
{projects.filter((p) => p.status === "running").length}
</span>
</div>
</div>
{/* ─── Project Grid ─── */}
{filtered.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-5">
{filtered.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
) : searchQuery ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-14 h-14 rounded-2xl bg-white/[0.04] flex items-center justify-center mb-5">
<Search className="w-7 h-7 text-text-muted" />
</div>
<h3 className="font-[family-name:var(--font-heading)] text-xl font-semibold text-text-primary mb-2">
</h3>
<p className="text-sm text-text-secondary">
</p>
</div>
) : (
<EmptyState onCreate={() => setShowCreateModal(true)} />
)}
{/* Create Project Modal */}
<CreateProjectModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
</div>
);
}