Commit 75992d7b authored by yaoke.yk's avatar yaoke.yk

企业级工业风格

parent 175f406c
import type { ReactNode } from "react";
import { Link } from "react-router";
import { ArrowLeft, ShieldCheck, Sparkles } from "lucide-react";
import { brand, brandHighlights, brandMetrics } from "../../lib/brand";
import { BrandLogo } from "./BrandLogo";
type AuthShellProps = {
title: string;
description: string;
children: ReactNode;
footer: ReactNode;
};
export function AuthShell({ title, description, children, footer }: AuthShellProps) {
return (
<div className="relative min-h-screen overflow-hidden bg-background">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(34,146,255,0.22),transparent_34%),radial-gradient(circle_at_bottom_right,_rgba(6,43,92,0.16),transparent_28%)]" />
<div className="absolute inset-0 opacity-60 [background-image:linear-gradient(rgba(15,87,157,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(15,87,157,0.06)_1px,transparent_1px)] [background-size:32px_32px]" />
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 lg:px-8">
<div className="mb-6 flex items-center justify-between">
<BrandLogo subtitle={brand.tagline} />
<Link
to="/"
className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-white/75 px-4 py-2 text-sm text-muted-foreground shadow-sm backdrop-blur hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
返回工作台
</Link>
</div>
<div className="grid flex-1 items-stretch gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-[linear-gradient(145deg,#0d294b_0%,#11477f_46%,#1f9ce8_100%)] p-8 text-white shadow-[0_32px_90px_rgba(9,42,82,0.24)] lg:p-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.1),transparent_28%)]" />
<div className="absolute -right-20 top-10 h-72 w-72 rounded-full border border-white/15" />
<div className="absolute right-10 top-28 h-44 w-44 rounded-full border border-white/10" />
<div className="absolute bottom-10 left-8 h-28 w-28 rounded-full border border-white/10" />
<div className="relative flex h-full flex-col">
<div className="mb-8 inline-flex w-fit items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-2 text-sm text-white/86 backdrop-blur">
<ShieldCheck className="h-4 w-4" />
企业级短剧生产平台
</div>
<div className="max-w-2xl">
<div className="mb-3 text-sm uppercase tracking-[0.32em] text-white/64">
{brand.companyName}
</div>
<h1 className="max-w-xl text-4xl font-semibold leading-tight lg:text-5xl">
{brand.heroTitle}
</h1>
<p className="mt-5 max-w-xl text-base leading-7 text-white/76 lg:text-lg">
{brand.heroDescription}
</p>
</div>
<div className="mt-10 grid gap-4 sm:grid-cols-3">
{brandMetrics.map((metric) => (
<div
key={metric.label}
className="rounded-3xl border border-white/14 bg-white/10 px-5 py-4 backdrop-blur-sm"
>
<div className="text-2xl font-semibold">{metric.value}</div>
<div className="mt-2 text-sm text-white/66">{metric.label}</div>
</div>
))}
</div>
<div className="mt-auto pt-10">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1.5 text-xs uppercase tracking-[0.28em] text-white/72">
<Sparkles className="h-3.5 w-3.5" />
Platform Value
</div>
<div className="grid gap-3">
{brandHighlights.map((item) => (
<div
key={item}
className="rounded-2xl border border-white/14 bg-white/8 px-4 py-3 text-sm leading-6 text-white/82"
>
{item}
</div>
))}
</div>
</div>
</div>
</section>
<section className="flex items-center">
<div className="w-full rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_30px_80px_rgba(15,57,99,0.12)] backdrop-blur-xl lg:p-8">
<div className="mb-6">
<div className="mb-2 text-xs uppercase tracking-[0.28em] text-primary/70">
Secure Access
</div>
<h2 className="text-3xl font-semibold text-foreground">{title}</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{children}
<div className="mt-6 border-t border-border/80 pt-5 text-sm text-muted-foreground">
{footer}
</div>
</div>
</section>
</div>
</div>
</div>
);
}
import { Link } from "react-router";
import { brand } from "../../lib/brand";
import { cn } from "./ui/utils";
type BrandLogoProps = {
compact?: boolean;
className?: string;
href?: string;
subtitle?: string;
};
export function BrandLogo({
compact = false,
className,
href = "/",
subtitle,
}: BrandLogoProps) {
const content = (
<div className={cn("flex items-center gap-3", className)}>
<div
className={cn(
"overflow-hidden rounded-2xl ring-1 ring-white/70 shadow-[0_12px_28px_rgba(15,77,143,0.16)]",
compact ? "h-10 w-10 rounded-xl" : "h-12 w-12",
)}
>
<img
src={brand.logo}
alt={brand.companyName}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-primary/70">
{brand.companyName}
</div>
<div
className={cn(
"truncate font-semibold text-foreground",
compact ? "text-sm" : "text-base",
)}
>
{brand.productName}
</div>
{subtitle ? (
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
) : null}
</div>
</div>
);
return href ? (
<Link to={href} className="inline-flex">
{content}
</Link>
) : (
content
);
}
import { Outlet, Link, useLocation, useParams, useNavigate } from "react-router"; import { Link, Outlet, useLocation, useNavigate, useParams } from "react-router";
import { import {
Film, BarChart3,
Users,
Sparkles,
FolderOpen,
Settings,
Search,
Bell, Bell,
Globe, BookOpenText,
Bot,
ChevronDown, ChevronDown,
BarChart3,
Image as ImageIcon,
FileText,
Sliders,
Clapperboard, Clapperboard,
Video,
Bot,
Cpu, Cpu,
Film,
FolderOpen,
Globe,
Image as ImageIcon,
LogOut, LogOut,
PanelLeftClose,
PanelLeftOpen,
Search,
Settings,
ShieldCheck,
Sliders,
Sparkles,
User, User,
Users,
Video,
} from "lucide-react"; } from "lucide-react";
import { useState, useEffect, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { useBalance } from "../../hooks/useUsage"; import { useBalance } from "../../hooks/useUsage";
import { useProject } from "../../hooks/useProjects"; import { useProject } from "../../hooks/useProjects";
import { useTeamGroups } from "../../hooks/useTeam"; import { useTeamGroups } from "../../hooks/useTeam";
import logoImage from "../../imports/ab7d1ce5faf061d2af2b02d93e11935.png"; import { BrandLogo } from "./BrandLogo";
import { brand } from "../../lib/brand";
import { cn } from "./ui/utils";
type NavItem = {
label: string;
icon: typeof FolderOpen;
path: string;
match: (pathname: string) => boolean;
};
type ProjectSection = {
id: string;
label: string;
icon: typeof BookOpenText;
path: string;
};
const notifications = [
{
id: 1,
title: "《归潮计划》大纲已完成解析,可继续生成分集。",
time: "5 分钟前",
},
{
id: 2,
title: "角色组“都市主角团”新增 3 个参考资产。",
time: "18 分钟前",
},
{
id: 3,
title: "平台已同步企业品牌主题与工作台布局。",
time: "1 小时前",
},
];
const primaryNav: NavItem[] = [
{
label: "项目工作台",
icon: FolderOpen,
path: "/",
match: (pathname) => pathname === "/",
},
{
label: "新建项目",
icon: Sparkles,
path: "/new-project",
match: (pathname) => pathname === "/new-project",
},
{
label: "资产中心",
icon: ImageIcon,
path: "/assets/character",
match: (pathname) => pathname.startsWith("/assets"),
},
];
const manageNav: NavItem[] = [
{
label: "团队管理",
icon: Users,
path: "/team",
match: (pathname) => pathname === "/team",
},
{
label: "用量统计",
icon: BarChart3,
path: "/usage",
match: (pathname) => pathname === "/usage",
},
{
label: "模型配置",
icon: Cpu,
path: "/models",
match: (pathname) => pathname === "/models",
},
{
label: "水印设置",
icon: Settings,
path: "/settings",
match: (pathname) => pathname === "/settings",
},
];
function SectionLabel({ children, collapsed }: { children: string; collapsed: boolean }) {
return collapsed ? null : (
<div className="px-3 text-[11px] font-semibold uppercase tracking-[0.28em] text-muted-foreground/78">
{children}
</div>
);
}
function SidebarLink({
item,
pathname,
collapsed,
}: {
item: NavItem;
pathname: string;
collapsed: boolean;
}) {
const active = item.match(pathname);
const Icon = item.icon;
return (
<Link
to={item.path}
className={cn(
"group flex items-center rounded-2xl border px-3 py-3 text-sm transition-all",
collapsed ? "justify-center" : "gap-3",
active
? "border-primary/20 bg-[linear-gradient(135deg,rgba(15,116,216,0.14),rgba(30,165,255,0.05))] text-primary shadow-[0_10px_24px_rgba(15,116,216,0.10)]"
: "border-transparent text-foreground/78 hover:border-border hover:bg-white/70 hover:text-foreground",
)}
>
<div
className={cn(
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-all",
active ? "bg-primary text-white" : "bg-muted text-muted-foreground group-hover:bg-accent group-hover:text-primary",
)}
>
<Icon className="h-4 w-4" />
</div>
{collapsed ? null : (
<div className="min-w-0">
<div className="truncate font-medium">{item.label}</div>
</div>
)}
</Link>
);
}
export function Layout() { export function Layout() {
const location = useLocation(); const location = useLocation();
const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [showNotifications, setShowNotifications] = useState(false); const params = useParams();
const [showUserMenu, setShowUserMenu] = useState(false);
const [currentGroupId, setCurrentGroupId] = useState<number | "">("");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null); const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
const { user, clearAuth } = useAuthStore(); const { user, clearAuth } = useAuthStore();
const { data: balance } = useBalance(); const { data: balance } = useBalance();
const { data: teamGroups = [] } = useTeamGroups(); const { data: teamGroups = [] } = useTeamGroups();
// Close user menu on outside click const [showNotifications, setShowNotifications] = useState(false);
useEffect(() => { const [showUserMenu, setShowUserMenu] = useState(false);
const handler = (e: MouseEvent) => { const [currentGroupId, setCurrentGroupId] = useState<number | "">("");
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
setShowUserMenu(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const notifications = [
{ id: 1, type: "task", title: "项目《重生之豪门千金》第3集分镜待审核", time: "5分钟前" },
{ id: 2, type: "system", title: "系统更新:新增AI水印功能", time: "1小时前" },
{ id: 3, type: "approval", title: "张三提交的角色设定已通过审核", time: "2小时前" },
];
// Check if we're in a project route
const projectId = params.projectId; const projectId = params.projectId;
const isProjectRoute = projectId !== undefined; const isProjectRoute = projectId !== undefined;
const { data: currentProject } = useProject(projectId ?? ""); const { data: currentProject } = useProject(projectId ?? "");
// Auto-collapse sidebar when in project detail routes
useEffect(() => { useEffect(() => {
setSidebarCollapsed(isProjectRoute); setSidebarCollapsed(isProjectRoute);
}, [isProjectRoute]); }, [isProjectRoute]);
// Determine active section based on current path useEffect(() => {
const getActiveSection = () => { const handler = (event: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
setShowNotifications(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const activeSection = useMemo(() => {
const path = location.pathname; const path = location.pathname;
if (path.includes("/agent")) return "agent"; if (path.includes("/agent")) return "agent";
if (path.includes("/storyboard")) return "storyboard"; if (path.includes("/storyboard")) return "storyboard";
...@@ -82,333 +211,333 @@ export function Layout() { ...@@ -82,333 +211,333 @@ export function Layout() {
) { ) {
return "settings"; return "settings";
} }
if (
path.includes("/outline") ||
path.includes("/episodes") ||
path === `/project/${projectId}`
) {
return "script";
}
return "script"; return "script";
}; }, [location.pathname]);
const activeSection = getActiveSection(); const projectSections: ProjectSection[] = [
{ id: "agent", label: "Agent 制作", icon: Bot, path: `/project/${projectId}/agent` },
const projectSections = [ { id: "script", label: "剧本拆解", icon: BookOpenText, path: `/project/${projectId}` },
{ id: "agent", label: "Agent制作", icon: Bot, path: `/project/${projectId}/agent` }, { id: "settings", label: "人物设定", icon: Sliders, path: `/project/${projectId}/characters` },
{ id: "script", label: "剧本", icon: FileText, path: `/project/${projectId}` }, { id: "storyboard", label: "分镜工作台", icon: Clapperboard, path: `/project/${projectId}/storyboard` },
{ { id: "video", label: "视频生成", icon: Video, path: `/project/${projectId}/video` },
id: "settings",
label: "设定",
icon: Sliders,
path: `/project/${projectId}/characters`,
},
{
id: "storyboard",
label: "分镜",
icon: Clapperboard,
path: `/project/${projectId}/storyboard`,
},
{ id: "video", label: "视频", icon: Video, path: `/project/${projectId}/video` },
]; ];
const currentGroupName =
currentGroupId === ""
? "全部小组"
: teamGroups.find((group) => group.id === currentGroupId)?.name ?? "当前小组";
return ( return (
<div className="h-screen flex bg-background"> <div className="flex min-h-screen bg-transparent">
{/* Left Sidebar */} <aside
<aside className={`border-r border-border bg-card flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-56'}`}> className={cn(
{/* Logo */} "relative flex h-screen flex-col border-r border-white/70 bg-sidebar/80 p-3 shadow-[20px_0_40px_rgba(11,44,81,0.06)] backdrop-blur-xl transition-all duration-300",
<div className="h-14 border-b border-border flex items-center justify-center px-4"> sidebarCollapsed ? "w-24" : "w-80",
<Link to="/" className="flex items-center gap-2"> )}
{/* ZK Logo with Film Icon */} >
<div className="relative"> <div className="rounded-[28px] border border-white/70 bg-white/60 p-3 shadow-sm">
<Film className="w-8 h-8 text-primary" /> <div className="flex items-center justify-between gap-3">
<div className="absolute inset-0 flex items-center justify-center"> {sidebarCollapsed ? (
<span className="text-[10px] font-bold text-primary">ZK</span> <BrandLogo compact className="gap-0" subtitle={undefined} />
) : (
<BrandLogo subtitle={brand.tagline} />
)}
<button
type="button"
onClick={() => setSidebarCollapsed((value) => !value)}
className="flex h-10 w-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
{sidebarCollapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
{sidebarCollapsed ? null : (
<div className="mt-4 rounded-3xl bg-[linear-gradient(160deg,#0d2c4f_0%,#11477f_54%,#1c94e8_100%)] p-5 text-white shadow-[0_24px_52px_rgba(12,52,96,0.18)]">
<div className="mb-2 inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white/72">
<ShieldCheck className="h-3.5 w-3.5" />
Enterprise
</div> </div>
<div className="text-lg font-semibold leading-7">中科集团统一创作空间</div>
<p className="mt-2 text-sm leading-6 text-white/74">
在一套工作台里管理剧本、大纲、角色、场景、分镜和视频任务。
</p>
</div> </div>
{/* Product Name and Version */} )}
<div className="flex items-baseline gap-1">
<span className="text-sm font-semibold text-foreground">DramaStudio</span>
<span className="text-xs text-muted-foreground">V1</span>
</div>
</Link>
</div> </div>
<nav className="flex-1 p-3 overflow-auto"> <div className="mt-4 flex-1 overflow-y-auto rounded-[28px] border border-white/70 bg-white/56 p-3 shadow-sm">
{/* 小组板块 */} <SectionLabel collapsed={sidebarCollapsed}>Workspace</SectionLabel>
<div className="text-xs text-muted-foreground px-3 mb-2">小组</div> <div className="mt-3 space-y-2">
<div className="mb-6 px-3"> {primaryNav.map((item) => (
<div className="relative"> <SidebarLink
<select key={item.path}
value={currentGroupId} item={item}
onChange={(e) => setCurrentGroupId(Number(e.target.value) || "")} pathname={location.pathname}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer appearance-none pr-8" collapsed={sidebarCollapsed}
> />
<option value="">全部小组</option> ))}
{teamGroups.map((g) => ( </div>
<option key={g.id} value={g.id}>{g.name}</option>
))} <div className="mt-6">
</select> <SectionLabel collapsed={sidebarCollapsed}>Team Scope</SectionLabel>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" /> <div className="mt-3">
{sidebarCollapsed ? (
<div className="flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-primary">
<Users className="h-5 w-5" />
</div>
</div>
) : (
<div className="rounded-3xl border border-border/80 bg-white/70 p-3">
<div className="mb-2 text-xs text-muted-foreground">当前团队范围</div>
<div className="relative">
<select
value={currentGroupId}
onChange={(e) => setCurrentGroupId(Number(e.target.value) || "")}
className="h-11 w-full appearance-none rounded-2xl border border-border/80 bg-background px-4 pr-10 text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">全部小组</option>
{teamGroups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
)}
</div> </div>
</div> </div>
{/* 创作板块 */} <div className="mt-6">
<div className="text-xs text-muted-foreground px-3 mb-2">创作</div> <SectionLabel collapsed={sidebarCollapsed}>Management</SectionLabel>
<Link <div className="mt-3 space-y-2">
to="/new-project" {manageNav.map((item) => (
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${ <SidebarLink
location.pathname === "/new-project" key={item.path}
? "bg-accent text-accent-foreground" item={item}
: "text-foreground hover:bg-muted" pathname={location.pathname}
}`} collapsed={sidebarCollapsed}
> />
<Sparkles className="w-4 h-4" /> ))}
剧本生成短剧 </div>
</Link>
{/* 我的板块 */}
<div className="text-xs text-muted-foreground px-3 mt-6 mb-2">我的</div>
<Link
to="/"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<FolderOpen className="w-4 h-4" />
项目
</Link>
<Link
to="/assets/character"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname.startsWith("/assets")
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<ImageIcon className="w-4 h-4" />
资产
</Link>
{/* 企业配置 */}
<div className="text-xs text-muted-foreground px-3 mt-6 mb-2">企业配置</div>
<Link
to="/team"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors mb-1"
>
<Users className="w-4 h-4" />
团队管理
</Link>
<Link
to="/usage"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors mb-1"
>
<BarChart3 className="w-4 h-4" />
用量统计
</Link>
<Link
to="/models"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/models"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<Cpu className="w-4 h-4" />
模型配置
</Link>
<Link
to="/settings"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === "/settings"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<Settings className="w-4 h-4" />
AI生成水印
</Link>
</nav>
{/* Credits Display */}
<div className="p-3 border-t border-border">
<div className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2 text-primary">
<Sparkles className="w-4 h-4" />
<span className="font-medium">
{Number(balance?.balance ?? 0).toLocaleString()}
</span>
</span>
<button
onClick={() => navigate("/usage")}
className="text-xs text-muted-foreground hover:text-foreground"
>
购买
</button>
</div> </div>
</div> </div>
</aside>
{/* Main Content */} <div className="mt-4 rounded-[28px] border border-white/70 bg-white/64 p-3 shadow-sm">
<div className="flex-1 flex flex-col overflow-hidden"> <div
{/* Top Header */} className={cn(
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-6"> "rounded-3xl border border-primary/10 bg-[linear-gradient(135deg,rgba(15,116,216,0.10),rgba(30,165,255,0.03))]",
<div className="flex items-center gap-4 flex-1"> sidebarCollapsed ? "px-3 py-4 text-center" : "px-4 py-4",
{/* Logo - Show when sidebar is collapsed */} )}
{sidebarCollapsed && ( >
<Link to="/" className="flex-shrink-0 flex items-center gap-2"> {sidebarCollapsed ? (
{/* ZK Logo with Film Icon */} <>
<div className="relative"> <div className="mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-white">
<Film className="w-7 h-7 text-primary" /> <Sparkles className="h-4 w-4" />
<div className="absolute inset-0 flex items-center justify-center"> </div>
<span className="text-[9px] font-bold text-primary">ZK</span> <div className="text-sm font-semibold text-foreground">
{Number(balance?.balance ?? 0).toLocaleString()}
</div>
</>
) : (
<>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">
Credits
</div>
<div className="mt-1 text-2xl font-semibold text-foreground">
{Number(balance?.balance ?? 0).toLocaleString()}
</div>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-white shadow-[0_16px_28px_rgba(15,116,216,0.22)]">
<Sparkles className="h-5 w-5" />
</div> </div>
</div> </div>
{/* Product Name and Version */} <div className="mt-3 text-sm text-muted-foreground">
<div className="flex items-baseline gap-1"> 当前团队范围:{currentGroupName}
<span className="text-sm font-semibold text-foreground">DramaStudio</span>
<span className="text-xs text-muted-foreground">V1</span>
</div> </div>
</Link> <button
type="button"
onClick={() => navigate("/usage")}
className="mt-4 inline-flex w-full items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
查看用量与采购
</button>
</>
)} )}
</div>
</div>
</aside>
{/* Search Bar */} <div className="flex min-w-0 flex-1 flex-col">
<div className="flex-1 max-w-md"> <header className="sticky top-0 z-30 border-b border-white/70 bg-white/72 px-6 py-4 backdrop-blur-xl">
<div className="relative"> <div className="flex items-center gap-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <div className="flex min-w-0 flex-1 items-center gap-4">
{sidebarCollapsed ? <BrandLogo compact subtitle={undefined} /> : null}
<div className="relative max-w-xl flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
type="text" type="text"
placeholder="搜索项目或文档" placeholder="搜索项目、资产、成员或文档"
className="w-full pl-9 pr-4 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="h-12 w-full rounded-2xl border border-white/80 bg-white/80 pl-11 pr-4 text-sm text-foreground shadow-sm outline-none ring-0 transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/> />
</div> </div>
</div> </div>
</div>
{/* Right Section */} <div className="hidden items-center gap-2 lg:flex">
<div className="flex items-center gap-4"> <button className="rounded-full border border-border/80 bg-white/80 px-4 py-2 text-sm text-muted-foreground shadow-sm transition hover:text-foreground">
{/* Top Nav Menu */} 操作指南
<nav className="flex items-center gap-1 text-sm text-muted-foreground"> </button>
<button className="px-3 py-1.5 hover:text-foreground transition-colors">工具</button> <button className="rounded-full border border-border/80 bg-white/80 px-4 py-2 text-sm text-muted-foreground shadow-sm transition hover:text-foreground">
<button className="px-3 py-1.5 hover:text-foreground transition-colors">费用</button> 平台文档
<button className="px-3 py-1.5 hover:text-foreground transition-colors">支持</button> </button>
<button className="px-3 py-1.5 hover:text-foreground transition-colors">文档</button> </div>
</nav>
<button className="hidden h-11 items-center gap-2 rounded-full border border-border/80 bg-white/80 px-4 text-sm text-muted-foreground shadow-sm md:inline-flex">
{/* Notifications */} <Globe className="h-4 w-4" />
<div className="relative"> 中文
</button>
<div className="relative" ref={notificationRef}>
<button <button
onClick={() => setShowNotifications(!showNotifications)} type="button"
className="relative p-2 hover:bg-muted rounded-lg transition-colors" onClick={() => setShowNotifications((value) => !value)}
className="relative flex h-11 w-11 items-center justify-center rounded-2xl border border-border/80 bg-white/80 text-muted-foreground shadow-sm transition hover:text-primary"
> >
<Bell className="w-4 h-4 text-muted-foreground" /> <Bell className="h-4 w-4" />
<span className="absolute top-1 right-1 w-2 h-2 bg-destructive rounded-full"></span> <span className="absolute right-3 top-3 h-2 w-2 rounded-full bg-sky-500" />
</button> </button>
{showNotifications && ( {showNotifications ? (
<div className="absolute right-0 top-12 w-80 bg-card border border-border rounded-xl shadow-lg z-50"> <div className="absolute right-0 top-14 w-96 rounded-[28px] border border-white/80 bg-white/92 p-3 shadow-[0_26px_70px_rgba(10,42,76,0.16)] backdrop-blur-xl">
<div className="p-4 border-b border-border"> <div className="mb-2 flex items-center justify-between px-2 py-1">
<h3 className="font-medium text-foreground">通知中心</h3> <div>
<div className="text-sm font-semibold text-foreground">消息中心</div>
<div className="text-xs text-muted-foreground">项目进度、资产更新与系统通知</div>
</div>
<span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
{notifications.length}
</span>
</div> </div>
<div className="max-h-96 overflow-auto"> <div className="space-y-2">
{notifications.map((notif) => ( {notifications.map((notification) => (
<button <button
key={notif.id} key={notification.id}
className="w-full p-4 text-left hover:bg-muted transition-colors border-b border-border last:border-b-0" className="w-full rounded-2xl border border-border/70 bg-white/70 px-4 py-3 text-left transition hover:border-primary/20 hover:bg-accent/60"
> >
<div className="text-sm text-foreground mb-1">{notif.title}</div> <div className="text-sm leading-6 text-foreground">{notification.title}</div>
<div className="text-xs text-muted-foreground">{notif.time}</div> <div className="mt-1 text-xs text-muted-foreground">{notification.time}</div>
</button> </button>
))} ))}
</div> </div>
<div className="p-3 border-t border-border text-center">
<button className="text-sm text-primary hover:underline">查看全部</button>
</div>
</div> </div>
)} ) : null}
</div> </div>
{/* Language Switcher */}
<button className="p-2 hover:bg-muted rounded-lg transition-colors">
<Globe className="w-4 h-4 text-muted-foreground" />
</button>
{/* User Avatar */}
<div className="relative" ref={userMenuRef}> <div className="relative" ref={userMenuRef}>
<button <button
onClick={() => setShowUserMenu(!showUserMenu)} type="button"
className="flex items-center gap-2 px-2 py-1.5 hover:bg-muted rounded-lg transition-colors" onClick={() => setShowUserMenu((value) => !value)}
className="flex items-center gap-3 rounded-2xl border border-border/80 bg-white/80 px-3 py-2 shadow-sm transition hover:border-primary/20"
> >
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-sm font-semibold text-white shadow-[0_12px_24px_rgba(15,116,216,0.18)]">
<span className="text-[11px] font-bold text-white"> {user?.username?.[0]?.toUpperCase() ?? "U"}
{user?.username?.[0]?.toUpperCase() ?? "U"}
</span>
</div> </div>
<ChevronDown className="w-3 h-3 text-muted-foreground" /> <div className="hidden text-left md:block">
<div className="max-w-36 truncate text-sm font-medium text-foreground">
{user?.username ?? "当前用户"}
</div>
<div className="text-xs text-muted-foreground">企业创作成员</div>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button> </button>
{showUserMenu && (
<div className="absolute right-0 top-12 w-48 bg-card border border-border rounded-xl shadow-lg z-50"> {showUserMenu ? (
<div className="px-4 py-3 border-b border-border"> <div className="absolute right-0 top-14 w-60 rounded-[28px] border border-white/80 bg-white/92 p-3 shadow-[0_24px_60px_rgba(10,42,76,0.14)] backdrop-blur-xl">
<div className="flex items-center gap-2"> <div className="rounded-3xl border border-border/70 bg-white/70 px-4 py-4">
<User className="w-4 h-4 text-muted-foreground" /> <div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground truncate">{user?.username}</span> <div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-primary">
<User className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">
{user?.username ?? "当前用户"}
</div>
<div className="text-xs text-muted-foreground">已加入企业创作空间</div>
</div>
</div> </div>
</div> </div>
<button <button
onClick={() => { clearAuth(); navigate("/login"); }} type="button"
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-destructive hover:bg-muted transition-colors rounded-b-xl" onClick={() => {
clearAuth();
navigate("/login");
}}
className="mt-3 flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm text-destructive transition hover:bg-red-50"
> >
<LogOut className="w-4 h-4" /> <LogOut className="h-4 w-4" />
退出登录 退出登录
</button> </button>
</div> </div>
)} ) : null}
</div> </div>
</div> </div>
</header>
{/* Project Navigation */} {isProjectRoute ? (
{isProjectRoute && ( <div className="mt-4 rounded-[30px] border border-white/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.92),rgba(229,241,251,0.82))] p-5 shadow-sm">
<div className="border-b border-border bg-card px-6 py-3"> <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="flex items-center gap-6"> <div>
{/* Project Name */} <div className="mb-2 inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<div className="flex items-center gap-2 pr-6 border-r border-border"> <Film className="h-3.5 w-3.5" />
<Film className="w-4 h-4 text-primary" /> Project Pipeline
<span className="font-medium text-foreground">{currentProject?.name ?? projectId}</span> </div>
</div> <div className="text-2xl font-semibold text-foreground">
{currentProject?.name ?? "当前项目"}
</div>
<div className="mt-2 text-sm text-muted-foreground">
按照剧本拆解、设定、分镜、视频的标准链路推进企业级短剧生产。
</div>
</div>
{/* Section Tabs */} <div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2"> {projectSections.map((section) => {
{projectSections.map((section) => { const Icon = section.icon;
const Icon = section.icon; const selected = activeSection === section.id;
return ( return (
<button <button
key={section.id} key={section.id}
onClick={() => navigate(section.path)} type="button"
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm transition-all ${ onClick={() => navigate(section.path)}
activeSection === section.id className={cn(
? "bg-accent text-accent-foreground font-medium" "inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all",
: "text-muted-foreground hover:text-foreground hover:bg-muted" selected
}`} ? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
> : "border-border/80 bg-white/78 text-muted-foreground hover:border-primary/20 hover:text-foreground",
<Icon className="w-4 h-4" /> )}
{section.label} >
</button> <Icon className="h-4 w-4" />
); {section.label}
})} </button>
);
})}
</div>
</div> </div>
</div> </div>
</div> ) : null}
)} </header>
{/* Page Content */} <main className="min-h-0 flex-1 overflow-auto px-6 py-6">
<main className="flex-1 overflow-auto">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
</div> </div>
); );
} }
\ No newline at end of file
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { Droplet, Upload, Eye, Save, Loader2 } from "lucide-react"; import { Droplet, Eye, Loader2, Save, Upload, WandSparkles } from "lucide-react";
import { useSetting, useSaveSetting } from "../../hooks/useSettings"; import { useSaveSetting, useSetting } from "../../hooks/useSettings";
const SETTING_KEY = "watermark"; const settingKey = "watermark";
interface WatermarkConfig { interface WatermarkConfig {
enabled: boolean; enabled: boolean;
...@@ -35,7 +35,7 @@ const sizes = [ ...@@ -35,7 +35,7 @@ const sizes = [
]; ];
export function AIWatermarkSettings() { export function AIWatermarkSettings() {
const { data: rawSetting, isLoading } = useSetting(SETTING_KEY); const { data: rawSetting, isLoading } = useSetting(settingKey);
const saveSetting = useSaveSetting(); const saveSetting = useSaveSetting();
const [config, setConfig] = useState<WatermarkConfig>(defaultConfig); const [config, setConfig] = useState<WatermarkConfig>(defaultConfig);
...@@ -43,140 +43,172 @@ export function AIWatermarkSettings() { ...@@ -43,140 +43,172 @@ export function AIWatermarkSettings() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => { useEffect(() => {
if (rawSetting) { if (!rawSetting) return;
try { try {
const parsed = JSON.parse(rawSetting) as WatermarkConfig; const parsed = JSON.parse(rawSetting) as WatermarkConfig;
setConfig({ ...defaultConfig, ...parsed }); setConfig({ ...defaultConfig, ...parsed });
} catch { } catch {
// keep default setConfig(defaultConfig);
}
} }
}, [rawSetting]); }, [rawSetting]);
const handleSave = async () => { const handleSave = async () => {
await saveSetting.mutateAsync({ key: SETTING_KEY, value: JSON.stringify(config) }); await saveSetting.mutateAsync({ key: settingKey, value: JSON.stringify(config) });
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); window.setTimeout(() => setSaved(false), 2000);
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="mx-auto max-w-[1380px]">
<div className="max-w-5xl mx-auto"> <section className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]">
<div className="mb-6"> <div className="relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]">
<h1 className="text-2xl font-semibold text-foreground mb-1">AI生成水印设置</h1> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]" />
<p className="text-sm text-muted-foreground">配置AI生成内容的默认水印样式</p> <div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" />
<div className="absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10" />
<div className="relative">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74">
<Droplet className="h-4 w-4" />
Watermark Policy
</div>
<h1 className="mt-5 max-w-2xl text-4xl font-semibold leading-tight">
配置 AI 生成内容的默认水印策略
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-white/76">
统一控制默认水印开关、文字内容、位置、透明度和尺寸,帮助团队输出具备明确标识的 AI 生成内容。
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{[
{ title: "统一策略", desc: "对 AI 生成内容保持一致的品牌和合规标识。" },
{ title: "灵活预览", desc: "直接在右侧面板中查看水印覆盖效果。" },
{ title: "快速落地", desc: "修改后立即保存为团队默认配置。" },
].map((item, index) => (
<div
key={item.title}
className="rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<div className="mb-3 text-xs uppercase tracking-[0.24em] text-white/64">
0{index + 1}
</div>
<div className="text-lg font-medium">{item.title}</div>
<div className="mt-2 text-sm leading-6 text-white/74">{item.desc}</div>
</div>
))}
</div>
</div>
</div> </div>
<div className="grid grid-cols-3 gap-6"> <div className="rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="col-span-2 space-y-6"> <div className="mb-6 flex items-start justify-between gap-4">
{/* Enable/Disable */} <div>
<div className="rounded-xl border border-border bg-card p-5"> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Watermark Config</div>
<div className="flex items-center justify-between mb-4"> <h2 className="mt-2 text-3xl font-semibold text-foreground">默认配置</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
保存后会作为 AI 生成内容的默认水印策略。
</p>
</div>
<div className="flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary">
<WandSparkles className="h-6 w-6" />
</div>
</div>
<div className="space-y-5">
<div className="rounded-[28px] border border-border/80 bg-white/70 p-5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div> <div>
<h3 className="font-medium text-foreground mb-1">启用水印</h3> <div className="text-base font-medium text-foreground">启用水印</div>
<p className="text-sm text-muted-foreground">为所有AI生成的内容添加水印</p> <div className="mt-2 text-sm text-muted-foreground">为所有 AI 生成内容叠加默认水印标识。</div>
</div> </div>
<button <button
onClick={() => setConfig({ ...config, enabled: !config.enabled })} onClick={() => setConfig((current) => ({ ...current, enabled: !current.enabled }))}
className={`relative w-12 h-6 rounded-full transition-colors ${ className={[
config.enabled ? "bg-primary" : "bg-muted" "relative h-7 w-14 rounded-full transition-colors",
}`} config.enabled ? "bg-primary" : "bg-muted",
].join(" ")}
> >
<div <div
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${ className={[
config.enabled ? "translate-x-7" : "translate-x-1" "absolute top-1 h-5 w-5 rounded-full bg-white transition-transform",
}`} config.enabled ? "translate-x-8" : "translate-x-1",
].join(" ")}
/> />
</button> </button>
</div> </div>
</div> </div>
{/* Watermark Text */} <div>
<div className="rounded-xl border border-border bg-card p-5"> <label className="mb-2 block text-sm font-medium text-foreground">水印文字</label>
<label className="block font-medium text-foreground mb-3">水印文字</label>
<input <input
type="text"
value={config.text} value={config.text}
onChange={(e) => setConfig({ ...config, text: e.target.value })} onChange={(e) => setConfig((current) => ({ ...current, text: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入水印文字" placeholder="输入水印文字"
className="w-full px-4 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/> />
</div> </div>
{/* Custom Logo */} <div className="rounded-[28px] border border-border/80 bg-white/70 p-5 shadow-sm">
<div className="rounded-xl border border-border bg-card p-5"> <div className="mb-3 text-sm font-medium text-foreground">自定义 Logo</div>
<label className="block font-medium text-foreground mb-3">自定义水印Logo</label> <label className="flex cursor-pointer items-center justify-between rounded-2xl border border-dashed border-border px-4 py-4 transition hover:border-primary/20">
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer"> <div>
<div className="text-sm text-foreground">
{customLogo ? customLogo.name : "点击上传 Logo 图片"}
</div>
<div className="mt-1 text-xs text-muted-foreground">支持 PNG、JPG,建议尺寸 200x200</div>
</div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Upload className="h-5 w-5" />
</div>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
onChange={(e) => {
if (e.target.files && e.target.files[0]) setCustomLogo(e.target.files[0]);
}}
className="hidden" className="hidden"
id="logo-upload" onChange={(e) => setCustomLogo(e.target.files?.[0] ?? null)}
/> />
<label htmlFor="logo-upload" className="cursor-pointer"> </label>
{customLogo ? (
<div>
<div className="w-16 h-16 rounded-lg bg-muted mx-auto mb-3 flex items-center justify-center">
<Droplet className="w-8 h-8 text-primary" />
</div>
<p className="text-foreground mb-1">{customLogo.name}</p>
<span className="text-sm text-primary hover:underline">重新上传</span>
</div>
) : (
<div>
<Upload className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground mb-1">点击上传Logo图片</p>
<p className="text-sm text-muted-foreground">支持 PNG、JPG 格式,建议尺寸 200x200</p>
</div>
)}
</label>
</div>
</div> </div>
{/* Position */} <div>
<div className="rounded-xl border border-border bg-card p-5"> <label className="mb-2 block text-sm font-medium text-foreground">水印位置</label>
<label className="block font-medium text-foreground mb-3">水印位置</label> <div className="grid gap-3 sm:grid-cols-3">
<div className="grid grid-cols-3 gap-3"> {positions.map((position) => (
{positions.map((pos) => (
<button <button
key={pos.value} key={position.value}
onClick={() => setConfig({ ...config, position: pos.value })} onClick={() => setConfig((current) => ({ ...current, position: position.value }))}
className={`px-4 py-3 rounded-lg border transition-all text-sm ${ className={[
config.position === pos.value "rounded-2xl border px-4 py-3 text-sm transition-all",
? "border-primary bg-accent text-primary" config.position === position.value
: "border-border bg-background text-foreground hover:bg-muted" ? "border-primary/20 bg-primary/5 text-primary"
}`} : "border-border/80 bg-white text-foreground hover:border-primary/20",
].join(" ")}
> >
{pos.label} {position.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Size */} <div>
<div className="rounded-xl border border-border bg-card p-5"> <label className="mb-2 block text-sm font-medium text-foreground">水印大小</label>
<label className="block font-medium text-foreground mb-3">水印大小</label> <div className="grid gap-3 sm:grid-cols-3">
<div className="grid grid-cols-3 gap-3">
{sizes.map((size) => ( {sizes.map((size) => (
<button <button
key={size.value} key={size.value}
onClick={() => setConfig({ ...config, size: size.value })} onClick={() => setConfig((current) => ({ ...current, size: size.value }))}
className={`px-4 py-3 rounded-lg border transition-all text-sm ${ className={[
"rounded-2xl border px-4 py-3 text-sm transition-all",
config.size === size.value config.size === size.value
? "border-primary bg-accent text-primary" ? "border-primary/20 bg-primary/5 text-primary"
: "border-border bg-background text-foreground hover:bg-muted" : "border-border/80 bg-white text-foreground hover:border-primary/20",
}`} ].join(" ")}
> >
{size.label} {size.label}
</button> </button>
...@@ -184,106 +216,89 @@ export function AIWatermarkSettings() { ...@@ -184,106 +216,89 @@ export function AIWatermarkSettings() {
</div> </div>
</div> </div>
{/* Opacity */} <div>
<div className="rounded-xl border border-border bg-card p-5"> <label className="mb-2 block text-sm font-medium text-foreground">透明度:{config.opacity}%</label>
<label className="block font-medium text-foreground mb-3">
透明度:{config.opacity}%
</label>
<input <input
type="range" type="range"
min="0" min="0"
max="100" max="100"
value={config.opacity} value={config.opacity}
onChange={(e) => setConfig({ ...config, opacity: Number(e.target.value) })} onChange={(e) => setConfig((current) => ({ ...current, opacity: Number(e.target.value) }))}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-muted accent-primary"
/> />
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saveSetting.isPending} disabled={saveSetting.isPending}
className="w-full py-3 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-70" className="inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{saveSetting.isPending ? ( {saveSetting.isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{saved ? "已保存" : "保存设置"} {saved ? "已保存" : "保存设置"}
</button> </button>
</div> </div>
</div>
</section>
{/* Preview Panel */} <section className="mt-6 rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="col-span-1"> <div className="mb-5 flex items-center gap-2">
<div className="rounded-xl border border-border bg-card p-5 sticky top-6"> <Eye className="h-5 w-5 text-primary" />
<div className="flex items-center gap-2 mb-4"> <h3 className="text-2xl font-semibold text-foreground">预览效果</h3>
<Eye className="w-4 h-4 text-primary" /> </div>
<h3 className="font-medium text-foreground">预览效果</h3>
</div>
<div className="aspect-video rounded-lg bg-gradient-to-br from-purple-100 to-blue-100 relative overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<Droplet className="w-16 h-16 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-sm text-muted-foreground/50">示例内容</p>
</div>
</div>
{config.enabled && ( <div className="grid gap-5 xl:grid-cols-[1fr_320px]">
<div <div className="relative aspect-video overflow-hidden rounded-[28px] bg-[linear-gradient(145deg,#dbeafe_0%,#eff6ff_48%,#bfdbfe_100%)]">
className={`absolute ${ <div className="absolute inset-0 flex items-center justify-center">
config.position === "top-left" ? "top-3 left-3" <div className="text-center">
: config.position === "top-right" ? "top-3 right-3" <Droplet className="mx-auto h-16 w-16 text-sky-900/20" />
: config.position === "bottom-left" ? "bottom-3 left-3" <div className="mt-3 text-sm text-sky-900/40">示例内容预览区</div>
: config.position === "bottom-right" ? "bottom-3 right-3"
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
}`}
style={{ opacity: config.opacity / 100 }}
>
<div
className={`px-3 py-1.5 rounded bg-black/20 backdrop-blur-sm text-white ${
config.size === "small" ? "text-xs"
: config.size === "large" ? "text-base"
: "text-sm"
}`}
>
{config.text}
</div>
</div>
)}
</div> </div>
</div>
<div className="mt-4 p-3 rounded-lg bg-muted space-y-2 text-sm"> {config.enabled ? (
<div className="flex justify-between"> <div
<span className="text-muted-foreground">状态:</span> className={[
<span className="text-foreground">{config.enabled ? "已启用" : "已禁用"}</span> "absolute",
</div> config.position === "top-left"
<div className="flex justify-between"> ? "left-5 top-5"
<span className="text-muted-foreground">位置:</span> : config.position === "top-right"
<span className="text-foreground"> ? "right-5 top-5"
{positions.find((p) => p.value === config.position)?.label} : config.position === "bottom-left"
</span> ? "bottom-5 left-5"
</div> : config.position === "center"
<div className="flex justify-between"> ? "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
<span className="text-muted-foreground">大小:</span> : "bottom-5 right-5",
<span className="text-foreground"> ].join(" ")}
{sizes.find((s) => s.value === config.size)?.label} style={{ opacity: config.opacity / 100 }}
</span> >
</div> <div
<div className="flex justify-between"> className={[
<span className="text-muted-foreground">透明度:</span> "rounded-2xl bg-slate-950/24 px-4 py-2 text-white backdrop-blur-sm",
<span className="text-foreground">{config.opacity}%</span> config.size === "small"
? "text-xs"
: config.size === "large"
? "text-base"
: "text-sm",
].join(" ")}
>
{config.text}
</div> </div>
</div> </div>
) : null}
</div>
<div className="rounded-[28px] border border-border/80 bg-white/78 p-5 shadow-sm">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Preview Summary</div>
<div className="mt-4 space-y-3 text-sm text-muted-foreground">
<div>状态:{config.enabled ? "启用" : "关闭"}</div>
<div>位置:{positions.find((item) => item.value === config.position)?.label ?? config.position}</div>
<div>大小:{sizes.find((item) => item.value === config.size)?.label ?? config.size}</div>
<div>透明度:{config.opacity}%</div>
<div>Logo:{customLogo ? "已上传" : "未上传"}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</div> </div>
); );
} }
import { useState } from "react"; import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router";
import { import {
Users as UsersIcon,
MapPin,
Box, Box,
Heart,
Image as ImageIcon,
Loader2,
MapPin,
Plus, Plus,
Search, Search,
Star, Sparkles,
Trash2, Trash2,
Filter, Users as UsersIcon,
User, X,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useGlobalAssets, useCreateAsset, useToggleFavorite, useDeleteAsset } from "../../hooks/useAssets"; import { useCreateAsset, useDeleteAsset, useGlobalAssets } from "../../hooks/useAssets";
import type { GlobalAsset } from "../../lib/api/assets";
type AssetTypeStr = "character" | "scene" | "prop"; type AssetType = "character" | "scene" | "prop";
type LibraryType = "personal" | "team";
const assetTypes = [ const assetTypeMeta: Record<AssetType, { label: string; icon: typeof UsersIcon }> = {
{ id: "character" as AssetTypeStr, label: "角色库", icon: UsersIcon }, character: { label: "角色资产", icon: UsersIcon },
{ id: "scene" as AssetTypeStr, label: "场景库", icon: MapPin }, scene: { label: "场景资产", icon: MapPin },
{ id: "prop" as AssetTypeStr, label: "道具库", icon: Box }, prop: { label: "道具资产", icon: Box },
]; };
export function AssetsManagement() { export function AssetsManagement() {
const navigate = useNavigate(); const [activeType, setActiveType] = useState<AssetType>("character");
const params = useParams();
const activeType = ((params["*"]?.split("/")[0]) || "character") as AssetTypeStr;
const [libraryType, setLibraryType] = useState<LibraryType>("personal");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [showModal, setShowModal] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false); const [form, setForm] = useState({
name: "",
// create form tags: "",
const [formName, setFormName] = useState(""); description: "",
const [formDesc, setFormDesc] = useState(""); imageUrl: "",
const [formTags, setFormTags] = useState("");
const [formLibrary, setFormLibrary] = useState<LibraryType>("personal");
const { data: assets = [], isLoading } = useGlobalAssets(activeType, libraryType);
const createAsset = useCreateAsset(activeType, libraryType);
const toggleFavorite = useToggleFavorite(activeType, libraryType);
const deleteAsset = useDeleteAsset(activeType, libraryType);
const filteredAssets = assets.filter((a) => {
if (searchTerm && !a.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (selectedTags.length > 0 && !selectedTags.some((t) => a.tags.includes(t))) return false;
return true;
}); });
const allTags = Array.from(new Set(assets.flatMap((a) => a.tags))); const { data: assets = [], isLoading } = useGlobalAssets(activeType, "personal");
const createAsset = useCreateAsset(activeType, "personal");
const deleteAsset = useDeleteAsset(activeType, "personal");
const toggleTag = (tag: string) => const filteredAssets = useMemo(() => {
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])); return assets.filter((asset) => {
if (!searchTerm) return true;
return asset.name.toLowerCase().includes(searchTerm.toLowerCase());
});
}, [assets, searchTerm]);
const ActiveIcon = assetTypeMeta[activeType].icon;
const handleCreate = async () => { const handleCreate = async () => {
if (!form.name.trim()) return;
await createAsset.mutateAsync({ await createAsset.mutateAsync({
name: formName, name: form.name,
assetType: activeType, assetType: activeType,
libraryType: formLibrary, libraryType: "personal",
tags: formTags, tags: form.tags,
description: formDesc, description: form.description,
imageUrl: form.imageUrl || undefined,
}); });
setFormName(""); setFormDesc(""); setFormTags(""); setFormLibrary("personal"); setShowModal(false);
setShowCreateModal(false); setForm({ name: "", tags: "", description: "", imageUrl: "" });
};
const handleDelete = async (asset: GlobalAsset) => {
if (!confirm(`确定要删除资产“${asset.name}”吗?`)) return;
await deleteAsset.mutateAsync(asset.id);
}; };
return ( return (
<div className="h-full flex flex-col bg-background"> <div className="mx-auto max-w-[1440px]">
{/* Sub Navigation */} <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="border-b border-border bg-card px-6 py-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4 mb-4"> <div>
<div className="flex items-center gap-2 p-1 rounded-lg bg-muted"> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<button <Sparkles className="h-3.5 w-3.5" />
onClick={() => setLibraryType("personal")} Asset Center
className={`px-4 py-1.5 rounded-md text-sm transition-colors ${libraryType === "personal" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`} </div>
> <h1 className="mt-4 text-3xl font-semibold text-foreground">资产中心</h1>
<div className="flex items-center gap-2"><User className="w-3 h-3" />个人库</div> <p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
</button> 统一沉淀角色、场景和道具资产,让项目流程中的引用与复用都来自同一套企业资产中心。
<button </p>
onClick={() => setLibraryType("team")}
className={`px-4 py-1.5 rounded-md text-sm transition-colors ${libraryType === "team" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<div className="flex items-center gap-2"><UsersIcon className="w-3 h-3" />团队库</div>
</button>
</div> </div>
<button
onClick={() => setShowModal(true)}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<Plus className="h-4 w-4" />
新建资产
</button>
</div>
<div className="mt-6 flex flex-wrap gap-3">
{(Object.keys(assetTypeMeta) as AssetType[]).map((type) => {
const Icon = assetTypeMeta[type].icon;
const active = activeType === type;
return (
<button
key={type}
onClick={() => setActiveType(type)}
className={[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all",
active
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
].join(" ")}
>
<Icon className="h-4 w-4" />
{assetTypeMeta[type].label}
</button>
);
})}
</div> </div>
<div className="flex items-center gap-1 mb-4"> <div className="mt-6 grid gap-4 md:grid-cols-3">
{assetTypes.map((type) => ( <div className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm">
<button <div className="text-xs uppercase tracking-[0.24em] text-primary/70">当前分类</div>
key={type.id} <div className="mt-3 text-2xl font-semibold text-foreground">{assetTypeMeta[activeType].label}</div>
onClick={() => navigate(`/assets/${type.id}`)} </div>
className={`px-4 py-2 rounded-lg text-sm flex items-center gap-2 transition-all ${ <div className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm">
activeType === type.id <div className="text-xs uppercase tracking-[0.24em] text-primary/70">资产总数</div>
? "bg-accent text-accent-foreground font-medium" <div className="mt-3 text-2xl font-semibold text-foreground">{assets.length}</div>
: "text-muted-foreground hover:text-foreground hover:bg-muted" </div>
}`} <div className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm">
> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">含图片</div>
<type.icon className="w-4 h-4" /> <div className="mt-3 text-2xl font-semibold text-foreground">
{type.label} {assets.filter((asset) => !!asset.imageUrl).length}
</button> </div>
))} </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="mt-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex-1 max-w-md relative"> <div className="relative w-full max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索资源名称..." className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" placeholder="搜索资产名称"
/> />
</div> </div>
<button <div className="inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm text-primary">
onClick={() => setShowCreateModal(true)} <ActiveIcon className="h-4 w-4" />
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2" {filteredAssets.length}
> </div>
<Plus className="w-4 h-4" />
新建{assetTypes.find((t) => t.id === activeType)?.label.replace("库", "")}
</button>
</div> </div>
</div>
{/* Content */} {isLoading ? (
<div className="flex-1 overflow-auto p-6"> <div className="flex items-center justify-center py-24">
<div className="max-w-7xl mx-auto"> <Loader2 className="h-8 w-8 animate-spin text-primary" />
{allTags.length > 0 && ( </div>
<div className="mb-6 p-4 rounded-xl border border-border bg-card"> ) : filteredAssets.length === 0 ? (
<div className="flex items-center gap-2 mb-3"> <div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center">
<Filter className="w-4 h-4 text-muted-foreground" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<span className="text-sm font-medium text-foreground">标签筛选</span> <ActiveIcon className="h-7 w-7" />
</div>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button key={tag} onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${selectedTags.includes(tag) ? "bg-primary text-white" : "bg-muted text-foreground hover:bg-muted-foreground/10"}`}>
{tag}
</button>
))}
{selectedTags.length > 0 && (
<button onClick={() => setSelectedTags([])} className="px-3 py-1.5 rounded-lg text-xs text-destructive hover:bg-destructive/10">清除</button>
)}
</div>
</div> </div>
)} <h3 className="mt-5 text-2xl font-semibold text-foreground">还没有可展示的资产</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
{isLoading && ( 当前分类下暂时没有资产,或筛选条件没有匹配结果。你可以直接创建一个新资产开始沉淀。
<div className="text-center py-16"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground mx-auto" /></div> </p>
)} </div>
) : (
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{filteredAssets.map((asset) => (
<div
key={asset.id}
className="overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<div className="aspect-[4/5] bg-muted">
{asset.imageUrl ? (
<img src={asset.imageUrl} alt={asset.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
</div>
)}
</div>
{!isLoading && filteredAssets.length > 0 ? ( <div className="p-5">
<div className={`grid gap-4 ${activeType === "character" ? "grid-cols-5" : "grid-cols-4"}`}> <div className="flex items-start justify-between gap-3">
{filteredAssets.map((asset) => ( <div>
<div key={asset.id} className="rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all group"> <div className="text-lg font-semibold text-foreground">{asset.name}</div>
<div className={`${activeType === "character" ? "aspect-[3/4]" : "aspect-video"} bg-muted relative overflow-hidden`}> <div className="mt-2 text-sm text-muted-foreground">
{asset.imageUrl ? ( {asset.tags.join(" / ") || "未分类"}
<img src={asset.imageUrl} alt={asset.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" />
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
{activeType === "character" ? <UsersIcon className="w-12 h-12" /> : activeType === "scene" ? <MapPin className="w-12 h-12" /> : <Box className="w-12 h-12" />}
</div> </div>
)} </div>
<button <span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
onClick={() => toggleFavorite.mutate(asset.id)} {assetTypeMeta[asset.assetType].label}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-card/80 backdrop-blur-sm flex items-center justify-center hover:bg-card transition-colors" </span>
>
<Star className={`w-4 h-4 ${asset.favorite ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"}`} />
</button>
</div> </div>
<div className="p-3"> <p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">
<h3 className="font-medium text-foreground mb-2 line-clamp-1">{asset.name}</h3> {asset.description || "待补充资产描述。"}
<div className="flex flex-wrap gap-1 mb-3"> </p>
{asset.tags.slice(0, 2).map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">{tag}</span> <div className="mt-5 flex items-center justify-between gap-3">
))} <div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
{asset.tags.length > 2 && ( <Heart className={asset.favorite ? "h-4 w-4 text-red-500" : "h-4 w-4"} />
<span className="px-2 py-0.5 rounded bg-muted text-xs text-muted-foreground">+{asset.tags.length - 2}</span> {asset.favorite ? "已收藏" : "未收藏"}
)}
</div>
{asset.description && <div className="text-xs text-muted-foreground mb-3 line-clamp-1">{asset.description}</div>}
<div className="flex gap-2">
<button
onClick={() => deleteAsset.mutate(asset.id)}
className="flex-1 px-3 py-1.5 rounded-lg border border-border bg-background text-destructive hover:bg-destructive/10 transition-colors text-xs flex items-center justify-center gap-1"
>
<Trash2 className="w-3 h-3" />
删除
</button>
</div> </div>
<button
onClick={() => handleDelete(asset)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
删除
</button>
</div> </div>
</div> </div>
))} </div>
</div> ))}
) : !isLoading && ( </div>
<div className="text-center py-16"> )}
{activeType === "character" && <UsersIcon className="w-16 h-16 text-muted-foreground mx-auto mb-4" />} </section>
{activeType === "scene" && <MapPin className="w-16 h-16 text-muted-foreground mx-auto mb-4" />}
{activeType === "prop" && <Box className="w-16 h-16 text-muted-foreground mx-auto mb-4" />}
<p className="text-muted-foreground mb-2">
暂无{assetTypes.find((t) => t.id === activeType)?.label.replace("库", "")}
</p>
<button onClick={() => setShowCreateModal(true)} className="text-sm text-primary hover:underline">立即创建</button>
</div>
)}
</div>
</div>
{/* Create Modal */} {showModal ? (
{showCreateModal && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="w-full max-w-3xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full"> <div className="mb-6 flex items-start justify-between gap-4">
<h2 className="text-xl font-semibold text-foreground mb-6"> <div>
新建{assetTypes.find((t) => t.id === activeType)?.label.replace("库", "")} <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Asset Editor</div>
</h2> <h3 className="mt-2 text-2xl font-semibold text-foreground">新建资产</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
填写名称、标签、描述和图片链接,将素材沉淀到企业资产中心。
</p>
</div>
<button
onClick={() => setShowModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 mb-6"> <div className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">名称</label> <label className="mb-2 block text-sm font-medium text-foreground">资产名称</label>
<input value={formName} onChange={(e) => setFormName(e.target.value)} type="text" placeholder="输入名称" <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入名称"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">标签(逗号分隔)</label> <label className="mb-2 block text-sm font-medium text-foreground">标签</label>
<input value={formTags} onChange={(e) => setFormTags(e.target.value)} type="text" placeholder="如:女主角,都市,现代" <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> value={form.tags}
onChange={(e) => setForm((current) => ({ ...current, tags: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="如:都市、现代、主角"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">描述</label> <label className="mb-2 block text-sm font-medium text-foreground">描述</label>
<textarea value={formDesc} onChange={(e) => setFormDesc(e.target.value)} rows={3} placeholder="输入描述" <textarea
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none" /> value={form.description}
onChange={(e) => setForm((current) => ({ ...current, description: e.target.value }))}
className="min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入资产描述"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">库类型</label> <label className="mb-2 block text-sm font-medium text-foreground">图片链接</label>
<select value={formLibrary} onChange={(e) => setFormLibrary(e.target.value as LibraryType)} <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"> value={form.imageUrl}
<option value="personal">个人库</option> onChange={(e) => setForm((current) => ({ ...current, imageUrl: e.target.value }))}
<option value="team">团队库</option> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
</select> placeholder="https://example.com/image.jpg"
/>
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="mt-6 flex items-center justify-end gap-3">
<button onClick={() => setShowCreateModal(false)} <button
className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"> onClick={() => setShowModal(false)}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消 取消
</button> </button>
<button onClick={handleCreate} disabled={createAsset.isPending || !formName.trim()} <button
className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"> onClick={handleCreate}
{createAsset.isPending && <Loader2 className="w-4 h-4 animate-spin" />} disabled={createAsset.isPending || !form.name.trim()}
创建 className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{createAsset.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
保存资产
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
import { useRef, useState, type ChangeEvent } from "react"; import { useRef, useState, type ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
ArrowRight, Box,
Check, Image as ImageIcon,
Edit2,
ImageIcon,
Loader2, Loader2,
MapPin, MapPin,
Plus, Plus,
...@@ -13,8 +11,8 @@ import { ...@@ -13,8 +11,8 @@ import {
Trash2, Trash2,
Upload, Upload,
Users, Users,
WandSparkles,
X, X,
Box,
} from "lucide-react"; } from "lucide-react";
import { import {
useCharacters, useCharacters,
...@@ -38,12 +36,20 @@ const ROLE_TYPES = [ ...@@ -38,12 +36,20 @@ const ROLE_TYPES = [
{ value: "supporting", label: "配角" }, { value: "supporting", label: "配角" },
]; ];
const CHARACTER_VIEWS: Array<{ type: CharacterViewType; label: string; shortLabel: string }> = [ const CHARACTER_VIEWS: Array<{ type: CharacterViewType; label: string }> = [
{ type: "front", label: "正面", shortLabel: "正" }, { type: "front", label: "正面" },
{ type: "side", label: "侧面", shortLabel: "侧" }, { type: "side", label: "侧面" },
{ type: "back", label: "背面", shortLabel: "背" }, { type: "back", label: "背面" },
]; ];
const roleTypeLabel: Record<string, string> = {
female_lead: "女主角",
male_lead: "男主角",
antagonist: "反派",
villain: "反派",
supporting: "配角",
};
const emptyForm = (): Partial<Character> => ({ const emptyForm = (): Partial<Character> => ({
name: "", name: "",
roleType: "supporting", roleType: "supporting",
...@@ -52,15 +58,16 @@ const emptyForm = (): Partial<Character> => ({ ...@@ -52,15 +58,16 @@ const emptyForm = (): Partial<Character> => ({
personality: "", personality: "",
costume: "", costume: "",
visualHint: "", visualHint: "",
status: "draft",
}); });
const emptyImageState = (): ImageState => ({ const emptyImages = (): ImageState => ({
front: null, front: null,
side: null, side: null,
back: null, back: null,
}); });
const emptyPendingFiles = (): PendingFiles => ({ const emptyFiles = (): PendingFiles => ({
front: null, front: null,
side: null, side: null,
back: null, back: null,
...@@ -76,8 +83,96 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV ...@@ -76,8 +83,96 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV
return character.backImageUrl ?? null; return character.backImageUrl ?? null;
} }
function hasCompleteThreeViews(character: Partial<Character>) { function CharacterCard({
return CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type)); character,
onEdit,
onGenerateImage,
onDelete,
generating,
}: {
character: Character;
onEdit: (character: Character) => void;
onGenerateImage: (character: Character) => void;
onDelete: (character: Character) => void;
generating: boolean;
}) {
const preview = getCharacterViewUrl(character, "front");
return (
<div className="overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]">
<div className="aspect-[4/5] bg-muted">
{preview ? (
<img src={preview} alt={character.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
</div>
)}
</div>
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-semibold text-foreground">{character.name}</div>
<div className="mt-2 text-sm text-muted-foreground">
{roleTypeLabel[character.roleType] ?? character.roleType}
</div>
</div>
<span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
{character.gender === "male" ? "男性" : "女性"}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{CHARACTER_VIEWS.map((view) => {
const exists = Boolean(getCharacterViewUrl(character, view.type));
return (
<span
key={view.type}
className={[
"rounded-full px-3 py-1 text-xs",
exists ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-600",
].join(" ")}
>
{view.label}
</span>
);
})}
</div>
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">
{character.visualHint || character.personality || "待补充角色视觉描述与性格特征。"}
</p>
<div className="mt-5 grid gap-2">
<button
onClick={() => onGenerateImage(character)}
disabled={generating}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <WandSparkles className="h-4 w-4" />}
{generating ? "正在生成..." : "AI 生成形象"}
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => onEdit(character)}
className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
编辑角色
</button>
<button
onClick={() => onDelete(character)}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
删除
</button>
</div>
</div>
</div>
</div>
);
} }
export function CharacterGeneration() { export function CharacterGeneration() {
...@@ -90,17 +185,17 @@ export function CharacterGeneration() { ...@@ -90,17 +185,17 @@ export function CharacterGeneration() {
const saveCharacter = useSaveCharacter(pid); const saveCharacter = useSaveCharacter(pid);
const generateImage = useGenerateCharacterImage(pid); const generateImage = useGenerateCharacterImage(pid);
const uploadImage = useUploadCharacterImage(pid); const uploadImage = useUploadCharacterImage(pid);
const deleteChar = useDeleteCharacter(pid); const deleteCharacter = useDeleteCharacter(pid);
const [activeTab] = useState<SettingsTab>("characters"); const [activeTab] = useState<SettingsTab>("characters");
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingChar, setEditingChar] = useState<Partial<Character>>(emptyForm()); const [editingCharacter, setEditingCharacter] = useState<Partial<Character>>(emptyForm());
const [imagePreviews, setImagePreviews] = useState<ImageState>(emptyImageState()); const [imagePreviews, setImagePreviews] = useState<ImageState>(emptyImages());
const [pendingFiles, setPendingFiles] = useState<PendingFiles>(emptyPendingFiles()); const [pendingFiles, setPendingFiles] = useState<PendingFiles>(emptyFiles());
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const fileInputRefs = useRef<Record<CharacterViewType, HTMLInputElement | null>>({ const fileRefs = useRef<Record<CharacterViewType, HTMLInputElement | null>>({
front: null, front: null,
side: null, side: null,
back: null, back: null,
...@@ -112,519 +207,373 @@ export function CharacterGeneration() { ...@@ -112,519 +207,373 @@ export function CharacterGeneration() {
{ id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` }, { id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` },
]; ];
const roleTypeLabel: Record<string, string> = {
female_lead: "女主角",
male_lead: "男主角",
antagonist: "反派",
villain: "反派",
supporting: "配角",
};
const generatedCount = characters.filter((character) => hasCompleteThreeViews(character)).length;
const generatingCount = characters.filter((character) => character.status === "generating").length;
const statusBadge = (status: string) => {
if (status === "ready") {
return <span className="px-2 py-0.5 rounded text-xs bg-green-500/10 text-green-600">三视图已完成</span>;
}
if (status === "generating") {
return (
<span className="px-2 py-0.5 rounded text-xs bg-blue-500/10 text-blue-600 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
生成中
</span>
);
}
if (status === "failed") {
return <span className="px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-600">生成失败</span>;
}
return <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-700">待生成</span>;
};
const handleExtract = () => { const handleExtract = () => {
extract.mutate(undefined, { extract.mutate(undefined, {
onError: (error) => alert(`提取失败: ${(error as Error).message}`), onError: (error) => alert(`提取角色失败:${(error as Error).message}`),
}); });
}; };
const openCreate = () => { const openCreate = () => {
setEditingChar(emptyForm()); setEditingCharacter(emptyForm());
setImagePreviews(emptyImageState()); setImagePreviews(emptyImages());
setPendingFiles(emptyPendingFiles()); setPendingFiles(emptyFiles());
setShowModal(true); setShowModal(true);
}; };
const openEdit = (character: Character) => { const openEdit = (character: Character) => {
setEditingChar({ ...character }); setEditingCharacter({ ...character });
setImagePreviews({ setImagePreviews({
front: getCharacterViewUrl(character, "front"), front: getCharacterViewUrl(character, "front"),
side: getCharacterViewUrl(character, "side"), side: getCharacterViewUrl(character, "side"),
back: getCharacterViewUrl(character, "back"), back: getCharacterViewUrl(character, "back"),
}); });
setPendingFiles(emptyPendingFiles()); setPendingFiles(emptyFiles());
setShowModal(true); setShowModal(true);
}; };
const handleFileSelect = (viewType: CharacterViewType, e: ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (viewType: CharacterViewType, event: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setPendingFiles((current) => ({ ...current, [viewType]: file })); setPendingFiles((current) => ({ ...current, [viewType]: file }));
setImagePreviews((current) => ({ ...current, [viewType]: URL.createObjectURL(file) })); setImagePreviews((current) => ({ ...current, [viewType]: URL.createObjectURL(file) }));
}; };
const clearImage = (viewType: CharacterViewType) => {
setPendingFiles((current) => ({ ...current, [viewType]: null }));
setImagePreviews((current) => ({
...current,
[viewType]: editingChar.id ? getCharacterViewUrl(editingChar, viewType) : null,
}));
if (fileInputRefs.current[viewType]) {
fileInputRefs.current[viewType]!.value = "";
}
};
const handleSave = async () => { const handleSave = async () => {
if (!editingChar.name?.trim()) return; if (!editingCharacter.name?.trim()) return;
setSaving(true); setSaving(true);
try { try {
const saved = await saveCharacter.mutateAsync({ ...editingChar, status: editingChar.status ?? "draft" }); const saved = await saveCharacter.mutateAsync({
for (const view of CHARACTER_VIEWS) { ...editingCharacter,
const pendingFile = pendingFiles[view.type]; status: editingCharacter.status ?? "draft",
if (!pendingFile) continue; });
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile, viewType: view.type });
} const uploads = CHARACTER_VIEWS.filter((view) => pendingFiles[view.type]).map((view) =>
uploadImage.mutateAsync({
id: String(saved.id),
file: pendingFiles[view.type] as File,
viewType: view.type,
}),
);
await Promise.all(uploads);
setShowModal(false); setShowModal(false);
} catch (error) { } catch (error) {
alert(`保存失败: ${(error as Error).message}`); alert(`保存角色失败:${(error as Error).message}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleGenerateImage = async (character: Character) => { const handleGenerateImage = (character: Character) => {
setGeneratingImageId(character.id); setGeneratingImageId(character.id);
generateImage.mutate(character.id, { generateImage.mutate(character.id, {
onSettled: () => setGeneratingImageId(null), onSettled: () => setGeneratingImageId(null),
onError: (error) => alert(`三视图生成失败: ${(error as Error).message}`), onError: (error) => alert(`角色形象生成失败:${(error as Error).message}`),
}); });
}; };
const handleDelete = (character: Character) => { const handleDelete = (character: Character) => {
if (!confirm(`确定要删除角色${character.name}吗?`)) return; if (!confirm(`确定要删除角色${character.name}吗?`)) return;
deleteChar.mutate(character.id, { deleteCharacter.mutate(character.id, {
onError: (error) => alert(`删除失败: ${(error as Error).message}`), onError: (error) => alert(`删除角色失败:${(error as Error).message}`),
}); });
}; };
return ( return (
<div className="h-full overflow-auto bg-background relative"> <div className="mx-auto max-w-[1440px]">
<div className="p-6 pb-24"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="max-w-7xl mx-auto"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="mb-6"> <div>
<div className="flex items-center gap-3 mb-2"> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> <Sparkles className="h-3.5 w-3.5" />
<Sparkles className="w-4 h-4 text-white" /> Character Setup
</div>
<h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
</div> </div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定。角色形象已升级为标准三视图。</p> <h1 className="mt-4 text-3xl font-semibold text-foreground">角色设定工作台</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
从剧本中提取角色、补充人物信息、上传三视图素材或直接使用 AI 生成形象,让后续分镜与视频生成具备统一角色基础。
</p>
</div> </div>
<div className="flex items-center justify-between mb-6"> <div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2"> <button
{settingsTabs.map((tab) => { onClick={handleExtract}
const Icon = tab.icon; disabled={extract.isPending}
return ( className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
<button >
key={tab.id} {extract.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
onClick={() => navigate(tab.path)} 从剧本提取角色
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${ </button>
activeTab === tab.id <button
? "bg-accent text-accent-foreground font-medium" onClick={openCreate}
: "text-muted-foreground hover:text-foreground hover:bg-muted" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
}`} >
> <Plus className="h-4 w-4" />
<Icon className="w-4 h-4" /> 新建角色
{tab.label} </button>
</button> </div>
); </div>
})}
</div> <div className="mt-6 flex flex-wrap gap-3">
{settingsTabs.map((tab) => {
const Icon = tab.icon;
const active = activeTab === tab.id;
<div className="flex items-center gap-2"> return (
<button <button
onClick={openCreate} key={tab.id}
className="px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2 text-sm" onClick={() => navigate(tab.path)}
className={[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all",
active
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
].join(" ")}
> >
<Plus className="w-4 h-4" /> <Icon className="h-4 w-4" />
手动添加 {tab.label}
</button> </button>
);
})}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{[
{ label: "角色总数", value: `${characters.length}` },
{
label: "已具备正面图",
value: `${characters.filter((character) => !!getCharacterViewUrl(character, "front")).length}`,
},
{
label: "三视图完整",
value: `${characters.filter((character) => CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type))).length}`,
},
].map((item) => (
<div
key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
</div>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : characters.length === 0 ? (
<div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<Users className="h-7 w-7" />
</div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">还没有角色设定</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
你可以直接新建角色,或者先从剧本里自动提取角色基础信息,再继续完善视觉和三视图素材。
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<button <button
onClick={handleExtract} onClick={handleExtract}
disabled={extract.isPending} disabled={extract.isPending}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60" className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
> >
{extract.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} {extract.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
AI提取角色 从剧本提取
</button>
<button
onClick={openCreate}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<Plus className="h-4 w-4" />
新建角色
</button> </button>
</div> </div>
</div> </div>
) : (
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"> <div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<div className="flex items-center gap-6"> {characters.map((character) => (
<div className="flex items-center gap-2"> <CharacterCard
<span className="text-sm text-muted-foreground">角色总数</span> key={character.id}
<span className="text-lg font-semibold text-foreground">{characters.length}</span> character={character}
</div> onEdit={openEdit}
<div className="flex items-center gap-2"> onGenerateImage={handleGenerateImage}
<span className="text-sm text-muted-foreground">三视图完整</span> onDelete={handleDelete}
<span className="text-lg font-semibold text-green-600">{generatedCount}</span> generating={generatingImageId === character.id}
</div> />
<div className="flex items-center gap-2"> ))}
<span className="text-sm text-muted-foreground">生成中</span>
<span className="text-lg font-semibold text-blue-600">{generatingCount}</span>
</div>
</div>
</div> </div>
)}
</section>
{!isLoading && characters.length === 0 && ( {showModal ? (
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<Users className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <div className="w-full max-w-5xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<h3 className="text-lg font-medium text-foreground mb-2">暂无角色</h3> <div className="mb-6 flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground mb-6"> <div>
角色设定页现在支持三视图展示。你可以手动添加角色,或从大纲和分集里自动提取。 <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Character Editor</div>
</p> <h3 className="mt-2 text-2xl font-semibold text-foreground">
<div className="flex items-center justify-center gap-3"> {editingCharacter.id ? "编辑角色资料" : "新建角色"}
<button </h3>
onClick={openCreate} <p className="mt-2 text-sm leading-6 text-muted-foreground">
className="px-5 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2" 补充角色名称、定位、视觉描述和三视图素材,为后续分镜与视频生成提供统一角色基础。
> </p>
<Plus className="w-4 h-4" />
手动添加
</button>
<button
onClick={handleExtract}
disabled={extract.isPending}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60"
>
{extract.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
AI提取角色
</button>
</div> </div>
<button
onClick={() => setShowModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button>
</div> </div>
)}
{isLoading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)}
{characters.length > 0 && (
<div className="grid grid-cols-2 gap-6">
{characters.map((character) => {
const mainImage = getCharacterViewUrl(character, "front")
?? getCharacterViewUrl(character, "side")
?? getCharacterViewUrl(character, "back");
const isGenerating = generatingImageId === character.id || character.status === "generating";
return (
<div key={character.id} className="rounded-xl border border-border bg-card overflow-hidden">
<div className="p-5 border-b border-border">
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-3">
<div className="w-14 h-14 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{mainImage ? (
<img src={mainImage} alt={character.name} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-foreground mb-1">{character.name}</h3>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{roleTypeLabel[character.roleType] ?? character.roleType}</span>
{statusBadge(character.status)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEdit(character)}
className="p-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(character)}
className="p-2 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-1.5 text-sm">
<div className="flex">
<span className="text-muted-foreground w-16">性别</span>
<span className="text-foreground">{character.gender === "male" ? "男" : character.gender === "female" ? "女" : character.gender}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">年龄</span>
<span className="text-foreground">{character.age || "未填写"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">性格</span>
<span className="text-foreground">{character.personality || "未填写"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">服装</span>
<span className="text-foreground line-clamp-1">{character.costume || "未填写"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-16">外貌</span>
<span className="text-foreground line-clamp-1">{character.visualHint || "未填写"}</span>
</div>
</div>
</div>
<div className="p-5 bg-muted/20"> <div className="grid gap-6 xl:grid-cols-[1fr_380px]">
<div className="flex items-center justify-between mb-3"> <div className="space-y-5">
<div> <div className="grid gap-4 md:grid-cols-2">
<span className="text-xs text-muted-foreground font-medium">角色三视图</span> <div>
<p className="text-[11px] text-muted-foreground mt-0.5">正面 + 侧面 + 背面,用于完整展示角色造型。</p> <label className="mb-2 block text-sm font-medium text-foreground">角色名称</label>
</div> <input
<button value={editingCharacter.name ?? ""}
onClick={() => handleGenerateImage(character)} onChange={(e) => setEditingCharacter((current) => ({ ...current, name: e.target.value }))}
disabled={isGenerating} className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
className="px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1.5 disabled:opacity-50" placeholder="例如:乔沫"
> />
{isGenerating ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />} </div>
{hasCompleteThreeViews(character) ? "重新生成三视图" : "AI生成三视图"} <div>
</button> <label className="mb-2 block text-sm font-medium text-foreground">角色定位</label>
</div> <select
value={editingCharacter.roleType ?? "supporting"}
<div className="grid grid-cols-3 gap-3"> onChange={(e) => setEditingCharacter((current) => ({ ...current, roleType: e.target.value }))}
{CHARACTER_VIEWS.map((view) => { className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
const imageUrl = getCharacterViewUrl(character, view.type); >
return ( {ROLE_TYPES.map((role) => (
<div key={view.type}> <option key={role.value} value={role.value}>
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div> {role.label}
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border"> </option>
{imageUrl ? ( ))}
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" /> </select>
) : ( </div>
<div className="w-full h-full flex flex-col items-center justify-center gap-2"> </div>
{isGenerating ? (
<>
<Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="text-xs text-muted-foreground">生成中</span>
</>
) : (
<>
<ImageIcon className="w-6 h-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">待补充</span>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
{character.imagePrompt && ( <div className="grid gap-4 md:grid-cols-2">
<p className="mt-3 text-xs text-muted-foreground line-clamp-3">{character.imagePrompt}</p> <div>
)} <label className="mb-2 block text-sm font-medium text-foreground">性别</label>
</div> <select
value={editingCharacter.gender ?? "female"}
onChange={(e) => setEditingCharacter((current) => ({ ...current, gender: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<option value="female">女性</option>
<option value="male">男性</option>
</select>
</div> </div>
); <div>
})} <label className="mb-2 block text-sm font-medium text-foreground">年龄</label>
</div> <input
)} value={editingCharacter.age ?? ""}
</div> onChange={(e) => setEditingCharacter((current) => ({ ...current, age: e.target.value }))}
</div> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="例如:28 岁"
/>
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"> <div>
<div className="max-w-7xl mx-auto flex items-center justify-end gap-3"> <label className="mb-2 block text-sm font-medium text-foreground">性格特征</label>
<button <textarea
onClick={() => navigate(`/project/${projectId}/scenes`)} value={editingCharacter.personality ?? ""}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 text-sm font-medium" onChange={(e) => setEditingCharacter((current) => ({ ...current, personality: e.target.value }))}
> className="min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<span>继续生成场景设定</span> placeholder="例如:善良坚韧,外柔内刚,遇事很有主见。"
<ArrowRight className="w-5 h-5" /> />
</button> </div>
</div>
</div>
{showModal && ( <div>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"> <label className="mb-2 block text-sm font-medium text-foreground">外观描述</label>
<div className="bg-card rounded-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col"> <textarea
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0"> value={editingCharacter.visualHint ?? ""}
<div> onChange={(e) => setEditingCharacter((current) => ({ ...current, visualHint: e.target.value }))}
<h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2> className="min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<p className="text-sm text-muted-foreground mt-1">支持上传正面、侧面、背面三张标准视角图。</p> placeholder="例如:黑长直,杏眼,气质清冷,身形修长。"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">服装与造型</label>
<textarea
value={editingCharacter.costume ?? ""}
onChange={(e) => setEditingCharacter((current) => ({ ...current, costume: e.target.value }))}
className="min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="例如:浅色风衣搭配衬衫长裤,整体简洁利落。"
/>
</div>
</div> </div>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div className="overflow-auto p-6 space-y-5 flex-1"> <div className="rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm">
<div> <div className="mb-4 text-base font-medium text-foreground">三视图素材</div>
<label className="block text-sm font-medium text-foreground mb-3">角色三视图上传(可选)</label> <div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{CHARACTER_VIEWS.map((view) => ( {CHARACTER_VIEWS.map((view) => (
<div key={view.type} className="rounded-xl border border-border bg-muted/20 p-3"> <div key={view.type} className="rounded-[24px] border border-border/80 bg-white/80 p-4">
<div className="flex items-center justify-between mb-2"> <div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{view.label}</span> <div className="text-sm font-medium text-foreground">{view.label}</div>
<span className="text-[11px] text-muted-foreground">{view.shortLabel}视图</span> <button
onClick={() => fileRefs.current[view.type]?.click()}
className="inline-flex items-center gap-2 rounded-xl border border-border/80 bg-white px-3 py-2 text-xs text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Upload className="h-3.5 w-3.5" />
上传图片
</button>
</div> </div>
<div
className="aspect-[3/4] rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden" <div className="aspect-[4/5] overflow-hidden rounded-[20px] bg-muted">
onClick={() => fileInputRefs.current[view.type]?.click()}
>
{imagePreviews[view.type] ? ( {imagePreviews[view.type] ? (
<img src={imagePreviews[view.type] ?? ""} alt={`${view.label}预览`} className="w-full h-full object-cover" /> <img
src={imagePreviews[view.type] ?? ""}
alt={view.label}
className="h-full w-full object-cover"
/>
) : ( ) : (
<div className="text-center px-4"> <div className="flex h-full items-center justify-center">
<Upload className="w-5 h-5 text-muted-foreground mx-auto mb-2" /> <ImageIcon className="h-8 w-8 text-muted-foreground" />
<span className="text-xs text-muted-foreground">点击上传{view.label}</span>
</div> </div>
)} )}
</div> </div>
<div className="mt-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">建议全身标准视角</span>
{imagePreviews[view.type] && (
<button onClick={() => clearImage(view.type)} className="text-destructive hover:underline">
移除
</button>
)}
</div>
<input <input
ref={(node) => { ref={(element) => {
fileInputRefs.current[view.type] = node; fileRefs.current[view.type] = element;
}} }}
type="file" type="file"
accept="image/*" accept="image/*"
className="hidden" className="hidden"
onChange={(e) => handleFileSelect(view.type, e)} onChange={(event) => handleFileSelect(view.type, event)}
/> />
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
角色名称
<span className="text-destructive ml-1">*</span>
</label>
<input
type="text"
value={editingChar.name ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, name: e.target.value })}
placeholder="例如:乔沫"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1">角色类型</label>
<select
value={editingChar.roleType ?? "supporting"}
onChange={(e) => setEditingChar({ ...editingChar, roleType: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
{ROLE_TYPES.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">性别</label>
<select
value={editingChar.gender ?? "female"}
onChange={(e) => setEditingChar({ ...editingChar, gender: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
<option value="female"></option>
<option value="male"></option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">年龄</label>
<input
type="text"
value={editingChar.age ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, age: e.target.value })}
placeholder="例如:28岁"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">性格描述</label>
<textarea
value={editingChar.personality ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, personality: e.target.value })}
placeholder="例如:善良坚韧,外柔内刚,遇事很有主见。"
rows={2}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">外貌特征</label>
<textarea
value={editingChar.visualHint ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, visualHint: e.target.value })}
placeholder="例如:黑长直,杏眼,气质清冷,身形修长。"
rows={2}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">服装描述</label>
<textarea
value={editingChar.costume ?? ""}
onChange={(e) => setEditingChar({ ...editingChar, costume: e.target.value })}
placeholder="例如:浅色风衣搭配衬衫长裤,整体简洁利落。"
rows={2}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
</div> </div>
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0"> <div className="mt-6 flex items-center justify-end gap-3">
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!editingChar.name?.trim() || saving} disabled={saving || !editingCharacter.name?.trim()}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
保存 保存角色
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
import { useState, useRef } from "react"; import { useRef, useState } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { import {
Plus, Film, Users, Clock, Play, Search, Calendar, Calendar,
Filter, Loader2, Pencil, Trash2, X, Check, Upload, Clock3,
Film,
Loader2,
Pencil,
Plus,
Search,
Sparkles,
Trash2,
TrendingUp,
Upload,
Users,
Video,
WandSparkles,
X,
} from "lucide-react"; } from "lucide-react";
import { useProjects, useUpdateProject, useDeleteProject } from "../../hooks/useProjects";
import { useTeamMembers } from "../../hooks/useTeam";
import { projectsApi } from "../../lib/api/projects";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { ProjectDTO } from "../../lib/api/projects"; import { useDeleteProject, useProjects, useUpdateProject } from "../../hooks/useProjects";
import { useTeamMembers } from "../../hooks/useTeam";
import { projectsApi, type ProjectDTO } from "../../lib/api/projects";
import { import {
aspectRatioOptions, aspectRatioOptions,
getAspectRatioLabel, getAspectRatioLabel,
...@@ -17,44 +29,101 @@ import { ...@@ -17,44 +29,101 @@ import {
projectStyleOptions, projectStyleOptions,
resolutionOptions, resolutionOptions,
} from "../../lib/projectStyles"; } from "../../lib/projectStyles";
import { brand } from "../../lib/brand";
// ── Default cover generator ─────────────────────────────────────────────────
function getProjectGradient(name: string): string { function getProjectGradient(name: string): string {
let h = 0; let hash = 0;
for (let i = 0; i < name.length; i++) { for (let i = 0; i < name.length; i += 1) {
h = (Math.imul(31, h) + name.charCodeAt(i)) | 0; hash = Math.imul(31, hash) + name.charCodeAt(i);
}
const hue = Math.abs(hash) % 360;
return `linear-gradient(135deg, hsl(${hue}, 68%, 32%), hsl(${(hue + 34) % 360}, 82%, 56%))`;
}
function getProjectProgress(status?: string) {
switch (status) {
case "completed":
return 100;
case "processing":
return 72;
case "storyboard":
return 68;
case "video":
return 88;
case "draft":
return 20;
default:
return 42;
} }
const hue = ((h >>> 0) % 360);
return `linear-gradient(135deg, hsl(${hue},60%,40%), hsl(${(hue + 45) % 360},65%,55%))`;
} }
function ProjectCover({ url, name, className = "" }: { url?: string; name: string; className?: string }) { const statusMeta: Record<string, { label: string; chip: string }> = {
const initials = name.slice(0, 2); draft: {
label: "待完善",
chip: "bg-slate-100 text-slate-700",
},
outline: {
label: "剧本拆解中",
chip: "bg-sky-100 text-sky-700",
},
processing: {
label: "处理中",
chip: "bg-amber-100 text-amber-700",
},
storyboard: {
label: "分镜推进中",
chip: "bg-indigo-100 text-indigo-700",
},
video: {
label: "视频生成中",
chip: "bg-cyan-100 text-cyan-700",
},
completed: {
label: "已完成",
chip: "bg-emerald-100 text-emerald-700",
},
};
function ProjectCover({
name,
url,
className = "",
}: {
name: string;
url?: string;
className?: string;
}) {
if (url) { if (url) {
return <img src={url} alt={name} className={`w-full h-full object-cover ${className}`} />; return <img src={url} alt={name} className={`h-full w-full object-cover ${className}`} />;
} }
return ( return (
<div <div
className={`w-full h-full flex items-center justify-center ${className}`} className={`flex h-full w-full items-center justify-center ${className}`}
style={{ background: getProjectGradient(name) }} style={{ background: getProjectGradient(name) }}
> >
<span className="text-white text-2xl font-bold tracking-wider select-none opacity-80"> <div className="rounded-3xl border border-white/16 bg-white/8 px-4 py-3 text-center backdrop-blur-sm">
{initials} <div className="text-[11px] uppercase tracking-[0.32em] text-white/76">Project</div>
</span> <div className="mt-2 text-3xl font-semibold tracking-[0.2em] text-white">
{name.slice(0, 2).toUpperCase()}
</div>
</div>
</div> </div>
); );
} }
// ── Edit Modal ─────────────────────────────────────────────────────────────── function EditProjectDialog({
function EditModal({
project, project,
onClose, onClose,
}: { }: {
project: ProjectDTO; project: ProjectDTO;
onClose: () => void; onClose: () => void;
}) { }) {
const qc = useQueryClient(); const queryClient = useQueryClient();
const updateProject = useUpdateProject(project.id); const updateProject = useUpdateProject(project.id);
const fileRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(project.name); const [name, setName] = useState(project.name);
const [style, setStyle] = useState(project.style ?? ""); const [style, setStyle] = useState(project.style ?? "");
const [aspectRatio, setAspectRatio] = useState(project.aspectRatio ?? ""); const [aspectRatio, setAspectRatio] = useState(project.aspectRatio ?? "");
...@@ -62,7 +131,6 @@ function EditModal({ ...@@ -62,7 +131,6 @@ function EditModal({
const [coverFile, setCoverFile] = useState<File | null>(null); const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string>(project.coverUrl ?? ""); const [coverPreview, setCoverPreview] = useState<string>(project.coverUrl ?? "");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
...@@ -74,6 +142,7 @@ function EditModal({ ...@@ -74,6 +142,7 @@ function EditModal({
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) return; if (!name.trim()) return;
setSaving(true); setSaving(true);
try { try {
await updateProject.mutateAsync({ await updateProject.mutateAsync({
name: name.trim(), name: name.trim(),
...@@ -81,11 +150,13 @@ function EditModal({ ...@@ -81,11 +150,13 @@ function EditModal({
aspectRatio: aspectRatio || undefined, aspectRatio: aspectRatio || undefined,
resolution: resolution || undefined, resolution: resolution || undefined,
}); });
if (coverFile) { if (coverFile) {
await projectsApi.uploadAsset(project.id, coverFile, "cover"); await projectsApi.uploadAsset(project.id, coverFile, "cover");
qc.invalidateQueries({ queryKey: ["projects"] }); await queryClient.invalidateQueries({ queryKey: ["projects"] });
qc.invalidateQueries({ queryKey: ["projects", project.id] }); await queryClient.invalidateQueries({ queryKey: ["projects", project.id] });
} }
onClose(); onClose();
} finally { } finally {
setSaving(false); setSaving(false);
...@@ -93,41 +164,42 @@ function EditModal({ ...@@ -93,41 +164,42 @@ function EditModal({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden"> <div className="w-full max-w-2xl rounded-[32px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
{/* Header */} <div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div>
<h3 className="text-base font-semibold text-foreground">编辑项目</h3> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Project Edit</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted transition"> <h3 className="mt-2 text-2xl font-semibold text-foreground">编辑项目资料</h3>
<X className="w-4 h-4 text-muted-foreground" /> <p className="mt-2 text-sm leading-6 text-muted-foreground">
调整项目名称、风格方向和封面信息,让项目首页与流程页保持统一视觉。
</p>
</div>
<button
onClick={onClose}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="px-6 py-5 space-y-5"> <div className="grid gap-6 lg:grid-cols-[240px_1fr]">
{/* Cover image */}
<div> <div>
<label className="block text-xs text-muted-foreground mb-2">项目封面</label> <div
<div className="flex items-center gap-4"> className="group relative aspect-[3/4] overflow-hidden rounded-[28px] border border-border/80 bg-muted shadow-sm"
{/* Preview */} onClick={() => fileRef.current?.click()}
<div >
className="w-24 h-16 rounded-lg overflow-hidden border border-border flex-shrink-0 cursor-pointer" <ProjectCover name={name || project.name} url={coverPreview || undefined} />
onClick={() => fileRef.current?.click()} <div className="absolute inset-x-4 bottom-4 rounded-2xl bg-slate-950/55 px-4 py-3 text-center text-sm text-white opacity-0 backdrop-blur transition group-hover:opacity-100">
> 点击上传新封面
<ProjectCover url={coverPreview || undefined} name={name || project.name} className="group-hover:scale-105 transition" />
</div>
<div className="flex-1">
<button
onClick={() => fileRef.current?.click()}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition w-full justify-center"
>
<Upload className="w-3.5 h-3.5" />
{coverPreview ? "更换封面图" : "上传封面图"}
</button>
<p className="text-[11px] text-muted-foreground mt-1.5 text-center">
支持 JPG、PNG,建议 16:9
</p>
</div> </div>
</div> </div>
<button
onClick={() => fileRef.current?.click()}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Upload className="h-4 w-4" />
上传封面
</button>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
...@@ -137,83 +209,92 @@ function EditModal({ ...@@ -137,83 +209,92 @@ function EditModal({
/> />
</div> </div>
{/* Project name */} <div className="space-y-5">
<div>
<label className="block text-xs text-muted-foreground mb-2">项目名称</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="请输入项目名称"
maxLength={200}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="block text-xs text-muted-foreground mb-2">视觉风格</label> <label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<select <input
value={style} type="text"
onChange={(e) => setStyle(e.target.value)} value={name}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" onChange={(e) => setName(e.target.value)}
> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<option value="">未设置</option> placeholder="请输入项目名称"
{projectStyleOptions.map((option) => ( maxLength={200}
<option key={option.value} value={option.value}> />
{option.label}
</option>
))}
</select>
</div> </div>
<div>
<label className="block text-xs text-muted-foreground mb-2">画幅比例</label> <div className="grid gap-4 md:grid-cols-3">
<select <div>
value={aspectRatio} <label className="mb-2 block text-sm font-medium text-foreground">视觉风格</label>
onChange={(e) => setAspectRatio(e.target.value)} <select
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" value={style}
> onChange={(e) => setStyle(e.target.value)}
<option value="">未设置</option> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
{aspectRatioOptions.map((option) => ( >
<option key={option.value} value={option.value}> <option value="">未设置</option>
{option.label} {option.helper ?? ""} {projectStyleOptions.map((option) => (
</option> <option key={option.value} value={option.value}>
))} {option.label}
</select> </option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">画幅比例</label>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<option value="">未设置</option>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">清晰度</label>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<option value="">未设置</option>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div>
</div> </div>
<div>
<label className="block text-xs text-muted-foreground mb-2">清晰度</label> <div className="rounded-[28px] border border-primary/10 bg-primary/5 px-5 py-4">
<select <div className="text-sm font-medium text-foreground">品牌展示建议</div>
value={resolution} <p className="mt-2 text-sm leading-6 text-muted-foreground">
onChange={(e) => setResolution(e.target.value)} 封面、风格和分辨率会直接影响项目工作台卡片、详情页头图和后续生成流程的展示一致性。
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" </p>
>
<option value="">未设置</option>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div> </div>
</div> </div>
</div> </div>
{/* Footer */} <div className="mt-6 flex items-center justify-end gap-3">
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition" className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || !name.trim()} disabled={saving || !name.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm disabled:opacity-50 hover:shadow-md transition" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
保存 保存更新
</button> </button>
</div> </div>
</div> </div>
...@@ -221,8 +302,7 @@ function EditModal({ ...@@ -221,8 +302,7 @@ function EditModal({
); );
} }
// ── Delete Confirm ──────────────────────────────────────────────────────────── function DeleteConfirmDialog({
function DeleteConfirm({
project, project,
onClose, onClose,
}: { }: {
...@@ -230,7 +310,6 @@ function DeleteConfirm({ ...@@ -230,7 +310,6 @@ function DeleteConfirm({
onClose: () => void; onClose: () => void;
}) { }) {
const deleteProject = useDeleteProject(); const deleteProject = useDeleteProject();
const navigate = useNavigate();
const handleDelete = async () => { const handleDelete = async () => {
await deleteProject.mutateAsync(project.id); await deleteProject.mutateAsync(project.id);
...@@ -238,29 +317,30 @@ function DeleteConfirm({ ...@@ -238,29 +317,30 @@ function DeleteConfirm({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-sm mx-4 p-6"> <div className="w-full max-w-md rounded-[32px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-3xl bg-red-100 text-red-600">
<Trash2 className="w-6 h-6 text-red-500" /> <Trash2 className="h-6 w-6" />
</div> </div>
<h3 className="text-base font-semibold text-foreground text-center mb-2">删除项目</h3> <h3 className="mt-5 text-center text-2xl font-semibold text-foreground">删除项目</h3>
<p className="text-sm text-muted-foreground text-center mb-6"> <p className="mt-3 text-center text-sm leading-6 text-muted-foreground">
确定删除「{project.name}」?此操作无法撤销,项目数据将被软删除 删除后,项目“{project.name}”将从工作台移除,相关流程记录不再在当前列表中展示
</p> </p>
<div className="flex gap-3">
<div className="mt-6 flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition" className="flex-1 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
取消 取消
</button> </button>
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={deleteProject.isPending} disabled={deleteProject.isPending}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600 transition disabled:opacity-50" className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl bg-red-500 px-4 py-3 text-sm font-medium text-white transition hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-60"
> >
{deleteProject.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />} {deleteProject.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
删除 确认删除
</button> </button>
</div> </div>
</div> </div>
...@@ -268,308 +348,454 @@ function DeleteConfirm({ ...@@ -268,308 +348,454 @@ function DeleteConfirm({
); );
} }
// ── Status helpers ────────────────────────────────────────────────────────────
const statusText: Record<string, string> = {
outline: "大纲阶段",
storyboard: "分镜制作",
video: "视频生成",
draft: "草稿",
processing: "处理中",
completed: "已完成",
};
const statusColor: Record<string, string> = {
outline: "bg-blue-100 text-blue-700",
storyboard: "bg-yellow-100 text-yellow-700",
video: "bg-green-100 text-green-700",
draft: "bg-gray-100 text-gray-600",
processing: "bg-orange-100 text-orange-700",
completed: "bg-green-100 text-green-700",
};
// ── Dashboard ─────────────────────────────────────────────────────────────────
export function Dashboard() { export function Dashboard() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [dateRange, setDateRange] = useState({ start: "", end: "" }); const [dateRange, setDateRange] = useState({ start: "", end: "" });
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const [showMemberFilter, setShowMemberFilter] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectDTO | null>(null); const [editingProject, setEditingProject] = useState<ProjectDTO | null>(null);
const [deletingProject, setDeletingProject] = useState<ProjectDTO | null>(null); const [deletingProject, setDeletingProject] = useState<ProjectDTO | null>(null);
const { data: apiProjects, isLoading } = useProjects(); const { data: apiProjects, isLoading } = useProjects();
const { data: teamMembersData = [] } = useTeamMembers(); const { data: teamMembers = [] } = useTeamMembers();
const allMembers = teamMembersData.map((m) => m.username).filter(Boolean);
const projects = (apiProjects ?? []).map((project) => {
const projects = (apiProjects ?? []).map((p) => ({ const status = project.status === "draft" ? "outline" : project.status;
raw: p, const progress = getProjectProgress(project.status);
id: p.id,
title: p.name, return {
description: p.description ?? "", raw: project,
status: p.status === "draft" ? "outline" : p.status, id: project.id,
progress: p.status === "completed" ? 100 : p.status === "draft" ? 0 : 50, title: project.name,
updatedAt: p.updatedAt?.slice(0, 10) ?? "", description: project.description ?? "",
createdAt: p.createdAt?.slice(0, 10) ?? "", status,
coverUrl: p.coverUrl, progress,
tags: [ updatedAt: project.updatedAt?.slice(0, 10) ?? "",
{ key: "style", label: getProjectStyleLabel(p.style) }, createdAt: project.createdAt?.slice(0, 10) ?? "",
{ key: "aspectRatio", label: getAspectRatioLabel(p.aspectRatio) }, coverUrl: project.coverUrl,
{ key: "resolution", label: getResolutionLabel(p.resolution) }, tags: [
].filter((tag) => tag.label !== "未设置"), getProjectStyleLabel(project.style),
})); getAspectRatioLabel(project.aspectRatio),
getResolutionLabel(project.resolution),
const filtered = projects.filter((p) => { ].filter((item) => item !== "未设置"),
if (searchTerm && !p.title.toLowerCase().includes(searchTerm.toLowerCase())) return false; };
if (dateRange.start && p.createdAt < dateRange.start) return false; });
if (dateRange.end && p.createdAt > dateRange.end) return false;
const filtered = projects.filter((project) => {
if (searchTerm && !project.title.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
if (dateRange.start && project.createdAt < dateRange.start) {
return false;
}
if (dateRange.end && project.createdAt > dateRange.end) {
return false;
}
return true; return true;
}); });
const stats = [
{
label: "项目总数",
value: filtered.length,
icon: Film,
hint: "覆盖所有在研与已完成项目",
},
{
label: "推进中项目",
value: filtered.filter((project) => project.progress < 100).length,
icon: TrendingUp,
hint: "处于拆解、设定、分镜或视频阶段",
},
{
label: "协作成员",
value: teamMembers.length,
icon: Users,
hint: "当前团队可参与创作的人数",
},
{
label: "已完成项目",
value: filtered.filter((project) => project.progress === 100).length,
icon: Video,
hint: "已进入交付或归档阶段",
},
];
const recentProject = filtered[0];
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="mx-auto max-w-[1440px]">
<div className="max-w-7xl mx-auto"> <section className="grid gap-5 xl:grid-cols-[1.25fr_0.75fr]">
<div className="relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]" />
<div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" />
<div className="absolute right-20 top-20 h-28 w-28 rounded-full border border-white/10" />
<div className="relative">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74">
<Sparkles className="h-4 w-4" />
Creative Command
</div>
<h1 className="mt-5 max-w-3xl text-4xl font-semibold leading-tight">
{brand.companyName} AI 短剧生产工作台
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-white/76">
在这里查看所有项目进度、团队产能和最近更新,统一推进从剧本拆解到视频生成的整条生产链路。
</p>
{/* Header */} <div className="mt-8 flex flex-wrap gap-3">
<div className="flex items-center justify-between mb-6"> <Link
<div> to="/new-project"
<h1 className="text-2xl font-semibold text-foreground mb-1">我的项目</h1> className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-3 text-sm font-medium text-[#0d4174] shadow-[0_18px_32px_rgba(8,31,63,0.14)] transition hover:translate-y-[-1px]"
<p className="text-sm text-muted-foreground">管理和创作您的AI短剧项目</p> >
<Plus className="h-4 w-4" />
创建短剧项目
</Link>
<button
onClick={() => navigate("/assets/character")}
className="inline-flex items-center gap-2 rounded-2xl border border-white/16 bg-white/10 px-5 py-3 text-sm text-white transition hover:bg-white/14"
>
<WandSparkles className="h-4 w-4" />
进入资产中心
</button>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{[
"统一管理剧本、大纲、角色、场景、分镜和视频任务",
"支持企业团队协作、品牌化展示与项目状态追踪",
"用一套工作台承接创作入口、资产沉淀与交付流程",
].map((item) => (
<div
key={item}
className="rounded-3xl border border-white/14 bg-white/10 px-4 py-4 text-sm leading-6 text-white/82 backdrop-blur-sm"
>
{item}
</div>
))}
</div>
</div> </div>
<Link
to="/new-project"
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center gap-2 hover:shadow-md transition-all text-sm"
>
<Plus className="w-4 h-4" />
新建项目
</Link>
</div> </div>
{/* Search & Filters */} <div className="grid gap-5">
<div className="mb-6 p-4 rounded-xl border border-border bg-card"> <div className="rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_20px_50px_rgba(11,44,81,0.10)] backdrop-blur-xl">
<div className="grid grid-cols-5 gap-4"> <div className="mb-4 flex items-center justify-between">
<div className="col-span-2"> <div>
<label className="block text-xs text-muted-foreground mb-2">搜索项目</label> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Today Focus</div>
<div className="relative"> <div className="mt-2 text-2xl font-semibold text-foreground">创作节奏总览</div>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="输入项目名称搜索..."
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div> </div>
</div> <div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<div> <Clock3 className="h-5 w-5" />
<label className="block text-xs text-muted-foreground mb-2">开始日期</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">结束日期</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div> </div>
</div> </div>
<div> <div className="space-y-3">
<label className="block text-xs text-muted-foreground mb-2">按成员筛选</label> {[
<div className="relative"> "优先跟进处于“剧本拆解中”和“分镜推进中”的项目,能最快带动整条链路推进。",
<button "将项目封面、风格和分辨率补全,有利于工作台和详情页展示一致性。",
onClick={() => setShowMemberFilter(!showMemberFilter)} "团队成员与资产中心已就绪时,可直接进入角色、场景与视频流程。",
className="w-full px-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground text-left flex items-center justify-between hover:bg-muted transition-colors" ].map((item, index) => (
<div
key={item}
className="rounded-2xl border border-border/70 bg-white/74 px-4 py-4 shadow-sm"
> >
<span className="flex items-center gap-2"> <div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">
<Filter className="w-4 h-4 text-muted-foreground" /> 0{index + 1}
{selectedMembers.length > 0 ? `${selectedMembers.length}个成员` : "选择成员"}
</span>
<span className="text-xs text-muted-foreground">{showMemberFilter ? "▲" : "▼"}</span>
</button>
{showMemberFilter && (
<div className="absolute top-full left-0 right-0 mt-2 p-3 rounded-lg border border-border bg-card shadow-lg z-10">
<div className="flex flex-wrap gap-2">
{allMembers.map((member) => (
<button
key={member}
onClick={() => setSelectedMembers((prev) =>
prev.includes(member) ? prev.filter((m) => m !== member) : [...prev, member]
)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
selectedMembers.includes(member) ? "bg-primary text-white" : "bg-muted text-foreground hover:bg-muted-foreground/10"
}`}
>
{member}
</button>
))}
</div>
{selectedMembers.length > 0 && (
<button onClick={() => setSelectedMembers([])} className="mt-3 text-xs text-destructive hover:underline">
清除筛选
</button>
)}
</div> </div>
)} <div className="text-sm leading-6 text-foreground/88">{item}</div>
</div>
))}
</div>
</div>
<div className="rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_20px_50px_rgba(11,44,81,0.10)] backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Recent Project</div>
<div className="mt-2 text-xl font-semibold text-foreground">
{recentProject?.title ?? "等待创建第一个项目"}
</div>
</div>
<div className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
{recentProject ? "最近更新" : "空状态"}
</div> </div>
</div> </div>
{recentProject ? (
<>
<div className="text-sm leading-6 text-muted-foreground">
{recentProject.description || "这个项目已经进入工作台,可继续推进剧本拆解、角色设定或分镜制作。"}
</div>
<div className="mt-5">
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
<span>完成度</span>
<span>{recentProject.progress}%</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#0f74d8_0%,#1ea5ff_100%)]"
style={{ width: `${recentProject.progress}%` }}
/>
</div>
</div>
</>
) : (
<div className="text-sm leading-6 text-muted-foreground">
还没有项目时,建议先从“创建短剧项目”开始,系统会自动串起后续工作流。
</div>
)}
</div> </div>
</div> </div>
</section>
{/* Stats */} <section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-4 gap-4 mb-6"> {stats.map((stat) => {
{[ const Icon = stat.icon;
{ label: "总项目", value: filtered.length, icon: Film }, return (
{ label: "进行中", value: filtered.filter((p) => p.progress < 100).length, icon: Play }, <div
{ label: "团队成员", value: allMembers.length, icon: Users }, key={stat.label}
{ label: "已完成", value: filtered.filter((p) => p.progress === 100).length, icon: Clock }, className="rounded-[30px] border border-white/80 bg-white/82 p-5 shadow-[0_18px_40px_rgba(11,44,81,0.08)] backdrop-blur-xl"
].map((s) => ( >
<div key={s.label} className="rounded-xl border border-border bg-card p-4"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center">
<s.icon className="w-5 h-5 text-primary" />
</div>
<div> <div>
<div className="text-2xl font-semibold text-foreground">{s.value}</div> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">{stat.label}</div>
<div className="text-xs text-muted-foreground">{s.label}</div> <div className="mt-3 text-3xl font-semibold text-foreground">{stat.value}</div>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div> </div>
</div> </div>
<div className="mt-3 text-sm leading-6 text-muted-foreground">{stat.hint}</div>
</div> </div>
))} );
})}
</section>
<section className="mt-6 rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_20px_50px_rgba(11,44,81,0.10)] backdrop-blur-xl">
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Project Matrix</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">项目矩阵</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
统一查看项目状态、封面表现和参数配置,快速进入项目详情、编辑资料或继续后续流程。
</p>
</div>
<Link
to="/new-project"
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<Plus className="h-4 w-4" />
新建项目
</Link>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-[1fr_auto_auto_auto]">
<div className="relative">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索项目名称"
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-11 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/>
</div>
<div className="relative">
<Calendar className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((current) => ({ ...current, start: e.target.value }))}
className="h-12 rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
/>
</div>
<div className="relative">
<Calendar className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((current) => ({ ...current, end: e.target.value }))}
className="h-12 rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
/>
</div>
<button
onClick={() => {
setSearchTerm("");
setDateRange({ start: "", end: "" });
}}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
清空筛选
</button>
</div>
<div className="mt-6 flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{filtered.length} 个项目,已接入 {teamMembers.length} 位团队成员协作。
</div>
<div className="hidden items-center gap-2 md:flex">
{["企业展示", "统一视觉", "流程驱动"].map((chip) => (
<span
key={chip}
className="rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
{chip}
</span>
))}
</div>
</div> </div>
{/* Projects Grid */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
) : filtered.length > 0 ? ( ) : filtered.length > 0 ? (
<div className="grid grid-cols-3 gap-4"> <div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((project) => ( {filtered.map((project) => {
<div key={project.id} className="group relative rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all"> const meta = statusMeta[project.status] ?? statusMeta.outline;
return (
{/* Cover */} <div
<Link to={`/project/${project.id}`} className="block aspect-video bg-muted overflow-hidden"> key={project.id}
<ProjectCover className="group overflow-hidden rounded-[30px] border border-white/80 bg-white/88 shadow-[0_20px_50px_rgba(11,44,81,0.10)] transition hover:translate-y-[-2px] hover:shadow-[0_28px_64px_rgba(11,44,81,0.14)]"
url={project.coverUrl} >
name={project.title} <div className="relative aspect-[16/10] overflow-hidden bg-muted">
className="group-hover:scale-105 transition-transform duration-300" <Link to={`/project/${project.id}`} className="block h-full w-full">
/> <ProjectCover
{/* Status badge */} name={project.title}
<div className={`absolute top-3 right-3 px-2.5 py-1 rounded-md text-xs font-medium ${statusColor[project.status] ?? "bg-gray-100 text-gray-600"}`}> url={project.coverUrl}
{statusText[project.status] ?? project.status} className="transition duration-500 group-hover:scale-105"
/>
</Link>
<div className="absolute left-4 top-4 flex gap-2 opacity-0 transition group-hover:opacity-100">
<button
onClick={(e) => {
e.preventDefault();
setEditingProject(project.raw);
}}
className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-950/55 text-white backdrop-blur transition hover:bg-slate-950/75"
title="编辑项目"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={(e) => {
e.preventDefault();
setDeletingProject(project.raw);
}}
className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-950/55 text-white backdrop-blur transition hover:bg-red-600/85"
title="删除项目"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className={`absolute right-4 top-4 rounded-full px-3 py-1 text-xs font-medium ${meta.chip}`}>
{meta.label}
</div>
</div> </div>
</Link>
{/* Action buttons — visible on hover */}
<div className="absolute top-3 left-3 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.preventDefault(); setEditingProject(project.raw); }}
className="w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-black/80 transition"
title="编辑项目"
>
<Pencil className="w-3.5 h-3.5 text-white" />
</button>
<button
onClick={(e) => { e.preventDefault(); setDeletingProject(project.raw); }}
className="w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-red-600/80 transition"
title="删除项目"
>
<Trash2 className="w-3.5 h-3.5 text-white" />
</button>
</div>
{/* Card body */} <div className="p-5">
<Link to={`/project/${project.id}`} className="block p-4"> <div className="flex items-start justify-between gap-4">
<h3 className="font-medium text-foreground mb-1.5 line-clamp-1">{project.title}</h3> <div className="min-w-0">
{project.description && ( <Link
<p className="text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed"> to={`/project/${project.id}`}
{project.description} className="block truncate text-lg font-semibold text-foreground transition hover:text-primary"
>
{project.title}
</Link>
<div className="mt-2 text-sm text-muted-foreground">
最近更新 {project.updatedAt || "暂无时间"}
</div>
</div>
<div className="rounded-2xl bg-accent px-3 py-2 text-xs text-primary">
#{project.id.slice(0, 6)}
</div>
</div>
<p className="mt-4 min-h-[48px] text-sm leading-6 text-foreground/80">
{project.description || "项目已创建完成,可继续推进剧本拆解、角色设定、分镜与视频生成。"}
</p> </p>
)}
{project.tags.length > 0 && ( <div className="mt-4 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5 mb-3"> {project.tags.length > 0 ? (
{project.tags.slice(0, 3).map((tag) => ( project.tags.slice(0, 3).map((tag) => (
<span key={tag.key} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground"> <span
{tag.label} key={tag}
className="rounded-full border border-border/80 bg-white px-3 py-1 text-xs text-muted-foreground"
>
{tag}
</span>
))
) : (
<span className="rounded-full border border-dashed border-border/80 px-3 py-1 text-xs text-muted-foreground">
待补充项目参数
</span> </span>
))} )}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{project.updatedAt
? new Date(project.updatedAt).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" })
: "—"}
</div> </div>
</div>
<div className="space-y-1.5"> <div className="mt-5">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
<span>完成度</span> <span>生产完成度</span>
<span>{project.progress}%</span> <span>{project.progress}%</span>
</div> </div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden"> <div className="h-2.5 overflow-hidden rounded-full bg-muted">
<div <div
className="h-full bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] rounded-full transition-all" className="h-full rounded-full bg-[linear-gradient(90deg,#0f74d8_0%,#1ea5ff_100%)] transition-all"
style={{ width: `${project.progress}%` }} style={{ width: `${project.progress}%` }}
/> />
</div>
</div> </div>
</div> </div>
</Link> </div>
</div> );
))} })}
</div> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="text-center py-16"> <div className="mt-6 rounded-[30px] border border-dashed border-border bg-white/70 px-6 py-20 text-center">
<Film className="w-16 h-16 text-muted-foreground mx-auto mb-4" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<p className="text-muted-foreground mb-4">还没有项目,开始创建第一个吧</p> <Film className="h-7 w-7" />
</div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">还没有项目</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
从剧本上传或空白创建开始,平台会自动接上大纲、设定、分镜和视频生成流程。
</p>
<Link <Link
to="/new-project" to="/new-project"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm hover:shadow-md transition-all" className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Plus className="w-4 h-4" /> <Plus className="h-4 w-4" />
新建项目 创建第一个项目
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="text-center py-16"> <div className="mt-6 rounded-[30px] border border-dashed border-border bg-white/70 px-6 py-20 text-center">
<Film className="w-16 h-16 text-muted-foreground mx-auto mb-4" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<p className="text-muted-foreground">没有找到符合条件的项目</p> <Search className="h-7 w-7" />
</div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">没有匹配的项目</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
当前筛选条件下没有找到对应项目,可以尝试放宽关键词或清除时间范围。
</p>
<button <button
onClick={() => { setSearchTerm(""); setDateRange({ start: "", end: "" }); setSelectedMembers([]); }} onClick={() => {
className="mt-4 text-sm text-primary hover:underline" setSearchTerm("");
setDateRange({ start: "", end: "" });
}}
className="mt-6 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
除所有筛选条件 空筛选
</button> </button>
</div> </div>
)} )}
</div> </section>
{/* Modals */} {editingProject ? (
{editingProject && ( <EditProjectDialog project={editingProject} onClose={() => setEditingProject(null)} />
<EditModal ) : null}
project={editingProject} {deletingProject ? (
onClose={() => setEditingProject(null)} <DeleteConfirmDialog project={deletingProject} onClose={() => setDeletingProject(null)} />
/> ) : null}
)}
{deletingProject && (
<DeleteConfirm
project={deletingProject}
onClose={() => setDeletingProject(null)}
/>
)}
</div> </div>
); );
} }
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { ArrowRight, Film, Loader2, AlertCircle, RefreshCw } from "lucide-react"; import {
AlertCircle,
ArrowRight,
Clapperboard,
Film,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
import { useEpisodes } from "../../hooks/useAi"; import { useEpisodes } from "../../hooks/useAi";
export function EpisodeGeneration() { export function EpisodeGeneration() {
...@@ -11,10 +19,14 @@ export function EpisodeGeneration() { ...@@ -11,10 +19,14 @@ export function EpisodeGeneration() {
if (isLoading || (isFetching && episodes.length === 0)) { if (isLoading || (isFetching && episodes.length === 0)) {
return ( return (
<div className="h-full flex items-center justify-center bg-background"> <div className="mx-auto max-w-[1180px]">
<div className="text-center"> <div className="flex min-h-[420px] items-center justify-center rounded-[36px] border border-white/80 bg-white/82 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<Loader2 className="w-10 h-10 text-primary animate-spin mx-auto mb-4" /> <div className="text-center">
<p className="text-sm text-muted-foreground">加载分集数据...</p> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[28px] bg-primary/10 text-primary">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
<p className="mt-5 text-sm text-muted-foreground">正在加载分集数据...</p>
</div>
</div> </div>
</div> </div>
); );
...@@ -22,10 +34,12 @@ export function EpisodeGeneration() { ...@@ -22,10 +34,12 @@ export function EpisodeGeneration() {
if (error) { if (error) {
return ( return (
<div className="h-full flex items-center justify-center p-6"> <div className="mx-auto max-w-[1180px]">
<div className="flex items-center gap-3 text-red-600"> <div className="flex min-h-[420px] items-center justify-center rounded-[36px] border border-white/80 bg-white/82 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<AlertCircle className="w-5 h-5" /> <div className="flex items-center gap-3 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-red-700">
<span className="text-sm">{(error as Error).message}</span> <AlertCircle className="h-5 w-5" />
<span className="text-sm">{(error as Error).message}</span>
</div>
</div> </div>
</div> </div>
); );
...@@ -33,24 +47,29 @@ export function EpisodeGeneration() { ...@@ -33,24 +47,29 @@ export function EpisodeGeneration() {
if (episodes.length === 0) { if (episodes.length === 0) {
return ( return (
<div className="h-full flex items-center justify-center bg-background p-6"> <div className="mx-auto max-w-[1180px]">
<div className="text-center"> <div className="rounded-[36px] border border-white/80 bg-white/82 px-6 py-16 text-center shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<Film className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[28px] bg-primary/10 text-primary">
<h3 className="text-lg font-medium text-foreground mb-2">暂无分集</h3> <Film className="h-8 w-8" />
<p className="text-sm text-muted-foreground mb-6">请先在大纲页面生成分集内容</p> </div>
<div className="flex gap-3 justify-center"> <h3 className="mt-5 text-2xl font-semibold text-foreground">暂时还没有分集内容</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
请先在大纲页面生成分集内容,系统会自动把结果同步到这里,供你继续进入分镜工作台。
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm flex items-center gap-2" className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="h-4 w-4" />
刷新 刷新
</button> </button>
<button <button
onClick={() => navigate(`/project/${projectId}/outline`)} onClick={() => navigate(`/project/${projectId}/outline`)}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
返回大纲页面 <Sparkles className="h-4 w-4" />
返回大纲页
</button> </button>
</div> </div>
</div> </div>
...@@ -59,57 +78,70 @@ export function EpisodeGeneration() { ...@@ -59,57 +78,70 @@ export function EpisodeGeneration() {
} }
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="mx-auto max-w-[1180px]">
<div className="max-w-6xl mx-auto"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground mb-1">分集列表</h1> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Episodes</div>
<p className="text-sm text-muted-foreground">AI已生成 {episodes.length} 集内容</p> <h1 className="mt-2 text-3xl font-semibold text-foreground">分集列表</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
AI 已生成 {episodes.length} 集内容,建议按顺序进入分镜工作台继续推进镜头设计。
</p>
</div> </div>
{isFetching && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {isFetching ? (
<Loader2 className="w-4 h-4 animate-spin" /> <div className="inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm text-primary">
刷新中... <Loader2 className="h-4 w-4 animate-spin" />
正在刷新
</div> </div>
)} ) : null}
</div> </div>
<div className="grid grid-cols-1 gap-3 mb-8"> <div className="grid gap-4">
{episodes.map((ep) => ( {episodes.map((episode) => (
<div <button
key={ep.id} key={episode.id}
onClick={() => navigate(`/project/${projectId}/storyboard/${ep.id}`)} onClick={() => navigate(`/project/${projectId}/storyboard/${episode.id}`)}
className="rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer" className="rounded-[28px] border border-white/80 bg-white/78 p-5 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center flex-shrink-0 text-white font-semibold text-sm"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-3xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-sm font-semibold text-white shadow-[0_14px_24px_rgba(15,116,216,0.16)]">
{ep.episodeNumber} {episode.episodeNumber}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2">
<Film className="w-3.5 h-3.5 text-primary flex-shrink-0" /> <Clapperboard className="h-4 w-4 text-primary" />
<span className="font-medium text-foreground text-sm">{ep.title || `第${ep.episodeNumber}集`}</span> <span className="text-lg font-medium text-foreground">
{episode.title || `第 ${episode.episodeNumber} 集`}
</span>
</div> </div>
<p className="text-xs text-muted-foreground line-clamp-2">{ep.summary}</p> <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
{episode.summary}
</p>
</div> </div>
<span className={`text-xs px-2 py-1 rounded-full flex-shrink-0 ${ <span
ep.status === "ready" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500" className={[
}`}> "rounded-full px-3 py-1 text-xs",
{ep.status === "ready" ? "已就绪" : "草稿"} episode.status === "ready"
? "bg-emerald-100 text-emerald-700"
: "bg-slate-100 text-slate-600",
].join(" ")}
>
{episode.status === "ready" ? "已就绪" : "草稿"}
</span> </span>
</div> </div>
</div> </button>
))} ))}
</div> </div>
<button <button
onClick={() => navigate(`/project/${projectId}/storyboard/${episodes[0].id}`)} onClick={() => navigate(`/project/${projectId}/storyboard/${episodes[0].id}`)}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all" className="mt-6 inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px]"
> >
<span>进入分镜工作台</span> 进入分镜工作台
<ArrowRight className="w-5 h-5" /> <ArrowRight className="h-5 w-5" />
</button> </button>
</div> </section>
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { LockKeyhole, Mail, ShieldCheck } from "lucide-react";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { useAuth } from "../../hooks/useAuth"; import { useAuth } from "../../hooks/useAuth";
import { AuthShell } from "../components/AuthShell";
export function LoginPage() { export function LoginPage() {
const { login, loginPending, loginError } = useAuth(); const { login, loginPending, loginError } = useAuth();
...@@ -16,56 +17,78 @@ export function LoginPage() { ...@@ -16,56 +17,78 @@ export function LoginPage() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background px-4"> <AuthShell
<Card className="w-full max-w-sm"> title="登录企业工作台"
<CardHeader className="text-center"> description="使用企业账号进入中科集团 AI 短剧工业化平台,继续推进剧本、设定、分镜与视频任务。"
<CardTitle className="text-2xl">YaoAI Comic Studio</CardTitle> footer={
<CardDescription>登录您的账号</CardDescription> <div className="flex items-center justify-between gap-3">
</CardHeader> <div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<CardContent> <ShieldCheck className="h-4 w-4 text-primary" />
<form onSubmit={handleSubmit} className="space-y-4"> 统一身份认证与团队空间隔离
<div className="space-y-2"> </div>
<Label htmlFor="email">邮箱</Label> <div>
<Input 没有账号?
id="email" <Link to="/register" className="ml-1 font-medium text-primary hover:underline">
type="email" 立即注册
placeholder="you@example.com" </Link>
required </div>
value={form.email} </div>
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} }
/> >
</div> <form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">密码</Label> <Label htmlFor="email" className="text-sm text-foreground">
<Input 邮箱账号
id="password" </Label>
type="password" <div className="relative">
placeholder="••••••••" <Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
required <Input
value={form.password} id="email"
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} type="email"
/> placeholder="you@example.com"
</div> required
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</div>
</div>
{loginError && ( <div className="space-y-2">
<p className="text-sm text-destructive"> <div className="flex items-center justify-between">
{loginError instanceof Error ? loginError.message : "登录失败"} <Label htmlFor="password" className="text-sm text-foreground">
</p> 登录密码
)} </Label>
<span className="text-xs text-muted-foreground">建议使用企业统一密码</span>
</div>
<div className="relative">
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
required
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={loginPending}> {loginError ? (
{loginPending ? "登录中..." : "登录"} <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
</Button> {loginError instanceof Error ? loginError.message : "登录失败,请检查账号和密码。"}
</div>
) : null}
<p className="text-center text-sm text-muted-foreground"> <Button
没有账号?{" "} type="submit"
<Link to="/register" className="text-primary underline-offset-4 hover:underline"> className="h-12 w-full rounded-xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-base font-medium shadow-[0_16px_30px_rgba(15,116,216,0.22)] transition hover:translate-y-[-1px] hover:shadow-[0_20px_38px_rgba(15,116,216,0.26)]"
立即注册 disabled={loginPending}
</Link> >
</p> {loginPending ? "正在登录..." : "进入工作台"}
</form> </Button>
</CardContent> </form>
</Card> </AuthShell>
</div>
); );
} }
import { useState, useEffect } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Sparkles,
Plus,
Trash2,
X,
Check, Check,
Search,
MessageSquare,
Image as ImageIcon, Image as ImageIcon,
Video,
Zap,
Settings2,
Loader2, Loader2,
MessageSquare,
Plus,
Save,
Search,
Settings2,
Sparkles,
Trash2,
Video,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
useModelProviders,
useCreateProvider, useCreateProvider,
useUpdateProvider,
useDeleteProvider, useDeleteProvider,
useModelDefaults, useModelDefaults,
useModelProviders,
useSaveModelDefaults, useSaveModelDefaults,
useUpdateProvider,
} from "../../hooks/useModelProviders"; } from "../../hooks/useModelProviders";
import type { ModelProvider, ModelDefaults, CreateProviderPayload } from "../../lib/api/modelProviders"; import type {
CreateProviderPayload,
ModelDefaults,
ModelProvider,
} from "../../lib/api/modelProviders";
type Capability = "text" | "image" | "video"; type Capability = "text" | "image" | "video";
type Protocol = "openai" | "gemini" | "dashscope" | "seedance" | "kling"; type Protocol = "openai" | "gemini" | "dashscope" | "seedance" | "kling";
const protocolOptions: { value: Protocol; label: string; capabilities: Capability[] }[] = [ const capabilityMeta = {
text: {
label: "语言模型",
icon: MessageSquare,
description: "用于剧本解析、角色提取与文本内容生成。",
},
image: {
label: "图片模型",
icon: ImageIcon,
description: "用于角色、场景与道具图像生成。",
},
video: {
label: "视频模型",
icon: Video,
description: "用于镜头视频生成与视频制作环节。",
},
} satisfies Record<Capability, { label: string; icon: typeof MessageSquare; description: string }>;
const protocolOptions: Array<{ value: Protocol; label: string; capabilities: Capability[] }> = [
{ value: "openai", label: "OpenAI 兼容", capabilities: ["text", "image"] }, { value: "openai", label: "OpenAI 兼容", capabilities: ["text", "image"] },
{ value: "dashscope", label: "阿里云百炼 (DashScope)", capabilities: ["text", "image"] }, { value: "dashscope", label: "阿里云百炼", capabilities: ["text", "image"] },
{ value: "gemini", label: "Google Gemini", capabilities: ["text", "image", "video"] }, { value: "gemini", label: "Google Gemini", capabilities: ["text", "image", "video"] },
{ value: "seedance", label: "字节跳动 Seedance", capabilities: ["video"] }, { value: "seedance", label: "字节 Seedance", capabilities: ["video"] },
{ value: "kling", label: "快手可灵 Kling", capabilities: ["image", "video"] }, { value: "kling", label: "可灵 Kling", capabilities: ["image", "video"] },
]; ];
const capabilityConfig = {
text: { label: "语言模型 (LLM)", icon: MessageSquare, color: "blue", description: "用于剧本解析、角色提取、分镜规划等文本生成任务" },
image: { label: "图片模型", icon: ImageIcon, color: "green", description: "用于角色四视图、场景图、道具图等图片生成任务" },
video: { label: "视频模型", icon: Video, color: "purple", description: "用于分镜视频生成、首尾帧插值等视频生成任务" },
};
const colorMap: Record<string, string> = {
blue: "from-blue-500 to-blue-600",
green: "from-green-500 to-green-600",
purple: "from-purple-500 to-purple-600",
};
const bgColorMap: Record<string, string> = {
blue: "bg-blue-500/10 text-blue-600",
green: "bg-green-500/10 text-green-600",
purple: "bg-purple-500/10 text-purple-600",
};
type EditingProvider = Omit<ModelProvider, "id"> & { id?: number }; type EditingProvider = Omit<ModelProvider, "id"> & { id?: number };
export function ModelSettings() { export function ModelSettings() {
const [activeCapability, setActiveCapability] = useState<Capability>("text"); const [activeCapability, setActiveCapability] = useState<Capability>("text");
const [showAddModal, setShowAddModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<EditingProvider | null>(null); const [editingProvider, setEditingProvider] = useState<EditingProvider | null>(null);
const [modelSearch, setModelSearch] = useState(""); const [modelSearch, setModelSearch] = useState("");
...@@ -69,70 +74,72 @@ export function ModelSettings() { ...@@ -69,70 +74,72 @@ export function ModelSettings() {
const [localDefaults, setLocalDefaults] = useState<ModelDefaults>({}); const [localDefaults, setLocalDefaults] = useState<ModelDefaults>({});
useEffect(() => { useEffect(() => {
if (!loadingDefaults) setLocalDefaults(defaults); if (!loadingDefaults) {
setLocalDefaults(defaults);
}
}, [loadingDefaults, defaults]); }, [loadingDefaults, defaults]);
const getProvidersByCapability = (cap: Capability) => const providersByCapability = useMemo(
providers.filter((p) => p.capability === cap); () => providers.filter((provider) => provider.capability === activeCapability),
[providers, activeCapability],
const getCheckedModels = (cap: Capability) => { );
const result: { providerId: number; providerName: string; modelId: string; modelName: string }[] = [];
providers
.filter((p) => p.capability === cap)
.forEach((p) => {
(p.models ?? []).filter((m) => m.checked).forEach((m) => {
result.push({ providerId: p.id, providerName: p.name, modelId: m.id, modelName: m.name });
});
});
return result;
};
const handleToggleModel = async (provider: ModelProvider, modelId: string) => {
const updated = {
...provider,
models: provider.models.map((m) =>
m.id === modelId ? { ...m, checked: !m.checked } : m
),
};
await updateProvider.mutateAsync({ id: provider.id, patch: updated });
};
const handleDeleteProvider = async (id: number) => { const checkedModels = useMemo(() => {
if (!confirm("确定要删除该供应商配置吗?")) return; return providers.map((provider) => ({
await deleteProvider.mutateAsync(id); provider,
}; models: provider.models.filter((model) => model.checked),
}));
}, [providers]);
const openAddProvider = (capability: Capability) => { const openCreate = (capability: Capability) => {
const proto = protocolOptions.find((p) => p.capabilities.includes(capability)); const protocol = protocolOptions.find((item) => item.capabilities.includes(capability));
setEditingProvider({ setEditingProvider({
name: "", name: "",
protocol: proto?.value || "openai", protocol: protocol?.value ?? "openai",
capability, capability,
baseUrl: "", baseUrl: "",
apiKey: "", apiKey: "",
secretKey: "", secretKey: "",
models: [], models: [],
}); });
setShowAddModal(true); setShowModal(true);
};
const handleToggleModel = async (provider: ModelProvider, modelId: string) => {
const patch = {
...provider,
models: provider.models.map((model) =>
model.id === modelId ? { ...model, checked: !model.checked } : model,
),
};
await updateProvider.mutateAsync({ id: provider.id, patch });
};
const handleDeleteProvider = async (id: number) => {
if (!confirm("确定要删除该模型提供商配置吗?")) return;
await deleteProvider.mutateAsync(id);
}; };
const handleSaveProvider = async () => { const handleSaveProvider = async () => {
if (!editingProvider || !editingProvider.name.trim()) return; if (!editingProvider || !editingProvider.name.trim()) return;
const payload: CreateProviderPayload = { const payload: CreateProviderPayload = {
name: editingProvider.name, name: editingProvider.name,
protocol: editingProvider.protocol, protocol: editingProvider.protocol,
capability: editingProvider.capability as Capability, capability: editingProvider.capability,
baseUrl: editingProvider.baseUrl, baseUrl: editingProvider.baseUrl,
apiKey: editingProvider.apiKey, apiKey: editingProvider.apiKey,
secretKey: editingProvider.secretKey || "", secretKey: editingProvider.secretKey,
models: editingProvider.models, models: editingProvider.models,
}; };
if (editingProvider.id) { if (editingProvider.id) {
await updateProvider.mutateAsync({ id: editingProvider.id, patch: payload }); await updateProvider.mutateAsync({ id: editingProvider.id, patch: payload });
} else { } else {
await createProvider.mutateAsync(payload); await createProvider.mutateAsync(payload);
} }
setShowAddModal(false);
setShowModal(false);
setEditingProvider(null); setEditingProvider(null);
}; };
...@@ -142,394 +149,340 @@ export function ModelSettings() { ...@@ -142,394 +149,340 @@ export function ModelSettings() {
if (loadingProviders) { if (loadingProviders) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto bg-background"> <div className="mx-auto max-w-[1440px]">
<div className="p-6"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="max-w-5xl mx-auto"> <div className="flex flex-wrap items-start justify-between gap-4">
{/* Header */} <div>
<div className="mb-8"> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<div className="flex items-center gap-3 mb-2"> <Settings2 className="h-3.5 w-3.5" />
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> Model Hub
<Settings2 className="w-4 h-4 text-white" />
</div>
<h1 className="text-2xl font-semibold text-foreground">模型配置</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <h1 className="mt-4 text-3xl font-semibold text-foreground">模型配置中心</h1>
配置 AI 模型供应商和默认模型,支持语言模型、图片模型和视频模型的独立配置 <p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
管理语言、图像与视频模型提供商,并为每类能力设置默认模型,保证生产流程的调用一致性。
</p> </p>
</div> </div>
{/* Default Model Selection */} <button
<div className="rounded-xl border border-border bg-card p-5 mb-6"> onClick={() => openCreate(activeCapability)}
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2"> className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
<Zap className="w-4 h-4 text-yellow-500" /> >
默认模型 <Plus className="h-4 w-4" />
</h2> 新建模型提供商
<p className="text-xs text-muted-foreground mb-4"> </button>
选择每种任务类型的默认模型,生成时可临时切换。仅显示已勾选的模型。 </div>
</p>
<div className="grid grid-cols-3 gap-4"> <div className="mt-6 grid gap-6 xl:grid-cols-[1fr_0.96fr]">
{(["text", "image", "video"] as Capability[]).map((cap) => { <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
const config = capabilityConfig[cap]; <div className="mb-4 flex items-center justify-between">
const Icon = config.icon; <div>
const checked = getCheckedModels(cap); <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Default Models</div>
const current = localDefaults[cap]; <h2 className="mt-2 text-2xl font-semibold text-foreground">默认模型</h2>
</div>
<button
onClick={handleSaveDefaults}
disabled={saveDefaults.isPending}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{saveDefaults.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
保存默认模型
</button>
</div>
<div className="grid gap-4 md:grid-cols-3">
{(["text", "image", "video"] as Capability[]).map((capability) => {
const meta = capabilityMeta[capability];
const Icon = meta.icon;
const available = checkedModels
.flatMap(({ provider, models }) =>
models
.filter(() => provider.capability === capability)
.map((model) => ({
providerId: provider.id,
providerName: provider.name,
modelId: model.id,
modelName: model.name,
})),
);
return ( return (
<div key={cap} className="rounded-lg border border-border p-4"> <div
<div className="flex items-center gap-2 mb-3"> key={capability}
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${bgColorMap[config.color]}`}> className="rounded-[28px] border border-border/80 bg-white px-4 py-5 shadow-sm"
<Icon className="w-3.5 h-3.5" /> >
</div> <div className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<span className="text-sm font-medium text-foreground">{config.label}</span> <Icon className="h-5 w-5" />
</div> </div>
<div className="text-base font-medium text-foreground">{meta.label}</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">{meta.description}</div>
<select <select
value={current ? `${current.providerId}:${current.modelId}` : ""} value={
localDefaults[capability]
? `${localDefaults[capability]?.providerId}:${localDefaults[capability]?.modelId}`
: ""
}
onChange={(e) => { onChange={(e) => {
if (!e.target.value) { if (!e.target.value) {
setLocalDefaults({ ...localDefaults, [cap]: null }); setLocalDefaults((current) => ({ ...current, [capability]: null }));
} else { } else {
const [pid, mid] = e.target.value.split(":"); const [providerId, modelId] = e.target.value.split(":");
setLocalDefaults({ ...localDefaults, [cap]: { providerId: Number(pid), modelId: mid } }); setLocalDefaults((current) => ({
...current,
[capability]: { providerId: Number(providerId), modelId },
}));
} }
}} }}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" className="mt-4 h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
> >
<option value="">未选择</option> <option value="">未选择</option>
{checked.map((m) => ( {available.map((model) => (
<option key={`${m.providerId}:${m.modelId}`} value={`${m.providerId}:${m.modelId}`}> <option
{m.providerName} / {m.modelName} key={`${model.providerId}:${model.modelId}`}
value={`${model.providerId}:${model.modelId}`}
>
{model.providerName} / {model.modelName}
</option> </option>
))} ))}
</select> </select>
{checked.length === 0 && (
<p className="text-xs text-muted-foreground mt-2">请先在下方勾选可用模型</p>
)}
</div> </div>
); );
})} })}
</div> </div>
<div className="mt-4 flex justify-end">
<button
onClick={handleSaveDefaults}
disabled={saveDefaults.isPending}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90 transition-opacity flex items-center gap-2 disabled:opacity-60"
>
{saveDefaults.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
保存默认模型
</button>
</div>
</div> </div>
{/* Capability Tabs */} <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="flex items-center gap-2 mb-6"> <div className="mb-4 flex items-center justify-between">
{(["text", "image", "video"] as Capability[]).map((cap) => { <div>
const config = capabilityConfig[cap]; <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Provider Overview</div>
const Icon = config.icon; <h2 className="mt-2 text-2xl font-semibold text-foreground">能力分类</h2>
const count = getProvidersByCapability(cap).length; </div>
return ( </div>
<button
key={cap} <div className="grid gap-4 md:grid-cols-3">
onClick={() => setActiveCapability(cap)} {(["text", "image", "video"] as Capability[]).map((capability) => {
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm transition-all ${ const meta = capabilityMeta[capability];
activeCapability === cap const Icon = meta.icon;
? `bg-gradient-to-r ${colorMap[config.color]} text-white shadow-sm` const active = activeCapability === capability;
: "bg-card border border-border text-foreground hover:bg-muted" return (
}`} <button
> key={capability}
<Icon className="w-4 h-4" /> onClick={() => setActiveCapability(capability)}
{config.label} className={[
<span className={`px-1.5 py-0.5 rounded text-xs ${ "rounded-[28px] border px-5 py-5 text-left transition-all",
activeCapability === cap ? "bg-white/20" : "bg-muted text-muted-foreground" active
}`}> ? "border-primary/20 bg-primary/5 shadow-[0_16px_28px_rgba(15,116,216,0.10)]"
{count} : "border-border/80 bg-white shadow-sm hover:border-primary/20",
</span> ].join(" ")}
</button> >
); <div className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
})} <Icon className="h-5 w-5" />
</div>
<div className="text-base font-medium text-foreground">{meta.label}</div>
<div className="mt-2 text-sm text-muted-foreground">
{providers.filter((provider) => provider.capability === capability).length} 个提供商
</div>
</button>
);
})}
</div>
</div> </div>
</div>
<div className="rounded-lg border border-border bg-muted/30 p-3 mb-4 flex items-start gap-2"> <div className="mt-6 rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<Sparkles className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" /> <div className="mb-4 flex flex-wrap items-center justify-between gap-4">
<span className="text-xs text-muted-foreground"> <div>
{capabilityConfig[activeCapability].description} <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Provider List</div>
</span> <h2 className="mt-2 text-2xl font-semibold text-foreground">
{capabilityMeta[activeCapability].label} 提供商
</h2>
</div>
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="搜索模型"
/>
</div>
</div> </div>
{/* Provider List */}
<div className="space-y-4"> <div className="space-y-4">
{getProvidersByCapability(activeCapability).map((provider) => ( {providersByCapability.length === 0 ? (
<div key={provider.id} className="rounded-xl border border-border bg-card overflow-hidden"> <div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
<div className="p-4 border-b border-border"> 当前能力下还没有配置提供商,请先新增一个模型提供商。
<div className="flex items-center justify-between"> </div>
<div className="flex items-center gap-3"> ) : (
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${bgColorMap[capabilityConfig[activeCapability].color]}`}> providersByCapability.map((provider) => (
<span className="text-xs font-bold">{provider.name[0]}</span> <div
key={provider.id}
className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-lg font-medium text-foreground">{provider.name}</div>
<div className="mt-2 text-sm text-muted-foreground">
协议:{protocolOptions.find((option) => option.value === provider.protocol)?.label ?? provider.protocol}
</div> </div>
<div> <div className="mt-2 text-sm text-muted-foreground">
<h3 className="text-sm font-semibold text-foreground">{provider.name}</h3> 接口地址:{provider.baseUrl || "未设置"}
<div className="flex items-center gap-2 mt-0.5">
<span className="px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground">
{protocolOptions.find((p) => p.value === provider.protocol)?.label}
</span>
<span className="text-xs text-muted-foreground">
{(provider.models ?? []).filter((m) => m.checked).length}/{(provider.models ?? []).length} 模型已启用
</span>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <button
<button onClick={() => handleDeleteProvider(provider.id)}
onClick={() => { setEditingProvider({ ...provider }); setShowAddModal(true); }} className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
className="px-3 py-1.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1" >
> <Trash2 className="h-4 w-4" />
<Settings2 className="w-3 h-3" /> 删除
配置 </button>
</button>
<button
onClick={() => handleDeleteProvider(provider.id)}
disabled={deleteProvider.isPending}
className="p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
<div className="mt-3 flex items-center gap-4">
<div className="flex items-center gap-2 flex-1">
<span className="text-xs text-muted-foreground w-16">Base URL:</span>
<span className="text-xs text-foreground font-mono truncate">{provider.baseUrl || "未配置"}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">API Key:</span>
{provider.apiKey ? (
<span className="text-xs text-foreground font-mono">{provider.apiKey}</span>
) : (
<span className="text-xs text-yellow-600">未配置</span>
)}
</div>
</div> </div>
</div>
{/* Model List */} <div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="p-4"> {provider.models
<div className="flex items-center justify-between mb-3"> .filter((model) => !modelSearch || model.name.toLowerCase().includes(modelSearch.toLowerCase()))
<span className="text-xs text-muted-foreground font-medium">可用模型</span> .map((model) => (
<div className="relative"> <button
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" /> key={model.id}
<input onClick={() => handleToggleModel(provider, model.id)}
type="text" className={[
placeholder="搜索模型..." "rounded-[20px] border px-4 py-4 text-left transition-all",
value={modelSearch} model.checked
onChange={(e) => setModelSearch(e.target.value)} ? "border-primary/20 bg-primary/5"
className="pl-7 pr-3 py-1 rounded-md border border-border bg-background text-xs text-foreground placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/20 w-48" : "border-border/80 bg-white hover:border-primary/20",
/> ].join(" ")}
</div> >
</div> <div className="flex items-start justify-between gap-3">
<div className="space-y-1"> <div>
{(provider.models ?? []) <div className="text-sm font-medium text-foreground">{model.name}</div>
.filter((m) => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()) || m.id.toLowerCase().includes(modelSearch.toLowerCase())) <div className="mt-1 text-xs text-muted-foreground">{model.id}</div>
.map((model) => {
const isDefault = localDefaults[provider.capability as Capability]?.providerId === provider.id &&
localDefaults[provider.capability as Capability]?.modelId === model.id;
return (
<button
key={model.id}
onClick={() => handleToggleModel(provider, model.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all text-left ${
model.checked ? "bg-accent border border-primary/30" : "border border-transparent hover:bg-muted"
}`}
>
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${
model.checked ? "border-primary bg-primary" : "border-muted-foreground"
}`}>
{model.checked && <Check className="w-3 h-3 text-white" />}
</div>
<div>
<span className="text-sm text-foreground">{model.name}</span>
<span className="text-xs text-muted-foreground ml-2 font-mono">{model.id}</span>
</div>
</div> </div>
{isDefault && ( {model.checked ? (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary font-medium"> <div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
默认 <Check className="h-3.5 w-3.5" />
</span> </div>
)} ) : null}
</button> </div>
); </button>
})} ))}
{(provider.models ?? []).length === 0 && (
<p className="text-xs text-muted-foreground py-2 text-center">暂无模型,请编辑供应商添加</p>
)}
</div> </div>
</div> </div>
</div> ))
))} )}
<button
onClick={() => openAddProvider(activeCapability)}
className="w-full py-4 rounded-xl border-2 border-dashed border-border bg-card hover:bg-muted transition-colors flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<Plus className="w-4 h-4" />
添加{capabilityConfig[activeCapability].label}供应商
</button>
</div> </div>
</div> </div>
</div> </section>
{/* Add/Edit Provider Modal */} {showModal && editingProvider ? (
{showAddModal && editingProvider && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="w-full max-w-2xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full max-h-[85vh] overflow-auto"> <div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-center justify-between mb-6"> <div>
<h2 className="text-lg font-semibold text-foreground"> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Provider Editor</div>
{editingProvider.id ? "编辑供应商" : "添加供应商"} <h3 className="mt-2 text-2xl font-semibold text-foreground">新建模型提供商</h3>
</h2> </div>
<button <button
onClick={() => { setShowAddModal(false); setEditingProvider(null); }} onClick={() => setShowModal(false)}
className="p-2 hover:bg-muted rounded-lg transition-colors" className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
> >
<X className="w-5 h-5 text-muted-foreground" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="space-y-4"> <div className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">供应商名称</label> <label className="mb-2 block text-sm font-medium text-foreground">提供商名称</label>
<input <input
type="text"
value={editingProvider.name} value={editingProvider.name}
onChange={(e) => setEditingProvider({ ...editingProvider, name: e.target.value })} onChange={(e) => setEditingProvider((current) => current ? { ...current, name: e.target.value } : current)}
placeholder="例如:通义千问、OpenAI、Gemini" className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" placeholder="例如:OpenAI、Gemini、可灵"
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">协议</label> <label className="mb-2 block text-sm font-medium text-foreground">协议</label>
<select <select
value={editingProvider.protocol} value={editingProvider.protocol}
onChange={(e) => setEditingProvider({ ...editingProvider, protocol: e.target.value })} onChange={(e) => setEditingProvider((current) => current ? { ...current, protocol: e.target.value as Protocol } : current)}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
> >
{protocolOptions {protocolOptions.map((option) => (
.filter((p) => p.capabilities.includes(editingProvider.capability as Capability)) <option key={option.value} value={option.value}>
.map((p) => ( {option.label}
<option key={p.value} value={p.value}>{p.label}</option> </option>
))} ))}
</select> </select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">能力类型</label>
<select
value={editingProvider.capability}
onChange={(e) => setEditingProvider((current) => current ? { ...current, capability: e.target.value as Capability } : current)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{(["text", "image", "video"] as Capability[]).map((capability) => (
<option key={capability} value={capability}>
{capabilityMeta[capability].label}
</option>
))}
</select>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">Base URL</label> <label className="mb-2 block text-sm font-medium text-foreground">接口地址</label>
<input <input
type="text"
value={editingProvider.baseUrl} value={editingProvider.baseUrl}
onChange={(e) => setEditingProvider({ ...editingProvider, baseUrl: e.target.value })} onChange={(e) => setEditingProvider((current) => current ? { ...current, baseUrl: e.target.value } : current)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="https://api.example.com/v1" placeholder="https://api.example.com/v1"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">API Key</label> <label className="mb-2 block text-sm font-medium text-foreground">API Key</label>
<input <input
type="password"
value={editingProvider.apiKey} value={editingProvider.apiKey}
onChange={(e) => setEditingProvider({ ...editingProvider, apiKey: e.target.value })} onChange={(e) => setEditingProvider((current) => current ? { ...current, apiKey: e.target.value } : current)}
placeholder="sk-xxxxxxxxxxxxxxxx(留空保持不变)" className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm" placeholder="sk-xxxxxxxx"
/> />
</div> </div>
{editingProvider.protocol === "kling" && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">Secret Key</label>
<input
type="password"
value={editingProvider.secretKey || ""}
onChange={(e) => setEditingProvider({ ...editingProvider, secretKey: e.target.value })}
placeholder="可灵 Secret Key"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">模型 ID(回车添加)</label> <label className="mb-2 block text-sm font-medium text-foreground">Secret Key</label>
<input <input
type="text" value={editingProvider.secretKey ?? ""}
placeholder="输入模型 ID,例如 gpt-4o" onChange={(e) => setEditingProvider((current) => current ? { ...current, secretKey: e.target.value } : current)}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm" className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
onKeyDown={(e) => { placeholder="如需额外 Secret Key,可在这里填写"
if (e.key === "Enter") {
const v = e.currentTarget.value.trim();
if (v) {
setEditingProvider({
...editingProvider,
models: [...editingProvider.models, { id: v, name: v, checked: true }],
});
e.currentTarget.value = "";
}
}
}}
/> />
</div> </div>
{editingProvider.models.length > 0 && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">已添加的模型</label>
<div className="space-y-1 max-h-40 overflow-auto">
{editingProvider.models.map((m) => (
<div key={m.id} className="flex items-center justify-between px-3 py-1.5 rounded-lg bg-muted">
<span className="text-xs font-mono text-foreground">{m.name}</span>
<button
onClick={() => setEditingProvider({
...editingProvider,
models: editingProvider.models.filter((em) => em.id !== m.id),
})}
className="p-1 rounded hover:bg-background transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
</div>
)}
</div> </div>
<div className="flex gap-3 mt-6 pt-6 border-t border-border"> <div className="mt-6 flex items-center justify-end gap-3">
<button <button
onClick={() => { setShowAddModal(false); setEditingProvider(null); }} onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSaveProvider} onClick={handleSaveProvider}
disabled={!editingProvider.name.trim() || createProvider.isPending || updateProvider.isPending} disabled={createProvider.isPending || updateProvider.isPending}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{(createProvider.isPending || updateProvider.isPending) ? ( {createProvider.isPending || updateProvider.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
<Loader2 className="w-4 h-4 animate-spin" /> 保存配置
) : (
<Check className="w-4 h-4" />
)}
保存
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
...@@ -3,6 +3,8 @@ import { useNavigate } from "react-router"; ...@@ -3,6 +3,8 @@ import { useNavigate } from "react-router";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
CheckCircle2,
Clapperboard,
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
...@@ -14,6 +16,7 @@ import { Progress } from "../components/ui/progress"; ...@@ -14,6 +16,7 @@ import { Progress } from "../components/ui/progress";
import { useCreateProject } from "../../hooks/useProjects"; import { useCreateProject } from "../../hooks/useProjects";
import { projectsApi } from "../../lib/api/projects"; import { projectsApi } from "../../lib/api/projects";
import { aspectRatioOptions, projectStyleOptions, resolutionOptions } from "../../lib/projectStyles"; import { aspectRatioOptions, projectStyleOptions, resolutionOptions } from "../../lib/projectStyles";
import { brand } from "../../lib/brand";
type SubmitStage = "idle" | "creating" | "uploading" | "redirecting"; type SubmitStage = "idle" | "creating" | "uploading" | "redirecting";
...@@ -22,24 +25,26 @@ const submitStageMeta: Record< ...@@ -22,24 +25,26 @@ const submitStageMeta: Record<
{ title: string; description: string; progress: number } { title: string; description: string; progress: number }
> = { > = {
creating: { creating: {
title: "正在创建项目", title: "正在创建项目空间",
description: "项目基础信息正在写入,请稍候。", description: "系统正在初始化项目基础信息,并为后续剧本拆解和资产生产准备工作区。",
progress: 28, progress: 28,
}, },
uploading: { uploading: {
title: "正在上传素材", title: "正在上传素材文件",
description: "如果你上传了剧本或封面,系统会先完成上传再进入大纲页。", description: "如果已上传剧本或封面,平台会先完成素材入库,再进入下一步流程。",
progress: 72, progress: 72,
}, },
redirecting: { redirecting: {
title: "准备进入剧本大纲", title: "正在进入剧本拆解流程",
description: "马上跳转到“生成剧本大纲”,可以继续解析已上传剧本,或手动输入内容。", description: "项目创建完成,马上跳转到大纲生成页,继续推进剧本拆解与分集流程。",
progress: 96, progress: 96,
}, },
}; };
export function NewProject() { export function NewProject() {
const navigate = useNavigate(); const navigate = useNavigate();
const createProject = useCreateProject();
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
const [scriptFile, setScriptFile] = useState<File | null>(null); const [scriptFile, setScriptFile] = useState<File | null>(null);
const [coverFile, setCoverFile] = useState<File | null>(null); const [coverFile, setCoverFile] = useState<File | null>(null);
...@@ -49,14 +54,13 @@ export function NewProject() { ...@@ -49,14 +54,13 @@ export function NewProject() {
const [style, setStyle] = useState(projectStyleOptions[0].value); const [style, setStyle] = useState(projectStyleOptions[0].value);
const [submitStage, setSubmitStage] = useState<SubmitStage>("idle"); const [submitStage, setSubmitStage] = useState<SubmitStage>("idle");
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const createProject = useCreateProject();
const isSubmitting = submitStage !== "idle"; const isSubmitting = submitStage !== "idle";
const hasUpload = !!scriptFile || !!coverFile; const hasUpload = Boolean(scriptFile || coverFile);
const activeSubmitMeta = submitStage === "idle" ? null : submitStageMeta[submitStage];
const handleScriptChange = (e: ChangeEvent<HTMLInputElement>) => { const handleScriptChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting) return; if (!e.target.files?.[0] || isSubmitting) return;
const file = e.target.files[0]; const file = e.target.files[0];
setScriptFile(file); setScriptFile(file);
...@@ -67,11 +71,10 @@ export function NewProject() { ...@@ -67,11 +71,10 @@ export function NewProject() {
const handleCoverChange = (e: ChangeEvent<HTMLInputElement>) => { const handleCoverChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting) return; if (!e.target.files?.[0] || isSubmitting) return;
const file = e.target.files[0]; const file = e.target.files[0];
setCoverFile(file); setCoverFile(file);
setCoverPreview((prev) => { setCoverPreview((previous) => {
if (prev) URL.revokeObjectURL(prev); if (previous) URL.revokeObjectURL(previous);
return URL.createObjectURL(file); return URL.createObjectURL(file);
}); });
}; };
...@@ -125,241 +128,350 @@ export function NewProject() { ...@@ -125,241 +128,350 @@ export function NewProject() {
}); });
} catch (error) { } catch (error) {
setSubmitStage("idle"); setSubmitStage("idle");
setSubmitError((error as Error)?.message ?? "创建项目失败,请重试"); setSubmitError((error as Error)?.message ?? "创建项目失败,请稍后重试。");
} }
}; };
const canSubmit = !isSubmitting;
const activeSubmitMeta = submitStage === "idle" ? null : submitStageMeta[submitStage];
return ( return (
<div className="relative h-full overflow-auto bg-background"> <div className="mx-auto max-w-[1380px]">
{activeSubmitMeta && ( {activeSubmitMeta ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-md">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-2xl"> <div className="w-full max-w-2xl rounded-[34px] border border-white/80 bg-white/92 p-7 shadow-[0_36px_90px_rgba(12,43,78,0.22)] backdrop-blur-xl">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]"> <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] shadow-[0_18px_36px_rgba(15,116,216,0.22)]">
<Loader2 className="h-7 w-7 animate-spin text-white" /> <Loader2 className="h-8 w-8 animate-spin text-white" />
</div> </div>
<h2 className="mb-2 text-xl font-semibold text-foreground">{activeSubmitMeta.title}</h2> <h2 className="text-2xl font-semibold text-foreground">{activeSubmitMeta.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-muted-foreground"> <p className="mt-3 text-sm leading-7 text-muted-foreground">
{activeSubmitMeta.description} {activeSubmitMeta.description}
</p> </p>
<Progress value={activeSubmitMeta.progress} className="mb-4 h-2.5" />
<div className="space-y-2 text-sm"> <div className="mt-6 rounded-[28px] border border-primary/10 bg-primary/5 p-5">
<div className="flex items-center justify-between text-foreground"> <div className="mb-3 flex items-center justify-between text-sm">
<span>创建项目</span> <span className="font-medium text-foreground">流程进度</span>
<span>{submitStage === "creating" ? "进行中" : "已准备"}</span> <span className="text-primary">{activeSubmitMeta.progress}%</span>
</div>
<div className="flex items-center justify-between text-foreground">
<span>上传素材</span>
<span>
{!hasUpload
? "已跳过"
: submitStage === "uploading"
? "进行中"
: submitStage === "redirecting"
? "已完成"
: "等待中"}
</span>
</div> </div>
<div className="flex items-center justify-between text-foreground"> <Progress value={activeSubmitMeta.progress} className="h-2.5" />
<span>进入大纲页</span> <div className="mt-5 space-y-3 text-sm">
<span>{submitStage === "redirecting" ? "进行中" : "等待中"}</span> <div className="flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3">
<span className="text-foreground">初始化项目空间</span>
<span className="text-muted-foreground">
{submitStage === "creating" ? "进行中" : "已完成"}
</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3">
<span className="text-foreground">上传剧本与封面</span>
<span className="text-muted-foreground">
{!hasUpload
? "已跳过"
: submitStage === "uploading"
? "进行中"
: submitStage === "redirecting"
? "已完成"
: "等待中"}
</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3">
<span className="text-foreground">进入剧本拆解</span>
<span className="text-muted-foreground">
{submitStage === "redirecting" ? "进行中" : "等待中"}
</span>
</div>
</div> </div>
</div> </div>
<p className="mt-4 text-xs text-muted-foreground">
处理中已锁定按钮,避免重复点击创建多个项目。 <p className="mt-5 text-xs leading-6 text-muted-foreground">
当前为前端阶段性反馈,后续可继续接入后端真实进度接口,显示更精确的任务状态。
</p> </p>
</div> </div>
</div> </div>
)} ) : null}
<div className="mx-auto max-w-4xl p-8"> <section className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]">
<div className="mb-8 text-center"> <div className="relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]">
<h1 className="mb-2 text-3xl font-semibold text-foreground">创建短剧项目</h1> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]" />
<p className="text-sm text-muted-foreground"> <div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" />
可以先上传剧本直接进入解析流程,也可以跳过上传,在下一页手动输入内容生成大纲。 <div className="absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10" />
</p>
</div> <div className="relative">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74">
<Sparkles className="h-4 w-4" />
Project Launch
</div>
<h1 className="mt-5 max-w-2xl text-4xl font-semibold leading-tight">
创建新项目,直接接入 {brand.shortProductName} 的完整生产链路
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-white/76">
上传剧本后,平台会继续推进大纲、分集、角色、场景、分镜与视频生成;如果暂时没有素材,也可以先创建项目再逐步补充。
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{[
{
title: "创建项目空间",
desc: "初始化项目基础参数与封面展示信息",
},
{
title: "导入剧本素材",
desc: "支持 doc、docx、txt、pdf、md 等常见格式",
},
{
title: "进入拆解流程",
desc: "自动接入大纲生成与后续工作流页面",
},
].map((item, index) => (
<div
key={item.title}
className="rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<div className="mb-3 text-xs uppercase tracking-[0.24em] text-white/64">
0{index + 1}
</div>
<div className="text-lg font-medium">{item.title}</div>
<div className="mt-2 text-sm leading-6 text-white/74">{item.desc}</div>
</div>
))}
</div>
{submitError && ( <div className="mt-8 rounded-[28px] border border-white/14 bg-white/10 p-5 backdrop-blur-sm">
<div className="mb-5 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4"> <div className="mb-3 text-xs uppercase tracking-[0.24em] text-white/64">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" /> Recommended Flow
<p className="text-sm text-red-700">{submitError}</p> </div>
<div className="grid gap-3 md:grid-cols-3">
{[
"先上传剧本,项目名可自动带入,减少手工录入。",
"尽量同时补充封面、风格和画幅,方便后续统一展示。",
"创建后直接进入剧本拆解页,最快形成完整项目骨架。",
].map((item) => (
<div
key={item}
className="rounded-2xl border border-white/12 bg-white/8 px-4 py-4 text-sm leading-6 text-white/82"
>
{item}
</div>
))}
</div>
</div>
</div> </div>
)}
<div className="mb-5">
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
disabled={isSubmitting}
placeholder="输入项目名称,或上传剧本后自动带入文件名"
className="w-full rounded-xl border border-border bg-card px-4 py-3 text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div> </div>
<div className="mb-6 flex gap-4"> <div className="rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="flex-shrink-0"> <div className="mb-6 flex items-start justify-between gap-4">
<label className="mb-2 block text-sm font-medium text-foreground"> <div>
封面图片 <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Project Setup</div>
<span className="ml-1 font-normal text-muted-foreground">(可选)</span> <h2 className="mt-2 text-3xl font-semibold text-foreground">项目基础配置</h2>
</label> <p className="mt-3 text-sm leading-6 text-muted-foreground">
<div className="relative flex h-40 w-28 items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-card"> 定义项目名称、剧本素材、封面与视觉参数,这些配置会贯穿整个创作流程。
<input </p>
type="file" </div>
accept="image/*" <div className="flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary">
onChange={handleCoverChange} <Clapperboard className="h-6 w-6" />
disabled={isSubmitting}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{coverPreview ? (
<>
<img src={coverPreview} alt="封面预览" className="h-full w-full object-cover" />
<button
onClick={removeCover}
disabled={isSubmitting}
className="absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 transition-colors hover:bg-black/80 disabled:opacity-50"
>
<X className="h-3 w-3 text-white" />
</button>
</>
) : (
<div className="p-2 text-center">
<ImageIcon className="mx-auto mb-1 h-6 w-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">点击上传</span>
</div>
)}
</div> </div>
</div> </div>
<div className="flex-1"> {submitError ? (
<label className="mb-2 block text-sm font-medium text-foreground"> <div className="mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4">
剧本文件 <AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" />
<span className="ml-1 font-normal text-muted-foreground">(可选)</span> <p className="text-sm leading-6 text-red-700">{submitError}</p>
</label> </div>
<div className="relative flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-border bg-card"> ) : null}
<div className="space-y-6">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<input <input
type="file" type="text"
accept=".txt,.doc,.docx,.pdf,.md" value={projectName}
onChange={handleScriptChange} onChange={(e) => setProjectName(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" placeholder="输入项目名称,或上传剧本后自动带入文件名"
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)] disabled:cursor-not-allowed disabled:opacity-60"
/> />
{scriptFile ? ( </div>
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]"> <div className="grid gap-5 md:grid-cols-[220px_1fr]">
<FileText className="h-6 w-6 text-white" /> <div>
</div> <div className="mb-2 flex items-center justify-between">
<div className="text-center"> <label className="block text-sm font-medium text-foreground">项目封面</label>
<div className="mb-0.5 text-sm font-medium text-foreground">{scriptFile.name}</div> <span className="text-xs text-muted-foreground">可选</span>
<div className="text-xs text-muted-foreground"> </div>
{(scriptFile.size / 1024).toFixed(1)} KB
</div> <div className="relative flex aspect-[3/4] items-center justify-center overflow-hidden rounded-[28px] border-2 border-dashed border-border bg-muted">
</div> <input
<button type="file"
onClick={(e) => { accept="image/*"
e.preventDefault(); onChange={handleCoverChange}
if (!isSubmitting) setScriptFile(null);
}}
disabled={isSubmitting} disabled={isSubmitting}
className="text-xs text-primary hover:underline disabled:no-underline disabled:opacity-50" className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
> />
重新上传 {coverPreview ? (
</button> <>
<img src={coverPreview} alt="封面预览" className="h-full w-full object-cover" />
<button
onClick={removeCover}
disabled={isSubmitting}
className="absolute right-3 top-3 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-slate-950/60 text-white transition hover:bg-slate-950/80 disabled:opacity-50"
>
<X className="h-4 w-4" />
</button>
</>
) : (
<div className="px-4 text-center">
<ImageIcon className="mx-auto mb-3 h-7 w-7 text-muted-foreground" />
<div className="text-sm text-foreground">点击上传封面</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
建议使用竖版海报或封面图,用于项目卡片与详情页头图展示。
</div>
</div>
)}
</div> </div>
) : ( </div>
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted"> <div>
<Upload className="h-6 w-6 text-muted-foreground" /> <div className="mb-2 flex items-center justify-between">
</div> <label className="block text-sm font-medium text-foreground">剧本文件</label>
<div className="text-center"> <span className="text-xs text-muted-foreground">可选</span>
<div className="mb-0.5 text-sm text-foreground">点击或拖拽剧本文件到这里</div> </div>
<div className="text-xs text-muted-foreground">
支持 doc、docx、txt、pdf、md,大小不超过 20M;也可以先跳过,下一页手动输入剧本。 <div className="relative flex min-h-[300px] items-center justify-center rounded-[28px] border-2 border-dashed border-border bg-white">
<input
type="file"
accept=".txt,.doc,.docx,.pdf,.md"
onChange={handleScriptChange}
disabled={isSubmitting}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{scriptFile ? (
<div className="flex flex-col items-center gap-4 px-8 py-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_30px_rgba(15,116,216,0.18)]">
<FileText className="h-8 w-8" />
</div>
<div>
<div className="text-base font-medium text-foreground">{scriptFile.name}</div>
<div className="mt-2 text-sm text-muted-foreground">
{(scriptFile.size / 1024).toFixed(1)} KB
</div>
</div>
<button
onClick={(e) => {
e.preventDefault();
if (!isSubmitting) setScriptFile(null);
}}
disabled={isSubmitting}
className="text-sm font-medium text-primary transition hover:underline disabled:no-underline disabled:opacity-50"
>
重新上传
</button>
</div> </div>
</div> ) : (
<div className="flex flex-col items-center gap-4 px-8 py-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-[28px] bg-muted text-muted-foreground">
<Upload className="h-8 w-8" />
</div>
<div>
<div className="text-base font-medium text-foreground">
点击或拖拽剧本文件到这里
</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">
支持 doc、docx、txt、pdf、md,建议单个文件不超过 20 MB。
如果先不上传,也可以在下一页手动输入剧本文本。
</div>
</div>
</div>
)}
</div> </div>
)} </div>
</div> </div>
</div>
</div>
<div className="mb-6 grid grid-cols-3 gap-4"> <div className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-border bg-card p-4"> <div className="rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm">
<div className="mb-2 text-xs text-muted-foreground">画幅比例</div> <div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">Aspect Ratio</div>
<select <div className="mb-3 text-lg font-medium text-foreground">画幅比例</div>
value={aspectRatio} <select
onChange={(e) => setAspectRatio(e.target.value)} value={aspectRatio}
disabled={isSubmitting} onChange={(e) => setAspectRatio(e.target.value)}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60" disabled={isSubmitting}
> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
{aspectRatioOptions.map((option) => ( >
<option key={option.value} value={option.value}> {aspectRatioOptions.map((option) => (
{option.label} · {option.helper} <option key={option.value} value={option.value}>
</option> {option.label} · {option.helper}
))} </option>
</select> ))}
</div> </select>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">清晰度</div>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} · {option.helper}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-border bg-card p-4"> <div className="rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm">
<div className="mb-2 text-xs text-muted-foreground">视觉风格</div> <div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">Resolution</div>
<select <div className="mb-3 text-lg font-medium text-foreground">清晰度</div>
value={style} <select
onChange={(e) => setStyle(e.target.value)} value={resolution}
disabled={isSubmitting} onChange={(e) => setResolution(e.target.value)}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60" disabled={isSubmitting}
> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
{projectStyleOptions.map((option) => ( >
<option key={option.value} value={option.value}> {resolutionOptions.map((option) => (
{option.label} <option key={option.value} value={option.value}>
</option> {option.label} · {option.helper}
))} </option>
</select> ))}
</div> </select>
</div> </div>
<div className="mb-6 rounded-xl border border-primary/20 bg-accent/50 p-4"> <div className="rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm">
<div className="flex items-start gap-3"> <div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">Style</div>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-3 text-lg font-medium text-foreground">视觉风格</div>
<Sparkles className="h-5 w-5 text-primary" /> <select
value={style}
onChange={(e) => setStyle(e.target.value)}
disabled={isSubmitting}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div> </div>
<div>
<h3 className="mb-1 text-sm font-medium text-foreground">AI 智能处理</h3> <div className="rounded-[28px] border border-primary/12 bg-primary/5 p-5">
<p className="text-sm leading-relaxed text-muted-foreground"> <div className="mb-3 flex items-center gap-3">
如果上传了剧本,下一页会继续自动解析;如果没有上传,也可以直接手动输入剧本内容生成大纲。这里选择的视觉风格会继续影响后续人物与场景的 AI 生成效果。 <div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
</p> <CheckCircle2 className="h-5 w-5" />
</div>
<div>
<div className="text-base font-medium text-foreground">创建后会发生什么?</div>
<div className="text-sm text-muted-foreground">项目会立刻接入后续 AI 剧本拆解流程。</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{[
"如已上传剧本,系统会优先尝试识别剧本结构。",
"如果暂时没有剧本,也可在下一页粘贴文本生成大纲。",
"这里选择的风格与画幅会影响后续角色和场景生成。",
].map((item) => (
<div
key={item}
className="rounded-2xl border border-border/70 bg-white/78 px-4 py-4 text-sm leading-6 text-foreground/82"
>
{item}
</div>
))}
</div>
</div> </div>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
<span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建新项目"}</span>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ArrowRight className="h-5 w-5" />}
</button>
</div> </div>
</div> </div>
</section>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
>
<span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建新短剧项目"}</span>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ArrowRight className="h-5 w-5" />}
</button>
</div>
</div> </div>
); );
} }
...@@ -11,11 +11,13 @@ import { ...@@ -11,11 +11,13 @@ import {
RefreshCw, RefreshCw,
Sparkles, Sparkles,
Users, Users,
WandSparkles,
} from "lucide-react"; } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Progress } from "../components/ui/progress"; import { Progress } from "../components/ui/progress";
import { useGenerateEpisodes, useGenerateOutline, useOutline } from "../../hooks/useAi"; import { useGenerateEpisodes, useGenerateOutline, useOutline } from "../../hooks/useAi";
import { projectsApi, type ScriptInfoDTO } from "../../lib/api/projects"; import { projectsApi, type ScriptInfoDTO } from "../../lib/api/projects";
import { brand } from "../../lib/brand";
type ProcessingStep = { type ProcessingStep = {
label: string; label: string;
...@@ -36,50 +38,50 @@ type OutlineLocationState = { ...@@ -36,50 +38,50 @@ type OutlineLocationState = {
const checkingOutlineState: ProcessingState = { const checkingOutlineState: ProcessingState = {
title: "正在检查项目状态", title: "正在检查项目状态",
description: "先确认这个项目是否已经有可用大纲。", description: "系统先确认当前项目是否已经存在可复用的大纲结果,以避免重复解析剧本。",
steps: [ steps: [
{ {
label: "检查已有大纲", label: "检查已有大纲",
hint: "正在确认是否需要重新解析上传的剧本。", hint: "确认当前项目是否已完成剧本拆解与结构生成。",
progress: 12, progress: 12,
}, },
], ],
}; };
const parsingScriptState: ProcessingState = { const parsingScriptState: ProcessingState = {
title: "正在解析上传剧本", title: "正在解析上传剧本",
description: "系统正在读取文档内容,并抽取剧情结构、人物与场景信息。", description: "平台正在读取文档内容,并抽取剧情结构、角色信息与关键场景,准备生成结构化预览。",
steps: [ steps: [
{ {
label: "读取剧本文件", label: "读取剧本文件",
hint: "正在加载上传的 Word、PDF 或文本内容。", hint: "正在加载 Word、PDF 或文本剧本内容。",
progress: 24, progress: 24,
}, },
{ {
label: "分析剧情结构", label: "分析剧情结构",
hint: "正在识别主线冲突、题材和整体节奏。", hint: "识别主线冲突、题材方向与故事节奏。",
progress: 52, progress: 52,
}, },
{ {
label: "整理角色与场景", label: "整理角色与场景",
hint: "正在生成可预览的结构化摘要。", hint: "输出便于确认的结构化摘要和生产入口。",
progress: 82, progress: 82,
}, },
], ],
}; };
const readingScriptState: ProcessingState = { const readingScriptState: ProcessingState = {
title: "正在准备剧本文本", title: "正在读取剧本文本",
description: "未拿到结构化摘要,正在回退为读取原始剧本文本。", description: "未获取到结构化摘要,平台正在回退为读取原始文本内容,方便你直接手动生成大纲。",
steps: [ steps: [
{ {
label: "取原始文本", label: "取原始文本",
hint: "正在提取上传剧本中的正文内容。", hint: "正在读取已上传剧本中的正文内容。",
progress: 36, progress: 36,
}, },
{ {
label: "填充编辑器", label: "填充编辑器",
hint: "马上就可以在这里直接生成大纲。", hint: "完成后即可在当前页面继续生成大纲。",
progress: 74, progress: 74,
}, },
], ],
...@@ -87,21 +89,21 @@ const readingScriptState: ProcessingState = { ...@@ -87,21 +89,21 @@ const readingScriptState: ProcessingState = {
const importingScriptState: ProcessingState = { const importingScriptState: ProcessingState = {
title: "正在导入剧本数据", title: "正在导入剧本数据",
description: "系统会把剧本拆解为大纲、分集、角色、场景和分镜,请稍候。", description: "系统会把剧本拆解为大纲、分集、角色、场景和分镜所需的初始骨架,请稍候。",
steps: [ steps: [
{ {
label: "析整体结构", label: "析整体结构",
hint: "正在理解故事主线与题材风格。", hint: "理解故事主线、角色关系和题材风格。",
progress: 24, progress: 24,
}, },
{ {
label: "生成大纲与分集", label: "生成大纲与分集",
hint: "正在生成故事梗概和每集节奏。", hint: "建立项目的基础剧情骨架和节奏划分。",
progress: 56, progress: 56,
}, },
{ {
label: "写入角色与场景", label: "写入项目空间",
hint: "正在把结果保存到项目工作区。", hint: "将结果同步到后续工作流所需的数据结构中。",
progress: 88, progress: 88,
}, },
], ],
...@@ -109,33 +111,37 @@ const importingScriptState: ProcessingState = { ...@@ -109,33 +111,37 @@ const importingScriptState: ProcessingState = {
const generatingOutlineState: ProcessingState = { const generatingOutlineState: ProcessingState = {
title: "AI 正在生成剧本大纲", title: "AI 正在生成剧本大纲",
description: "正在根据剧本文本组织故事主线、题材和概要描述。", description: "平台正在根据剧本文本组织题材、故事线与内容摘要,并准备生成分集入口。",
steps: [ steps: [
{ {
label: "理解剧本主线", label: "理解故事主线",
hint: "正在提取核心矛盾和人物关系。", hint: "提取核心矛盾、题材调性和角色关系。",
progress: 30, progress: 30,
}, },
{ {
label: "归纳故事结构", label: "归纳结构节奏",
hint: "正在组织题材、世界观和章节节奏。", hint: "整理大纲、世界观和情节推进逻辑。",
progress: 62, progress: 62,
}, },
{ {
label: "输出大纲结果", label: "输出大纲结果",
hint: "正在整理可直接预览的大纲内容。", hint: "生成可直接预览的大纲内容。",
progress: 90, progress: 90,
}, },
], ],
}; };
function formatElapsed(seconds: number) { function formatElapsed(seconds: number) {
const mins = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const secs = seconds % 60; const remainSeconds = seconds % 60;
return mins > 0 ? `${mins}${secs}秒` : `${secs}秒`; return minutes > 0 ? `${minutes}${remainSeconds} 秒` : `${remainSeconds} 秒`;
} }
function ProcessingScreen({ state, elapsedSeconds, projectName }: { function ProcessingScreen({
state,
elapsedSeconds,
projectName,
}: {
state: ProcessingState; state: ProcessingState;
elapsedSeconds: number; elapsedSeconds: number;
projectName?: string; projectName?: string;
...@@ -153,64 +159,93 @@ function ProcessingScreen({ state, elapsedSeconds, projectName }: { ...@@ -153,64 +159,93 @@ function ProcessingScreen({ state, elapsedSeconds, projectName }: {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [state]); }, [state]);
const progressValue = state.steps[Math.min(activeStep, state.steps.length - 1)]?.progress ?? 0; const currentStep = state.steps[Math.min(activeStep, state.steps.length - 1)];
return ( return (
<div className="h-full bg-background flex items-center justify-center p-6"> <div className="mx-auto max-w-[1380px]">
<div className="w-full max-w-2xl rounded-3xl border border-border bg-card shadow-xl p-8"> <div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="flex items-start gap-4 mb-6"> <div className="relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center flex-shrink-0"> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]" />
<Loader2 className="w-8 h-8 text-white animate-spin" /> <div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" />
</div> <div className="absolute right-20 top-20 h-32 w-32 rounded-full border border-white/10" />
<div className="flex-1">
<div className="flex items-center justify-between gap-4 mb-2"> <div className="relative">
<h1 className="text-2xl font-semibold text-foreground">{state.title}</h1> <div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74">
<span className="text-xs text-muted-foreground whitespace-nowrap">已用时 {formatElapsed(elapsedSeconds)}</span> <WandSparkles className="h-4 w-4" />
Outline Processing
</div>
<h1 className="mt-5 max-w-2xl text-4xl font-semibold leading-tight">
{state.title}
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-white/76">{state.description}</p>
<div className="mt-6 flex flex-wrap gap-3">
<div className="rounded-full border border-white/16 bg-white/10 px-4 py-2 text-sm text-white/84">
企业平台:{brand.companyName}
</div>
<div className="rounded-full border border-white/16 bg-white/10 px-4 py-2 text-sm text-white/84">
已用时:{formatElapsed(elapsedSeconds)}
</div>
{projectName ? (
<div className="rounded-full border border-white/16 bg-white/10 px-4 py-2 text-sm text-white/84">
当前项目:{projectName}
</div>
) : null}
</div> </div>
<p className="text-sm text-muted-foreground leading-relaxed">{state.description}</p>
{projectName && (
<p className="text-xs text-primary mt-2">当前项目:{projectName}</p>
)}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-primary/15 bg-primary/5 p-5 mb-6"> <div className="rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="flex items-center justify-between text-sm mb-3"> <div className="mb-5 flex items-center justify-between">
<span className="text-foreground font-medium">{state.steps[Math.min(activeStep, state.steps.length - 1)]?.label}</span> <div>
<span className="text-primary">{progressValue}%</span> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Live Progress</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{currentStep?.label}</div>
</div>
<div className="flex h-16 w-16 items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] shadow-[0_18px_36px_rgba(15,116,216,0.22)]">
<Loader2 className="h-8 w-8 animate-spin text-white" />
</div>
</div> </div>
<Progress value={progressValue} className="h-2.5 mb-3" />
<p className="text-sm text-muted-foreground">{state.steps[Math.min(activeStep, state.steps.length - 1)]?.hint}</p>
</div>
<div className="space-y-3"> <div className="rounded-[28px] border border-primary/10 bg-primary/5 p-5">
{state.steps.map((step, index) => { <div className="mb-3 flex items-center justify-between text-sm">
const isDone = index < activeStep; <span className="font-medium text-foreground">{currentStep?.label}</span>
const isCurrent = index === activeStep; <span className="text-primary">{currentStep?.progress ?? 0}%</span>
</div>
return ( <Progress value={currentStep?.progress ?? 0} className="h-2.5" />
<div key={step.label} className="flex items-start gap-3 rounded-xl border border-border/70 bg-background px-4 py-3"> <p className="mt-4 text-sm leading-6 text-muted-foreground">{currentStep?.hint}</p>
<div className="mt-0.5"> </div>
{isDone ? (
<CheckCircle2 className="w-5 h-5 text-green-500" /> <div className="mt-5 space-y-3">
) : isCurrent ? ( {state.steps.map((step, index) => {
<Loader2 className="w-5 h-5 text-primary animate-spin" /> const done = index < activeStep;
) : ( const current = index === activeStep;
<div className="w-5 h-5 rounded-full border-2 border-border" />
)} return (
</div> <div
<div> key={step.label}
<div className="text-sm font-medium text-foreground">{step.label}</div> className="flex items-start gap-3 rounded-[24px] border border-border/70 bg-white/78 px-4 py-4 shadow-sm"
<div className="text-xs text-muted-foreground mt-0.5">{step.hint}</div> >
<div className="mt-0.5">
{done ? (
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
) : current ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : (
<div className="h-5 w-5 rounded-full border-2 border-border" />
)}
</div>
<div>
<div className="text-sm font-medium text-foreground">{step.label}</div>
<div className="mt-1 text-xs leading-6 text-muted-foreground">{step.hint}</div>
</div>
</div> </div>
</div> );
); })}
})} </div>
</div>
<p className="text-xs text-muted-foreground mt-5"> <p className="mt-5 text-xs leading-6 text-muted-foreground">
当前为前端阶段性实时反馈。如果后端后续提供解析进度接口,这里可以进一步切成真实百分比。 当前为阶段性前端反馈,后续如果接入后端实时进度接口,这里可以展示更准确的百分比与步骤状态。
</p> </p>
</div>
</div> </div>
</div> </div>
); );
...@@ -221,7 +256,7 @@ export function OutlineGeneration() { ...@@ -221,7 +256,7 @@ export function OutlineGeneration() {
const location = useLocation(); const location = useLocation();
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const pid = projectId ?? ""; const pid = projectId ?? "";
const qc = useQueryClient(); const queryClient = useQueryClient();
const outlineState = location.state as OutlineLocationState | null; const outlineState = location.state as OutlineLocationState | null;
const projectNameFromState = outlineState?.projectName; const projectNameFromState = outlineState?.projectName;
...@@ -246,7 +281,8 @@ export function OutlineGeneration() { ...@@ -246,7 +281,8 @@ export function OutlineGeneration() {
setPreparingState("parsing"); setPreparingState("parsing");
setImportError(null); setImportError(null);
projectsApi.parseScript(pid) projectsApi
.parseScript(pid)
.then((info) => { .then((info) => {
if (cancelled) return; if (cancelled) return;
setScriptInfo(info); setScriptInfo(info);
...@@ -265,7 +301,7 @@ export function OutlineGeneration() { ...@@ -265,7 +301,7 @@ export function OutlineGeneration() {
setScript(text.slice(0, 8000)); setScript(text.slice(0, 8000));
} }
} catch { } catch {
// Keep manual editor empty so the user can paste script content manually. // Keep manual editor empty so users can paste script content directly.
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoadingScript(false); setLoadingScript(false);
...@@ -291,13 +327,13 @@ export function OutlineGeneration() { ...@@ -291,13 +327,13 @@ export function OutlineGeneration() {
try { try {
await projectsApi.importScript(pid); await projectsApi.importScript(pid);
await qc.invalidateQueries({ queryKey: ["outline", pid] }); await queryClient.invalidateQueries({ queryKey: ["outline", pid] });
await qc.invalidateQueries({ queryKey: ["episodes", pid] }); await queryClient.invalidateQueries({ queryKey: ["episodes", pid] });
await qc.invalidateQueries({ queryKey: ["characters", pid] }); await queryClient.invalidateQueries({ queryKey: ["characters", pid] });
await qc.invalidateQueries({ queryKey: ["scenes", pid] }); await queryClient.invalidateQueries({ queryKey: ["scenes", pid] });
navigate(`/project/${projectId}/episodes`); navigate(`/project/${projectId}/episodes`);
} catch (error) { } catch (error) {
setImportError((error as Error)?.message ?? "导入失败,请重试"); setImportError((error as Error)?.message ?? "导入失败,请稍后重试。");
} finally { } finally {
setImporting(false); setImporting(false);
} }
...@@ -305,7 +341,6 @@ export function OutlineGeneration() { ...@@ -305,7 +341,6 @@ export function OutlineGeneration() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!script.trim()) return; if (!script.trim()) return;
setImportError(null); setImportError(null);
await generateOutline.mutateAsync(script); await generateOutline.mutateAsync(script);
setShowManual(false); setShowManual(false);
...@@ -313,7 +348,6 @@ export function OutlineGeneration() { ...@@ -313,7 +348,6 @@ export function OutlineGeneration() {
const handleContinue = async () => { const handleContinue = async () => {
if (!outline) return; if (!outline) return;
await generateEpisodes.mutateAsync(); await generateEpisodes.mutateAsync();
navigate(`/project/${projectId}/episodes`); navigate(`/project/${projectId}/episodes`);
}; };
...@@ -353,239 +387,371 @@ export function OutlineGeneration() { ...@@ -353,239 +387,371 @@ export function OutlineGeneration() {
if (!outline) { if (!outline) {
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="mx-auto max-w-[1380px]">
<div className="max-w-3xl mx-auto"> <div className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]">
<div className="mb-6 flex items-center gap-3"> <section className="relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]" />
<Sparkles className="w-4 h-4 text-white" /> <div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" />
</div> <div className="absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10" />
<div>
<h1 className="text-2xl font-semibold text-foreground">生成剧本大纲</h1> <div className="relative">
<p className="text-sm text-muted-foreground">先预览剧本解析结果,再决定直接导入或手动生成大纲。</p> <div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74">
</div> <Sparkles className="h-4 w-4" />
</div> Outline Stage
</div>
{(importError || generateOutline.isError) && ( <h1 className="mt-5 max-w-2xl text-4xl font-semibold leading-tight">
<div className="mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3"> 从剧本文本进入结构化拆解,生成项目大纲与后续流程骨架
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" /> </h1>
<p className="text-sm text-red-700"> <p className="mt-4 max-w-2xl text-base leading-7 text-white/76">
{importError ?? (generateOutline.error as Error)?.message} 你可以直接导入平台识别到的剧本摘要,也可以手动粘贴剧本文本,让 AI 生成适合企业工作流的大纲结果。
</p> </p>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{[
{
title: "自动识别",
desc: "优先使用上传剧本的结构化摘要,减少重复输入。",
},
{
title: "人工确认",
desc: "支持改为手动编辑剧本文本,再决定生成方式。",
},
{
title: "继续生产",
desc: "大纲完成后,可直接接入分集、角色和分镜流程。",
},
].map((item, index) => (
<div
key={item.title}
className="rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<div className="mb-3 text-xs uppercase tracking-[0.24em] text-white/64">
0{index + 1}
</div>
<div className="text-lg font-medium">{item.title}</div>
<div className="mt-2 text-sm leading-6 text-white/74">{item.desc}</div>
</div>
))}
</div>
</div> </div>
)} </section>
{scriptInfo && !showManual ? ( <section className="rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="rounded-xl border border-primary/40 bg-primary/5 p-6 mb-4"> <div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-start justify-between mb-4"> <div>
<div className="flex items-center gap-2"> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Outline Builder</div>
<FileText className="w-5 h-5 text-primary" /> <h2 className="mt-2 text-3xl font-semibold text-foreground">剧本拆解入口</h2>
<span className="text-sm font-medium text-primary">已识别上传剧本</span> <p className="mt-3 text-sm leading-6 text-muted-foreground">
</div> 先预览自动识别结果,再决定一键导入还是手动编辑生成大纲。
<CheckCircle2 className="w-5 h-5 text-green-500" /> </p>
</div> </div>
<div className="flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary">
<WandSparkles className="h-6 w-6" />
</div>
</div>
<h2 className="text-xl font-bold text-foreground mb-1">{scriptInfo.title || "未识别标题"}</h2> {importError || generateOutline.isError ? (
<p className="text-sm text-muted-foreground mb-4">{scriptInfo.genre || "待补充题材"}</p> <div className="mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" />
{scriptInfo.synopsis && ( <p className="text-sm leading-6 text-red-700">
<p className="text-sm text-foreground/80 bg-background/60 rounded-lg p-3 mb-4 leading-relaxed"> {importError ?? (generateOutline.error as Error)?.message}
{scriptInfo.synopsis.length > 120 ? `${scriptInfo.synopsis.slice(0, 120)}...` : scriptInfo.synopsis}
</p> </p>
)} </div>
) : null}
{scriptInfo && !showManual ? (
<div className="space-y-5">
<div className="rounded-[30px] border border-primary/12 bg-primary/5 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary shadow-sm">
<FileText className="h-3.5 w-3.5" />
Script Summary Detected
</div>
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
<div className="grid grid-cols-4 gap-3 mb-5"> <h3 className="text-2xl font-semibold text-foreground">
{[ {scriptInfo.title || "已识别上传剧本"}
{ icon: <Film className="w-4 h-4" />, label: "集数", value: `${scriptInfo.episodeCount} 集` }, </h3>
{ icon: <Users className="w-4 h-4" />, label: "角色", value: `${scriptInfo.characterCount} 个` }, <p className="mt-3 text-sm text-muted-foreground">
{ icon: <MapPin className="w-4 h-4" />, label: "场景", value: `${scriptInfo.sceneCount} 个` }, {scriptInfo.genre || "题材待补充"}
{ icon: <Sparkles className="w-4 h-4" />, label: "分镜脚本", value: `${scriptInfo.storyboardCount} 条` }, </p>
].map(({ icon, label, value }) => (
<div key={label} className="rounded-lg bg-background border border-border p-3 text-center"> {scriptInfo.synopsis ? (
<div className="flex justify-center mb-1 text-primary">{icon}</div> <p className="mt-4 rounded-[24px] border border-border/70 bg-white/78 p-4 text-sm leading-7 text-foreground/84">
<div className="text-lg font-bold text-foreground">{value}</div> {scriptInfo.synopsis.length > 160
<div className="text-xs text-muted-foreground">{label}</div> ? `${scriptInfo.synopsis.slice(0, 160)}...`
: scriptInfo.synopsis}
</p>
) : null}
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[
{
icon: <Film className="h-4 w-4" />,
label: "分集规模",
value: `${scriptInfo.episodeCount} 集`,
},
{
icon: <Users className="h-4 w-4" />,
label: "角色数量",
value: `${scriptInfo.characterCount} 个`,
},
{
icon: <MapPin className="h-4 w-4" />,
label: "场景数量",
value: `${scriptInfo.sceneCount} 个`,
},
{
icon: <Sparkles className="h-4 w-4" />,
label: "分镜线索",
value: `${scriptInfo.storyboardCount} 条`,
},
].map((item) => (
<div
key={item.label}
className="rounded-[24px] border border-border/70 bg-white/78 px-4 py-4 shadow-sm"
>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{item.icon}
</div>
<div className="text-lg font-semibold text-foreground">{item.value}</div>
<div className="mt-2 text-sm text-muted-foreground">{item.label}</div>
</div>
))}
</div> </div>
))} </div>
</div>
<button <button
onClick={handleImport} onClick={handleImport}
disabled={importing} disabled={importing}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{importing ? ( {importing ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span>导入中...</span> 正在导入剧本骨架...
</> </>
) : ( ) : (
<> <>
<Sparkles className="w-5 h-5" /> <Sparkles className="h-5 w-5" />
<span>一键导入大纲 · 分集 · 角色 · 分镜</span> 一键导入大纲、分集、角色与分镜骨架
<ArrowRight className="w-4 h-4" /> <ArrowRight className="h-5 w-5" />
</> </>
)}
</button>
<button
onClick={() => setShowManual(true)}
className="w-full mt-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
手动编辑剧本内容,用 AI 生成大纲
</button>
</div>
) : (
<div className="rounded-xl border border-border bg-card p-6">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-foreground">剧本内容</label>
<div className="flex items-center gap-2">
{loadingScript && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
正在加载上传剧本...
</span>
)}
{!loadingScript && script && (
<span className="text-xs text-green-600">已自动带入上传剧本文本</span>
)} )}
{scriptInfo && ( </button>
<button
onClick={() => setShowManual(true)}
className="w-full rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
改为手动编辑剧本文本,再由 AI 生成大纲
</button>
</div>
) : (
<div className="space-y-5">
<div className="rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground">剧本文本</label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{loadingScript ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载已上传剧本...
</>
) : script ? (
<span className="text-emerald-600">已自动带入剧本文本</span>
) : null}
</div>
</div>
<textarea
className="h-72 w-full rounded-[24px] border border-border/80 bg-white p-4 text-sm leading-7 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="将剧本文本粘贴到这里,AI 会为你生成结构化大纲..."
value={script}
onChange={(e) => setScript(e.target.value)}
/>
<p className="mt-3 text-xs leading-6 text-muted-foreground">
支持中英文剧本,建议正文长度在 500 到 5000 字之间,便于生成更稳定的大纲结果。
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending}
className="inline-flex h-14 items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{generateOutline.isPending ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
正在生成大纲...
</>
) : (
<>
<Sparkles className="h-5 w-5" />
AI 生成大纲
</>
)}
</button>
{scriptInfo ? (
<button <button
onClick={() => setShowManual(false)} onClick={() => setShowManual(false)}
className="text-xs text-primary underline" className="rounded-[22px] border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
> >
返回文档预览 返回自动识别摘要
</button> </button>
)} ) : null}
</div> </div>
</div> </div>
)}
<textarea </section>
className="w-full h-64 p-4 rounded-lg border border-border bg-background text-sm text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="将您的剧本文本粘贴到此处,AI 将自动生成大纲..."
value={script}
onChange={(e) => setScript(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-2">支持中英文剧本,建议正文长度 500 到 5000 字。</p>
<button
onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending}
className="w-full mt-4 py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{generateOutline.isPending ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>生成中...</span>
</>
) : (
<>
<Sparkles className="w-5 h-5" />
<span>AI 生成大纲</span>
</>
)}
</button>
</div>
)}
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="mx-auto max-w-[1380px]">
<div className="max-w-5xl mx-auto"> <section className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]">
<div className="mb-6 flex items-start justify-between"> <div className="relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]">
<div> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]" />
<div className="flex items-center gap-3 mb-2"> <div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" />
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> <div className="absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10" />
<Sparkles className="w-4 h-4 text-white" />
</div> <div className="relative">
<h1 className="text-2xl font-semibold text-foreground">剧本大纲</h1> <div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74">
<CheckCircle2 className="h-4 w-4" />
Outline Ready
</div>
<h1 className="mt-5 max-w-2xl text-4xl font-semibold leading-tight">
项目大纲已生成,可以继续进入分集与设定流程
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-white/76">
当前大纲已经作为项目的结构化骨架写入工作台,接下来建议立即生成分集,保持整个工作流连贯推进。
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{[
{
title: "题材已确定",
desc: outline.genre,
},
{
title: "集数规模",
desc: `${outline.episodeCount} 集`,
},
{
title: "下一步建议",
desc: "继续生成分集内容",
},
].map((item) => (
<div
key={item.title}
className="rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-white/64">{item.title}</div>
<div className="mt-3 text-lg font-medium">{item.desc}</div>
</div>
))}
</div> </div>
<p className="text-sm text-muted-foreground">AI 已为您生成当前项目的大纲结果。</p>
</div> </div>
<button
onClick={() => setShowManual(true)}
className="px-4 py-2 rounded-lg border border-border bg-card text-foreground flex items-center gap-2 hover:bg-muted transition-colors text-sm"
>
<RefreshCw className="w-4 h-4" />
重新生成
</button>
</div> </div>
{showManual && ( <section className="rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="mb-6 rounded-xl border border-border bg-card p-6"> <div className="mb-6 flex items-start justify-between gap-4">
<label className="block text-sm font-medium text-foreground mb-3">输入新的剧本内容</label> <div>
<textarea <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Outline Result</div>
className="w-full h-40 p-3 rounded-lg border border-border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary" <h2 className="mt-2 text-3xl font-semibold text-foreground">大纲结果确认</h2>
value={script} <p className="mt-3 text-sm leading-6 text-muted-foreground">
onChange={(e) => setScript(e.target.value)} 你可以在这里快速检查大纲内容,确认无误后继续生成分集;如需修改,也可以重新提交剧本文本。
/> </p>
<div className="flex gap-2 mt-3">
<button
onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm disabled:opacity-50 flex items-center gap-2"
>
{generateOutline.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{generateOutline.isPending ? "生成中..." : "确认生成"}
</button>
<button
onClick={() => setShowManual(false)}
className="px-4 py-2 rounded-lg border border-border text-sm"
>
取消
</button>
</div> </div>
<button
onClick={() => setShowManual(true)}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<RefreshCw className="h-4 w-4" />
重新生成
</button>
</div> </div>
)}
{generateEpisodes.isError && ( {showManual ? (
<div className="mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3"> <div className="mb-5 rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" /> <label className="mb-3 block text-sm font-medium text-foreground">输入新的剧本文本</label>
<p className="text-sm text-red-700">{(generateEpisodes.error as Error)?.message}</p> <textarea
</div> className="h-48 w-full rounded-[24px] border border-border/80 bg-white p-4 text-sm leading-7 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
)} value={script}
onChange={(e) => setScript(e.target.value)}
/>
<div className="mt-4 flex gap-3">
<button
onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{generateOutline.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{generateOutline.isPending ? "生成中..." : "确认生成"}
</button>
<button
onClick={() => setShowManual(false)}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
</button>
</div>
</div>
) : null}
<div className="rounded-xl border border-border bg-card p-6 mb-4"> {generateEpisodes.isError ? (
<div className="grid grid-cols-2 gap-6 mb-6"> <div className="mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4">
<div> <AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" />
<label className="block text-xs text-muted-foreground mb-2">剧名</label> <p className="text-sm leading-6 text-red-700">{(generateEpisodes.error as Error)?.message}</p>
<div className="text-xl font-semibold text-foreground">{outline.title}</div>
</div> </div>
<div> ) : null}
<label className="block text-xs text-muted-foreground mb-2">类型</label>
<div className="text-xl font-semibold text-foreground">{outline.genre}</div> <div className="rounded-[30px] border border-border/80 bg-white/70 p-6 shadow-sm">
<div className="grid gap-5 md:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Title</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{outline.title}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Genre</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{outline.genre}</div>
</div>
</div>
<div className="mt-6">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Synopsis</div>
<p className="mt-3 text-sm leading-7 text-foreground/84">{outline.synopsis}</p>
</div>
<div className="mt-6 inline-flex rounded-full bg-accent px-4 py-2 text-sm text-primary">
{outline.episodeCount}
</div> </div>
</div> </div>
<div className="mb-4">
<label className="block text-xs text-muted-foreground mb-2">故事梗概</label>
<p className="text-sm text-foreground leading-relaxed">{outline.synopsis}</p>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">集数</label>
<span className="px-3 py-1 rounded-lg bg-accent text-primary text-sm">{outline.episodeCount}</span>
</div>
</div>
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={generateEpisodes.isPending} disabled={generateEpisodes.isPending}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50" className="mt-6 inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{generateEpisodes.isPending ? ( {generateEpisodes.isPending ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span>生成分集中...</span> 正在生成分集内容...
</> </>
) : ( ) : (
<> <>
<span>生成分集内容</span> 继续生成分集
<ArrowRight className="w-5 h-5" /> <ArrowRight className="h-5 w-5" />
</> </>
)} )}
</button> </button>
</div> </section>
</section>
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
Film, ArrowRight,
Play, BookOpenText,
ChevronRight, ChevronRight,
Clapperboard,
Film,
Image as ImageIcon, Image as ImageIcon,
Loader2,
MapPin, MapPin,
PlayCircle,
Sparkles, Sparkles,
Edit2,
Loader2,
BookOpen,
Users, Users,
Clapperboard,
Video, Video,
} from "lucide-react"; } from "lucide-react";
import { useProject } from "../../hooks/useProjects"; import { useProject } from "../../hooks/useProjects";
import { useOutline, useEpisodes, useCharacters, useScenes, useVideoTasks } from "../../hooks/useAi"; import { useCharacters, useEpisodes, useOutline, useScenes, useVideoTasks } from "../../hooks/useAi";
type Tab = "outline" | "characters" | "storyboard" | "video"; type Tab = "outline" | "characters" | "storyboard" | "video";
function ProjectCover({ coverUrl, name }: { coverUrl?: string; name: string }) {
if (coverUrl) {
return <img src={coverUrl} alt={name} className="h-full w-full object-cover" />;
}
return (
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)]">
<div className="rounded-[28px] border border-white/14 bg-white/10 px-5 py-4 text-center text-white backdrop-blur-sm">
<div className="text-[11px] uppercase tracking-[0.32em] text-white/72">Project</div>
<div className="mt-2 text-3xl font-semibold tracking-[0.18em]">
{name.slice(0, 2).toUpperCase()}
</div>
</div>
</div>
);
}
function SectionTab({
active,
label,
icon: Icon,
onClick,
}: {
active: boolean;
label: string;
icon: typeof BookOpenText;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all",
active
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
].join(" ")}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
}
export function ProjectDetail() { export function ProjectDetail() {
const { projectId } = useParams(); const { projectId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -34,156 +79,220 @@ export function ProjectDetail() { ...@@ -34,156 +79,220 @@ export function ProjectDetail() {
const [activeTab, setActiveTab] = useState<Tab>("outline"); const [activeTab, setActiveTab] = useState<Tab>("outline");
const tabs = [ const tabs = [
{ id: "outline" as Tab, label: "大纲", icon: BookOpen }, { id: "outline" as Tab, label: "剧本拆解", icon: BookOpenText },
{ id: "characters" as Tab, label: "角色/场景", icon: Users }, { id: "characters" as Tab, label: "角色场景", icon: Users },
{ id: "storyboard" as Tab, label: "分镜", icon: Clapperboard }, { id: "storyboard" as Tab, label: "分镜工作台", icon: Clapperboard },
{ id: "video" as Tab, label: "视频", icon: Video }, { id: "video" as Tab, label: "视频生成", icon: Video },
]; ];
if (projectLoading) { if (projectLoading) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto bg-background relative"> <div className="mx-auto max-w-[1440px]">
{/* Header */} <section className="overflow-hidden rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="border-b border-border bg-card px-6 py-3"> <div className="grid gap-6 xl:grid-cols-[320px_1fr]">
<div className="flex gap-4 items-center"> <div className="overflow-hidden rounded-[30px] border border-white/70 bg-muted shadow-sm">
<div className="w-20 aspect-[2/3] rounded-lg overflow-hidden bg-muted flex-shrink-0 flex items-center justify-center"> <div className="aspect-[3/4]">
{project?.coverUrl ? ( <ProjectCover coverUrl={project?.coverUrl} name={project?.name ?? "项目"} />
<img src={project.coverUrl} alt="封面" className="w-full h-full object-cover" /> </div>
) : (
<Film className="w-8 h-8 text-muted-foreground" />
)}
</div> </div>
<div className="flex-1"> <div className="flex flex-col">
<div className="flex items-center gap-3 mb-2"> <div className="flex flex-wrap items-start justify-between gap-4">
<h1 className="text-lg font-semibold text-foreground"> <div>
{project?.name ?? "加载中..."} <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
</h1> <Film className="h-3.5 w-3.5" />
{outline && ( Project Overview
<span className="px-2 py-0.5 rounded bg-accent text-foreground text-xs border border-border"> </div>
{outline.genre} <h1 className="mt-4 text-4xl font-semibold text-foreground">
</span> {project?.name ?? "当前项目"}
)} </h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-muted-foreground">
这里是项目的流程总控台,可以统一查看剧本拆解、角色场景、分镜与视频任务,并从任意阶段继续推进。
</p>
</div>
<div className="flex flex-wrap gap-3">
{episodes.length === 0 ? (
<button
onClick={() => navigate(`/project/${projectId}/outline`)}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<Sparkles className="h-4 w-4" />
开始剧本拆解
</button>
) : (
<button
onClick={() => navigate(`/project/${projectId}/storyboard`)}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
进入工作流
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div> </div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{outline && <span>{outline.episodeCount}</span>} <div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{episodes.length > 0 && <span>已生成 {episodes.length} 集分集</span>} {[
{characters.length > 0 && <span>{characters.length} 个角色</span>} {
{scenes.length > 0 && <span>{scenes.length} 个场景</span>} label: "题材类型",
{project?.updatedAt && ( value: outline?.genre ?? "待生成",
<span>更新于 {new Date(project.updatedAt).toLocaleDateString("zh-CN")}</span> },
)} {
label: "分集数量",
value: outline ? `${outline.episodeCount} 集` : "待生成",
},
{
label: "角色数量",
value: `${characters.length} 个`,
},
{
label: "场景数量",
value: `${scenes.length} 个`,
},
].map((item) => (
<div
key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">
{item.label}
</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
</div>
))}
</div> </div>
</div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="mt-6 rounded-[30px] border border-primary/12 bg-primary/5 p-5">
{episodes.length === 0 ? ( <div className="mb-4 flex items-center gap-3">
<button <div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
onClick={() => navigate(`/project/${projectId}/outline`)} <PlayCircle className="h-5 w-5" />
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2" </div>
> <div>
<Sparkles className="w-4 h-4" /> <div className="text-base font-medium text-foreground">项目推进节奏</div>
生成大纲 <div className="text-sm text-muted-foreground">
</button> 按“剧本拆解 → 设定 → 分镜 → 视频”顺序推进,当前工作台会持续沉淀项目资产与状态。
) : ( </div>
<button </div>
onClick={() => navigate(`/project/${projectId}/storyboard`)} </div>
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
> <div className="grid gap-3 md:grid-cols-4">
进入工作台 {[
<ChevronRight className="w-4 h-4" /> {
</button> title: "剧本拆解",
)} desc: outline ? "已生成大纲,可继续补充分集。" : "建议先完成大纲和分集结构。",
},
{
title: "角色与场景",
desc:
characters.length + scenes.length > 0
? "已沉淀设定资产,可继续补充细节。"
: "角色和场景仍待生成。",
},
{
title: "分镜工作台",
desc: episodes.length > 0 ? "可进入分镜页继续推进。" : "需先完成分集生成。",
},
{
title: "视频生成",
desc: videoTasks.length > 0 ? "已有视频任务记录。" : "分镜完成后可提交视频任务。",
},
].map((item) => (
<div
key={item.title}
className="rounded-2xl border border-border/70 bg-white/78 px-4 py-4 text-sm"
>
<div className="font-medium text-foreground">{item.title}</div>
<div className="mt-2 leading-6 text-muted-foreground">{item.desc}</div>
</div>
))}
</div>
</div>
</div> </div>
</div> </div>
</section>
{/* Tabs */} <section className="mt-6 rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="flex items-center gap-1 mt-3"> <div className="flex flex-wrap gap-3">
{tabs.map((tab) => { {tabs.map((tab) => (
const Icon = tab.icon; <SectionTab
return ( key={tab.id}
<button active={activeTab === tab.id}
key={tab.id} label={tab.label}
onClick={() => setActiveTab(tab.id)} icon={tab.icon}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-all ${ onClick={() => setActiveTab(tab.id)}
activeTab === tab.id />
? "bg-accent text-accent-foreground font-medium" ))}
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div> </div>
</div>
{/* Content */} <div className="mt-6">
<div className="p-6"> {activeTab === "outline" ? (
<div className="max-w-6xl mx-auto">
{activeTab === "outline" && (
<OutlineTab <OutlineTab
outline={outline} outline={outline}
episodes={episodes} episodes={episodes}
projectId={projectId}
onNavigateOutline={() => navigate(`/project/${projectId}/outline`)} onNavigateOutline={() => navigate(`/project/${projectId}/outline`)}
onNavigateEpisode={(epId) => navigate(`/project/${projectId}/storyboard/${epId}`)} onNavigateEpisode={(episodeId) => navigate(`/project/${projectId}/storyboard/${episodeId}`)}
/> />
)} ) : null}
{activeTab === "characters" && (
{activeTab === "characters" ? (
<CharactersTab <CharactersTab
characters={characters} characters={characters}
scenes={scenes} scenes={scenes}
projectId={projectId}
onNavigateChars={() => navigate(`/project/${projectId}/characters`)} onNavigateChars={() => navigate(`/project/${projectId}/characters`)}
onNavigateScenes={() => navigate(`/project/${projectId}/scenes`)} onNavigateScenes={() => navigate(`/project/${projectId}/scenes`)}
/> />
)} ) : null}
{activeTab === "storyboard" && (
<StoryboardTab episodes={episodes} projectId={projectId} /> {activeTab === "storyboard" ? (
)} <StoryboardTab
{activeTab === "video" && ( episodes={episodes}
projectId={projectId}
/>
) : null}
{activeTab === "video" ? (
<VideoTab videoTasks={videoTasks} projectId={projectId} /> <VideoTab videoTasks={videoTasks} projectId={projectId} />
)} ) : null}
</div> </div>
</div> </section>
</div> </div>
); );
} }
// ---- Outline Tab ----
function OutlineTab({ function OutlineTab({
outline, outline,
episodes, episodes,
projectId,
onNavigateOutline, onNavigateOutline,
onNavigateEpisode, onNavigateEpisode,
}: { }: {
outline: import("../../lib/api/ai").Outline | undefined; outline: import("../../lib/api/ai").Outline | undefined;
episodes: import("../../lib/api/ai").Episode[]; episodes: import("../../lib/api/ai").Episode[];
projectId?: string;
onNavigateOutline: () => void; onNavigateOutline: () => void;
onNavigateEpisode: (epId: string) => void; onNavigateEpisode: (episodeId: string) => void;
}) { }) {
if (!outline) { if (!outline) {
return ( return (
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center"> <div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center">
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<h3 className="text-lg font-medium text-foreground mb-2">尚未生成大纲</h3> <BookOpenText className="h-7 w-7" />
<p className="text-sm text-muted-foreground mb-6">上传剧本后,AI 可自动生成大纲和分集</p> </div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">尚未生成剧本大纲</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
上传剧本后,AI 会自动帮助你完成大纲整理,并进一步推动分集、角色与分镜流程。
</p>
<button <button
onClick={onNavigateOutline} onClick={onNavigateOutline}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 mx-auto" className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="h-4 w-4" />
前往生成大纲 前往生成大纲
</button> </button>
</div> </div>
...@@ -191,65 +300,73 @@ function OutlineTab({ ...@@ -191,65 +300,73 @@ function OutlineTab({
} }
return ( return (
<div> <div className="space-y-6">
{/* Outline Card */} <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="mb-8"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between mb-4"> <div>
<h2 className="text-lg font-semibold text-foreground">剧本大纲</h2> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Outline</div>
<h3 className="mt-2 text-3xl font-semibold text-foreground">{outline.title}</h3>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full bg-accent px-3 py-1 text-sm text-primary">
{outline.genre}
</span>
<span className="rounded-full border border-border/80 bg-white px-3 py-1 text-sm text-muted-foreground">
{outline.episodeCount}
</span>
</div>
</div>
<button <button
onClick={onNavigateOutline} onClick={onNavigateOutline}
className="text-sm text-primary hover:underline flex items-center gap-1" className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
> >
<Edit2 className="w-3 h-3" /> 编辑大纲
编辑
</button> </button>
</div> </div>
<div className="rounded-xl border border-border bg-card p-6"> <p className="mt-5 text-sm leading-7 text-foreground/84">{outline.synopsis}</p>
<h3 className="text-base font-semibold text-foreground mb-2">{outline.title}</h3>
<div className="flex items-center gap-3 mb-3 text-sm text-muted-foreground">
<span>类型:{outline.genre}</span>
<span>·</span>
<span>{outline.episodeCount}</span>
</div>
<p className="text-sm text-foreground leading-relaxed">{outline.synopsis}</p>
</div>
</div> </div>
{/* Episode List */} {episodes.length > 0 ? (
{episodes.length > 0 && (
<div> <div>
<h2 className="text-lg font-semibold text-foreground mb-4"> <div className="mb-4 flex items-center justify-between">
分集列表 ({episodes.length} 集) <h3 className="text-2xl font-semibold text-foreground">分集总览</h3>
</h2> <span className="rounded-full bg-accent px-3 py-1 text-sm text-primary">
<div className="grid grid-cols-3 gap-4"> {episodes.length}
{episodes.map((ep) => ( </span>
<div </div>
key={ep.id} <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
onClick={() => onNavigateEpisode(ep.id)} {episodes.map((episode) => (
className="rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer" <button
key={episode.id}
onClick={() => onNavigateEpisode(episode.id)}
className="rounded-[28px] border border-white/80 bg-white/78 p-5 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20"
> >
<div className="flex items-center gap-2 mb-2"> <div className="inline-flex rounded-full bg-primary/8 px-3 py-1 text-xs text-primary">
<span className="text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded"> {episode.episodeNumber}
{ep.episodeNumber}
</span>
</div> </div>
<h3 className="font-medium text-foreground text-sm mb-1">{ep.title}</h3> <div className="mt-4 text-lg font-medium text-foreground">{episode.title}</div>
<p className="text-xs text-muted-foreground line-clamp-3">{ep.summary}</p> <p className="mt-3 line-clamp-4 text-sm leading-6 text-muted-foreground">
</div> {episode.summary}
</p>
<div className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary">
进入分镜工作台
<ArrowRight className="h-4 w-4" />
</div>
</button>
))} ))}
</div> </div>
</div> </div>
)} ) : (
<div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center">
{episodes.length === 0 && outline && ( <h3 className="text-2xl font-semibold text-foreground">大纲已准备好,下一步生成分集</h3>
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center"> <p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
<p className="text-sm text-muted-foreground mb-4">大纲已生成,点击前往生成分集内容</p> 继续生成分集后,就可以进入角色设定与分镜制作阶段。
</p>
<button <button
onClick={onNavigateOutline} onClick={onNavigateOutline}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto" className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="h-4 w-4" />
生成分集 前往生成分集
</button> </button>
</div> </div>
)} )}
...@@ -257,118 +374,133 @@ function OutlineTab({ ...@@ -257,118 +374,133 @@ function OutlineTab({
); );
} }
// ---- Characters Tab ----
function CharactersTab({ function CharactersTab({
characters, characters,
scenes, scenes,
projectId,
onNavigateChars, onNavigateChars,
onNavigateScenes, onNavigateScenes,
}: { }: {
characters: import("../../lib/api/ai").Character[]; characters: import("../../lib/api/ai").Character[];
scenes: import("../../lib/api/ai").Scene[]; scenes: import("../../lib/api/ai").Scene[];
projectId?: string;
onNavigateChars: () => void; onNavigateChars: () => void;
onNavigateScenes: () => void; onNavigateScenes: () => void;
}) { }) {
const getMainCharacterImage = (char: import("../../lib/api/ai").Character) => const getCharacterPreview = (character: import("../../lib/api/ai").Character) =>
char.frontImageUrl ?? char.imageUrl ?? char.sideImageUrl ?? char.backImageUrl ?? null; character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Characters */}
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Users className="w-5 h-5 text-primary" /> <div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<h2 className="text-lg font-semibold text-foreground">角色设定</h2> <Users className="h-5 w-5" />
<span className="text-sm text-muted-foreground">({characters.length})</span> </div>
<div>
<h3 className="text-2xl font-semibold text-foreground">角色设定</h3>
<div className="text-sm text-muted-foreground">累计 {characters.length} 个角色资产</div>
</div>
</div> </div>
<button onClick={onNavigateChars} className="text-sm text-primary hover:underline"> <button
onClick={onNavigateChars}
className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
管理角色 管理角色
</button> </button>
</div> </div>
{characters.length === 0 ? ( {characters.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center"> <div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center">
<p className="text-sm text-muted-foreground mb-4">暂无角色,去角色页面提取</p> <p className="text-sm leading-6 text-muted-foreground">还没有角色设定,建议先从剧本中提取主要人物。</p>
<button <button
onClick={onNavigateChars} onClick={onNavigateChars}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto" className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="h-4 w-4" />
AI 提取角色 AI 提取角色
</button> </button>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-4 gap-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{characters.slice(0, 8).map((char) => ( {characters.slice(0, 8).map((character) => (
<div <button
key={char.id} key={character.id}
onClick={onNavigateChars} onClick={onNavigateChars}
className="rounded-xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors" className="rounded-[28px] border border-white/80 bg-white/78 p-5 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20"
> >
<div className="w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center"> <div className="flex h-24 items-center justify-center overflow-hidden rounded-[24px] bg-muted">
{getMainCharacterImage(char) ? ( {getCharacterPreview(character) ? (
<img src={getMainCharacterImage(char) ?? ""} alt={char.name} className="w-full h-full object-cover" /> <img
src={getCharacterPreview(character) ?? ""}
alt={character.name}
className="h-full w-full object-cover"
/>
) : ( ) : (
<ImageIcon className="w-8 h-8 text-muted-foreground" /> <ImageIcon className="h-8 w-8 text-muted-foreground" />
)} )}
</div> </div>
<div className="font-medium text-foreground text-sm mb-1">{char.name}</div> <div className="mt-4 text-lg font-medium text-foreground">{character.name}</div>
<div className="text-xs text-muted-foreground">{char.roleType}</div> <div className="mt-2 text-sm text-muted-foreground">{character.roleType}</div>
</div> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Scenes */}
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<MapPin className="w-5 h-5 text-primary" /> <div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<h2 className="text-lg font-semibold text-foreground">场景设定</h2> <MapPin className="h-5 w-5" />
<span className="text-sm text-muted-foreground">({scenes.length})</span> </div>
<div>
<h3 className="text-2xl font-semibold text-foreground">场景设定</h3>
<div className="text-sm text-muted-foreground">累计 {scenes.length} 个场景资产</div>
</div>
</div> </div>
<button onClick={onNavigateScenes} className="text-sm text-primary hover:underline"> <button
onClick={onNavigateScenes}
className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
管理场景 管理场景
</button> </button>
</div> </div>
{scenes.length === 0 ? ( {scenes.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center"> <div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center">
<p className="text-sm text-muted-foreground mb-4">暂无场景,去场景页面提取</p> <p className="text-sm leading-6 text-muted-foreground">还没有场景设定,建议在角色之后继续提取关键场景。</p>
<button <button
onClick={onNavigateScenes} onClick={onNavigateScenes}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto" className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="h-4 w-4" />
AI 提取场景 AI 提取场景
</button> </button>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-3 gap-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{scenes.slice(0, 6).map((scene) => ( {scenes.slice(0, 6).map((scene) => (
<div <button
key={scene.id} key={scene.id}
onClick={onNavigateScenes} onClick={onNavigateScenes}
className="rounded-xl border border-border bg-card overflow-hidden cursor-pointer hover:border-primary transition-colors" className="overflow-hidden rounded-[28px] border border-white/80 bg-white/78 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20"
> >
<div className="aspect-video bg-muted flex items-center justify-center"> <div className="aspect-video bg-muted">
{scene.imageUrl ? ( {scene.imageUrl ? (
<img src={scene.imageUrl} alt={scene.name} className="w-full h-full object-cover" /> <img src={scene.imageUrl} alt={scene.name} className="h-full w-full object-cover" />
) : ( ) : (
<ImageIcon className="w-8 h-8 text-muted-foreground" /> <div className="flex h-full items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)} )}
</div> </div>
<div className="p-3 flex items-center justify-between"> <div className="p-4">
<div className="font-medium text-foreground text-sm">{scene.name}</div> <div className="text-lg font-medium text-foreground">{scene.name}</div>
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted"> <div className="mt-2 text-sm text-muted-foreground">
{scene.sceneType === "indoor" ? "室内" : "室外"} {scene.sceneType === "indoor" ? "室内场景" : "室外场景"}
</span> </div>
</div> </div>
</div> </button>
))} ))}
</div> </div>
)} )}
...@@ -377,7 +509,6 @@ function CharactersTab({ ...@@ -377,7 +509,6 @@ function CharactersTab({
); );
} }
// ---- Storyboard Tab ----
function StoryboardTab({ function StoryboardTab({
episodes, episodes,
projectId, projectId,
...@@ -386,65 +517,67 @@ function StoryboardTab({ ...@@ -386,65 +517,67 @@ function StoryboardTab({
projectId?: string; projectId?: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedEpId, setSelectedEpId] = useState<string | null>( const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>(
episodes.length > 0 ? episodes[0].id : null episodes.length > 0 ? episodes[0].id : null,
); );
if (episodes.length === 0) { if (episodes.length === 0) {
return ( return (
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center"> <div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center">
<Clapperboard className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<h3 className="text-lg font-medium text-foreground mb-2">尚未生成分集</h3> <Clapperboard className="h-7 w-7" />
<p className="text-sm text-muted-foreground">先生成大纲和分集,再进入分镜工作台</p> </div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">尚未进入分镜阶段</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
请先完成大纲与分集生成,再进入分镜工作台推进镜头设计与视频指令。
</p>
</div> </div>
); );
} }
return ( const selectedEpisode = episodes.find((episode) => episode.id === selectedEpisodeId) ?? episodes[0];
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">分镜工作台</h2>
<button
onClick={() => navigate(`/project/${projectId}/storyboard/${selectedEpId ?? episodes[0].id}`)}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
>
进入分镜工作台
<ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2 mb-6 flex-wrap"> return (
{episodes.map((ep) => ( <div className="space-y-6">
<div className="flex flex-wrap gap-3">
{episodes.map((episode) => (
<button <button
key={ep.id} key={episode.id}
onClick={() => setSelectedEpId(ep.id)} onClick={() => setSelectedEpisodeId(episode.id)}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${ className={[
selectedEpId === ep.id "rounded-2xl border px-4 py-3 text-sm transition-all",
? "bg-primary text-white" selectedEpisodeId === episode.id
: "bg-card border border-border text-foreground hover:border-primary" ? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
}`} : "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
].join(" ")}
> >
{ep.episodeNumber} {episode.episodeNumber}
</button> </button>
))} ))}
</div> </div>
{selectedEpId && ( <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div <div className="flex flex-wrap items-start justify-between gap-4">
onClick={() => navigate(`/project/${projectId}/storyboard/${selectedEpId}`)} <div>
className="rounded-xl border border-border bg-card p-6 text-center hover:border-primary transition-colors cursor-pointer" <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Storyboard</div>
> <h3 className="mt-2 text-3xl font-semibold text-foreground">{selectedEpisode.title}</h3>
<Clapperboard className="w-12 h-12 text-muted-foreground mx-auto mb-3" /> <p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
<p className="text-sm text-muted-foreground"> {selectedEpisode.summary}
点击进入第 {episodes.find(e => e.id === selectedEpId)?.episodeNumber} 集分镜工作台 </p>
</p> </div>
<button
onClick={() => navigate(`/project/${projectId}/storyboard/${selectedEpisode.id}`)}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
进入分镜工作台
<ChevronRight className="h-4 w-4" />
</button>
</div> </div>
)} </div>
</div> </div>
); );
} }
// ---- Video Tab ----
function VideoTab({ function VideoTab({
videoTasks, videoTasks,
projectId, projectId,
...@@ -454,70 +587,77 @@ function VideoTab({ ...@@ -454,70 +587,77 @@ function VideoTab({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const statusMap: Record<string, { label: string; dot: string }> = {
pending: { label: "排队中", dot: "bg-amber-400" },
running: { label: "生成中", dot: "bg-sky-500" },
succeeded: { label: "已完成", dot: "bg-emerald-500" },
failed: { label: "失败", dot: "bg-red-500" },
};
if (videoTasks.length === 0) { if (videoTasks.length === 0) {
return ( return (
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center"> <div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center">
<Video className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<h3 className="text-lg font-medium text-foreground mb-2">尚无视频任务</h3> <Video className="h-7 w-7" />
<p className="text-sm text-muted-foreground mb-6">在视频生成页面提交生成任务</p> </div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">暂时还没有视频任务</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
分镜完成后即可在视频页提交生成任务,平台会在这里展示视频结果和执行状态。
</p>
<button <button
onClick={() => navigate(`/project/${projectId}/video`)} onClick={() => navigate(`/project/${projectId}/video`)}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 mx-auto" className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Sparkles className="w-4 h-4" />
前往视频生成 前往视频生成
<ArrowRight className="h-4 w-4" />
</button> </button>
</div> </div>
); );
} }
const statusConfig: Record<string, { label: string; color: string }> = {
pending: { label: "排队中", color: "bg-yellow-500" },
running: { label: "生成中", color: "bg-blue-500" },
succeeded: { label: "已完成", color: "bg-green-500" },
failed: { label: "失败", color: "bg-red-500" },
};
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground"> <h3 className="text-2xl font-semibold text-foreground">视频任务总览</h3>
视频任务 ({videoTasks.length})
</h2>
<button <button
onClick={() => navigate(`/project/${projectId}/video`)} onClick={() => navigate(`/project/${projectId}/video`)}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2" className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
> >
视频生成页面 打开视频页
<ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="grid grid-cols-3 gap-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{videoTasks.map((task) => { {videoTasks.map((task) => {
const cfg = statusConfig[task.status] ?? { label: task.status, color: "bg-gray-400" }; const status = statusMap[task.status] ?? { label: task.status, dot: "bg-slate-400" };
return ( return (
<div key={task.id} className="rounded-xl border border-border bg-card overflow-hidden"> <div
<div className="aspect-video bg-muted flex items-center justify-center relative"> key={task.id}
className="overflow-hidden rounded-[28px] border border-white/80 bg-white/78 shadow-sm"
>
<div className="aspect-video bg-muted">
{task.resultVideoUrl ? ( {task.resultVideoUrl ? (
<video src={task.resultVideoUrl} className="w-full h-full object-cover" controls /> <video src={task.resultVideoUrl} className="h-full w-full object-cover" controls />
) : ( ) : (
<div className="text-center"> <div className="flex h-full items-center justify-center">
{task.status === "running" ? ( {task.status === "running" ? (
<Loader2 className="w-10 h-10 text-primary animate-spin mx-auto mb-2" /> <Loader2 className="h-10 w-10 animate-spin text-primary" />
) : ( ) : (
<Film className="w-10 h-10 text-muted-foreground mx-auto mb-2" /> <Film className="h-10 w-10 text-muted-foreground" />
)} )}
</div> </div>
)} )}
</div> </div>
<div className="p-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground"> <div className="p-4">
{new Date(task.createdAt).toLocaleDateString("zh-CN")} <div className="flex items-center justify-between gap-4">
</span> <div className="text-sm text-muted-foreground">
<div className="flex items-center gap-1"> {new Date(task.createdAt).toLocaleDateString("zh-CN")}
<div className={`w-2 h-2 rounded-full ${cfg.color}`} /> </div>
<span className="text-xs text-muted-foreground">{cfg.label}</span> <div className="inline-flex items-center gap-2 rounded-full bg-accent px-3 py-1 text-xs text-primary">
<span className={`h-2 w-2 rounded-full ${status.dot}`} />
{status.label}
</div>
</div> </div>
</div> </div>
</div> </div>
......
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { import {
Users, UserPlus, Shield, Trash2, Loader2, X, Check, Settings, Check,
Loader2,
Settings,
Shield,
Trash2,
UserPlus,
Users,
X,
} from "lucide-react"; } from "lucide-react";
import { useTeamMembers, useUpdateMember, useRemoveMember, useAddMember } from "../../hooks/useTeam"; import { useAddMember, useRemoveMember, useTeamMembers, useUpdateMember } from "../../hooks/useTeam";
import { useProject, useUpdateProject } from "../../hooks/useProjects"; import { useProject, useUpdateProject } from "../../hooks/useProjects";
import { import {
aspectRatioOptions, aspectRatioOptions,
getAspectRatioLabel,
getProjectStyleLabel,
getResolutionLabel,
projectStyleOptions, projectStyleOptions,
resolutionOptions, resolutionOptions,
} from "../../lib/projectStyles"; } from "../../lib/projectStyles";
const ROLES = ["owner", "admin", "member"] as const; const roles = ["owner", "admin", "member"] as const;
type Role = typeof ROLES[number]; type Role = (typeof roles)[number];
const roleLabels: Record<string, string> = { const roleLabels: Record<Role, string> = {
owner: "所有者", owner: "所有者",
admin: "管理员", admin: "管理员",
member: "成员", member: "成员",
}; };
const rolePermissions: Record<string, string[]> = { const roleDescriptions: Record<Role, string> = {
owner: ["完全控制", "删除项目", "管理成员", "编辑内容", "查看内容"], owner: "可管理项目设置、成员与全部流程。",
admin: ["管理成员", "编辑内容", "审核分镜", "生成视频", "查看内容"], admin: "可协作推进项目内容并管理部分成员。",
member: ["编辑内容", "创建分镜", "修改分镜", "查看内容"], member: "参与内容生产与流程执行。",
}; };
export function ProjectSettings() { export function ProjectSettings() {
const { projectId } = useParams(); const { projectId } = useParams();
const numId = projectId ?? ""; const pid = projectId ?? "";
const { data: project, isLoading: loadingProject } = useProject(numId); const { data: project, isLoading: loadingProject } = useProject(pid);
const { data: members = [], isLoading: loadingMembers } = useTeamMembers(); const { data: members = [], isLoading: loadingMembers } = useTeamMembers();
const updateMember = useUpdateMember(); const updateMember = useUpdateMember();
const removeMember = useRemoveMember(); const removeMember = useRemoveMember();
const addMember = useAddMember(); const addMember = useAddMember();
const updateProject = useUpdateProject(numId); const updateProject = useUpdateProject(pid);
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({ username: "", email: "", password: "", role: "member" });
const [inviteError, setInviteError] = useState("");
const [editingProject, setEditingProject] = useState(false); const [editingProject, setEditingProject] = useState(false);
const [projectForm, setProjectForm] = useState({ const [projectForm, setProjectForm] = useState({
...@@ -53,6 +53,15 @@ export function ProjectSettings() { ...@@ -53,6 +53,15 @@ export function ProjectSettings() {
resolution: "", resolution: "",
}); });
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({
username: "",
email: "",
password: "",
role: "member",
});
const [inviteError, setInviteError] = useState("");
const openEditProject = () => { const openEditProject = () => {
setProjectForm({ setProjectForm({
name: project?.name ?? "", name: project?.name ?? "",
...@@ -69,303 +78,364 @@ export function ProjectSettings() { ...@@ -69,303 +78,364 @@ export function ProjectSettings() {
setEditingProject(false); setEditingProject(false);
}; };
const handleRoleChange = async (userId: number, role: string) => {
await updateMember.mutateAsync({ userId, patch: { role } });
};
const handleRemove = async (userId: number) => {
if (!confirm("确定要移除该成员吗?")) return;
await removeMember.mutateAsync(userId);
};
const handleInvite = async () => { const handleInvite = async () => {
setInviteError(""); setInviteError("");
if (!inviteForm.username.trim() || !inviteForm.email.trim() || !inviteForm.password.trim()) { if (!inviteForm.username.trim() || !inviteForm.email.trim() || !inviteForm.password.trim()) {
setInviteError("用户名、邮箱和密码不能为空"); setInviteError("用户名、邮箱和密码不能为空");
return; return;
} }
try { try {
await addMember.mutateAsync(inviteForm); await addMember.mutateAsync(inviteForm);
setShowInviteModal(false); setShowInviteModal(false);
setInviteForm({ username: "", email: "", password: "", role: "member" }); setInviteForm({ username: "", email: "", password: "", role: "member" });
} catch { } catch {
setInviteError("添加失败,该邮箱可能已存在"); setInviteError("添加成员失败,该邮箱可能已经存在。");
} }
}; };
const roleCounts = (role: string) => members.filter((m) => m.role === role).length; const handleRoleChange = async (userId: number, role: string) => {
await updateMember.mutateAsync({ userId, patch: { role } });
};
const handleRemove = async (userId: number) => {
if (!confirm("确定要移除该成员吗?")) return;
await removeMember.mutateAsync(userId);
};
if (loadingProject || loadingMembers) { if (loadingProject || loadingMembers) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
); );
} }
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="mx-auto max-w-[1440px]">
<div className="max-w-6xl mx-auto"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="mb-6"> <div className="flex flex-wrap items-start justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground mb-1">项目设置</h1> <div>
<p className="text-sm text-muted-foreground">管理项目信息和团队成员权限</p> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<Settings className="h-3.5 w-3.5" />
Project Settings
</div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">项目设置与成员权限</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
统一维护项目基础参数、创作风格和协作成员权限,让项目配置与生产流程保持一致。
</p>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<UserPlus className="h-4 w-4" />
添加成员
</button>
</div> </div>
{/* Project Info */} <div className="mt-6 grid gap-4 md:grid-cols-4">
<div className="rounded-xl border border-border bg-card p-5 mb-6"> <div className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm">
<div className="flex items-center justify-between mb-4"> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">成员总数</div>
<h2 className="font-semibold text-foreground flex items-center gap-2"> <div className="mt-3 text-2xl font-semibold text-foreground">{members.length}</div>
<Settings className="w-4 h-4" /> 项目信息
</h2>
{!editingProject && (
<button
onClick={openEditProject}
className="px-3 py-1.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors"
>
编辑
</button>
)}
</div> </div>
{editingProject ? ( {roles.map((role) => (
<div className="space-y-3"> <div
<div> key={role}
<label className="block text-xs text-muted-foreground mb-1">项目名称</label> className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
<input >
type="text" <div className="text-xs uppercase tracking-[0.24em] text-primary/70">
value={projectForm.name} {roleLabels[role]}
onChange={(e) => setProjectForm({ ...projectForm, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div> </div>
<div className="mt-3 text-2xl font-semibold text-foreground">
{members.filter((member) => member.role === role).length}
</div>
</div>
))}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[1fr_0.92fr]">
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">项目描述</label> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Project Profile</div>
<textarea <h2 className="mt-2 text-2xl font-semibold text-foreground">项目基础信息</h2>
value={projectForm.description}
onChange={(e) => setProjectForm({ ...projectForm, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div> </div>
<div className="flex gap-2"> {!editingProject ? (
<button <button
onClick={() => setEditingProject(false)} onClick={openEditProject}
className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors" className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
> >
取消 编辑项目
</button> </button>
<button ) : null}
onClick={handleSaveProject}
disabled={updateProject.isPending}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90 flex items-center gap-2 disabled:opacity-60"
>
{updateProject.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
保存
</button>
</div>
</div>
) : (
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">名称:</span>
<span className="text-foreground font-medium">{project?.name}</span>
</div>
<div>
<span className="text-muted-foreground">描述:</span>
<span className="text-foreground">{project?.description || "—"}</span>
</div>
<div>
<span className="text-muted-foreground">状态:</span>
<span className="text-foreground">{project?.status}</span>
</div>
</div> </div>
)}
</div>
{/* Team Overview */} {editingProject ? (
<div className="grid grid-cols-4 gap-4 mb-6"> <div className="space-y-5">
<div className="rounded-xl border border-border bg-card p-4"> <div>
<div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center mb-3"> <label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<Users className="w-5 h-5 text-primary" /> <input
</div> value={projectForm.name}
<div className="text-2xl font-semibold text-foreground mb-0.5">{members.length}</div> onChange={(e) => setProjectForm((current) => ({ ...current, name: e.target.value }))}
<div className="text-xs text-muted-foreground">团队成员</div> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
</div> />
{["owner", "admin", "member"].map((role) => ( </div>
<div key={role} className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-semibold text-foreground mb-0.5">{roleCounts(role)}</div>
<div className="text-xs text-muted-foreground">{roleLabels[role]}</div>
</div>
))}
</div>
{/* Invite Button */} <div>
<div className="mb-6"> <label className="mb-2 block text-sm font-medium text-foreground">项目描述</label>
<button <textarea
onClick={() => setShowInviteModal(true)} value={projectForm.description}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center gap-2 hover:shadow-md transition-all text-sm" onChange={(e) => setProjectForm((current) => ({ ...current, description: e.target.value }))}
> rows={4}
<UserPlus className="w-4 h-4" /> className="w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
添加成员 />
</button> </div>
</div>
{/* Members List */} <div className="grid gap-4 md:grid-cols-3">
<div className="mb-8"> <div>
<h2 className="text-lg font-semibold text-foreground mb-4">团队成员</h2> <label className="mb-2 block text-sm font-medium text-foreground">视觉风格</label>
{members.length === 0 ? ( <select
<div className="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground"> value={projectForm.style}
暂无成员 onChange={(e) => setProjectForm((current) => ({ ...current, style: e.target.value }))}
</div> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
) : ( >
<div className="space-y-3"> <option value="">未设置</option>
{members.map((member) => ( {projectStyleOptions.map((option) => (
<div <option key={option.value} value={option.value}>
key={member.userId} {option.label}
className="rounded-xl border border-border bg-card p-4 flex items-center justify-between" </option>
> ))}
<div className="flex items-center gap-4"> </select>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/40 flex items-center justify-center">
<span className="text-sm font-semibold text-primary">
{member.username?.[0]?.toUpperCase() ?? "U"}
</span>
</div>
<div>
<div className="text-sm font-medium text-foreground mb-0.5">{member.username}</div>
<div className="text-xs text-muted-foreground">{member.email}</div>
</div>
</div> </div>
<div className="flex items-center gap-4"> <div>
<div className="text-xs text-muted-foreground"> <label className="mb-2 block text-sm font-medium text-foreground">画幅比例</label>
加入于 {member.joinedAt?.slice(0, 10)}
</div>
<select <select
value={member.role} value={projectForm.aspectRatio}
onChange={(e) => handleRoleChange(member.userId, e.target.value)} onChange={(e) => setProjectForm((current) => ({ ...current, aspectRatio: e.target.value }))}
disabled={member.role === "owner" || updateMember.isPending} className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
className="px-3 py-1.5 rounded-lg border border-border bg-card text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
> >
{ROLES.map((r) => ( <option value="">未设置</option>
<option key={r} value={r}>{roleLabels[r]}</option> {aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))} ))}
</select> </select>
{member.role !== "owner" && (
<button
onClick={() => handleRemove(member.userId)}
disabled={removeMember.isPending}
className="p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div> </div>
</div>
))}
</div>
)}
</div>
{/* Role Permissions */} <div>
<div> <label className="mb-2 block text-sm font-medium text-foreground">清晰度</label>
<h2 className="text-lg font-semibold text-foreground mb-4">角色权限说明</h2> <select
<div className="grid grid-cols-3 gap-4"> value={projectForm.resolution}
{Object.entries(rolePermissions).map(([key, perms]) => ( onChange={(e) => setProjectForm((current) => ({ ...current, resolution: e.target.value }))}
<div key={key} className="rounded-xl border border-border bg-card p-4"> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
<div className="flex items-center gap-3 mb-3"> >
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center"> <option value="">未设置</option>
<Shield className="w-4 h-4 text-primary" /> {resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div> </div>
<h3 className="font-medium text-foreground">{roleLabels[key]}</h3>
</div> </div>
<div className="space-y-2">
{perms.map((p, i) => ( <div className="flex items-center justify-end gap-3">
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground"> <button
<div className="w-1 h-1 rounded-full bg-primary" /> onClick={() => setEditingProject(false)}
{p} className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
</div> >
))} 取消
</button>
<button
onClick={handleSaveProject}
disabled={updateProject.isPending}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{updateProject.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
保存更新
</button>
</div> </div>
</div> </div>
))} ) : (
<div className="grid gap-4 md:grid-cols-2">
{[
{ label: "项目名称", value: project?.name || "未设置" },
{ label: "当前状态", value: project?.status || "未设置" },
{ label: "视觉风格", value: project?.style || "未设置" },
{ label: "画幅比例", value: project?.aspectRatio || "未设置" },
{ label: "清晰度", value: project?.resolution || "未设置" },
{ label: "项目描述", value: project?.description || "暂无描述" },
].map((item) => (
<div
key={item.label}
className="rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
<div className="mt-3 text-sm leading-6 text-foreground">{item.value}</div>
</div>
))}
</div>
)}
</div> </div>
</div>
{/* Invite Modal */} <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
{showInviteModal && ( <div className="mb-4">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Team Access</div>
<div className="bg-card rounded-2xl border border-border p-6 max-w-md w-full"> <h2 className="mt-2 text-2xl font-semibold text-foreground">成员权限管理</h2>
<div className="flex items-center justify-between mb-6"> </div>
<h2 className="text-xl font-semibold text-foreground">添加成员</h2>
<button onClick={() => setShowInviteModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div className="space-y-4 mb-6"> <div className="space-y-4">
<div> {members.length === 0 ? (
<label className="block text-sm font-medium text-foreground mb-2">用户名</label> <div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
<input 当前还没有成员,点击右上角“添加成员”即可邀请新成员加入项目协作。
type="text"
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.target.value })}
placeholder="输入用户名"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div> </div>
<div> ) : (
<label className="block text-sm font-medium text-foreground mb-2">邮箱</label> members.map((member) => (
<input <div
type="email" key={member.userId}
value={inviteForm.email} className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
placeholder="member@example.com"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">初始密码</label>
<input
type="password"
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.target.value })}
placeholder="设置初始登录密码"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">角色</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm({ ...inviteForm, role: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
> >
<option value="admin">管理员</option> <div className="flex flex-wrap items-start justify-between gap-4">
<option value="member">成员</option> <div>
</select> <div className="text-lg font-medium text-foreground">{member.username}</div>
</div> <div className="mt-2 text-sm text-muted-foreground">{member.email}</div>
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>} </div>
<button
onClick={() => handleRemove(member.userId)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
移除
</button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_1fr]">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">角色权限</label>
<select
value={member.role}
onChange={(e) => handleRoleChange(member.userId, e.target.value)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{roles.map((role) => (
<option key={role} value={role}>
{roleLabels[role]}
</option>
))}
</select>
</div>
<div className="rounded-[20px] border border-primary/10 bg-primary/5 px-4 py-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Shield className="h-4 w-4 text-primary" />
权限说明
</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">
{roleDescriptions[(member.role as Role) ?? "member"]}
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</section>
{showInviteModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="w-full max-w-xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Invite Member</div>
<h3 className="mt-2 text-2xl font-semibold text-foreground">添加项目成员</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
为项目添加新的协作成员,并设置初始角色权限。
</p>
</div> </div>
<button
onClick={() => setShowInviteModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex gap-3"> <div className="space-y-5">
<button <div>
onClick={() => setShowInviteModal(false)} <label className="mb-2 block text-sm font-medium text-foreground">用户名</label>
className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" <input
> value={inviteForm.username}
取消 onChange={(e) => setInviteForm((current) => ({ ...current, username: e.target.value }))}
</button> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<button placeholder="输入用户名"
onClick={handleInvite} />
disabled={addMember.isPending} </div>
className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2" <div>
<label className="mb-2 block text-sm font-medium text-foreground">邮箱</label>
<input
value={inviteForm.email}
onChange={(e) => setInviteForm((current) => ({ ...current, email: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="member@example.com"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">初始密码</label>
<input
type="password"
value={inviteForm.password}
onChange={(e) => setInviteForm((current) => ({ ...current, password: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="设置初始登录密码"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">角色权限</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm((current) => ({ ...current, role: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
> >
{addMember.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />} {roles.map((role) => (
添加 <option key={role} value={role}>
</button> {roleLabels[role]}
</option>
))}
</select>
</div> </div>
{inviteError ? (
<div className="rounded-[24px] border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
{inviteError}
</div>
) : null}
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button
onClick={() => setShowInviteModal(false)}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
</button>
<button
onClick={handleInvite}
disabled={addMember.isPending}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{addMember.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
确认添加
</button>
</div> </div>
</div> </div>
)} </div>
</div> ) : null}
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
ArrowRight, Box, Sparkles, Users, MapPin, Plus, Box,
Edit2, Trash2, Upload, X, Check, RefreshCw, Loader2, Image as ImageIcon,
Link2,
Loader2,
MapPin,
Plus,
Sparkles,
Trash2,
Users,
X,
} from "lucide-react"; } from "lucide-react";
import { useGlobalAssets, useCreateAsset, useDeleteAsset } from "../../hooks/useAssets"; import { useCreateAsset, useDeleteAsset, useGlobalAssets } from "../../hooks/useAssets";
import type { GlobalAsset } from "../../lib/api/assets"; import type { GlobalAsset } from "../../lib/api/assets";
type SettingsTab = "characters" | "scenes" | "props"; type SettingsTab = "characters" | "scenes" | "props";
interface EditForm { interface EditForm {
id?: string;
name: string; name: string;
category: string; category: string;
description: string; description: string;
imageUrl: string; imageUrl: string;
} }
const CATEGORIES = ["首饰", "文件", "配饰", "文具", "饮品", "电子设备", "武器", "交通工具", "其他"]; const categories = ["饰品", "文件", "配件", "文具", "饮品", "电子设备", "武器", "交通工具", "其他"];
export function PropsGeneration() { export function PropsGeneration() {
const navigate = useNavigate(); const navigate = useNavigate();
const { projectId } = useParams(); const { projectId } = useParams();
const numProjectId = projectId ?? ""; const currentProjectId = projectId ?? "";
const { data: props = [], isLoading } = useGlobalAssets("prop", "personal"); const { data: props = [], isLoading } = useGlobalAssets("prop", "personal");
const createAsset = useCreateAsset("prop", "personal"); const createAsset = useCreateAsset("prop", "personal");
const deleteAsset = useDeleteAsset("prop", "personal"); const deleteAsset = useDeleteAsset("prop", "personal");
const [activeTab, setActiveTab] = useState<SettingsTab>("props"); const [activeTab] = useState<SettingsTab>("props");
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editForm, setEditForm] = useState<EditForm | null>(null); const [form, setForm] = useState<EditForm>({
name: "",
category: "配件",
description: "",
imageUrl: "",
});
const settingsTabs = [ const settingsTabs = [
{ id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` }, { id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` },
...@@ -38,263 +50,240 @@ export function PropsGeneration() { ...@@ -38,263 +50,240 @@ export function PropsGeneration() {
{ id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` }, { id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` },
]; ];
const openAdd = () => { const handleCreate = async () => {
setEditForm({ name: "", category: "配饰", description: "", imageUrl: "" }); if (!form.name.trim()) return;
setShowModal(true);
};
const openEdit = (p: GlobalAsset) => {
setEditForm({
id: p.id,
name: p.name,
category: p.tags?.[0] ?? "其他",
description: p.description ?? "",
imageUrl: p.imageUrl ?? "",
});
setShowModal(true);
};
const closeModal = () => { setShowModal(false); setEditForm(null); };
const handleSave = async () => {
if (!editForm || !editForm.name.trim()) return;
await createAsset.mutateAsync({ await createAsset.mutateAsync({
name: form.name,
assetType: "prop", assetType: "prop",
libraryType: "personal", libraryType: "personal",
name: editForm.name, tags: form.category,
description: editForm.description, description: form.description,
tags: editForm.category, imageUrl: form.imageUrl || undefined,
imageUrl: editForm.imageUrl || undefined, sourceProjectId: currentProjectId || undefined,
sourceProjectId: numProjectId || undefined,
}); });
closeModal(); setShowModal(false);
setForm({ name: "", category: "配件", description: "", imageUrl: "" });
}; };
const handleDelete = async (id: string) => { const handleDelete = async (asset: GlobalAsset) => {
if (!confirm("确定要删除这个道具吗?")) return; if (!confirm(`确定要删除道具“${asset.name}”吗?`)) return;
await deleteAsset.mutateAsync(id); await deleteAsset.mutateAsync(asset.id);
}; };
const withImage = props.filter((p) => p.imageUrl).length;
return ( return (
<div className="h-full overflow-auto bg-background relative"> <div className="mx-auto max-w-[1440px]">
<div className="p-6 pb-24"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="max-w-7xl mx-auto"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="mb-6"> <div>
<div className="flex items-center gap-3 mb-2"> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> <Sparkles className="h-3.5 w-3.5" />
<Sparkles className="w-4 h-4 text-white" /> Prop Setup
</div>
<h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
</div> </div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p> <h1 className="mt-4 text-3xl font-semibold text-foreground">道具资产工作台</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
管理项目中复用频率较高的道具与物件资产,可以按类别沉淀,也可通过图片链接快速补充参考。
</p>
</div> </div>
{/* Tabs */} <button
<div className="flex items-center justify-between mb-6"> onClick={() => setShowModal(true)}
<div className="flex items-center gap-2"> className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
{settingsTabs.map((tab) => { >
const Icon = tab.icon; <Plus className="h-4 w-4" />
return ( 新建道具
<button </button>
key={tab.id} </div>
onClick={() => { setActiveTab(tab.id); navigate(tab.path); }}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${ <div className="mt-6 flex flex-wrap gap-3">
activeTab === tab.id {settingsTabs.map((tab) => {
? "bg-accent text-accent-foreground font-medium" const Icon = tab.icon;
: "text-muted-foreground hover:text-foreground hover:bg-muted" const active = activeTab === tab.id;
}`}
> return (
<Icon className="w-4 h-4" /> <button
{tab.label} key={tab.id}
</button> onClick={() => navigate(tab.path)}
); className={[
})} "inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all",
</div> active
<button ? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
onClick={openAdd} : "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 text-sm" ].join(" ")}
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{[
{ label: "道具总数", value: `${props.length}` },
{ label: "含参考图", value: `${props.filter((item) => !!item.imageUrl).length}` },
{ label: "分类数", value: `${new Set(props.flatMap((item) => item.tags)).size}` },
].map((item) => (
<div
key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
> >
<Plus className="w-4 h-4" /> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
新增道具 <div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
</button> </div>
</div> ))}
</div>
{/* Stats bar */} {isLoading ? (
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"> <div className="flex items-center justify-center py-24">
<div className="flex items-center gap-6"> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex items-center gap-2"> </div>
<span className="text-sm text-muted-foreground">总计:</span> ) : props.length === 0 ? (
<span className="text-lg font-semibold text-foreground">{props.length}</span> <div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center">
</div> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<div className="flex items-center gap-2"> <Box className="h-7 w-7" />
<span className="text-sm text-muted-foreground">有图片:</span>
<span className="text-lg font-semibold text-green-600">{withImage}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">无图片:</span>
<span className="text-lg font-semibold text-muted-foreground">{props.length - withImage}</span>
</div>
</div> </div>
<button <h3 className="mt-5 text-2xl font-semibold text-foreground">还没有道具资产</h3>
onClick={() => {}} <p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
className="p-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" 为常用道具建立统一资产库,可以让后续人物、场景和分镜流程复用同一套视觉素材。
> </p>
<RefreshCw className="w-4 h-4" />
</button>
</div> </div>
) : (
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{props.map((asset) => (
<div
key={asset.id}
className="overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<div className="aspect-[4/5] bg-muted">
{asset.imageUrl ? (
<img src={asset.imageUrl} alt={asset.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
</div>
)}
</div>
{/* Grid */} <div className="p-5">
{isLoading ? ( <div className="flex items-start justify-between gap-3">
<div className="flex items-center justify-center py-20"> <div>
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <div className="text-lg font-semibold text-foreground">{asset.name}</div>
</div> <div className="mt-2 text-sm text-muted-foreground">
) : props.length === 0 ? ( {asset.tags[0] ?? "未分类"}
<div className="flex flex-col items-center justify-center py-20 text-center">
<Box className="w-12 h-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground mb-4">暂无道具,点击"新增道具"开始添加</p>
<button onClick={openAdd} className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4" />新增道具
</button>
</div>
) : (
<div className="grid grid-cols-6 gap-4 mb-8">
{props.map((prop) => (
<div key={prop.id} className="rounded-xl border border-border bg-card overflow-hidden group">
<div className="aspect-square relative bg-muted">
{prop.imageUrl ? (
<img src={prop.imageUrl} alt={prop.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Box className="w-10 h-10 text-muted-foreground/30" />
</div> </div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
<button
onClick={() => openEdit(prop)}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
>
<Edit2 className="w-4 h-4 text-white" />
</button>
</div>
</div>
<div className="p-3">
<div className="flex items-center justify-between mb-1">
<h3 className="font-medium text-foreground text-sm truncate flex-1">{prop.name}</h3>
<button
onClick={() => handleDelete(prop.id)}
disabled={deleteAsset.isPending}
className="p-0.5 rounded hover:bg-destructive/10 transition-colors flex-shrink-0 disabled:opacity-50"
>
<Trash2 className="w-3 h-3 text-destructive" />
</button>
</div> </div>
{prop.tags?.[0] && ( <span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
<div className="mb-2"> 道具
<span className="px-2 py-0.5 rounded-md bg-accent text-primary text-xs">{prop.tags[0]}</span> </span>
</div>
)}
{prop.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{prop.description}</p>
)}
</div> </div>
</div>
))}
</div>
)}
</div>
</div>
{/* Floating bottom bar */} <p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">
<div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"> {asset.description || "待补充道具说明、用途和适用场景。"}
<div className="max-w-7xl mx-auto flex items-center justify-end gap-3"> </p>
<button
onClick={() => navigate(`/project/${projectId}/storyboard`)} <button
className="px-6 py-2 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-md transition-all flex items-center gap-2 text-sm font-medium" onClick={() => handleDelete(asset)}
> className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
<ArrowRight className="w-4 h-4" /> >
进入分镜 <Trash2 className="h-4 w-4" />
</button> 删除道具
</div> </button>
</div> </div>
</div>
))}
</div>
)}
</section>
{/* Add / Edit Modal */} {showModal ? (
{showModal && editForm && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="w-full max-w-3xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full"> <div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-center justify-between mb-6"> <div>
<h2 className="text-xl font-semibold text-foreground"> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Prop Editor</div>
{editForm.id ? "编辑道具" : "新增道具"} <h3 className="mt-2 text-2xl font-semibold text-foreground">新建道具资产</h3>
</h2> <p className="mt-2 text-sm leading-6 text-muted-foreground">
<button onClick={closeModal} className="p-2 hover:bg-muted rounded-lg transition-colors"> 录入名称、分类、描述和图片链接,让常用道具沉淀为可复用的项目资产。
<X className="w-5 h-5 text-muted-foreground" /> </p>
</div>
<button
onClick={() => setShowModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="space-y-4"> <div className="space-y-5">
<div className="grid grid-cols-2 gap-4"> <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">道具名称</label> <label className="mb-2 block text-sm font-medium text-foreground">道具名称</label>
<input <input
type="text" value={form.name}
value={editForm.name} onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入道具名称" placeholder="例如:角色工牌"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">道具分类</label> <label className="mb-2 block text-sm font-medium text-foreground">分类</label>
<select <select
value={editForm.category} value={form.category}
onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} onChange={(e) => setForm((current) => ({ ...current, category: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
> >
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)} {categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">道具描述</label> <label className="mb-2 block text-sm font-medium text-foreground">描述</label>
<textarea <textarea
value={editForm.description} value={form.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} onChange={(e) => setForm((current) => ({ ...current, description: e.target.value }))}
placeholder="描述道具的外观、用途和重要性" className="min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
rows={3} placeholder="描述道具的外观、用途和重要性。"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">图片 URL(可选)</label> <label className="mb-2 block text-sm font-medium text-foreground">图片链接</label>
<input <div className="relative">
type="text" <Link2 className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
value={editForm.imageUrl} <input
onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })} value={form.imageUrl}
placeholder="https://example.com/prop.jpg" onChange={(e) => setForm((current) => ({ ...current, imageUrl: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm" className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/> placeholder="https://example.com/prop.jpg"
/>
</div>
</div> </div>
</div> </div>
<div className="flex gap-3 mt-6 pt-6 border-t border-border"> <div className="mt-6 flex items-center justify-end gap-3">
<button onClick={closeModal} className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"> <button
onClick={() => setShowModal(false)}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleCreate}
disabled={!editForm.name.trim() || createAsset.isPending} disabled={createAsset.isPending || !form.name.trim()}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{createAsset.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {createAsset.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
保存 保存道具
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Building2, LockKeyhole, Mail, UserRound } from "lucide-react";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { useAuth } from "../../hooks/useAuth"; import { useAuth } from "../../hooks/useAuth";
import { AuthShell } from "../components/AuthShell";
export function RegisterPage() { export function RegisterPage() {
const { register, registerPending, registerError } = useAuth(); const { register, registerPending, registerError } = useAuth();
...@@ -26,78 +27,109 @@ export function RegisterPage() { ...@@ -26,78 +27,109 @@ export function RegisterPage() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background px-4"> <AuthShell
<Card className="w-full max-w-sm"> title="创建企业账号"
<CardHeader className="text-center"> description="注册后即可创建团队空间,统一管理短剧项目、成员权限、资产沉淀与 AI 生成流程。"
<CardTitle className="text-2xl">创建账号</CardTitle> footer={
<CardDescription>免费开始使用 YaoAI Comic Studio</CardDescription> <div className="flex items-center justify-between gap-3">
</CardHeader> <span className="text-xs text-muted-foreground">支持个人试用,也支持企业团队空间接入</span>
<CardContent> <div>
<form onSubmit={handleSubmit} className="space-y-4"> 已有账号?
<div className="space-y-2"> <Link to="/login" className="ml-1 font-medium text-primary hover:underline">
<Label htmlFor="username">用户名</Label> 立即登录
<Input </Link>
id="username" </div>
placeholder="your-name" </div>
required }
value={form.username} >
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} <form onSubmit={handleSubmit} className="space-y-5">
/> <div className="space-y-2">
</div> <Label htmlFor="username" className="text-sm text-foreground">
<div className="space-y-2"> 用户名
<Label htmlFor="email">邮箱</Label> </Label>
<Input <div className="relative">
id="email" <UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
type="email" <Input
placeholder="you@example.com" id="username"
required placeholder="your-name"
value={form.email} required
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} value={form.username}
/> onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
</div> className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
<div className="space-y-2"> />
<Label htmlFor="password">密码</Label> </div>
<Input </div>
id="password"
type="password"
placeholder="至少 8 位"
required
minLength={8}
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenantName">
工作空间名称 <span className="text-muted-foreground text-xs">(可选)</span>
</Label>
<Input
id="tenantName"
placeholder="我的工作室"
value={form.tenantName}
onChange={(e) => setForm((f) => ({ ...f, tenantName: e.target.value }))}
/>
</div>
{registerError && ( <div className="space-y-2">
<p className="text-sm text-destructive"> <Label htmlFor="email" className="text-sm text-foreground">
{registerError instanceof Error ? registerError.message : "注册失败"} 邮箱
</p> </Label>
)} <div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
required
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={registerPending}> <div className="space-y-2">
{registerPending ? "注册中..." : "注册"} <Label htmlFor="password" className="text-sm text-foreground">
</Button> 密码
</Label>
<div className="relative">
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="至少 8 位"
required
minLength={8}
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</div>
</div>
<p className="text-center text-sm text-muted-foreground"> <div className="space-y-2">
已有账号?{" "} <div className="flex items-center justify-between">
<Link to="/login" className="text-primary underline-offset-4 hover:underline"> <Label htmlFor="tenantName" className="text-sm text-foreground">
立即登录 工作空间名称
</Link> </Label>
</p> <span className="text-xs text-muted-foreground">可选</span>
</form> </div>
</CardContent> <div className="relative">
</Card> <Building2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div> <Input
id="tenantName"
placeholder="我的工作室"
value={form.tenantName}
onChange={(e) => setForm((f) => ({ ...f, tenantName: e.target.value }))}
className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</div>
</div>
{registerError ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{registerError instanceof Error ? registerError.message : "注册失败,请稍后重试。"}
</div>
) : null}
<Button
type="submit"
className="h-12 w-full rounded-xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-base font-medium shadow-[0_16px_30px_rgba(15,116,216,0.22)] transition hover:translate-y-[-1px] hover:shadow-[0_20px_38px_rgba(15,116,216,0.26)]"
disabled={registerPending}
>
{registerPending ? "正在注册..." : "创建账号"}
</Button>
</form>
</AuthShell>
); );
} }
import { useState, useRef } from "react"; import { useRef, useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { ArrowRight, Sparkles, Plus, Edit2, Users, MapPin, Box, Trash2, X, Check, RefreshCw, ImageIcon, Loader2, Upload } from "lucide-react"; import {
import { useScenes, useExtractScenes, useGenerateSceneImage, useDeleteScene, useSaveScene, useUploadSceneImage } from "../../hooks/useAi"; Box,
Image as ImageIcon,
Loader2,
MapPin,
Plus,
RefreshCw,
Sparkles,
Trash2,
Upload,
Users,
WandSparkles,
X,
} from "lucide-react";
import {
useDeleteScene,
useExtractScenes,
useGenerateSceneImage,
useSaveScene,
useScenes,
useUploadSceneImage,
} from "../../hooks/useAi";
import type { Scene } from "../../lib/api/ai"; import type { Scene } from "../../lib/api/ai";
type SettingsTab = "characters" | "scenes" | "props"; type SettingsTab = "characters" | "scenes" | "props";
const emptyForm = (): Partial<Scene> => ({ const emptyForm = (): Partial<Scene> => ({
name: "", sceneType: "indoor", description: "", name: "",
sceneType: "indoor",
description: "",
status: "draft",
}); });
export function SceneGeneration() { export function SceneGeneration() {
...@@ -23,15 +46,13 @@ export function SceneGeneration() { ...@@ -23,15 +46,13 @@ export function SceneGeneration() {
const deleteScene = useDeleteScene(pid); const deleteScene = useDeleteScene(pid);
const [activeTab] = useState<SettingsTab>("scenes"); const [activeTab] = useState<SettingsTab>("scenes");
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
// modal state
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingScene, setEditingScene] = useState<Partial<Scene>>(emptyForm()); const [editingScene, setEditingScene] = useState<Partial<Scene>>(emptyForm());
const [imagePreview, setImagePreview] = useState<string | null>(null); const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null); const [pendingFile, setPendingFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const settingsTabs = [ const settingsTabs = [
{ id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` }, { id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` },
...@@ -40,41 +61,40 @@ export function SceneGeneration() { ...@@ -40,41 +61,40 @@ export function SceneGeneration() {
]; ];
const handleExtract = () => { const handleExtract = () => {
extract.mutate(undefined, { onError: (e) => alert("提取失败: " + (e as Error).message) }); extract.mutate(undefined, {
onError: (error) => alert(`提取场景失败:${(error as Error).message}`),
});
}; };
const openCreate = () => { const openCreate = () => {
setEditingScene(emptyForm()); setEditingScene(emptyForm());
setImagePreview(null);
setPendingFile(null); setPendingFile(null);
setImagePreview(null);
setShowModal(true); setShowModal(true);
}; };
const openEdit = (scene: Scene) => { const openEdit = (scene: Scene) => {
setEditingScene({ ...scene }); setEditingScene({ ...scene });
setImagePreview(scene.imageUrl ?? null);
setPendingFile(null); setPendingFile(null);
setImagePreview(scene.imageUrl ?? null);
setShowModal(true); setShowModal(true);
}; };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setPendingFile(f);
setImagePreview(URL.createObjectURL(f));
};
const handleSave = async () => { const handleSave = async () => {
if (!editingScene.name?.trim()) return; if (!editingScene.name?.trim()) return;
setSaving(true); setSaving(true);
try { try {
const saved = await saveScene.mutateAsync({ ...editingScene, status: editingScene.status ?? "draft" }); const saved = await saveScene.mutateAsync({
...editingScene,
status: editingScene.status ?? "draft",
});
if (pendingFile) { if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile }); await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile });
} }
setShowModal(false); setShowModal(false);
} catch (e) { } catch (error) {
alert("保存失败: " + (e as Error).message); alert(`保存场景失败:${(error as Error).message}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
...@@ -84,328 +104,287 @@ export function SceneGeneration() { ...@@ -84,328 +104,287 @@ export function SceneGeneration() {
setGeneratingImageId(scene.id); setGeneratingImageId(scene.id);
generateImage.mutate(scene.id, { generateImage.mutate(scene.id, {
onSettled: () => setGeneratingImageId(null), onSettled: () => setGeneratingImageId(null),
onError: (e) => alert("图片生成失败: " + (e as Error).message), onError: (error) => alert(`场景图生成失败:${(error as Error).message}`),
}); });
}; };
const handleDelete = (scene: Scene) => { const handleDelete = (scene: Scene) => {
if (!confirm(`确定要删除场景「${scene.name}」吗?`)) return; if (!confirm(`确定要删除场景“${scene.name}”吗?`)) return;
deleteScene.mutate(scene.id, { onError: (e) => alert("删除失败: " + (e as Error).message) }); deleteScene.mutate(scene.id, {
}; onError: (error) => alert(`删除场景失败:${(error as Error).message}`),
});
const statusBadge = (status: string) => {
if (status === "ready") return <span className="px-1.5 py-0.5 rounded text-xs bg-green-500/10 text-green-600">已生成</span>;
if (status === "generating") return <span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/10 text-blue-600 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" />生成中</span>;
if (status === "failed") return <span className="px-1.5 py-0.5 rounded text-xs bg-red-500/10 text-red-600">失败</span>;
return <span className="px-1.5 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-600">待生成</span>;
}; };
return ( return (
<div className="h-full overflow-auto bg-background relative"> <div className="mx-auto max-w-[1440px]">
<div className="p-6 pb-24"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="max-w-7xl mx-auto"> <div className="flex flex-wrap items-start justify-between gap-4">
{/* Header */} <div>
<div className="mb-6"> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<div className="flex items-center gap-3 mb-2"> <Sparkles className="h-3.5 w-3.5" />
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"> Scene Setup
<Sparkles className="w-4 h-4 text-white" />
</div>
<h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
</div> </div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p> <h1 className="mt-4 text-3xl font-semibold text-foreground">场景设定工作台</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
管理项目场景资产,支持从剧本中提取场景、补充环境描述、上传参考图或直接由 AI 生成场景图。
</p>
</div> </div>
{/* Settings Navigation Tabs */} <div className="flex flex-wrap gap-3">
<div className="flex items-center justify-between mb-6"> <button
<div className="flex items-center gap-2"> onClick={handleExtract}
{settingsTabs.map((tab) => { disabled={extract.isPending}
const Icon = tab.icon; className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
return ( >
<button {extract.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
key={tab.id} 从剧本提取场景
onClick={() => navigate(tab.path)} </button>
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${ <button
activeTab === tab.id onClick={openCreate}
? "bg-accent text-accent-foreground font-medium" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
: "text-muted-foreground hover:text-foreground hover:bg-muted" >
}`} <Plus className="h-4 w-4" />
> 新建场景
<Icon className="w-4 h-4" /> </button>
{tab.label} </div>
</button> </div>
);
})} <div className="mt-6 flex flex-wrap gap-3">
</div> {settingsTabs.map((tab) => {
<div className="flex items-center gap-2"> const Icon = tab.icon;
<button const active = activeTab === tab.id;
onClick={openCreate}
className="px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2 text-sm" return (
>
<Plus className="w-4 h-4" />
手动添加
</button>
<button <button
onClick={handleExtract} key={tab.id}
disabled={extract.isPending} onClick={() => navigate(tab.path)}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60" className={[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all",
active
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
].join(" ")}
> >
{extract.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} <Icon className="h-4 w-4" />
AI提取场景 {tab.label}
</button> </button>
);
})}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{[
{ label: "场景总数", value: `${scenes.length}` },
{
label: "室内场景",
value: `${scenes.filter((scene) => scene.sceneType === "indoor").length}`,
},
{
label: "已有参考图",
value: `${scenes.filter((scene) => !!scene.imageUrl).length}`,
},
].map((item) => (
<div
key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
</div> </div>
</div> ))}
</div>
{/* Statistics */} {isLoading ? (
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"> <div className="flex items-center justify-center py-24">
<div className="flex items-center gap-6"> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex items-center gap-2"> </div>
<span className="text-sm text-muted-foreground">总计:</span> ) : scenes.length === 0 ? (
<span className="text-lg font-semibold text-foreground">{scenes.length}</span> <div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center">
</div> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<div className="flex items-center gap-2"> <MapPin className="h-7 w-7" />
<span className="text-sm text-muted-foreground">已生成图片:</span>
<span className="text-lg font-semibold text-green-600">
{scenes.filter(s => s.status === "ready").length}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">室内:</span>
<span className="text-lg font-semibold text-foreground">
{scenes.filter(s => s.sceneType === "indoor").length}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">室外:</span>
<span className="text-lg font-semibold text-foreground">
{scenes.filter(s => s.sceneType === "outdoor").length}
</span>
</div>
</div> </div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">还没有场景设定</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
先从剧本里提取关键场景,或手动创建基础场景,后续再补充参考图和环境描述。
</p>
</div> </div>
) : (
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{scenes.map((scene) => (
<div
key={scene.id}
className="overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<div className="aspect-[16/10] bg-muted">
{scene.imageUrl ? (
<img src={scene.imageUrl} alt={scene.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
</div>
)}
</div>
{/* Empty State */} <div className="p-5">
{!isLoading && scenes.length === 0 && ( <div className="flex items-start justify-between gap-3">
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center"> <div>
<MapPin className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <div className="text-lg font-semibold text-foreground">{scene.name}</div>
<h3 className="text-lg font-medium text-foreground mb-2">暂无场景</h3> <div className="mt-2 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground mb-6">手动添加场景,或点击「AI提取场景」从大纲和分集自动提取主要场景</p> {scene.sceneType === "indoor" ? "室内场景" : "室外场景"}
<div className="flex items-center justify-center gap-3"> </div>
<button </div>
onClick={openCreate} <span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
className="px-5 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2" {scene.status === "ready" ? "已完成" : "待完善"}
> </span>
<Plus className="w-4 h-4" /> </div>
手动添加
</button>
<button
onClick={handleExtract}
disabled={extract.isPending}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60"
>
{extract.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
AI提取场景
</button>
</div>
</div>
)}
{isLoading && ( <p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">
<div className="flex items-center justify-center py-16"> {scene.description || "待补充环境氛围、陈设与镜头相关描述。"}
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> </p>
</div>
)}
{/* Scene Grid */} <div className="mt-5 grid gap-2">
{scenes.length > 0 && ( <button
<div className="grid grid-cols-3 gap-4 mb-8"> onClick={() => handleGenerateImage(scene)}
{scenes.map((scene) => ( disabled={generatingImageId === scene.id}
<div key={scene.id} className="rounded-xl border border-border bg-card overflow-hidden group"> className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
{/* Image */} >
<div className="aspect-video relative overflow-hidden bg-muted"> {generatingImageId === scene.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <WandSparkles className="h-4 w-4" />}
{scene.imageUrl ? ( {generatingImageId === scene.id ? "正在生成..." : "AI 生成场景图"}
<img src={scene.imageUrl} alt={scene.name} className="w-full h-full object-cover" /> </button>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-2"> <div className="grid grid-cols-2 gap-2">
{scene.status === "generating" ? (
<Loader2 className="w-8 h-8 text-primary animate-spin" />
) : (
<ImageIcon className="w-8 h-8 text-muted-foreground" />
)}
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button <button
onClick={() => handleGenerateImage(scene)} onClick={() => openEdit(scene)}
disabled={generatingImageId === scene.id || scene.status === "generating"} className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
className="p-2 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title="AI重新生成"
> >
<RefreshCw className={`w-5 h-5 text-white ${generatingImageId === scene.id ? "animate-spin" : ""}`} /> 编辑场景
</button> </button>
</div>
<div className="absolute top-2 right-2 px-2 py-1 rounded-md bg-card/90 backdrop-blur-sm text-xs text-foreground border border-border">
{scene.sceneType === "indoor" ? "室内" : "室外"}
</div>
<div className="absolute top-2 left-2">
{statusBadge(scene.status)}
</div>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<h3 className="font-medium text-foreground truncate">{scene.name}</h3>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => openEdit(scene)}
className="p-1 rounded hover:bg-muted transition-colors"
>
<Edit2 className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => handleDelete(scene)}
className="p-1 rounded hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</button>
</div>
</div>
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{scene.description}</p>
{!scene.imageUrl && scene.status !== "generating" && (
<button <button
onClick={() => handleGenerateImage(scene)} onClick={() => handleDelete(scene)}
disabled={generatingImageId === scene.id} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
className="w-full mt-1 px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center justify-center gap-1.5 disabled:opacity-50"
> >
<Sparkles className="w-3 h-3" /> <Trash2 className="h-4 w-4" />
生成场景图片 删除
</button> </button>
)} </div>
</div> </div>
</div> </div>
))} </div>
</div> ))}
)} </div>
</div> )}
</div> </section>
{/* Floating Bottom Bar */}
<div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10">
<div className="max-w-7xl mx-auto flex items-center justify-end gap-3">
<button
onClick={() => navigate(`/project/${projectId}/storyboard`)}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 text-sm font-medium"
>
<span>前往分镜工作台</span>
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Add/Edit Scene Modal */} {showModal ? (
{showModal && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"> <div className="w-full max-w-4xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="bg-card rounded-2xl border border-border w-full max-w-md max-h-[90vh] flex flex-col"> <div className="mb-6 flex items-start justify-between gap-4">
{/* Modal Header */} <div>
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0"> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Scene Editor</div>
<h2 className="text-lg font-semibold text-foreground"> <h3 className="mt-2 text-2xl font-semibold text-foreground">
{editingScene.id ? "编辑场景" : "手动添加场景"} {editingScene.id ? "编辑场景" : "新建场景"}
</h2> </h3>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors"> <p className="mt-2 text-sm leading-6 text-muted-foreground">
<X className="w-5 h-5 text-muted-foreground" /> 补充场景名称、环境描述和参考图,为角色互动和镜头设计提供统一空间背景。
</p>
</div>
<button
onClick={() => setShowModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Modal Body */} <div className="grid gap-6 xl:grid-cols-[1fr_320px]">
<div className="overflow-auto p-6 space-y-4 flex-1"> <div className="space-y-5">
{/* Image Upload */} <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">场景图片(可选)</label> <label className="mb-2 block text-sm font-medium text-foreground">场景名称</label>
<div <input
className="w-full aspect-video rounded-lg border-2 border-dashed border-border bg-muted flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden" value={editingScene.name ?? ""}
onClick={() => fileInputRef.current?.click()} onChange={(e) => setEditingScene((current) => ({ ...current, name: e.target.value }))}
> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="例如:小区居民房"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">场景类型</label>
<select
value={editingScene.sceneType ?? "indoor"}
onChange={(e) => setEditingScene((current) => ({ ...current, sceneType: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<option value="indoor">室内</option>
<option value="outdoor">室外</option>
</select>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">环境描述</label>
<textarea
value={editingScene.description ?? ""}
onChange={(e) => setEditingScene((current) => ({ ...current, description: e.target.value }))}
className="min-h-40 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="描述场景的环境、氛围、陈设和镜头需要注意的空间信息。"
/>
</div>
</div>
<div className="rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm">
<div className="mb-4 text-base font-medium text-foreground">场景参考图</div>
<div className="aspect-[4/5] overflow-hidden rounded-[24px] bg-muted">
{imagePreview ? ( {imagePreview ? (
<img src={imagePreview} alt="预览" className="w-full h-full object-cover" /> <img src={imagePreview} alt="场景参考" className="h-full w-full object-cover" />
) : ( ) : (
<div className="text-center"> <div className="flex h-full items-center justify-center">
<Upload className="w-8 h-8 text-muted-foreground mx-auto mb-2" /> <ImageIcon className="h-8 w-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">点击上传场景参考图</span>
</div> </div>
)} )}
</div> </div>
{imagePreview && (
<button
onClick={() => { setImagePreview(null); setPendingFile(null); }}
className="mt-1 text-xs text-destructive hover:underline"
>
移除图片
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
</div>
{/* Name */} <button
<div> onClick={() => fileRef.current?.click()}
<label className="block text-sm font-medium text-foreground mb-1">场景名称 <span className="text-destructive">*</span></label> className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
<input
type="text"
value={editingScene.name ?? ""}
onChange={(e) => setEditingScene({ ...editingScene, name: e.target.value })}
placeholder="例如:小区居民房"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
{/* Scene Type */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">场景类型</label>
<select
value={editingScene.sceneType ?? "indoor"}
onChange={(e) => setEditingScene({ ...editingScene, sceneType: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
> >
<option value="indoor">室内</option> <Upload className="h-4 w-4" />
<option value="outdoor">室外</option> 上传参考图
</select> </button>
</div> <input
ref={fileRef}
{/* Description */} type="file"
<div> accept="image/*"
<label className="block text-sm font-medium text-foreground mb-1">场景描述</label> className="hidden"
<textarea onChange={(event) => {
value={editingScene.description ?? ""} const file = event.target.files?.[0];
onChange={(e) => setEditingScene({ ...editingScene, description: e.target.value })} if (!file) return;
placeholder="描述场景的环境、氛围、陈设等" setPendingFile(file);
rows={3} setImagePreview(URL.createObjectURL(file));
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20" }}
/> />
</div> </div>
</div> </div>
{/* Modal Footer */} <div className="mt-6 flex items-center justify-end gap-3">
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!editingScene.name?.trim() || saving} disabled={saving || !editingScene.name?.trim()}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
> >
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
保存 保存场景
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
import { useState, useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
Plus, Wand2, Play, Trash2, Copy, ArrowRight,
Loader2, ArrowRight, Layers, Download, Check, X, Clapperboard,
ChevronDown, Sparkles, Copy,
Image as ImageIcon,
Layers,
Loader2,
Plus,
Sparkles,
Trash2,
Wand2,
} from "lucide-react"; } from "lucide-react";
import { import {
useStoryboards, useCharacters,
useCreateStoryboard, useCreateStoryboard,
useGenerateStoryboards,
useUpdateStoryboard,
useDeleteStoryboard, useDeleteStoryboard,
useAssemblyTask, useEpisodes,
useStartAssembly,
useGenerateVideo,
useVideoTasks,
useGenerateStoryboardPrompt, useGenerateStoryboardPrompt,
useCharacters, useGenerateStoryboards,
useGenerateVideo,
useScenes, useScenes,
useEpisodes, useStoryboards,
useUpdateStoryboard,
useVideoTasks,
} from "../../hooks/useAi"; } from "../../hooks/useAi";
import type { Storyboard, Character, Scene, Episode } from "../../lib/api/ai"; import type { Storyboard } from "../../lib/api/ai";
const VIDEO_MODELS = [ const videoModels = [
{ id: "doubao-seedance-2-0-fast", label: "Seedance 2.0 Fast" }, { id: "doubao-seedance-2-0-fast", label: "Seedance 2.0 Fast" },
{ id: "doubao-seedance-2-0", label: "Seedance 2.0" }, { id: "doubao-seedance-2-0", label: "Seedance 2.0" },
]; ];
const DURATION_OPTIONS = [5, 10, 15, 20, 30]; const durationOptions = [5, 10, 15, 20, 30];
const CREDITS_PER_VIDEO = 2500;
function getTaskStatusLabel(status?: string) {
switch (status) {
case "succeeded":
return "已完成";
case "running":
case "submitted":
case "pending":
return "生成中";
case "failed":
return "失败";
default:
return "待生成";
}
}
function getTaskStatusClass(status?: string) {
switch (status) {
case "succeeded":
return "bg-emerald-100 text-emerald-700";
case "running":
case "submitted":
case "pending":
return "bg-sky-100 text-sky-700";
case "failed":
return "bg-red-100 text-red-700";
default:
return "bg-slate-100 text-slate-600";
}
}
export function StoryboardWorkspace() { export function StoryboardWorkspace() {
const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>(); const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const pid = projectId ?? ""; const pid = projectId ?? "";
const { data: episodes = [], isLoading: episodesLoading } = useEpisodes(pid); const { data: episodes = [], isLoading: loadingEpisodes } = useEpisodes(pid);
const [activeEpisodeId, setActiveEpisodeId] = useState<string>(""); const [activeEpisodeId, setActiveEpisodeId] = useState("");
const [selectedStoryboardId, setSelectedStoryboardId] = useState("");
const [selectedDraft, setSelectedDraft] = useState<Storyboard | null>(null);
const [selectedModel, setSelectedModel] = useState(videoModels[0].id);
const [selectedDuration, setSelectedDuration] = useState(15);
const [promptDraft, setPromptDraft] = useState("");
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
// Initialize active episode from URL param or first episode
useEffect(() => { useEffect(() => {
if (urlEpisodeId) { if (urlEpisodeId) {
setActiveEpisodeId(urlEpisodeId); setActiveEpisodeId(urlEpisodeId);
} else if (episodes.length > 0 && !activeEpisodeId) { } else if (episodes.length > 0 && !activeEpisodeId) {
setActiveEpisodeId(episodes[0].id); setActiveEpisodeId(episodes[0].id);
} }
}, [urlEpisodeId, episodes.length]); }, [urlEpisodeId, episodes, activeEpisodeId]);
const eid = activeEpisodeId; const eid = activeEpisodeId;
const { data: storyboards = [], isLoading } = useStoryboards(pid, eid); const { data: storyboards = [], isLoading: loadingStoryboards } = useStoryboards(pid, eid);
const { data: videoTasks = [] } = useVideoTasks(pid); const { data: videoTasks = [] } = useVideoTasks(pid);
const { data: characters = [] } = useCharacters(pid); const { data: characters = [] } = useCharacters(pid);
const { data: scenes = [] } = useScenes(pid); const { data: scenes = [] } = useScenes(pid);
const { data: assemblyTask } = useAssemblyTask(pid, eid);
const generateSbs = useGenerateStoryboards(pid);
const createSb = useCreateStoryboard(pid, eid);
const updateSb = useUpdateStoryboard(pid, eid);
const deleteSb = useDeleteStoryboard(pid, eid);
const generateVideo = useGenerateVideo(pid);
const startAssembly = useStartAssembly(pid, eid);
const generatePrompt = useGenerateStoryboardPrompt(pid);
const [selected, setSelected] = useState<Storyboard | null>(null); const generateStoryboards = useGenerateStoryboards(pid);
const [promptDraft, setPromptDraft] = useState(""); const createStoryboard = useCreateStoryboard(pid, eid);
const [promptChanged, setPromptChanged] = useState(false); const updateStoryboard = useUpdateStoryboard(pid, eid);
const [selectedModel, setSelectedModel] = useState(VIDEO_MODELS[0].id); const deleteStoryboard = useDeleteStoryboard(pid, eid);
const [selectedDuration, setSelectedDuration] = useState(15); const generatePrompt = useGenerateStoryboardPrompt(pid);
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null); const generateVideo = useGenerateVideo(pid);
const [batchGenerating, setBatchGenerating] = useState(false);
const getCharacterPrimaryImage = (character: Character) =>
character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null;
// Reset selection when episode changes
useEffect(() => {
setSelected(null);
setPromptDraft("");
setPromptChanged(false);
}, [activeEpisodeId]);
// Sync prompt draft when selected storyboard changes
useEffect(() => { useEffect(() => {
if (selected) { if (storyboards.length > 0 && !selectedStoryboardId) {
const draft = selected.startFramePrompt || selected.detailedDescription || ""; setSelectedStoryboardId(storyboards[0].id);
setPromptDraft(draft);
setPromptChanged(false);
} }
}, [selected?.id]); }, [storyboards, selectedStoryboardId]);
const selectedStoryboard = useMemo(
() => storyboards.find((storyboard) => storyboard.id === selectedStoryboardId) ?? null,
[storyboards, selectedStoryboardId],
);
// Auto-select first storyboard when list loads
useEffect(() => { useEffect(() => {
if (storyboards.length > 0 && !selected) { if (!selectedStoryboard) {
setSelected(storyboards[0]); setPromptDraft("");
setSelectedDraft(null);
return;
} }
}, [storyboards.length]); setSelectedDraft(selectedStoryboard);
setPromptDraft(selectedStoryboard.startFramePrompt || selectedStoryboard.detailedDescription || "");
const getTaskForSb = (sb: Storyboard) => { }, [selectedStoryboard?.id]);
const byId = videoTasks.filter((t) => t.storyboardId === sb.id);
// 优先取已成功的任务;同一分镜多次生成时,最新的可能还在执行中 const taskMap = useMemo(() => {
return byId.find((t) => t.status === "succeeded") const map = new Map<string, (typeof videoTasks)[number]>();
?? byId.find((t) => t.status === "running" || t.status === "submitted" || t.status === "pending") storyboards.forEach((storyboard) => {
?? byId[0] const task = videoTasks.find((item) => item.storyboardId === storyboard.id);
?? videoTasks.find((t) => t.episodeId === sb.episodeId && t.storyboardId == null); if (task) map.set(storyboard.id, task);
}; });
return map;
const getDotColor = (sb: Storyboard) => { }, [storyboards, videoTasks]);
const task = getTaskForSb(sb);
if (!task) return "bg-gray-300 dark:bg-gray-600";
if (task.status === "succeeded") return "bg-green-500";
if (task.status === "failed") return "bg-red-400";
return "bg-orange-400 animate-pulse";
};
const handleEpisodeSelect = (ep: Episode) => {
setActiveEpisodeId(ep.id);
navigate(`/project/${pid}/storyboard/${ep.id}`, { replace: true });
};
const handleGenerate = async () => { const handleGenerateStoryboards = async () => {
if (!eid) return; if (!eid) return;
const result = await generateSbs.mutateAsync(eid); const created = await generateStoryboards.mutateAsync(eid);
if (result.length > 0) setSelected(result[0]); if (created.length > 0) {
setSelectedStoryboardId(created[0].id);
}
}; };
const handleCreate = async () => { const handleCreateStoryboard = async () => {
const newSb = await createSb.mutateAsync({ const created = await createStoryboard.mutateAsync({
sequenceNum: storyboards.length + 1, sequenceNum: storyboards.length + 1,
sceneNumber: `场景${storyboards.length + 1}`, sceneNumber: `场景 ${storyboards.length + 1}`,
shortDescription: "新镜头", shortDescription: "新镜头",
detailedDescription: "", detailedDescription: "",
characters: "", characters: "",
...@@ -138,143 +152,56 @@ export function StoryboardWorkspace() { ...@@ -138,143 +152,56 @@ export function StoryboardWorkspace() {
motionScript: "", motionScript: "",
notes: "", notes: "",
status: "draft", status: "draft",
} as unknown as Storyboard); } as Storyboard);
setSelected(newSb); setSelectedStoryboardId(created.id);
}; };
const handleDelete = async (sb: Storyboard) => { const handleDeleteStoryboard = async (storyboard: Storyboard) => {
if (!confirm(`确定删除分镜 #${sb.sequenceNum}?`)) return; if (!confirm(`确定要删除分镜 #${storyboard.sequenceNum} 吗?`)) return;
await deleteSb.mutateAsync(sb.id); await deleteStoryboard.mutateAsync(storyboard.id);
if (selected?.id === sb.id) setSelected(storyboards.find((s) => s.id !== sb.id) ?? null); if (selectedStoryboardId === storyboard.id) {
setSelectedStoryboardId("");
}
}; };
const handleCopy = async () => { const handleCopyStoryboard = async () => {
if (!selected) return; if (!selectedStoryboard) return;
const copy = await createSb.mutateAsync({ const copy = await createStoryboard.mutateAsync({
...selected, ...selectedStoryboard,
id: undefined, id: undefined,
sequenceNum: storyboards.length + 1, sequenceNum: storyboards.length + 1,
sceneNumber: `${selected.sceneNumber}-副本`, sceneNumber: `${selectedStoryboard.sceneNumber}-副本`,
status: "draft", status: "draft",
} as unknown as Storyboard); } as unknown as Storyboard);
setSelected(copy); setSelectedStoryboardId(copy.id);
};
const handleSavePrompt = async () => {
if (!selected || !promptChanged) return;
await updateSb.mutateAsync({ id: selected.id, patch: { startFramePrompt: promptDraft } });
setSelected({ ...selected, startFramePrompt: promptDraft });
setPromptChanged(false);
}; };
const handleGeneratePrompt = async () => { const handleSaveStoryboard = async () => {
if (!selected) return; if (!selectedDraft) return;
const generatedPrompt = await generatePrompt.mutateAsync(selected.id); await updateStoryboard.mutateAsync({
setPromptDraft(generatedPrompt); id: selectedDraft.id,
setPromptChanged(true); patch: {
}; ...selectedDraft,
startFramePrompt: promptDraft,
// 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。 },
const refImages = useMemo<Array<{
label: string;
name: string;
imageUrl: string | null;
imageTosKey: string;
kind: "character" | "scene";
viewLabel?: string;
}>>(() => {
const characterRefImages = characters.flatMap((character) => {
const views = [
{
viewLabel: "正面",
imageUrl: character.frontImageUrl ?? character.imageUrl ?? null,
imageTosKey: character.frontImageTosKey ?? character.imageTosKey ?? null,
},
{
viewLabel: "侧面",
imageUrl: character.sideImageUrl ?? null,
imageTosKey: character.sideImageTosKey ?? null,
},
{
viewLabel: "背面",
imageUrl: character.backImageUrl ?? null,
imageTosKey: character.backImageTosKey ?? null,
},
];
return views
.filter((view) => !!view.imageTosKey)
.map((view) => ({
label: `${character.name}·${view.viewLabel}`,
name: character.name,
imageUrl: view.imageUrl,
imageTosKey: view.imageTosKey!,
kind: "character" as const,
viewLabel: view.viewLabel,
}));
}); });
const sceneRefImages = scenes
.filter((scene) => !!scene.imageTosKey)
.map((scene) => ({
label: scene.name,
name: scene.name,
imageUrl: scene.imageUrl,
imageTosKey: scene.imageTosKey!,
kind: "scene" as const,
}));
return [...characterRefImages, ...sceneRefImages];
}, [characters, scenes]);
const insertMention = (name: string) => {
const mention = `@${name}`;
setPromptDraft((prev) => (prev ? prev + " " + mention : mention));
setPromptChanged(true);
};
const insertRefImage = (idx: number) => {
const tag = `@图${idx + 1}`;
setPromptDraft((prev) => (prev ? prev + " " + tag : tag));
setPromptChanged(true);
}; };
// 从 prompt 解析所有 @图N 引用(按数字顺序去重),返回对应的 imageTosKey 列表 const handleGeneratePrompt = async () => {
const parseRefImageKeys = (text: string): string[] => { if (!selectedStoryboard) return;
const matches = [...text.matchAll(/@图(\d+)/g)]; const generated = await generatePrompt.mutateAsync(selectedStoryboard.id);
const seen = new Set<number>(); setPromptDraft(generated);
const keys: string[] = [];
for (const m of matches) {
const idx = parseInt(m[1], 10) - 1;
if (!seen.has(idx) && refImages[idx]?.imageTosKey) {
seen.add(idx);
keys.push(refImages[idx].imageTosKey);
}
}
return keys;
}; };
const handleGenerateVideo = async () => { const handleGenerateVideo = async () => {
if (!selected) return; if (!selectedDraft || !promptDraft.trim()) return;
setGeneratingVideoId(selected.id); setGeneratingVideoId(selectedDraft.id);
try { try {
const patch: Record<string, unknown> = {};
if (promptChanged) patch.startFramePrompt = promptDraft;
// 同步实际生成时长到分镜,确保时长统计准确
if (selectedDuration !== selected.durationSeconds) patch.durationSeconds = selectedDuration;
if (Object.keys(patch).length > 0) {
await updateSb.mutateAsync({ id: selected.id, patch });
setSelected({ ...selected, ...patch });
setPromptChanged(false);
}
const currentPrompt = promptDraft || selected.startFramePrompt;
const imageKeys = parseRefImageKeys(currentPrompt);
await generateVideo.mutateAsync({ await generateVideo.mutateAsync({
episodeId: selected.episodeId, episodeId: eid,
storyboardId: selected.id, storyboardId: selectedDraft.id,
prompt: currentPrompt, prompt: promptDraft,
videoPrompt: selected.motionScript || currentPrompt, videoPrompt: promptDraft,
imageKeys: imageKeys.length > 0 ? imageKeys : null,
duration: selectedDuration, duration: selectedDuration,
}); });
} finally { } finally {
...@@ -282,454 +209,308 @@ export function StoryboardWorkspace() { ...@@ -282,454 +209,308 @@ export function StoryboardWorkspace() {
} }
}; };
const handleBatchGenerate = async () => { const updateSelected = (patch: Partial<Storyboard>) => {
setBatchGenerating(true); if (!selectedDraft) return;
const pending = storyboards.filter((sb) => { setSelectedDraft((current) => (current ? { ...current, ...patch } : current));
const t = getTaskForSb(sb);
return !t || t.status === "failed";
});
for (const sb of pending) {
const batchPrompt = sb.startFramePrompt || sb.detailedDescription;
const batchImageKeys = parseRefImageKeys(batchPrompt);
await generateVideo.mutateAsync({
episodeId: sb.episodeId,
storyboardId: sb.id,
prompt: batchPrompt,
videoPrompt: sb.motionScript || batchPrompt,
imageKeys: batchImageKeys.length > 0 ? batchImageKeys : null,
duration: sb.durationSeconds > 0 ? sb.durationSeconds : selectedDuration,
});
}
setBatchGenerating(false);
}; };
const selectedTask = selected ? getTaskForSb(selected) : null; if (loadingEpisodes) {
return (
return ( <div className="flex h-full items-center justify-center">
<div className="h-full flex overflow-hidden bg-background"> <Loader2 className="h-8 w-8 animate-spin text-primary" />
{/* ─── Far Left: Episode list strip ─── */}
<div className="flex-shrink-0 border-r border-border bg-muted/20 flex flex-col overflow-hidden" style={{ width: 52 }}>
<div className="h-14 border-b border-border flex items-center justify-center flex-shrink-0">
<span className="text-[10px] text-muted-foreground font-medium">集数</span>
</div>
<div className="flex-1 overflow-y-auto py-1">
{episodesLoading ? (
<div className="flex items-center justify-center pt-4">
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />
</div>
) : episodes.length === 0 ? (
<div className="px-1 pt-4 text-center">
<span className="text-[9px] text-muted-foreground leading-tight">暂无集数</span>
</div>
) : (
episodes.map((ep) => (
<button
key={ep.id}
onClick={() => handleEpisodeSelect(ep)}
title={ep.title || `第${ep.episodeNumber}集`}
className={`w-full flex flex-col items-center justify-center py-2 transition-colors ${
activeEpisodeId === ep.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
<span className="text-[11px] font-semibold leading-none"></span>
<span className="text-base font-bold leading-tight">{ep.episodeNumber}</span>
<span className="text-[11px] font-semibold leading-none"></span>
</button>
))
)}
</div>
</div> </div>
);
{/* ─── Left: storyboard list ─── */} }
<div className="flex flex-col flex-shrink-0 border-r border-border overflow-hidden" style={{ width: 240 }}>
{/* Header */} if (episodes.length === 0) {
<div className="h-14 px-3 border-b border-border flex items-center gap-2 flex-shrink-0"> return (
<button <div className="mx-auto max-w-[1180px]">
onClick={handleCreate} <div className="rounded-[36px] border border-white/80 bg-white/82 px-6 py-20 text-center shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
disabled={createSb.isPending || !eid} <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs font-medium hover:shadow-md transition disabled:opacity-50" <Clapperboard className="h-7 w-7" />
> </div>
{createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />} <h2 className="mt-5 text-2xl font-semibold text-foreground">还没有可用分集</h2>
新建分镜 <p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
</button> 请先完成大纲和分集生成,再进入分镜工作台。
</p>
<button <button
onClick={handleGenerate} onClick={() => navigate(`/project/${projectId}/outline`)}
disabled={generateSbs.isPending || !eid} className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
title="AI批量生成分镜"
className="p-1.5 rounded-lg border border-border hover:bg-muted transition text-muted-foreground disabled:opacity-50"
> >
{generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />} 返回大纲页
<ArrowRight className="h-4 w-4" />
</button> </button>
</div> </div>
</div>
);
}
{/* List */} return (
<div className="flex-1 overflow-y-auto py-1"> <div className="mx-auto max-w-[1480px]">
{!eid ? ( <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="px-3 py-8 text-center"> <div className="flex flex-wrap items-start justify-between gap-4">
<p className="text-xs text-muted-foreground">请从左侧选择集数</p> <div>
</div> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
) : isLoading ? ( <Layers className="h-3.5 w-3.5" />
<div className="flex items-center justify-center py-10"> Storyboard Workspace
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : storyboards.length === 0 ? (
<div className="px-3 py-8 text-center">
<p className="text-xs text-muted-foreground">点击「AI生成」或「新建分镜」开始</p>
</div> </div>
) : ( <h1 className="mt-4 text-3xl font-semibold text-foreground">分镜工作台</h1>
storyboards.map((sb, i) => ( <p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
<div 选择分集后统一管理镜头描述、角色与场景线索、提示词和视频生成入口。
key={sb.id} </p>
onClick={() => setSelected(sb)} </div>
className={`mx-2 my-0.5 rounded-lg px-3 py-2.5 cursor-pointer transition-colors border ${
selected?.id === sb.id <div className="flex flex-wrap gap-3">
? "border-primary bg-accent" <button
: "border-transparent hover:bg-muted" onClick={handleGenerateStoryboards}
}`} disabled={!eid || generateStoryboards.isPending}
> className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
<div className="flex items-center justify-between mb-1"> >
<span className="text-xs font-semibold text-foreground"> {generateStoryboards.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
分镜 {String(sb.sequenceNum).padStart(3, "0")} AI 生成分镜
</span> </button>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${getDotColor(sb)}`} /> <button
</div> onClick={handleCreateStoryboard}
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed"> disabled={!eid}
{sb.shortDescription || sb.sceneNumber} className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
</p> >
</div> <Plus className="h-4 w-4" />
)) 新建分镜
)} </button>
</div>
</div> </div>
{/* Bottom: batch generate */} <div className="mt-6 flex flex-wrap gap-3">
<div className="p-3 border-t border-border flex-shrink-0"> {episodes.map((episode) => (
<button <button
onClick={handleBatchGenerate} key={episode.id}
disabled={batchGenerating || storyboards.length === 0} onClick={() => {
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-green-600 text-white text-xs font-medium hover:bg-green-700 transition disabled:opacity-50" setActiveEpisodeId(episode.id);
> setSelectedStoryboardId("");
{batchGenerating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />} navigate(`/project/${pid}/storyboard/${episode.id}`, { replace: true });
批量生成视频 }}
</button> className={[
"rounded-2xl border px-4 py-3 text-sm transition-all",
activeEpisodeId === episode.id
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
].join(" ")}
>
{episode.episodeNumber}
</button>
))}
</div> </div>
</div>
{/* ─── Middle: storyboard detail + prompt edit ─── */} <div className="mt-6 grid gap-6 xl:grid-cols-[420px_1fr]">
<div className="flex flex-col border-r border-border overflow-hidden" style={{ width: 400 }}> <div className="rounded-[32px] border border-white/80 bg-white/78 p-5 shadow-sm">
{!selected ? ( <div className="mb-4 flex items-center justify-between">
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">选择左侧分镜查看详情</p>
</div>
) : (
<>
{/* Header */}
<div className="h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0">
<div> <div>
<h2 className="text-base font-semibold text-foreground"> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Storyboard List</div>
分镜 {String(selected.sequenceNum).padStart(3, "0")} <div className="mt-2 text-xl font-semibold text-foreground">{storyboards.length} 个镜头</div>
</h2>
<p className="text-xs text-muted-foreground">编辑分镜详细信息</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
disabled={createSb.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition"
>
<Copy className="w-3.5 h-3.5" />
复制
</button>
<button
onClick={() => handleDelete(selected)}
disabled={deleteSb.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-200 text-xs text-destructive hover:bg-destructive/10 transition"
>
<Trash2 className="w-3.5 h-3.5" />
删除
</button>
</div> </div>
{loadingStoryboards ? <Loader2 className="h-5 w-5 animate-spin text-primary" /> : null}
</div> </div>
{/* Prompt edit area */} <div className="space-y-3">
<div className="flex-1 overflow-y-auto px-5 py-4"> {storyboards.length === 0 ? (
<div className="flex items-center justify-between mb-2"> <div className="rounded-[24px] border border-dashed border-border bg-white px-4 py-12 text-center text-sm text-muted-foreground">
<label className="text-xs font-medium text-muted-foreground">分镜描述</label> 当前分集还没有分镜,可以先通过 AI 自动生成,或手动新建一个镜头。
<div className="flex items-center gap-2"> </div>
<button ) : (
onClick={handleGeneratePrompt} storyboards.map((storyboard) => {
disabled={generatePrompt.isPending} const task = taskMap.get(storyboard.id);
className="text-xs text-violet-600 dark:text-violet-400 flex items-center gap-1 hover:underline disabled:opacity-50" return (
title="根据分镜剧本自动生成描述"
>
{generatePrompt.isPending
? <Loader2 className="w-3 h-3 animate-spin" />
: <Sparkles className="w-3 h-3" />}
AI生成描述
</button>
{promptChanged && (
<button <button
onClick={handleSavePrompt} key={storyboard.id}
disabled={updateSb.isPending} onClick={() => setSelectedStoryboardId(storyboard.id)}
className="text-xs text-primary flex items-center gap-1 hover:underline" className={[
"w-full rounded-[24px] border px-4 py-4 text-left transition-all",
selectedStoryboardId === storyboard.id
? "border-primary/20 bg-primary/5 shadow-[0_16px_28px_rgba(15,116,216,0.10)]"
: "border-border/80 bg-white shadow-sm hover:border-primary/20",
].join(" ")}
> >
{updateSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3 h-3" />} <div className="flex items-start justify-between gap-3">
保存 <div>
<div className="text-sm font-medium text-foreground">
#{storyboard.sequenceNum} {storyboard.shortDescription || "未命名镜头"}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{storyboard.sceneNumber || "未设置场景号"}
</div>
</div>
<span className={`rounded-full px-3 py-1 text-xs ${getTaskStatusClass(task?.status)}`}>
{getTaskStatusLabel(task?.status)}
</span>
</div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
{storyboard.detailedDescription || "待补充镜头描述。"}
</div>
</button> </button>
)} );
</div> })
</div>
<textarea
className="w-full h-full min-h-[320px] px-3 py-3 rounded-lg border border-border bg-background text-sm text-foreground leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
value={promptDraft}
onChange={(e) => {
setPromptDraft(e.target.value);
setPromptChanged(e.target.value !== (selected.startFramePrompt || selected.detailedDescription || ""));
}}
placeholder="在此输入或编辑分镜描述/提示词,AI 将根据此内容生成视频..."
/>
{/* shortDescription */}
{selected.shortDescription && (
<p className="mt-3 text-xs text-muted-foreground border-l-2 border-primary/30 pl-2">
{selected.shortDescription}
</p>
)} )}
</div>
</div>
{/* @提及:角色/场景名 */} <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
{(characters.length > 0 || scenes.length > 0) && ( {selectedDraft ? (
<div className="mt-3"> <div className="space-y-5">
<div className="text-[11px] text-muted-foreground mb-1.5">@ 提及角色/场景</div> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-wrap gap-1.5"> <div>
{characters.map((c) => ( <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Selected Shot</div>
<button <h2 className="mt-2 text-2xl font-semibold text-foreground">
key={c.id} #{selectedDraft.sequenceNum} {selectedDraft.shortDescription || "未命名镜头"}
onClick={() => insertMention(c.name)} </h2>
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700 text-[11px] text-violet-700 dark:text-violet-300 hover:bg-violet-100 transition" </div>
> <div className="flex flex-wrap gap-2">
{getCharacterPrimaryImage(c) && <img src={getCharacterPrimaryImage(c) ?? ""} className="w-3.5 h-3.5 rounded-full object-cover" />} <button
@{c.name} onClick={handleCopyStoryboard}
</button> className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
))} >
{scenes.map((s) => ( <Copy className="h-4 w-4" />
<button 复制镜头
key={s.id} </button>
onClick={() => insertMention(s.name)} <button
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-700 text-[11px] text-sky-700 dark:text-sky-300 hover:bg-sky-100 transition" onClick={() => handleDeleteStoryboard(selectedDraft)}
> className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
{s.imageUrl && <img src={s.imageUrl} className="w-3.5 h-3.5 rounded object-cover" />} >
@{s.name} <Trash2 className="h-4 w-4" />
</button> 删除
))} </button>
</div> </div>
</div> </div>
)}
{/* @图N:参考图面板 */} <div className="grid gap-4 md:grid-cols-2">
{refImages.length > 0 && ( <div>
<div className="mt-3"> <label className="mb-2 block text-sm font-medium text-foreground">镜头标题</label>
<div className="text-[11px] text-muted-foreground mb-1.5"> <input
@图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。 value={selectedDraft.shortDescription || ""}
onChange={(e) => updateSelected({ shortDescription: e.target.value })}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div>
{refImages.map((img, idx) => ( <label className="mb-2 block text-sm font-medium text-foreground">场景编号</label>
<button <input
key={img.imageTosKey} value={selectedDraft.sceneNumber || ""}
onClick={() => insertRefImage(idx)} onChange={(e) => updateSelected({ sceneNumber: e.target.value })}
className="flex flex-col items-center gap-0.5 group" className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
title={`点击插入 @图${idx + 1}(${img.label})`} />
>
<div className="w-12 h-12 rounded-lg overflow-hidden border border-border group-hover:border-primary transition bg-muted">
{img.imageUrl ? (
<img src={img.imageUrl} alt={img.label} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-[10px]">无图</div>
)}
</div>
<span className="text-[10px] text-primary font-medium">@图{idx + 1}</span>
<span className="text-[9px] text-muted-foreground leading-none max-w-[56px] truncate">{img.label}</span>
<span
className={`text-[9px] leading-none ${
img.kind === "character" ? "text-violet-600" : "text-sky-600"
}`}
>
{img.kind === "character" ? img.viewLabel : "场景"}
</span>
</button>
))}
</div> </div>
</div> </div>
)}
</div>
{/* Bottom: model + duration + generate */} <div>
<div className="px-4 py-3 border-t border-border flex items-center gap-2 flex-shrink-0 bg-card"> <label className="mb-2 block text-sm font-medium text-foreground">详细描述</label>
{/* Model selector */} <textarea
<div className="relative flex-1"> value={selectedDraft.detailedDescription || ""}
<select onChange={(e) => updateSelected({ detailedDescription: e.target.value })}
value={selectedModel} className="min-h-28 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
onChange={(e) => setSelectedModel(e.target.value)} />
className="w-full appearance-none pl-3 pr-7 py-2 rounded-lg border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" </div>
>
{VIDEO_MODELS.map((m) => (
<option key={m.id} value={m.id}>{m.label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
{/* Duration */}
<div className="relative">
<select
value={selectedDuration}
onChange={(e) => setSelectedDuration(Number(e.target.value))}
className="appearance-none pl-2 pr-6 py-2 rounded-lg border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer w-16"
>
{DURATION_OPTIONS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
</div>
{/* Generate video */}
<button
onClick={handleGenerateVideo}
disabled={generatingVideoId === selected.id || !promptDraft}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs font-medium hover:shadow-md transition disabled:opacity-50 whitespace-nowrap"
>
{generatingVideoId === selected.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
生成视频 ({CREDITS_PER_VIDEO.toLocaleString()}积分)
</button>
</div>
</>
)}
</div>
{/* ─── Right: video preview ─── */} <div className="grid gap-4 md:grid-cols-2">
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <div>
<div className="h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0"> <label className="mb-2 block text-sm font-medium text-foreground">角色线索</label>
<span className="text-sm font-medium text-foreground">视频预览</span> <textarea
{/* Assembly controls */} value={selectedDraft.characters || ""}
<div className="flex items-center gap-2"> onChange={(e) => updateSelected({ characters: e.target.value })}
{assemblyTask?.status === "succeeded" && assemblyTask.downloadUrl && ( className="min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<a placeholder={characters.map((character) => character.name).join("、") || "可填写角色名称或动作描述"}
href={assemblyTask.downloadUrl} />
target="_blank" </div>
rel="noreferrer" <div>
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition" <label className="mb-2 block text-sm font-medium text-foreground">场景线索</label>
> <textarea
<Download className="w-3.5 h-3.5" /> value={selectedDraft.notes || ""}
下载合集 onChange={(e) => updateSelected({ notes: e.target.value })}
</a> className="min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
)} placeholder={scenes.map((scene) => scene.name).join("、") || "可填写场景、机位或备注信息"}
<button />
onClick={() => startAssembly.mutate()} </div>
disabled={startAssembly.isPending || assemblyTask?.status === "running" || assemblyTask?.status === "pending" || storyboards.length === 0} </div>
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-xs text-foreground hover:bg-muted transition disabled:opacity-50"
>
{startAssembly.isPending || assemblyTask?.status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Layers className="w-3.5 h-3.5" />
)}
合成本集
</button>
<button
onClick={() => navigate(`/project/${projectId}/video`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-xs text-foreground hover:bg-muted transition"
>
视频管理
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5"> <div className="rounded-[28px] border border-primary/10 bg-primary/5 p-5">
{!selected ? ( <div className="mb-3 flex items-center justify-between gap-4">
<div className="h-full flex items-center justify-center"> <div>
<p className="text-sm text-muted-foreground">选择分镜后查看视频</p> <div className="text-sm font-medium text-foreground">视频提示词</div>
</div> <div className="mt-1 text-sm text-muted-foreground">可手动编辑,也可基于当前分镜内容自动生成。</div>
) : selectedTask?.status === "succeeded" && selectedTask.resultVideoUrl ? (
/* Video ready */
<div className="space-y-4">
<div className="rounded-xl overflow-hidden bg-black aspect-video">
<video
key={selectedTask.resultVideoUrl}
src={selectedTask.resultVideoUrl}
controls
autoPlay={false}
className="w-full h-full object-contain"
/>
</div>
{/* Thumbnail strip */}
<div className="flex gap-2 overflow-x-auto pb-1">
{Array.from({ length: Math.min(Math.ceil(selectedDuration / 4), 6) }).map((_, i) => (
<div
key={i}
className="flex-shrink-0 w-20 rounded-lg bg-muted overflow-hidden relative"
style={{ aspectRatio: "16/9" }}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[10px] text-muted-foreground">{(i + 1) * Math.floor(selectedDuration / Math.min(Math.ceil(selectedDuration / 4), 6))}s</span>
</div> </div>
<button
onClick={handleGeneratePrompt}
disabled={generatePrompt.isPending}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{generatePrompt.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
自动生成提示词
</button>
</div> </div>
))} <textarea
</div> value={promptDraft}
<a onChange={(e) => setPromptDraft(e.target.value)}
href={selectedTask.resultVideoUrl} className="min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
target="_blank" placeholder="在这里填写或编辑用于视频生成的提示词。"
rel="noreferrer" />
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline" </div>
>
<Download className="w-3.5 h-3.5" /> <div className="grid gap-4 md:grid-cols-[1fr_160px_160px]">
下载此镜头视频 <div>
</a> <label className="mb-2 block text-sm font-medium text-foreground">视频模型</label>
</div> <select
) : selectedTask?.status === "running" || selectedTask?.status === "submitted" || selectedTask?.status === "pending" ? ( value={selectedModel}
/* Generating */ onChange={(e) => setSelectedModel(e.target.value)}
<div className="h-full flex flex-col items-center justify-center gap-4"> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center"> >
<Loader2 className="w-8 h-8 text-yellow-600 animate-spin" /> {videoModels.map((model) => (
</div> <option key={model.id} value={model.id}>
<div className="text-center"> {model.label}
<p className="text-sm font-medium text-foreground mb-1">视频生成中...</p> </option>
<p className="text-xs text-muted-foreground">通常需要 30-60 秒,请稍候</p> ))}
</div> </select>
</div> </div>
) : selectedTask?.status === "failed" ? ( <div>
/* Failed */ <label className="mb-2 block text-sm font-medium text-foreground">时长</label>
<div className="rounded-xl border border-red-200 bg-red-50 p-6"> <select
<div className="flex items-center gap-2 mb-2"> value={selectedDuration}
<X className="w-4 h-4 text-red-500" /> onChange={(e) => setSelectedDuration(Number(e.target.value))}
<span className="text-sm font-medium text-red-700">生成失败</span> className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{durationOptions.map((duration) => (
<option key={duration} value={duration}>
{duration}
</option>
))}
</select>
</div>
<div className="flex items-end">
<button
onClick={handleSaveStoryboard}
disabled={updateStoryboard.isPending}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{updateStoryboard.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
保存分镜
</button>
</div>
</div>
<button
onClick={handleGenerateVideo}
disabled={!promptDraft.trim() || generatingVideoId === selectedDraft.id}
className="inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{generatingVideoId === selectedDraft.id ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
生成该分镜视频
</button>
</div> </div>
<p className="text-xs text-red-600">{selectedTask.errorMessage || "未知错误"}</p> ) : (
<button <div className="rounded-[24px] border border-dashed border-border bg-white px-4 py-16 text-center text-sm text-muted-foreground">
onClick={handleGenerateVideo} 请选择左侧分镜卡片,或先生成当前分集的镜头列表。
className="mt-3 text-xs text-red-600 hover:underline"
>
重新生成
</button>
</div>
) : (
/* No video yet */
<div className="h-full flex flex-col items-center justify-center gap-4">
<div className="w-full max-w-sm aspect-video rounded-xl border-2 border-dashed border-border bg-muted/30 flex flex-col items-center justify-center gap-3">
<Play className="w-12 h-12 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">尚未生成视频</p>
<p className="text-xs text-muted-foreground/70">编辑左侧提示词后点击「生成视频」</p>
</div> </div>
</div> )}
)} </div>
</div> </div>
</div> </section>
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { ArrowRight, Check, Loader2 } from "lucide-react"; import { ArrowRight, Check, Loader2 } from "lucide-react";
import { useUpdateProject } from "../../hooks/useProjects"; import { useUpdateProject } from "../../hooks/useProjects";
...@@ -20,10 +20,9 @@ export function StyleSelection() { ...@@ -20,10 +20,9 @@ export function StyleSelection() {
<div className="h-full overflow-auto bg-background p-6"> <div className="h-full overflow-auto bg-background p-6">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="mb-2 text-3xl font-semibold text-foreground">选择项目视觉风格</h1> <h1 className="mb-2 text-3xl font-semibold text-foreground">閫夋嫨椤圭洰瑙嗚椋庢牸</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
这里选择的是视觉呈现方式,不是题材类型。AI 会根据所选风格影响人物、场景与后续图像生成。 杩欓噷閫夋嫨鐨勬槸瑙嗚鍛堢幇鏂瑰紡锛屼笉鏄鏉愮被鍨嬨€侫I 浼氭牴鎹墍閫夐鏍煎奖鍝嶄汉鐗┿€佸満鏅笌鍚庣画鍥惧儚鐢熸垚銆? </p>
</p>
</div> </div>
<div className="mb-8 grid grid-cols-3 gap-4"> <div className="mb-8 grid grid-cols-3 gap-4">
...@@ -61,13 +60,13 @@ export function StyleSelection() { ...@@ -61,13 +60,13 @@ export function StyleSelection() {
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={!selectedStyle || updateProject.isPending} disabled={!selectedStyle || updateProject.isPending}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none" className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#0f74d8] to-[#1ea5ff] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
> >
{updateProject.isPending ? ( {updateProject.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
<> <>
<span>继续</span> <span>缁х画</span>
<ArrowRight className="h-5 w-5" /> <ArrowRight className="h-5 w-5" />
</> </>
)} )}
...@@ -76,3 +75,4 @@ export function StyleSelection() { ...@@ -76,3 +75,4 @@ export function StyleSelection() {
</div> </div>
); );
} }
import { useState } from "react"; import { useMemo, useState } from "react";
import { import {
Users, FolderKanban,
UserPlus, Loader2,
Mail,
Pencil,
Plus,
Search, Search,
Edit2,
Trash2,
Shield, Shield,
Check, Trash2,
Loader2, Users,
X, X,
UserMinus,
} from "lucide-react"; } from "lucide-react";
import { import {
useTeamMembers,
useAddMember, useAddMember,
useUpdateMember, useCreateGroup,
useDeleteGroup,
useRemoveMember, useRemoveMember,
useTeamGroups, useTeamGroups,
useCreateGroup, useTeamMembers,
useUpdateGroup, useUpdateGroup,
useDeleteGroup, useUpdateMember,
useAddGroupMember,
useRemoveGroupMember,
} from "../../hooks/useTeam"; } from "../../hooks/useTeam";
import type { TeamMember, TeamGroup } from "../../lib/api/team";
type TabType = "employees" | "groups" | "permissions";
const roleLabels: Record<string, string> = { const roleLabels: Record<string, string> = {
owner: "所有者", owner: "所有者",
...@@ -34,488 +29,450 @@ const roleLabels: Record<string, string> = { ...@@ -34,488 +29,450 @@ const roleLabels: Record<string, string> = {
}; };
export function TeamManagement() { export function TeamManagement() {
const [activeTab, setActiveTab] = useState<TabType>("employees");
const [searchTerm, setSearchTerm] = useState("");
// ---- add member modal ----
const [showAddMemberModal, setShowAddMemberModal] = useState(false);
const [formUsername, setFormUsername] = useState("");
const [formEmail, setFormEmail] = useState("");
const [formPassword, setFormPassword] = useState("");
const [formRole, setFormRole] = useState("member");
const [formPosition, setFormPosition] = useState("");
// ---- edit member modal ----
const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
const [editRole, setEditRole] = useState("");
const [editPosition, setEditPosition] = useState("");
// ---- add group modal ----
const [showAddGroupModal, setShowAddGroupModal] = useState(false);
const [formGroupName, setFormGroupName] = useState("");
const [formGroupDesc, setFormGroupDesc] = useState("");
// ---- edit group modal ----
const [editingGroup, setEditingGroup] = useState<TeamGroup | null>(null);
const [editGroupName, setEditGroupName] = useState("");
const [editGroupDesc, setEditGroupDesc] = useState("");
// ---- manage group members modal ----
const [managingGroupId, setManagingGroupId] = useState<number | null>(null);
const { data: members = [], isLoading: loadingMembers } = useTeamMembers(); const { data: members = [], isLoading: loadingMembers } = useTeamMembers();
const { data: groups = [], isLoading: loadingGroups } = useTeamGroups(); const { data: groups = [], isLoading: loadingGroups } = useTeamGroups();
const addMember = useAddMember(); const addMember = useAddMember();
const updateMember = useUpdateMember(); const updateMember = useUpdateMember();
const removeMember = useRemoveMember(); const removeMember = useRemoveMember();
const createGroup = useCreateGroup(); const createGroup = useCreateGroup();
const updateGroup = useUpdateGroup(); const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup(); const deleteGroup = useDeleteGroup();
const addGroupMember = useAddGroupMember();
const removeGroupMember = useRemoveGroupMember();
const tabs = [
{ id: "employees" as TabType, label: "员工管理", icon: Users },
{ id: "groups" as TabType, label: "小组管理", icon: Users },
{ id: "permissions" as TabType, label: "权限说明", icon: Shield },
];
const filteredMembers = members.filter(
(m) =>
m.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.email.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredGroups = groups.filter((g) => const [searchTerm, setSearchTerm] = useState("");
g.name.toLowerCase().includes(searchTerm.toLowerCase()) const [showMemberModal, setShowMemberModal] = useState(false);
); const [showGroupModal, setShowGroupModal] = useState(false);
const [editingMemberId, setEditingMemberId] = useState<number | null>(null);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [memberForm, setMemberForm] = useState({
username: "",
email: "",
password: "",
role: "member",
position: "",
});
const [groupForm, setGroupForm] = useState({
name: "",
description: "",
});
const filteredMembers = useMemo(() => {
return members.filter((member) => {
if (!searchTerm) return true;
const target = `${member.username} ${member.email} ${member.position ?? ""}`.toLowerCase();
return target.includes(searchTerm.toLowerCase());
});
}, [members, searchTerm]);
const openCreateMember = () => {
setEditingMemberId(null);
setMemberForm({ username: "", email: "", password: "", role: "member", position: "" });
setShowMemberModal(true);
};
const memberById = Object.fromEntries(members.map((m) => [m.userId, m])); const openEditMember = (member: (typeof members)[number]) => {
const managingGroup = managingGroupId !== null ? groups.find((g) => g.id === managingGroupId) ?? null : null; setEditingMemberId(member.userId);
setMemberForm({
username: member.username,
email: member.email,
password: "",
role: member.role,
position: member.position ?? "",
});
setShowMemberModal(true);
};
// handlers const openCreateGroup = () => {
const handleAddMember = async () => { setEditingGroupId(null);
await addMember.mutateAsync({ username: formUsername, email: formEmail, password: formPassword, role: formRole, position: formPosition }); setGroupForm({ name: "", description: "" });
setFormUsername(""); setFormEmail(""); setFormPassword(""); setFormRole("member"); setFormPosition(""); setShowGroupModal(true);
setShowAddMemberModal(false);
}; };
const openEditMember = (m: TeamMember) => { const openEditGroup = (group: (typeof groups)[number]) => {
setEditingMember(m); setEditingGroupId(group.id);
setEditRole(m.role); setGroupForm({
setEditPosition(m.position ?? ""); name: group.name,
description: group.description ?? "",
});
setShowGroupModal(true);
}; };
const handleEditMember = async () => { const handleSaveMember = async () => {
if (!editingMember) return; if (!memberForm.username.trim() || !memberForm.email.trim()) return;
await updateMember.mutateAsync({ userId: editingMember.userId, patch: { role: editRole, position: editPosition } });
setEditingMember(null); if (editingMemberId) {
await updateMember.mutateAsync({
userId: editingMemberId,
patch: { role: memberForm.role, position: memberForm.position },
});
} else {
await addMember.mutateAsync(memberForm);
}
setShowMemberModal(false);
}; };
const handleAddGroup = async () => { const handleRemoveMember = async (userId: number) => {
await createGroup.mutateAsync({ name: formGroupName, description: formGroupDesc }); if (!confirm("确定要移除该成员吗?")) return;
setFormGroupName(""); setFormGroupDesc(""); await removeMember.mutateAsync(userId);
setShowAddGroupModal(false);
}; };
const openEditGroup = (g: TeamGroup) => { const handleSaveGroup = async () => {
setEditingGroup(g); if (!groupForm.name.trim()) return;
setEditGroupName(g.name);
setEditGroupDesc(g.description ?? ""); if (editingGroupId) {
await updateGroup.mutateAsync({
groupId: editingGroupId,
patch: groupForm,
});
} else {
await createGroup.mutateAsync(groupForm);
}
setShowGroupModal(false);
}; };
const handleEditGroup = async () => { const handleDeleteGroup = async (groupId: number) => {
if (!editingGroup) return; if (!confirm("确定要删除该小组吗?")) return;
await updateGroup.mutateAsync({ groupId: editingGroup.id, patch: { name: editGroupName, description: editGroupDesc } }); await deleteGroup.mutateAsync(groupId);
setEditingGroup(null);
}; };
const addBtnLabel = activeTab === "employees" ? "添加成员" : activeTab === "groups" ? "创建小组" : ""; const loading = loadingMembers || loadingGroups;
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return ( return (
<div className="h-full flex flex-col bg-background"> <div className="mx-auto max-w-[1440px]">
{/* Header */} <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="border-b border-border bg-card px-6 py-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center justify-between mb-4">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground mb-1">团队管理</h1> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<p className="text-sm text-muted-foreground">管理团队成员、小组和权限</p> <Users className="h-3.5 w-3.5" />
Team Admin
</div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">团队与小组管理</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
维护团队成员、角色权限与小组结构,让创作流程中的协作边界更清晰。
</p>
</div> </div>
{addBtnLabel && (
<div className="flex flex-wrap gap-3">
<button <button
onClick={() => activeTab === "employees" ? setShowAddMemberModal(true) : setShowAddGroupModal(true)} onClick={openCreateGroup}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2" className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
> >
<UserPlus className="w-4 h-4" /> <FolderKanban className="h-4 w-4" />
{addBtnLabel} 新建小组
</button> </button>
)} <button
onClick={openCreateMember}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<Plus className="h-4 w-4" />
添加成员
</button>
</div>
</div> </div>
<div className="flex gap-1"> <div className="mt-6 grid gap-4 md:grid-cols-4">
{tabs.map((tab) => ( {[
<button key={tab.id} onClick={() => setActiveTab(tab.id)} { label: "团队成员", value: `${members.length}` },
className={`px-4 py-2 rounded-lg text-sm flex items-center gap-2 transition-all ${ { label: "管理员", value: `${members.filter((member) => member.role === "admin").length}` },
activeTab === tab.id ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted" { label: "所有者", value: `${members.filter((member) => member.role === "owner").length}` },
}`}> { label: "小组数量", value: `${groups.length}` },
<tab.icon className="w-4 h-4" /> ].map((item) => (
{tab.label} <div
</button> key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
</div>
))} ))}
</div> </div>
</div>
{/* Content */} <div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="flex-1 overflow-auto p-6"> <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="max-w-7xl mx-auto"> <div className="mb-4 flex flex-wrap items-center justify-between gap-4">
<div className="mb-6"> <div>
<div className="relative max-w-md"> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Members</div>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <h2 className="mt-2 text-2xl font-semibold text-foreground">成员列表</h2>
<input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} </div>
placeholder="搜索..." <div className="relative w-full max-w-sm">
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-card text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> <Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="搜索成员"
/>
</div>
</div> </div>
</div>
{/* Employees Tab */} <div className="space-y-4">
{activeTab === "employees" && (
<div className="space-y-3">
{loadingMembers && <div className="text-center py-8"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></div>}
{filteredMembers.map((member) => ( {filteredMembers.map((member) => (
<div key={member.userId} className="rounded-xl border border-border bg-card p-4 flex items-center justify-between"> <div
<div className="flex items-center gap-4"> key={member.userId}
<div className="w-12 h-12 rounded-full bg-accent flex items-center justify-center text-primary font-semibold text-lg"> className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
{member.username.charAt(0).toUpperCase()} >
</div> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-3 mb-1"> <div className="text-lg font-medium text-foreground">{member.username}</div>
<span className="font-medium text-foreground">{member.username}</span> <div className="mt-2 inline-flex items-center gap-2 text-sm text-muted-foreground">
{member.position && <span className="text-xs text-muted-foreground">{member.position}</span>} <Mail className="h-4 w-4" />
<span className="px-2 py-0.5 rounded bg-accent text-primary text-xs">{roleLabels[member.role] ?? member.role}</span> {member.email}
{member.status === 1 </div>
? <span className="px-2 py-0.5 rounded bg-green-100 text-green-700 text-xs">已启用</span> <div className="mt-2 text-sm text-muted-foreground">
: <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-xs">已禁用</span>} 岗位:{member.position || "未设置"}
</div> </div>
<div className="text-sm text-muted-foreground">{member.email}</div>
</div>
</div>
<div className="flex items-center gap-2">
{member.role !== "owner" && (
<>
<button onClick={() => openEditMember(member)}
className="px-3 py-1.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1">
<Edit2 className="w-3 h-3" />编辑
</button>
<button onClick={() => removeMember.mutate(member.userId)}
className="p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
))}
{!loadingMembers && filteredMembers.length === 0 && (
<div className="text-center py-16 text-muted-foreground">暂无成员</div>
)}
</div>
)}
{/* Groups Tab */}
{activeTab === "groups" && (
<div className="grid grid-cols-2 gap-4">
{loadingGroups && <div className="col-span-2 text-center py-8"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></div>}
{filteredGroups.map((group) => (
<div key={group.id} className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium text-foreground mb-1">{group.name}</h3>
{group.description && <p className="text-sm text-muted-foreground">{group.description}</p>}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex flex-wrap gap-2">
<button onClick={() => openEditGroup(group)} <span className="inline-flex items-center gap-2 rounded-full bg-accent px-3 py-1 text-xs text-primary">
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"> <Shield className="h-3.5 w-3.5" />
<Edit2 className="w-4 h-4" /> {roleLabels[member.role] ?? member.role}
</span>
<button
onClick={() => openEditMember(member)}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Pencil className="h-4 w-4" />
编辑
</button> </button>
<button onClick={() => deleteGroup.mutate(group.id)} <button
className="p-1.5 rounded-lg hover:bg-destructive/10 text-destructive transition-colors"> onClick={() => handleRemoveMember(member.userId)}
<Trash2 className="w-4 h-4" /> className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
移除
</button> </button>
</div> </div>
</div> </div>
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-muted-foreground">成员 ({group.memberIds.length})</div>
<button onClick={() => setManagingGroupId(group.id)}
className="text-xs text-primary hover:underline flex items-center gap-1">
<UserPlus className="w-3 h-3" />管理成员
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{group.memberIds.map((uid) => {
const m = memberById[uid];
return (
<span key={uid} className="px-2.5 py-1 rounded-lg bg-muted text-foreground text-xs flex items-center gap-1">
{m ? m.username : `#${uid}`}
<button onClick={() => removeGroupMember.mutate({ groupId: group.id, userId: uid })}
className="text-muted-foreground hover:text-destructive transition-colors">
<X className="w-3 h-3" />
</button>
</span>
);
})}
{group.memberIds.length === 0 && (
<span className="text-xs text-muted-foreground">暂无成员</span>
)}
</div>
</div>
</div> </div>
))} ))}
{!loadingGroups && filteredGroups.length === 0 && (
<div className="col-span-2 text-center py-16 text-muted-foreground">暂无小组</div> {filteredMembers.length === 0 ? (
)} <div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
没有找到匹配的成员。
</div>
) : null}
</div> </div>
)} </div>
{/* Permissions Tab */} <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
{activeTab === "permissions" && ( <div className="mb-4">
<div className="grid grid-cols-2 gap-4"> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Groups</div>
{[ <h2 className="mt-2 text-2xl font-semibold text-foreground">小组结构</h2>
{ name: "owner(所有者)", description: "最高权限,可管理成员、计费、所有功能", includes: ["成员管理", "计费管理", "项目管理", "资产库", "全部操作"] }, </div>
{ name: "admin(管理员)", description: "可管理成员和项目,但不能操作计费", includes: ["成员管理", "项目管理", "资产库", "生成操作"] },
{ name: "member(成员)", description: "可操作已授权的项目功能", includes: ["项目查看", "分镜编辑", "视频生成", "资产库查看"] }, <div className="space-y-4">
].map((role, idx) => ( {groups.length === 0 ? (
<div key={idx} className="rounded-xl border border-border bg-card p-5"> <div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
<div className="flex items-start gap-3 mb-4"> 当前还没有小组,建议按职能或项目阶段划分小组,便于成员协作。
<div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center flex-shrink-0"> </div>
<Shield className="w-5 h-5 text-primary" /> ) : (
</div> groups.map((group) => (
<div> <div
<h3 className="font-medium text-foreground mb-1">{role.name}</h3> key={group.id}
<p className="text-sm text-muted-foreground">{role.description}</p> className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
</div> >
</div> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-xs text-muted-foreground mb-2">包含权限</div> <div className="text-lg font-medium text-foreground">{group.name}</div>
<div className="space-y-2"> <div className="mt-2 text-sm leading-6 text-muted-foreground">
{role.includes.map((item) => ( {group.description || "暂无描述"}
<div key={item} className="flex items-center gap-2 text-sm text-foreground"> </div>
<Check className="w-4 h-4 text-green-500" />{item} <div className="mt-2 text-sm text-muted-foreground">
成员数:{group.memberCount ?? 0}
</div> </div>
))} </div>
<div className="flex gap-2">
<button
onClick={() => openEditGroup(group)}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Pencil className="h-4 w-4" />
编辑
</button>
<button
onClick={() => handleDeleteGroup(group.id)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
删除
</button>
</div>
</div> </div>
</div> </div>
</div> ))
))} )}
</div> </div>
)} </div>
</div> </div>
</div> </section>
{/* ---- Add Member Modal ---- */} {showMemberModal ? (
{showAddMemberModal && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="w-full max-w-xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full"> <div className="mb-6 flex items-start justify-between gap-4">
<h2 className="text-xl font-semibold text-foreground mb-6">添加团队成员</h2>
<div className="space-y-4 mb-6">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">用户名</label> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Member Editor</div>
<input value={formUsername} onChange={(e) => setFormUsername(e.target.value)} type="text" placeholder="输入用户名" <h3 className="mt-2 text-2xl font-semibold text-foreground">
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> {editingMemberId ? "编辑成员" : "添加成员"}
</h3>
</div> </div>
<button
onClick={() => setShowMemberModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">邮箱</label> <label className="mb-2 block text-sm font-medium text-foreground">用户名</label>
<input value={formEmail} onChange={(e) => setFormEmail(e.target.value)} type="email" placeholder="输入邮箱" <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> value={memberForm.username}
onChange={(e) => setMemberForm((current) => ({ ...current, username: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入用户名"
disabled={editingMemberId !== null}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">初始密码</label> <label className="mb-2 block text-sm font-medium text-foreground">邮箱</label>
<input value={formPassword} onChange={(e) => setFormPassword(e.target.value)} type="password" placeholder="设置初始密码" <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> value={memberForm.email}
onChange={(e) => setMemberForm((current) => ({ ...current, email: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入邮箱"
disabled={editingMemberId !== null}
/>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {editingMemberId === null ? (
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">角色</label> <label className="mb-2 block text-sm font-medium text-foreground">初始密码</label>
<select value={formRole} onChange={(e) => setFormRole(e.target.value)} <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"> type="password"
<option value="member">成员</option> value={memberForm.password}
<option value="admin">管理员</option> onChange={(e) => setMemberForm((current) => ({ ...current, password: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="设置初始密码"
/>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">角色权限</label>
<select
value={memberForm.role}
onChange={(e) => setMemberForm((current) => ({ ...current, role: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{Object.entries(roleLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">职位</label> <label className="mb-2 block text-sm font-medium text-foreground">岗位</label>
<input value={formPosition} onChange={(e) => setFormPosition(e.target.value)} type="text" placeholder="如:分镜师" <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> value={memberForm.position}
onChange={(e) => setMemberForm((current) => ({ ...current, position: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="如:分镜师"
/>
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-3">
<button onClick={() => setShowAddMemberModal(false)} <div className="mt-6 flex items-center justify-end gap-3">
className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">取消</button> <button
<button onClick={handleAddMember} disabled={addMember.isPending || !formUsername || !formEmail || !formPassword} onClick={() => setShowMemberModal(false)}
className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"> className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
{addMember.isPending && <Loader2 className="w-4 h-4 animate-spin" />}确定 >
取消
</button> </button>
</div> <button
</div> onClick={handleSaveMember}
</div> disabled={addMember.isPending || updateMember.isPending}
)} className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{/* ---- Edit Member Modal ---- */} {addMember.isPending || updateMember.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{editingMember && ( 保存成员
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="bg-card rounded-2xl border border-border p-6 max-w-md w-full">
<h2 className="text-xl font-semibold text-foreground mb-2">编辑成员</h2>
<p className="text-sm text-muted-foreground mb-6">{editingMember.username} · {editingMember.email}</p>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-foreground mb-2">角色</label>
<select value={editRole} onChange={(e) => setEditRole(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20">
<option value="member">成员</option>
<option value="admin">管理员</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">职位</label>
<input value={editPosition} onChange={(e) => setEditPosition(e.target.value)} type="text" placeholder="如:分镜师"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" />
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setEditingMember(null)}
className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">取消</button>
<button onClick={handleEditMember} disabled={updateMember.isPending}
className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2">
{updateMember.isPending && <Loader2 className="w-4 h-4 animate-spin" />}保存
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
{/* ---- Add Group Modal ---- */} {showGroupModal ? (
{showAddGroupModal && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="w-full max-w-xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<div className="bg-card rounded-2xl border border-border p-6 max-w-md w-full"> <div className="mb-6 flex items-start justify-between gap-4">
<h2 className="text-xl font-semibold text-foreground mb-6">创建小组</h2>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-foreground mb-2">小组名称</label>
<input value={formGroupName} onChange={(e) => setFormGroupName(e.target.value)} type="text" placeholder="输入小组名称"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" />
</div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">描述</label> <div className="text-xs uppercase tracking-[0.28em] text-primary/70">Group Editor</div>
<textarea value={formGroupDesc} onChange={(e) => setFormGroupDesc(e.target.value)} rows={3} placeholder="输入小组描述" <h3 className="mt-2 text-2xl font-semibold text-foreground">
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none" /> {editingGroupId ? "编辑小组" : "新建小组"}
</h3>
</div> </div>
</div> <button
<div className="flex gap-3"> onClick={() => setShowGroupModal(false)}
<button onClick={() => setShowAddGroupModal(false)} className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">取消</button> >
<button onClick={handleAddGroup} disabled={createGroup.isPending || !formGroupName.trim()} <X className="h-4 w-4" />
className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2">
{createGroup.isPending && <Loader2 className="w-4 h-4 animate-spin" />}创建
</button> </button>
</div> </div>
</div>
</div> <div className="space-y-5">
)}
{/* ---- Edit Group Modal ---- */}
{editingGroup && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="bg-card rounded-2xl border border-border p-6 max-w-md w-full">
<h2 className="text-xl font-semibold text-foreground mb-6">编辑小组</h2>
<div className="space-y-4 mb-6">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">小组名称</label> <label className="mb-2 block text-sm font-medium text-foreground">小组名称</label>
<input value={editGroupName} onChange={(e) => setEditGroupName(e.target.value)} type="text" <input
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" /> value={groupForm.name}
onChange={(e) => setGroupForm((current) => ({ ...current, name: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入小组名称"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">描述</label> <label className="mb-2 block text-sm font-medium text-foreground">小组描述</label>
<textarea value={editGroupDesc} onChange={(e) => setEditGroupDesc(e.target.value)} rows={3} <textarea
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none" /> value={groupForm.description}
onChange={(e) => setGroupForm((current) => ({ ...current, description: e.target.value }))}
className="min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入小组描述"
/>
</div> </div>
</div> </div>
<div className="flex gap-3">
<button onClick={() => setEditingGroup(null)}
className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">取消</button>
<button onClick={handleEditGroup} disabled={updateGroup.isPending || !editGroupName.trim()}
className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2">
{updateGroup.isPending && <Loader2 className="w-4 h-4 animate-spin" />}保存
</button>
</div>
</div>
</div>
)}
{/* ---- Manage Group Members Modal ---- */}
{managingGroup && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-foreground">管理小组成员 · {managingGroup.name}</h2>
<button onClick={() => setManagingGroupId(null)} className="p-1.5 rounded-lg hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div className="mb-4"> <div className="mt-6 flex items-center justify-end gap-3">
<div className="text-sm font-medium text-foreground mb-3">当前成员</div> <button
{managingGroup.memberIds.length === 0 onClick={() => setShowGroupModal(false)}
? <p className="text-sm text-muted-foreground">暂无成员</p> className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
: ( >
<div className="space-y-2"> 取消
{managingGroup.memberIds.map((uid) => { </button>
const m = memberById[uid]; <button
return ( onClick={handleSaveGroup}
<div key={uid} className="flex items-center justify-between rounded-lg bg-muted px-3 py-2"> disabled={createGroup.isPending || updateGroup.isPending}
<span className="text-sm text-foreground">{m ? `${m.username} (${m.email})` : `#${uid}`}</span> className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
<button onClick={() => removeGroupMember.mutate({ groupId: managingGroup.id, userId: uid })} >
className="p-1 rounded hover:bg-destructive/10 text-destructive transition-colors"> {createGroup.isPending || updateGroup.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
<UserMinus className="w-4 h-4" /> 保存小组
</button>
</div>
);
})}
</div>
)}
</div>
<div>
<div className="text-sm font-medium text-foreground mb-3">添加成员</div>
<div className="space-y-2">
{members
.filter((m) => !managingGroup.memberIds.includes(m.userId))
.map((m) => (
<div key={m.userId} className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<span className="text-sm text-foreground">{m.username} <span className="text-muted-foreground">({m.email})</span></span>
<button onClick={() => addGroupMember.mutate({ groupId: managingGroup.id, userId: m.userId })}
className="px-3 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-xs">
添加
</button>
</div>
))}
{members.filter((m) => !managingGroup.memberIds.includes(m.userId)).length === 0 && (
<p className="text-sm text-muted-foreground">所有成员均已在小组中</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end">
<button onClick={() => setManagingGroupId(null)}
className="px-6 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm">
完成
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
import { import {
Sparkles,
TrendingUp,
Calendar, Calendar,
Filter, Filter,
Users,
Film,
Image as ImageIcon, Image as ImageIcon,
X,
Check,
Loader2, Loader2,
Sparkles,
TrendingUp,
Users,
Video,
WandSparkles,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid, CartesianGrid,
Tooltip,
Legend, Legend,
Line,
LineChart,
ResponsiveContainer, ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"; } from "recharts";
import { useBalance, useRecords, usePurchase } from "../../hooks/useUsage"; import { useBalance, usePurchase, useRecords } from "../../hooks/useUsage";
import type { BillingRecord } from "../../lib/api/usage"; import type { BillingRecord } from "../../lib/api/usage";
const opLabels: Record<string, string> = { const operationLabels: Record<string, string> = {
outline_generate: "大纲生成", outline_generate: "大纲生成",
episode_generate: "分集生成", episode_generate: "分集生成",
image_generate: "图片生成", image_generate: "图片生成",
video_generate: "视频生成", video_generate: "视频生成",
assembly: "视频合成", assembly: "视频合成",
recharge: "充值", recharge: "积分充值",
}; };
const opIcons: Record<string, typeof Film> = { const operationIcons: Record<string, typeof Sparkles> = {
outline_generate: Film, outline_generate: WandSparkles,
episode_generate: Film, episode_generate: Sparkles,
image_generate: ImageIcon, image_generate: ImageIcon,
video_generate: Film, video_generate: Video,
assembly: Film, assembly: Video,
recharge: Sparkles, recharge: Sparkles,
}; };
const PACKAGES = [ const packages = [
{ id: "starter", label: "入门包", credits: 1_000, price: "¥9.9", desc: "适合个人试用" }, { id: "starter", label: "入门包", credits: 1000, price: "¥9.9", desc: "适合个人试用" },
{ id: "basic", label: "基础包", credits: 5_000, price: "¥39", desc: "适合小团队" }, { id: "basic", label: "基础包", credits: 5000, price: "¥39", desc: "适合小团队" },
{ id: "pro", label: "专业包", credits: 20_000, price: "¥129", desc: "最受欢迎" }, { id: "pro", label: "专业包", credits: 20000, price: "¥129", desc: "适合持续生产" },
{ id: "enterprise", label: "企业包", credits: 100_000, price: "¥499", desc: "适合大规模制作" }, { id: "enterprise", label: "企业包", credits: 100000, price: "¥499", desc: "适合大规模内容制作" },
]; ];
function buildDailyConsumptionData(records: BillingRecord[]) {
const result: Array<{ date: string; actual: number; predicted: number }> = [];
const today = new Date();
const consumptionMap = new Map<string, number>();
records.forEach((record) => {
if (record.operation === "recharge") return;
const key = record.createdAt.slice(5, 10);
const current = consumptionMap.get(key) ?? 0;
consumptionMap.set(key, current + Math.abs(Number(record.credits)));
});
for (let offset = 29; offset >= 0; offset -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - offset);
const key = `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const actual = consumptionMap.get(key) ?? 0;
result.push({
date: `${date.getMonth() + 1}/${date.getDate()}`,
actual,
predicted: Math.round(actual * 1.08 + 200),
});
}
return result;
}
export function UsageManagement() { export function UsageManagement() {
const [dateRange, setDateRange] = useState({ start: "", end: "" }); const [dateRange, setDateRange] = useState({ start: "", end: "" });
const [selectedType, setSelectedType] = useState(""); const [selectedType, setSelectedType] = useState("");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [showPurchaseModal, setShowPurchaseModal] = useState(false); const [showPurchaseModal, setShowPurchaseModal] = useState(false);
const [selectedPackage, setSelectedPackage] = useState("pro"); const [selectedPackage, setSelectedPackage] = useState("pro");
const [purchased, setPurchased] = useState(false);
const PAGE_SIZE = 20; const pageSize = 20;
const { data: balance } = useBalance(); const { data: balance } = useBalance();
const { data: recordsPage } = useRecords(PAGE_SIZE, page * PAGE_SIZE); const { data: recordsPage } = useRecords(pageSize, page * pageSize);
const purchase = usePurchase(); const purchase = usePurchase();
const records: BillingRecord[] = recordsPage?.items ?? []; const records = recordsPage?.items ?? [];
const totalRecords = recordsPage?.total ?? 0; const totalRecords = recordsPage?.total ?? 0;
const filteredRecords = records.filter((r) => { const filteredRecords = records.filter((record) => {
if (selectedType && r.operation !== selectedType) return false; if (selectedType && record.operation !== selectedType) {
if (dateRange.start && r.createdAt < dateRange.start + "T00:00:00") return false; return false;
if (dateRange.end && r.createdAt > dateRange.end + "T23:59:59") return false; }
if (dateRange.start && record.createdAt < `${dateRange.start}T00:00:00`) {
return false;
}
if (dateRange.end && record.createdAt > `${dateRange.end}T23:59:59`) {
return false;
}
return true; return true;
}); });
// Generate daily consumption data for the past 30 days const chartData = useMemo(() => buildDailyConsumptionData(records), [records]);
const dailyConsumptionData = useMemo(() => {
const data = [];
const baseDate = new Date("2026-04-12");
for (let i = 29; i >= 0; i--) {
const date = new Date(baseDate);
date.setDate(baseDate.getDate() - i);
const month = date.getMonth() + 1;
const day = date.getDate();
const dateStr = `${month}/${day}`;
// Deterministic generation based on index to ensure uniqueness const totalRecharged = Number(balance?.totalRecharged ?? 0);
const baseConsumption = 50000 + (i * 1234) % 30000; const totalSpent = Number(balance?.totalSpent ?? 0);
const variance = ((i * 7) % 10000) - 5000; const remaining = Number(balance?.balance ?? 0);
const actual = baseConsumption + variance;
const predicted = baseConsumption + ((i * 3) % 5000) - 2500;
data.push({
date: dateStr,
actual,
predicted,
});
}
return data; const handlePurchase = async () => {
}, []); await purchase.mutateAsync(selectedPackage);
setShowPurchaseModal(false);
const remainingCredits = balance?.balance ?? 0; };
const usedCredits = balance?.totalSpent ?? 0;
const totalRecharged = balance?.totalRecharged ?? 0;
return ( return (
<> <div className="mx-auto max-w-[1440px]">
<div className="h-full overflow-auto bg-background p-6"> <section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="max-w-7xl mx-auto"> <div className="flex flex-wrap items-start justify-between gap-4">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground mb-1">用量统计</h1> <div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<p className="text-sm text-muted-foreground">查看和管理积分使用情况</p> <TrendingUp className="h-3.5 w-3.5" />
Usage Analytics
</div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">用量统计与积分管理</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
跟踪项目生产过程中的积分消耗、充值记录和操作趋势,帮助团队评估当前产能与预算投入。
</p>
</div> </div>
<button <button
onClick={() => { setShowPurchaseModal(true); setPurchased(false); }} onClick={() => setShowPurchaseModal(true)}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2" className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="h-4 w-4" />
购买积分资源包 购买积分资源包
</button> </button>
</div> </div>
{/* Overview Cards */} <div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="grid grid-cols-3 gap-4 mb-6"> {[
<div className="rounded-xl border border-border bg-card p-5"> { label: "剩余积分", value: remaining.toLocaleString(), hint: `累计充值 ${totalRecharged.toLocaleString()}` },
<div className="flex items-center gap-3 mb-3"> { label: "已消耗积分", value: totalSpent.toLocaleString(), hint: `共 ${totalRecords} 条记录` },
<div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center"> { label: "本页记录数", value: `${records.length}`, hint: `第 ${page + 1} / ${Math.max(1, Math.ceil(totalRecords / pageSize))} 页` },
<Sparkles className="w-5 h-5 text-primary" /> ].map((item, index) => (
</div> <div
<div className="text-xs text-muted-foreground">剩余积分</div> key={item.label}
</div> className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
<div className="text-2xl font-semibold text-foreground mb-1"> >
{remainingCredits.toLocaleString()} <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
</div> {index === 0 ? <Sparkles className="h-5 w-5" /> : index === 1 ? <TrendingUp className="h-5 w-5" /> : <Users className="h-5 w-5" />}
<div className="text-xs text-muted-foreground">
累计充值 {Number(totalRecharged).toLocaleString()} 积分
</div>
</div>
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-red-600" />
</div> </div>
<div className="text-xs text-muted-foreground">已消耗</div> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
</div> <div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
<div className="text-2xl font-semibold text-foreground mb-1"> <div className="mt-2 text-sm text-muted-foreground">{item.hint}</div>
{Number(usedCredits).toLocaleString()}
</div> </div>
<div className="text-xs text-muted-foreground"> ))}
{totalRecords} 条记录
</div>
</div>
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<div className="text-xs text-muted-foreground">本页记录数</div>
</div>
<div className="text-2xl font-semibold text-foreground mb-1">
{records.length}
</div>
<div className="text-xs text-muted-foreground">
{page + 1} 页 / 共 {Math.max(1, Math.ceil(totalRecords / PAGE_SIZE))}
</div>
</div>
</div> </div>
{/* Filters */} <div className="mt-6 rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="mb-6 p-4 rounded-xl border border-border bg-card"> <div className="mb-4 flex items-center gap-2">
<div className="flex items-center gap-2 mb-3"> <Filter className="h-4 w-4 text-muted-foreground" />
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">筛选条件</span> <span className="text-sm font-medium text-foreground">筛选条件</span>
</div> </div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-4 xl:grid-cols-3">
<div> <div>
<label className="block text-xs text-muted-foreground mb-2">开始日期</label> <label className="mb-2 block text-sm font-medium text-foreground">开始日期</label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Calendar className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.start} value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })} onChange={(e) => setDateRange((current) => ({ ...current, start: e.target.value }))}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-2">结束日期</label> <label className="mb-2 block text-sm font-medium text-foreground">结束日期</label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Calendar className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.end} value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })} onChange={(e) => setDateRange((current) => ({ ...current, end: e.target.value }))}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-2">操作类型</label> <label className="mb-2 block text-sm font-medium text-foreground">操作类型</label>
<select <select
value={selectedType} value={selectedType}
onChange={(e) => { setSelectedType(e.target.value); setPage(0); }} onChange={(e) => {
className="w-full px-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" setSelectedType(e.target.value);
setPage(0);
}}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
> >
<option value="">全部类型</option> <option value="">全部类型</option>
{Object.entries(opLabels).map(([k, v]) => ( {Object.entries(operationLabels).map(([value, label]) => (
<option key={k} value={k}>{v}</option> <option key={value} value={value}>
{label}
</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
</div> </div>
{/* Credits Consumption Chart */} <div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="mb-6 p-6 rounded-xl border border-border bg-card"> <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="flex items-center justify-between mb-6"> <div className="mb-5">
<div> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Consumption Trend</div>
<h2 className="font-medium text-foreground mb-1">积分消耗趋势</h2> <h2 className="mt-2 text-2xl font-semibold text-foreground">积分消耗趋势</h2>
<p className="text-xs text-muted-foreground">每日积分消耗对比(最近30天)</p> <p className="mt-2 text-sm leading-6 text-muted-foreground">
基于最近 30 天的消耗记录,观察项目生产过程中积分使用的波动情况。
</p>
</div> </div>
</div>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<LineChart <LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
data={dailyConsumptionData} <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
margin={{ top: 5, right: 30, left: 20, bottom: 5 }} <XAxis dataKey="date" tick={{ fill: "#6b7280", fontSize: 12 }} stroke="#9ca3af" />
> <YAxis tick={{ fill: "#6b7280", fontSize: 12 }} stroke="#9ca3af" />
<CartesianGrid key="grid" strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
key="xaxis"
dataKey="date"
tick={{ fill: "#6b7280", fontSize: 12 }}
stroke="#9ca3af"
/>
<YAxis
key="yaxis"
tick={{ fill: "#6b7280", fontSize: 12 }}
stroke="#9ca3af"
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
/>
<Tooltip <Tooltip
key="tooltip"
contentStyle={{ contentStyle={{
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
border: "1px solid #e5e7eb", border: "1px solid #e5e7eb",
borderRadius: "8px", borderRadius: "12px",
fontSize: "12px", fontSize: "12px",
}} }}
formatter={(value: number) => [value.toLocaleString(), ""]}
labelStyle={{ color: "#111827", fontWeight: 500 }}
/>
<Legend
key="legend"
wrapperStyle={{ fontSize: "12px" }}
iconType="line"
/> />
<Legend />
<Line <Line
key="actual-line"
type="monotone" type="monotone"
dataKey="actual" dataKey="actual"
stroke="#5b5ff9"
strokeWidth={2}
name="实际消耗" name="实际消耗"
dot={{ fill: "#5b5ff9", r: 3 }} stroke="#0f74d8"
activeDot={{ r: 5 }} strokeWidth={3}
isAnimationActive={false} dot={{ fill: "#0f74d8", r: 3 }}
/> />
<Line <Line
key="predicted-line"
type="monotone" type="monotone"
dataKey="predicted" dataKey="predicted"
stroke="#94a3b8" name="预测趋势"
stroke="#1ea5ff"
strokeWidth={2} strokeWidth={2}
strokeDasharray="5 5" strokeDasharray="6 6"
name="预计消耗" dot={false}
dot={{ fill: "#94a3b8", r: 3 }}
activeDot={{ r: 5 }}
isAnimationActive={false}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div>
{/* Usage Table */}
<div className="rounded-xl border border-border bg-card overflow-hidden">
<div className="p-4 border-b border-border">
<h2 className="font-medium text-foreground">消耗明细</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<table className="w-full"> <div className="mb-5">
<thead className="bg-muted/50"> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Billing Records</div>
<tr> <h2 className="mt-2 text-2xl font-semibold text-foreground">最近记录</h2>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">操作类型</th> </div>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">项目ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">消耗积分</th> <div className="space-y-3">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">模式</th> {filteredRecords.length === 0 ? (
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">时间</th> <div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
</tr> 当前筛选条件下暂无记录。
</thead> </div>
<tbody className="divide-y divide-border"> ) : (
{filteredRecords.length === 0 && ( filteredRecords.map((record) => {
<tr> const Icon = operationIcons[record.operation] ?? Sparkles;
<td colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">暂无数据</td> const credits = Number(record.credits);
</tr>
)}
{filteredRecords.map((record) => {
const Icon = opIcons[record.operation] ?? Film;
const isCharge = record.operation !== "recharge";
return ( return (
<tr key={record.id} className="hover:bg-muted/30 transition-colors"> <div
<td className="px-4 py-3"> key={record.id}
<div className="flex items-center gap-2"> className="rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm"
<Icon className="w-4 h-4 text-muted-foreground" /> >
<span className="text-sm text-foreground">{opLabels[record.operation] ?? record.operation}</span> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<div>
<div className="text-sm font-medium text-foreground">
{operationLabels[record.operation] ?? record.operation}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{new Date(record.createdAt).toLocaleString("zh-CN")}
</div>
</div>
</div>
<div className={credits < 0 ? "text-sm font-medium text-red-600" : "text-sm font-medium text-emerald-600"}>
{credits > 0 ? "+" : ""}
{credits.toLocaleString()}
</div> </div>
</td> </div>
<td className="px-4 py-3 text-sm text-muted-foreground">{record.projectId ?? "—"}</td> </div>
<td className="px-4 py-3">
<span className={`text-sm font-medium ${isCharge ? "text-destructive" : "text-green-600"}`}>
{isCharge ? "-" : "+"}{Number(record.credits).toLocaleString()}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{record.billingMode}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{record.createdAt?.replace("T", " ").slice(0, 16)}</td>
</tr>
); );
})} })
</tbody> )}
</table> </div>
</div>
{totalRecords > PAGE_SIZE && ( <div className="mt-5 flex items-center justify-between">
<div className="flex items-center justify-center gap-3 p-4 border-t border-border">
<button <button
onClick={() => setPage((current) => Math.max(current - 1, 0))}
disabled={page === 0} disabled={page === 0}
onClick={() => setPage(p => p - 1)} className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm text-foreground hover:bg-muted disabled:opacity-40 transition-colors"
> >
上一页 上一页
</button> </button>
<span className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{page + 1} / {Math.ceil(totalRecords / PAGE_SIZE)} {page + 1} 页 / 共 {Math.max(1, Math.ceil(totalRecords / pageSize))}
</span> </div>
<button <button
disabled={(page + 1) * PAGE_SIZE >= totalRecords} onClick={() => setPage((current) => (current + 1 < Math.ceil(totalRecords / pageSize) ? current + 1 : current))}
onClick={() => setPage(p => p + 1)} disabled={page + 1 >= Math.ceil(totalRecords / pageSize)}
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm text-foreground hover:bg-muted disabled:opacity-40 transition-colors" className="rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
> >
下一页 下一页
</button> </button>
</div> </div>
)}
</div>
</div>
</div>
{/* Purchase Modal */}
{showPurchaseModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border p-6 w-full max-w-2xl">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">购买积分资源包</h2>
<p className="text-sm text-muted-foreground mt-0.5">积分到账后立即可用,永久有效</p>
</div>
<button onClick={() => setShowPurchaseModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div> </div>
</div>
</section>
{purchased ? ( {showPurchaseModal ? (
<div className="py-12 text-center"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4"> <div className="w-full max-w-4xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<Check className="w-8 h-8 text-green-600" /> <div className="mb-6 flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Purchase Credits</div>
<h3 className="mt-2 text-2xl font-semibold text-foreground">选择积分资源包</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
选择适合团队当前阶段的积分包,平台会在购买完成后立即同步余额。
</p>
</div> </div>
<h3 className="text-lg font-semibold text-foreground mb-2">购买成功!</h3>
<p className="text-sm text-muted-foreground mb-6">
{PACKAGES.find(p => p.id === selectedPackage)?.credits.toLocaleString()} 积分已到账
</p>
<button <button
onClick={() => setShowPurchaseModal(false)} onClick={() => setShowPurchaseModal(false)}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm" className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
> >
完成 <X className="h-4 w-4" />
</button> </button>
</div> </div>
) : (
<>
<div className="grid grid-cols-2 gap-3 mb-6">
{PACKAGES.map((pkg) => (
<button
key={pkg.id}
onClick={() => setSelectedPackage(pkg.id)}
className={`rounded-xl border-2 p-4 text-left transition-all ${
selectedPackage === pkg.id
? "border-primary bg-accent"
: "border-border bg-background hover:border-primary/40"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-foreground">{pkg.label}</span>
{pkg.id === "pro" && (
<span className="px-2 py-0.5 rounded-full bg-primary text-white text-xs">推荐</span>
)}
</div>
<div className="text-2xl font-bold text-primary mb-1">
{pkg.credits.toLocaleString()} <span className="text-sm font-normal text-muted-foreground">积分</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{pkg.desc}</span>
<span className="text-lg font-semibold text-foreground">{pkg.price}</span>
</div>
</button>
))}
</div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted mb-6"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Sparkles className="w-5 h-5 text-primary flex-shrink-0" /> {packages.map((pkg) => (
<div className="text-sm text-muted-foreground">
当前余额:<span className="font-semibold text-foreground">
{Number(balance?.balance ?? 0).toLocaleString()}
</span> 积分 &nbsp;·&nbsp; 购买后变为:<span className="font-semibold text-foreground">
{(Number(balance?.balance ?? 0) + (PACKAGES.find(p => p.id === selectedPackage)?.credits ?? 0)).toLocaleString()}
</span> 积分
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowPurchaseModal(false)}
className="flex-1 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm"
>
取消
</button>
<button <button
onClick={async () => { key={pkg.id}
await purchase.mutateAsync(selectedPackage); onClick={() => setSelectedPackage(pkg.id)}
setPurchased(true); className={[
}} "rounded-[28px] border p-5 text-left transition-all",
disabled={purchase.isPending} selectedPackage === pkg.id
className="flex-1 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 text-sm disabled:opacity-70" ? "border-primary/20 bg-primary/5 shadow-[0_20px_40px_rgba(15,116,216,0.12)]"
: "border-border/80 bg-white shadow-sm hover:border-primary/20",
].join(" ")}
> >
{purchase.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} <div className="text-xs uppercase tracking-[0.24em] text-primary/70">{pkg.label}</div>
确认购买 {PACKAGES.find(p => p.id === selectedPackage)?.price} <div className="mt-3 text-3xl font-semibold text-foreground">
{pkg.credits.toLocaleString()}
</div>
<div className="mt-2 text-sm text-muted-foreground">{pkg.desc}</div>
<div className="mt-5 text-lg font-medium text-primary">{pkg.price}</div>
</button> </button>
</div> ))}
</> </div>
)}
<div className="mt-6 flex items-center justify-end gap-3">
<button
onClick={() => setShowPurchaseModal(false)}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
</button>
<button
onClick={handlePurchase}
disabled={purchase.isPending}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{purchase.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
确认购买
</button>
</div>
</div>
</div> </div>
</div> ) : null}
)} </div>
</>
); );
} }
import { useState, useEffect, useRef } from "react"; import { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
Loader2, Play, Pause, Download, Trash2, Edit2, Clapperboard,
SkipBack, SkipForward, Check, Film, Download,
Film,
Loader2,
PlayCircle,
Trash2,
Video,
} from "lucide-react"; } from "lucide-react";
import { import {
useVideoTasks,
useEpisodes,
useStoryboards,
useAssemblyTask, useAssemblyTask,
useStartAssembly,
useDeleteVideoTask, useDeleteVideoTask,
useEpisodes,
useStartAssembly,
useStoryboards,
useVideoTasks,
} from "../../hooks/useAi"; } from "../../hooks/useAi";
import type { AiTask, Episode, Storyboard } from "../../lib/api/ai"; import type { AiTask } from "../../lib/api/ai";
function fmt(secs: number) { function getTaskLabel(status: string) {
const m = Math.floor(secs / 60); switch (status) {
const s = secs % 60; case "succeeded":
return `${m}:${String(s).padStart(2, "0")}`; return "已完成";
case "running":
case "submitted":
case "pending":
return "生成中";
case "failed":
return "失败";
default:
return status;
}
} }
function timeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime(); function getTaskClass(status: string) {
const h = Math.floor(diff / 3600000); switch (status) {
if (h < 1) return "刚刚"; case "succeeded":
if (h < 24) return `${h}小时前`; return "bg-emerald-100 text-emerald-700";
return `${Math.floor(h / 24)}天前`; case "running":
case "submitted":
case "pending":
return "bg-sky-100 text-sky-700";
case "failed":
return "bg-red-100 text-red-700";
default:
return "bg-slate-100 text-slate-600";
}
} }
export function VideoGeneration() { export function VideoGeneration() {
...@@ -32,439 +54,223 @@ export function VideoGeneration() { ...@@ -32,439 +54,223 @@ export function VideoGeneration() {
const navigate = useNavigate(); const navigate = useNavigate();
const pid = projectId ?? ""; const pid = projectId ?? "";
const { data: episodes = [], isLoading: epLoading } = useEpisodes(pid); const { data: episodes = [], isLoading: loadingEpisodes } = useEpisodes(pid);
const { data: allTasks = [] } = useVideoTasks(pid); const { data: allTasks = [] } = useVideoTasks(pid);
const [activeEpisodeId, setActiveEpisodeId] = useState("");
const [activeEpId, setActiveEpId] = useState<string>("");
useEffect(() => { useEffect(() => {
if (episodes.length > 0 && !activeEpId) { if (episodes.length > 0 && !activeEpisodeId) {
setActiveEpId(episodes[0].id); setActiveEpisodeId(episodes[0].id);
} }
}, [episodes.length]); }, [episodes, activeEpisodeId]);
const { data: storyboards = [] } = useStoryboards(pid, activeEpId); const { data: storyboards = [] } = useStoryboards(pid, activeEpisodeId);
const { data: assemblyTask } = useAssemblyTask(pid, activeEpId); const { data: assemblyTask } = useAssemblyTask(pid, activeEpisodeId);
const startAssembly = useStartAssembly(pid, activeEpId); const startAssembly = useStartAssembly(pid, activeEpisodeId);
const deleteTask = useDeleteVideoTask(pid); const deleteTask = useDeleteVideoTask(pid);
// Tasks for active episode const episodeTasks = useMemo(
const epTasks = allTasks.filter((t) => t.episodeId === activeEpId); () => allTasks.filter((task) => task.episodeId === activeEpisodeId),
[allTasks, activeEpisodeId],
const getTaskForSb = (sb: Storyboard): AiTask | undefined => { );
const byId = epTasks.filter((t) => t.storyboardId === sb.id);
return byId.find((t) => t.status === "succeeded")
?? byId.find((t) => t.status === "running" || t.status === "submitted" || t.status === "pending")
?? byId[0];
};
// Succeeded shots in sequence order
const orderedShots = storyboards
.map((sb) => ({ sb, task: getTaskForSb(sb) }))
.filter(({ task }) => task?.status === "succeeded" && task.resultVideoUrl);
// Stats
const totalShots = storyboards.length;
const doneShots = orderedShots.length;
const pendingShots = epTasks.filter(
(t) => t.status === "pending" || t.status === "submitted" || t.status === "running"
).length;
// Use actual storyboard durationSeconds (updated when video is generated)
const totalDuration = storyboards.reduce((s, sb) => s + (sb.durationSeconds || 0), 0);
// ─── Player state ───
// "assembly" = show assembled video, "shot" = show individual storyboard video
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [playerMode, setPlayerMode] = useState<"assembly" | "shot">("assembly");
const [currentShotIdx, setCurrentShotIdx] = useState(0);
const hasAssembly = assemblyTask?.status === "succeeded" && !!assemblyTask.downloadUrl;
// Current video URL to show in player
const currentVideoUrl = playerMode === "assembly" && hasAssembly
? assemblyTask!.downloadUrl!
: orderedShots[currentShotIdx]?.task?.resultVideoUrl ?? null;
const currentShotLabel = playerMode === "shot"
? `分镜 ${String(orderedShots[currentShotIdx]?.sb.sequenceNum ?? 1).padStart(3, "0")}`
: "整集";
// Sync video src when currentVideoUrl changes const completedTasks = episodeTasks.filter((task) => task.status === "succeeded" && task.resultVideoUrl);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.pause();
setPlaying(false);
v.load();
}, [currentVideoUrl]);
const togglePlay = () => { const stats = [
const v = videoRef.current; { label: "分镜数量", value: `${storyboards.length}` },
if (!v) return; { label: "已完成视频", value: `${completedTasks.length}` },
if (v.paused) { v.play(); setPlaying(true); } {
else { v.pause(); setPlaying(false); } label: "合成状态",
}; value:
assemblyTask?.status === "succeeded"
? "已完成"
: assemblyTask?.status === "failed"
? "失败"
: assemblyTask?.status === "running"
? "进行中"
: "未开始",
},
];
const selectShot = (idx: number) => { const handleDelete = async (task: AiTask) => {
setCurrentShotIdx(idx); if (!confirm("确定要删除该视频任务吗?")) return;
setPlayerMode("shot"); await deleteTask.mutateAsync(task.id);
}; };
const handleDelete = async (taskId: string) => { if (loadingEpisodes) {
if (!confirm("确定删除此视频?")) return; return (
await deleteTask.mutateAsync(taskId); <div className="flex h-full items-center justify-center">
}; <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
// Episode stats helper if (episodes.length === 0) {
const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0; return (
const epDoneCount = (ep: Episode) => <div className="mx-auto max-w-[1180px]">
allTasks.filter((t) => t.episodeId === ep.id && t.status === "succeeded").length; <div className="rounded-[36px] border border-white/80 bg-white/82 px-6 py-20 text-center shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<Video className="h-7 w-7" />
</div>
<h2 className="mt-5 text-2xl font-semibold text-foreground">还没有可用分集</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
请先完成分集和分镜流程,再在这里查看视频任务与成片合成结果。
</p>
</div>
</div>
);
}
return ( return (
<div className="h-full flex overflow-hidden bg-background"> <div className="mx-auto max-w-[1480px]">
<section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<Film className="h-3.5 w-3.5" />
Video Pipeline
</div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">视频生成与合成</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
查看当前分集下的镜头视频任务,完成后可直接发起整集合成并下载结果。
</p>
</div>
{/* ─── Left: Episode list ─── */} <button
<div className="flex-shrink-0 border-r border-border flex flex-col" style={{ width: 180 }}> onClick={() => navigate(`/project/${projectId}/storyboard/${activeEpisodeId}`)}
<div className="h-14 px-4 border-b border-border flex items-center flex-shrink-0"> className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
<span className="text-sm font-medium text-foreground">选集</span> >
<Clapperboard className="h-4 w-4" />
返回分镜工作台
</button>
</div> </div>
<div className="flex-1 overflow-y-auto py-2">
{epLoading ? ( <div className="mt-6 flex flex-wrap gap-3">
<div className="flex justify-center pt-6"> {episodes.map((episode) => (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> <button
</div> key={episode.id}
) : episodes.length === 0 ? ( onClick={() => setActiveEpisodeId(episode.id)}
<p className="text-xs text-muted-foreground text-center pt-6 px-3">暂无分集</p> className={[
) : ( "rounded-2xl border px-4 py-3 text-sm transition-all",
episodes.map((ep) => { activeEpisodeId === episode.id
const total = epShotCount(ep); ? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
const done = epDoneCount(ep); : "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground",
const isActive = ep.id === activeEpId; ].join(" ")}
return ( >
<button {episode.episodeNumber}
key={ep.id} </button>
onClick={() => { setActiveEpId(ep.id); setPlayerMode("assembly"); setCurrentShotIdx(0); }} ))}
className={`w-full text-left px-4 py-3 border-b border-border/50 transition-colors ${
isActive ? "bg-accent" : "hover:bg-muted"
}`}
>
<div className="flex items-center justify-between mb-0.5">
<span className={`text-sm font-semibold ${isActive ? "text-primary" : "text-foreground"}`}>
第{ep.episodeNumber}集
</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{total}个分镜</span>
<span className={`text-[11px] ${done > 0 ? "text-green-600" : "text-muted-foreground"}`}>
{done}/{total}完成
</span>
</div>
</button>
);
})
)}
</div> </div>
</div>
{/* ─── Right: Main content ─── */} <div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="flex-1 overflow-y-auto"> {stats.map((stat) => (
<div className="p-6 space-y-6"> <div
key={stat.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{stat.label}</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{stat.value}</div>
</div>
))}
</div>
{/* Section 1: 视频播放器 */} <div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div> <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="flex items-center justify-between mb-1"> <div className="mb-4 flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">整集视频</h2> <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Shot Videos</div>
<p className="text-xs text-muted-foreground">点击下方分镜缩略图可切换单镜预览</p> <h2 className="mt-2 text-2xl font-semibold text-foreground">镜头视频任务</h2>
</div>
<div className="flex items-center gap-2">
{/* Assembly controls */}
{hasAssembly && (
<button
onClick={() => setPlayerMode("assembly")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition ${
playerMode === "assembly"
? "bg-primary text-primary-foreground"
: "border border-border text-foreground hover:bg-muted"
}`}
>
<Film className="w-3.5 h-3.5" />
整集
</button>
)}
{(!assemblyTask || assemblyTask.status === "failed") && (
<button
onClick={() => startAssembly.mutate()}
disabled={startAssembly.isPending || doneShots === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition disabled:opacity-50"
>
{startAssembly.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Film className="w-3.5 h-3.5" />}
合成整集
</button>
)}
{(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />合成中...
</span>
)}
{hasAssembly && (
<a
href={assemblyTask!.downloadUrl!}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition"
>
<Download className="w-3.5 h-3.5" />
导出整集
</a>
)}
</div> </div>
</div> </div>
{/* Player */} <div className="space-y-4">
<div className="rounded-xl overflow-hidden bg-black relative" style={{ aspectRatio: "16/9", maxHeight: 440 }}> {episodeTasks.length === 0 ? (
{currentVideoUrl ? ( <div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
<> 当前分集还没有视频任务,请先回到分镜工作台生成镜头视频。
{/* Badge */} </div>
<div className="absolute top-3 left-3 z-10 bg-black/60 text-white text-xs px-2.5 py-1.5 rounded-lg backdrop-blur-sm"> ) : (
<div className="text-[10px] opacity-70 leading-none mb-0.5"> episodeTasks.map((task) => (
{playerMode === "assembly" ? "整集合成" : "分镜预览"} <div
</div> key={task.id}
<div className="font-medium">{currentShotLabel}</div> className="rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm"
</div>
{/* Duration badge */}
<div className="absolute top-3 right-3 z-10 bg-black/60 text-white text-xs px-2.5 py-1.5 rounded-lg backdrop-blur-sm font-mono">
{fmt(totalDuration)}
</div>
<video
ref={videoRef}
src={currentVideoUrl}
className="w-full h-full object-contain"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
// Auto-advance to next shot in shot mode
if (playerMode === "shot" && currentShotIdx < orderedShots.length - 1) {
setCurrentShotIdx((i) => i + 1);
}
}}
/>
{/* Play/Pause overlay */}
<button
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center group"
> >
<div className={`w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center group-hover:bg-white/30 transition ${playing ? "opacity-0 group-hover:opacity-100" : ""}`}> <div className="flex items-start justify-between gap-4">
{playing <div>
? <Pause className="w-7 h-7 text-white" /> <div className="text-sm font-medium text-foreground">
: <Play className="w-7 h-7 text-white ml-0.5" />} 任务 {task.id.slice(0, 8)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{new Date(task.createdAt).toLocaleString("zh-CN")}
</div>
</div>
<span className={`rounded-full px-3 py-1 text-xs ${getTaskClass(task.status)}`}>
{getTaskLabel(task.status)}
</span>
</div> </div>
</button>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3">
<Film className="w-12 h-12 text-white/20" />
<p className="text-sm text-white/50">
{assemblyTask?.status === "running" || assemblyTask?.status === "pending"
? "整集视频合成中..."
: doneShots === 0
? "暂无已完成的分镜视频"
: "整集视频尚未合成"}
</p>
</div>
)}
</div>
{/* Controls row */} {task.resultVideoUrl ? (
<div className="flex items-center gap-3 mt-3"> <video src={task.resultVideoUrl} controls className="mt-4 aspect-video w-full rounded-2xl bg-muted object-cover" />
<button ) : (
onClick={() => { <div className="mt-4 flex aspect-video items-center justify-center rounded-2xl bg-muted">
const idx = Math.max(0, currentShotIdx - 1); <PlayCircle className="h-10 w-10 text-muted-foreground" />
selectShot(idx); </div>
}} )}
disabled={orderedShots.length === 0 || (playerMode === "shot" && currentShotIdx === 0)}
className="p-1.5 rounded-lg hover:bg-muted transition text-muted-foreground disabled:opacity-30"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
disabled={!currentVideoUrl}
className="w-8 h-8 rounded-full bg-primary flex items-center justify-center hover:bg-primary/90 transition disabled:opacity-40"
>
{playing
? <Pause className="w-4 h-4 text-primary-foreground" />
: <Play className="w-4 h-4 text-primary-foreground ml-0.5" />}
</button>
<button
onClick={() => {
const idx = Math.min(orderedShots.length - 1, currentShotIdx + 1);
selectShot(idx);
}}
disabled={orderedShots.length === 0 || (playerMode === "shot" && currentShotIdx >= orderedShots.length - 1)}
className="p-1.5 rounded-lg hover:bg-muted transition text-muted-foreground disabled:opacity-30"
>
<SkipForward className="w-4 h-4" />
</button>
<span className="text-xs text-muted-foreground ml-1">
{playerMode === "shot" && orderedShots.length > 0
? `${currentShotIdx + 1} / ${orderedShots.length}`
: `共 ${orderedShots.length} 个分镜`}
</span>
</div>
{/* Thumbnail strip — click to switch player to that shot */} <div className="mt-4 flex justify-end">
{orderedShots.length > 0 && ( <button
<div className="flex gap-2 mt-3 overflow-x-auto pb-1"> onClick={() => handleDelete(task)}
{orderedShots.map(({ sb, task }, i) => ( className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
<button >
key={sb.id} <Trash2 className="h-4 w-4" />
onClick={() => selectShot(i)} 删除任务
className={`flex-shrink-0 relative rounded-lg overflow-hidden border-2 transition ${ </button>
playerMode === "shot" && currentShotIdx === i
? "border-primary"
: "border-transparent hover:border-border"
}`}
style={{ width: 100, aspectRatio: "16/9" }}
>
<video
src={task!.resultVideoUrl!}
className="w-full h-full object-cover"
muted
/>
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between px-1.5 py-1 bg-gradient-to-t from-black/70">
<span className="text-[10px] text-white font-medium">{String(sb.sequenceNum).padStart(3, "0")}</span>
<span className="text-[10px] text-white/80">{sb.durationSeconds}s</span>
</div> </div>
</button> </div>
))} ))
</div> )}
)} </div>
</div> </div>
{/* Section 2: Stats */} <div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="grid grid-cols-4 gap-4"> <div className="mb-4">
{[ <div className="text-xs uppercase tracking-[0.24em] text-primary/70">Assembly</div>
{ label: "总分镜数", value: totalShots, color: "text-foreground" }, <h2 className="mt-2 text-2xl font-semibold text-foreground">整集合成</h2>
{ label: "已完成", value: doneShots, color: "text-green-600" }, </div>
{ label: "生成中", value: pendingShots, color: "text-orange-500" },
{ label: "总时长", value: fmt(totalDuration), color: "text-foreground" },
].map((s) => (
<div key={s.label} className="rounded-xl border border-border bg-card p-4">
<div className={`text-2xl font-bold mb-1 ${s.color}`}>{s.value}</div>
<div className="text-xs text-muted-foreground">{s.label}</div>
</div>
))}
</div>
{/* Section 3: 分镜管理 */} <div className="rounded-[28px] border border-primary/10 bg-primary/5 p-5">
<div> <div className="text-sm leading-6 text-muted-foreground">
<h3 className="text-base font-semibold text-foreground mb-4">分镜管理</h3> 当前分集下共有 {completedTasks.length} 个可用镜头视频。确认镜头视频已经生成完成后,可以发起整集合成任务。
{storyboards.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground">
{!activeEpId ? "请从左侧选择集数" : "该集暂无分镜"}
</div> </div>
) : ( <button
<div className="grid grid-cols-3 gap-4"> onClick={() => startAssembly.mutate()}
{storyboards.map((sb) => { disabled={completedTasks.length === 0 || startAssembly.isPending}
const task = getTaskForSb(sb); className="mt-5 inline-flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
const hasVideo = task?.status === "succeeded" && task.resultVideoUrl; >
const isPending = task?.status === "pending" || task?.status === "submitted" || task?.status === "running"; {startAssembly.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Film className="h-4 w-4" />}
return ( 发起整集合成
<div key={sb.id} className="rounded-xl border border-border bg-card overflow-hidden"> </button>
{/* Thumbnail — click plays in top player */} </div>
<button
className="relative bg-black w-full"
style={{ aspectRatio: "16/9" }}
onClick={() => {
if (!hasVideo) return;
const idx = orderedShots.findIndex((s) => s.sb.id === sb.id);
if (idx >= 0) selectShot(idx);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
{hasVideo ? (
<video
src={task!.resultVideoUrl!}
className="w-full h-full object-cover"
muted
/>
) : isPending ? (
<div className="w-full h-full flex flex-col items-center justify-center gap-1">
<Loader2 className="w-6 h-6 animate-spin text-white/40" />
<span className="text-[10px] text-white/40">生成中</span>
</div>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-8 h-8 text-white/20" />
</div>
)}
{/* Badges */}
<div className="absolute top-2 left-2 bg-black/60 text-white text-[11px] font-medium px-1.5 py-0.5 rounded backdrop-blur-sm">
分镜 {String(sb.sequenceNum).padStart(3, "0")}
</div>
{sb.durationSeconds > 0 && (
<div className="absolute top-2 right-2 bg-black/60 text-white text-[11px] px-1.5 py-0.5 rounded backdrop-blur-sm">
{sb.durationSeconds}s
</div>
)}
{hasVideo && <div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-green-400" />}
{isPending && <div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-orange-400 animate-pulse" />}
{/* Play hint overlay */}
{hasVideo && (
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition">
<Play className="w-8 h-8 text-white drop-shadow" />
</div>
)}
</button>
{/* Footer */} <div className="mt-5 rounded-[28px] border border-border/80 bg-white p-5 shadow-sm">
<div className="px-3 py-2.5 flex items-center justify-between"> <div className="text-sm font-medium text-foreground">当前状态</div>
<span className="text-[11px] text-muted-foreground"> <div className="mt-2 text-sm leading-6 text-muted-foreground">
{task ? timeAgo(task.createdAt) : "未生成"} {assemblyTask?.status ? getTaskLabel(assemblyTask.status) : "尚未开始合成"}
</span>
<div className="flex items-center gap-3">
<button
onClick={() => navigate(`/project/${pid}/storyboard/${activeEpId}`)}
className="flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<Edit2 className="w-3 h-3" />
编辑
</button>
{hasVideo && (
<a
href={task!.resultVideoUrl!}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-[11px] text-foreground hover:text-primary"
>
<Download className="w-3 h-3" />
下载
</a>
)}
{task && (
<button
onClick={() => handleDelete(task.id)}
disabled={deleteTask.isPending}
className="text-destructive hover:text-red-700 disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
);
})}
</div> </div>
)} {assemblyTask?.downloadUrl ? (
<a
href={assemblyTask.downloadUrl}
target="_blank"
rel="noreferrer"
className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Download className="h-4 w-4" />
下载合成结果
</a>
) : null}
</div>
</div> </div>
</div> </div>
</div> </section>
</div> </div>
); );
} }
import logoImage from "../imports/ab7d1ce5faf061d2af2b02d93e11935.png";
export const brand = {
companyName: "中科集团",
productName: "AI 短剧工业化平台",
shortProductName: "短剧工业化平台",
tagline: "从剧本拆解到视频成片的企业级创作中台",
heroTitle: "面向企业协同的 AI 短剧生产工作台",
heroDescription:
"统一管理剧本、大纲、角色、场景、分镜与视频任务,让团队在同一条生产链路中协作推进。",
logo: logoImage,
};
export const brandMetrics = [
{ label: "流程阶段", value: "6+" },
{ label: "团队协作", value: "多角色" },
{ label: "内容资产", value: "统一管理" },
];
export const brandHighlights = [
"品牌化工作台与中科集团统一视觉体系",
"覆盖剧本、设定、分镜、视频的完整生产链路",
"支持团队协作、资产沉淀与项目进度追踪",
];
:root {
--font-sans-brand:
"Avenir Next",
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans SC",
sans-serif;
}
...@@ -2,43 +2,43 @@ ...@@ -2,43 +2,43 @@
:root { :root {
--font-size: 14px; --font-size: 14px;
--background: #f8f9fa; --background: #eef4f9;
--foreground: #1a1a1a; --foreground: #12263f;
--card: #ffffff; --card: rgba(255, 255, 255, 0.92);
--card-foreground: #1a1a1a; --card-foreground: #12263f;
--popover: #ffffff; --popover: #ffffff;
--popover-foreground: #1a1a1a; --popover-foreground: #12263f;
--primary: #5b5ff9; --primary: #0f74d8;
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--secondary: #f1f3f5; --secondary: #d9e7f4;
--secondary-foreground: #1a1a1a; --secondary-foreground: #163b62;
--muted: #f8f9fa; --muted: #f4f8fc;
--muted-foreground: #6c757d; --muted-foreground: #5d6f84;
--accent: #e7f5ff; --accent: #e5f1fb;
--accent-foreground: #1971c2; --accent-foreground: #0f4d8f;
--destructive: #f03e3e; --destructive: #d95050;
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.06); --border: rgba(16, 74, 132, 0.12);
--input: rgba(0, 0, 0, 0.06); --input: rgba(16, 74, 132, 0.12);
--input-background: #ffffff; --input-background: rgba(255, 255, 255, 0.92);
--switch-background: #dee2e6; --switch-background: #c3d4e5;
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-normal: 400; --font-weight-normal: 400;
--ring: #5b5ff9; --ring: #0f74d8;
--chart-1: #5b5ff9; --chart-1: #0f74d8;
--chart-2: #22b8cf; --chart-2: #28a5ea;
--chart-3: #51cf66; --chart-3: #2fbe93;
--chart-4: #ff6b6b; --chart-4: #ff8b52;
--chart-5: #fcc419; --chart-5: #f2c84b;
--radius: 0.75rem; --radius: 1rem;
--sidebar: #ffffff; --sidebar: rgba(255, 255, 255, 0.86);
--sidebar-foreground: #1a1a1a; --sidebar-foreground: #12263f;
--sidebar-primary: #5b5ff9; --sidebar-primary: #0f74d8;
--sidebar-primary-foreground: #ffffff; --sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f8f9fa; --sidebar-accent: #eff5fb;
--sidebar-accent-foreground: #1a1a1a; --sidebar-accent-foreground: #123c67;
--sidebar-border: rgba(0, 0, 0, 0.06); --sidebar-border: rgba(16, 74, 132, 0.12);
--sidebar-ring: #5b5ff9; --sidebar-ring: #0f74d8;
} }
.dark { .dark {
...@@ -126,6 +126,26 @@ ...@@ -126,6 +126,26 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: var(--font-sans-brand);
min-height: 100vh;
background-image:
radial-gradient(circle at top left, rgba(30, 165, 255, 0.18), transparent 26%),
radial-gradient(circle at bottom right, rgba(12, 59, 106, 0.12), transparent 24%),
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.72));
background-attachment: fixed;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(15, 87, 157, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 87, 157, 0.035) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.7), transparent 92%);
z-index: -1;
} }
/** /**
...@@ -178,4 +198,9 @@ ...@@ -178,4 +198,9 @@
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
line-height: 1.5; line-height: 1.5;
} }
::selection {
background: rgba(15, 116, 216, 0.18);
color: var(--foreground);
}
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment