- 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>
537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|