Commit 16da3a9f authored by yaoke.yk's avatar yaoke.yk

Revert "企业级工业风格"

This reverts commit 75992d7b.
parent 75992d7b
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 { Link, Outlet, useLocation, useNavigate, useParams } from "react-router"; import { Outlet, Link, useLocation, useParams, useNavigate } from "react-router";
import { import {
BarChart3,
Bell,
BookOpenText,
Bot,
ChevronDown,
Clapperboard,
Cpu,
Film, Film,
Users,
Sparkles,
FolderOpen, FolderOpen,
Settings,
Search,
Bell,
Globe, Globe,
ChevronDown,
BarChart3,
Image as ImageIcon, Image as ImageIcon,
LogOut, FileText,
PanelLeftClose,
PanelLeftOpen,
Search,
Settings,
ShieldCheck,
Sliders, Sliders,
Sparkles, Clapperboard,
User,
Users,
Video, Video,
Bot,
Cpu,
LogOut,
User,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useState, useEffect, useRef } 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 { BrandLogo } from "./BrandLogo"; import logoImage from "../../imports/ab7d1ce5faf061d2af2b02d93e11935.png";
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 navigate = useNavigate();
const params = useParams(); const params = useParams();
const navigate = useNavigate();
const [showNotifications, setShowNotifications] = useState(false);
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();
const [showNotifications, setShowNotifications] = useState(false); // Close user menu on outside click
const [showUserMenu, setShowUserMenu] = useState(false); useEffect(() => {
const [currentGroupId, setCurrentGroupId] = useState<number | "">(""); const handler = (e: MouseEvent) => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
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]);
useEffect(() => { // Determine active section based on current path
const handler = (event: MouseEvent) => { const getActiveSection = () => {
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";
...@@ -211,319 +82,319 @@ export function Layout() { ...@@ -211,319 +82,319 @@ 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 projectSections: ProjectSection[] = [ const activeSection = getActiveSection();
{ id: "agent", label: "Agent 制作", icon: Bot, path: `/project/${projectId}/agent` },
{ id: "script", label: "剧本拆解", icon: BookOpenText, path: `/project/${projectId}` },
{ 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 = const projectSections = [
currentGroupId === "" { id: "agent", label: "Agent制作", icon: Bot, path: `/project/${projectId}/agent` },
? "全部小组" { id: "script", label: "剧本", icon: FileText, path: `/project/${projectId}` },
: teamGroups.find((group) => group.id === currentGroupId)?.name ?? "当前小组"; {
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` },
];
return ( return (
<div className="flex min-h-screen bg-transparent"> <div className="h-screen flex bg-background">
<aside {/* Left Sidebar */}
className={cn( <aside className={`border-r border-border bg-card flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-56'}`}>
"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", {/* Logo */}
sidebarCollapsed ? "w-24" : "w-80", <div className="h-14 border-b border-border flex items-center justify-center px-4">
)} <Link to="/" className="flex items-center gap-2">
> {/* ZK Logo with Film Icon */}
<div className="rounded-[28px] border border-white/70 bg-white/60 p-3 shadow-sm"> <div className="relative">
<div className="flex items-center justify-between gap-3"> <Film className="w-8 h-8 text-primary" />
{sidebarCollapsed ? ( <div className="absolute inset-0 flex items-center justify-center">
<BrandLogo compact className="gap-0" subtitle={undefined} /> <span className="text-[10px] font-bold text-primary">ZK</span>
) : (
<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> </div>
</Link>
<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="mt-3 space-y-2">
{primaryNav.map((item) => (
<SidebarLink
key={item.path}
item={item}
pathname={location.pathname}
collapsed={sidebarCollapsed}
/>
))}
</div> </div>
<div className="mt-6"> <nav className="flex-1 p-3 overflow-auto">
<SectionLabel collapsed={sidebarCollapsed}>Team Scope</SectionLabel> {/* 小组板块 */}
<div className="mt-3"> <div className="text-xs text-muted-foreground px-3 mb-2">小组</div>
{sidebarCollapsed ? ( <div className="mb-6 px-3">
<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"> <div className="relative">
<select <select
value={currentGroupId} value={currentGroupId}
onChange={(e) => setCurrentGroupId(Number(e.target.value) || "")} 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" 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"
> >
<option value="">全部小组</option> <option value="">全部小组</option>
{teamGroups.map((group) => ( {teamGroups.map((g) => (
<option key={group.id} value={group.id}> <option key={g.id} value={g.id}>{g.name}</option>
{group.name}
</option>
))} ))}
</select> </select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
</div>
</div>
)}
</div> </div>
</div> </div>
<div className="mt-6"> {/* 创作板块 */}
<SectionLabel collapsed={sidebarCollapsed}>Management</SectionLabel> <div className="text-xs text-muted-foreground px-3 mb-2">创作</div>
<div className="mt-3 space-y-2"> <Link
{manageNav.map((item) => ( to="/new-project"
<SidebarLink className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
key={item.path} location.pathname === "/new-project"
item={item} ? "bg-accent text-accent-foreground"
pathname={location.pathname} : "text-foreground hover:bg-muted"
collapsed={sidebarCollapsed} }`}
/> >
))} <Sparkles className="w-4 h-4" />
</div> 剧本生成短剧
</div> </Link>
</div>
<div className="mt-4 rounded-[28px] border border-white/70 bg-white/64 p-3 shadow-sm"> {/* 我的板块 */}
<div <div className="text-xs text-muted-foreground px-3 mt-6 mb-2">我的</div>
className={cn( <Link
"rounded-3xl border border-primary/10 bg-[linear-gradient(135deg,rgba(15,116,216,0.10),rgba(30,165,255,0.03))]", to="/"
sidebarCollapsed ? "px-3 py-4 text-center" : "px-4 py-4", 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"
}`}
> >
{sidebarCollapsed ? ( <FolderOpen className="w-4 h-4" />
<> 项目
<div className="mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-white"> </Link>
<Sparkles className="h-4 w-4" />
</div> <Link
<div className="text-sm font-semibold text-foreground"> to="/assets/character"
{Number(balance?.balance ?? 0).toLocaleString()} className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
</div> location.pathname.startsWith("/assets")
</> ? "bg-accent text-accent-foreground"
) : ( : "text-foreground hover:bg-muted"
<> }`}
<div className="flex items-center justify-between gap-3"> >
<div> <ImageIcon className="w-4 h-4" />
<div className="text-xs uppercase tracking-[0.24em] text-primary/70"> 资产
Credits </Link>
</div>
<div className="mt-1 text-2xl font-semibold text-foreground"> {/* 企业配置 */}
<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()} {Number(balance?.balance ?? 0).toLocaleString()}
</div> </span>
</div> </span>
<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 className="mt-3 text-sm text-muted-foreground">
当前团队范围:{currentGroupName}
</div>
<button <button
type="button"
onClick={() => navigate("/usage")} 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]" className="text-xs text-muted-foreground hover:text-foreground"
> >
查看用量与采购 购买
</button> </button>
</>
)}
</div> </div>
</div> </div>
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col"> {/* Main Content */}
<header className="sticky top-0 z-30 border-b border-white/70 bg-white/72 px-6 py-4 backdrop-blur-xl"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center gap-4"> {/* Top Header */}
<div className="flex min-w-0 flex-1 items-center gap-4"> <header className="h-14 border-b border-border bg-card flex items-center justify-between px-6">
{sidebarCollapsed ? <BrandLogo compact subtitle={undefined} /> : null} <div className="flex items-center gap-4 flex-1">
<div className="relative max-w-xl flex-1"> {/* Logo - Show when sidebar is collapsed */}
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> {sidebarCollapsed && (
<Link to="/" className="flex-shrink-0 flex items-center gap-2">
{/* ZK Logo with Film Icon */}
<div className="relative">
<Film className="w-7 h-7 text-primary" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[9px] font-bold text-primary">ZK</span>
</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>
)}
{/* Search Bar */}
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" type="text"
placeholder="搜索项目、资产、成员或文档" placeholder="搜索项目或文档"
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)]" 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"
/> />
</div> </div>
</div> </div>
<div className="hidden items-center gap-2 lg:flex">
<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>
<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>
</div> </div>
<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"> {/* Right Section */}
<Globe className="h-4 w-4" /> <div className="flex items-center gap-4">
中文 {/* Top Nav Menu */}
</button> <nav className="flex items-center gap-1 text-sm text-muted-foreground">
<button className="px-3 py-1.5 hover:text-foreground transition-colors">工具</button>
<div className="relative" ref={notificationRef}> <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 className="px-3 py-1.5 hover:text-foreground transition-colors">文档</button>
</nav>
{/* Notifications */}
<div className="relative">
<button <button
type="button" onClick={() => setShowNotifications(!showNotifications)}
onClick={() => setShowNotifications((value) => !value)} className="relative p-2 hover:bg-muted rounded-lg transition-colors"
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="h-4 w-4" /> <Bell className="w-4 h-4 text-muted-foreground" />
<span className="absolute right-3 top-3 h-2 w-2 rounded-full bg-sky-500" /> <span className="absolute top-1 right-1 w-2 h-2 bg-destructive rounded-full"></span>
</button> </button>
{showNotifications ? ( {showNotifications && (
<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="absolute right-0 top-12 w-80 bg-card border border-border rounded-xl shadow-lg z-50">
<div className="mb-2 flex items-center justify-between px-2 py-1"> <div className="p-4 border-b border-border">
<div> <h3 className="font-medium text-foreground">通知中心</h3>
<div className="text-sm font-semibold text-foreground">消息中心</div>
<div className="text-xs text-muted-foreground">项目进度、资产更新与系统通知</div>
</div> </div>
<span className="rounded-full bg-accent px-3 py-1 text-xs text-primary"> <div className="max-h-96 overflow-auto">
{notifications.length} 条 {notifications.map((notif) => (
</span>
</div>
<div className="space-y-2">
{notifications.map((notification) => (
<button <button
key={notification.id} key={notif.id}
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" className="w-full p-4 text-left hover:bg-muted transition-colors border-b border-border last:border-b-0"
> >
<div className="text-sm leading-6 text-foreground">{notification.title}</div> <div className="text-sm text-foreground mb-1">{notif.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{notification.time}</div> <div className="text-xs text-muted-foreground">{notif.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>
) : null}
</div> </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
type="button" onClick={() => setShowUserMenu(!showUserMenu)}
onClick={() => setShowUserMenu((value) => !value)} className="flex items-center gap-2 px-2 py-1.5 hover:bg-muted rounded-lg transition-colors"
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="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)]"> <div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
<span className="text-[11px] font-bold text-white">
{user?.username?.[0]?.toUpperCase() ?? "U"} {user?.username?.[0]?.toUpperCase() ?? "U"}
</span>
</div> </div>
<div className="hidden text-left md:block"> <ChevronDown className="w-3 h-3 text-muted-foreground" />
<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 && (
{showUserMenu ? ( <div className="absolute right-0 top-12 w-48 bg-card border border-border rounded-xl shadow-lg z-50">
<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="px-4 py-3 border-b border-border">
<div className="rounded-3xl border border-border/70 bg-white/70 px-4 py-4"> <div className="flex items-center gap-2">
<div className="flex items-center gap-3"> <User className="w-4 h-4 text-muted-foreground" />
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-primary"> <span className="text-sm font-medium text-foreground truncate">{user?.username}</span>
<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
type="button" onClick={() => { clearAuth(); navigate("/login"); }}
onClick={() => { 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"
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="h-4 w-4" /> <LogOut className="w-4 h-4" />
退出登录 退出登录
</button> </button>
</div> </div>
) : null} )}
</div> </div>
</div> </div>
</header>
{isProjectRoute ? ( {/* Project Navigation */}
<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"> {isProjectRoute && (
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"> <div className="border-b border-border bg-card px-6 py-3">
<div> <div className="flex items-center gap-6">
<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"> {/* Project Name */}
<Film className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 pr-6 border-r border-border">
Project Pipeline <Film className="w-4 h-4 text-primary" />
</div> <span className="font-medium text-foreground">{currentProject?.name ?? projectId}</span>
<div className="text-2xl font-semibold text-foreground">
{currentProject?.name ?? "当前项目"}
</div>
<div className="mt-2 text-sm text-muted-foreground">
按照剧本拆解、设定、分镜、视频的标准链路推进企业级短剧生产。
</div>
</div> </div>
<div className="flex flex-wrap gap-2"> {/* Section Tabs */}
<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}
type="button"
onClick={() => navigate(section.path)} onClick={() => navigate(section.path)}
className={cn( className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm transition-all ${
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all", activeSection === section.id
selected ? "bg-accent text-accent-foreground font-medium"
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]" : "text-muted-foreground hover:text-foreground hover:bg-muted"
: "border-border/80 bg-white/78 text-muted-foreground hover:border-primary/20 hover:text-foreground", }`}
)}
> >
<Icon className="h-4 w-4" /> <Icon className="w-4 h-4" />
{section.label} {section.label}
</button> </button>
); );
...@@ -531,10 +402,10 @@ export function Layout() { ...@@ -531,10 +402,10 @@ export function Layout() {
</div> </div>
</div> </div>
</div> </div>
) : null} )}
</header>
<main className="min-h-0 flex-1 overflow-auto px-6 py-6"> {/* Page Content */}
<main className="flex-1 overflow-auto">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
......
import { useEffect, useState } from "react"; import { useState, useEffect } from "react";
import { Droplet, Eye, Loader2, Save, Upload, WandSparkles } from "lucide-react"; import { Droplet, Upload, Eye, Save, Loader2 } from "lucide-react";
import { useSaveSetting, useSetting } from "../../hooks/useSettings"; import { useSetting, useSaveSetting } from "../../hooks/useSettings";
const settingKey = "watermark"; const SETTING_KEY = "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(settingKey); const { data: rawSetting, isLoading } = useSetting(SETTING_KEY);
const saveSetting = useSaveSetting(); const saveSetting = useSaveSetting();
const [config, setConfig] = useState<WatermarkConfig>(defaultConfig); const [config, setConfig] = useState<WatermarkConfig>(defaultConfig);
...@@ -43,172 +43,140 @@ export function AIWatermarkSettings() { ...@@ -43,172 +43,140 @@ export function AIWatermarkSettings() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => { useEffect(() => {
if (!rawSetting) return; if (rawSetting) {
try { try {
const parsed = JSON.parse(rawSetting) as WatermarkConfig; const parsed = JSON.parse(rawSetting) as WatermarkConfig;
setConfig({ ...defaultConfig, ...parsed }); setConfig({ ...defaultConfig, ...parsed });
} catch { } catch {
setConfig(defaultConfig); // keep default
}
} }
}, [rawSetting]); }, [rawSetting]);
const handleSave = async () => { const handleSave = async () => {
await saveSetting.mutateAsync({ key: settingKey, value: JSON.stringify(config) }); await saveSetting.mutateAsync({ key: SETTING_KEY, value: JSON.stringify(config) });
setSaved(true); setSaved(true);
window.setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="w-6 h-6 animate-spin text-primary" />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-[1380px]"> <div className="h-full overflow-auto bg-background p-6">
<section className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]"> <div className="max-w-5xl mx-auto">
<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="mb-6">
<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%)]" /> <h1 className="text-2xl font-semibold text-foreground mb-1">AI生成水印设置</h1>
<div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" /> <p className="text-sm text-muted-foreground">配置AI生成内容的默认水印样式</p>
<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="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="grid grid-cols-3 gap-6">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="col-span-2 space-y-6">
{/* Enable/Disable */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center justify-between mb-4">
<div> <div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Watermark Config</div> <h3 className="font-medium text-foreground mb-1">启用水印</h3>
<h2 className="mt-2 text-3xl font-semibold text-foreground">默认配置</h2> <p className="text-sm text-muted-foreground">为所有AI生成的内容添加水印</p>
<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 className="text-base font-medium text-foreground">启用水印</div>
<div className="mt-2 text-sm text-muted-foreground">为所有 AI 生成内容叠加默认水印标识。</div>
</div> </div>
<button <button
onClick={() => setConfig((current) => ({ ...current, enabled: !current.enabled }))} onClick={() => setConfig({ ...config, enabled: !config.enabled })}
className={[ className={`relative w-12 h-6 rounded-full transition-colors ${
"relative h-7 w-14 rounded-full transition-colors", config.enabled ? "bg-primary" : "bg-muted"
config.enabled ? "bg-primary" : "bg-muted", }`}
].join(" ")}
> >
<div <div
className={[ className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
"absolute top-1 h-5 w-5 rounded-full bg-white transition-transform", config.enabled ? "translate-x-7" : "translate-x-1"
config.enabled ? "translate-x-8" : "translate-x-1", }`}
].join(" ")}
/> />
</button> </button>
</div> </div>
</div> </div>
<div> {/* Watermark Text */}
<label className="mb-2 block text-sm font-medium text-foreground">水印文字</label> <div className="rounded-xl border border-border bg-card p-5">
<label className="block font-medium text-foreground mb-3">水印文字</label>
<input <input
type="text"
value={config.text} value={config.text}
onChange={(e) => setConfig((current) => ({ ...current, text: e.target.value }))} onChange={(e) => setConfig({ ...config, 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>
<div className="rounded-[28px] border border-border/80 bg-white/70 p-5 shadow-sm"> {/* Custom Logo */}
<div className="mb-3 text-sm font-medium text-foreground">自定义 Logo</div> <div className="rounded-xl border border-border bg-card p-5">
<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"> <label className="block font-medium text-foreground mb-3">自定义水印Logo</label>
<div> <div className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer">
<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"
onChange={(e) => setCustomLogo(e.target.files?.[0] ?? null)} id="logo-upload"
/> />
<label htmlFor="logo-upload" className="cursor-pointer">
{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> </label>
</div> </div>
</div>
<div> {/* Position */}
<label className="mb-2 block text-sm font-medium text-foreground">水印位置</label> <div className="rounded-xl border border-border bg-card p-5">
<div className="grid gap-3 sm:grid-cols-3"> <label className="block font-medium text-foreground mb-3">水印位置</label>
{positions.map((position) => ( <div className="grid grid-cols-3 gap-3">
{positions.map((pos) => (
<button <button
key={position.value} key={pos.value}
onClick={() => setConfig((current) => ({ ...current, position: position.value }))} onClick={() => setConfig({ ...config, position: pos.value })}
className={[ className={`px-4 py-3 rounded-lg border transition-all text-sm ${
"rounded-2xl border px-4 py-3 text-sm transition-all", config.position === pos.value
config.position === position.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(" ")}
> >
{position.label} {pos.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div> {/* Size */}
<label className="mb-2 block text-sm font-medium text-foreground">水印大小</label> <div className="rounded-xl border border-border bg-card p-5">
<div className="grid gap-3 sm:grid-cols-3"> <label className="block font-medium text-foreground mb-3">水印大小</label>
<div className="grid grid-cols-3 gap-3">
{sizes.map((size) => ( {sizes.map((size) => (
<button <button
key={size.value} key={size.value}
onClick={() => setConfig((current) => ({ ...current, size: size.value }))} onClick={() => setConfig({ ...config, size: size.value })}
className={[ className={`px-4 py-3 rounded-lg border transition-all text-sm ${
"rounded-2xl border px-4 py-3 text-sm transition-all",
config.size === size.value config.size === size.value
? "border-primary/20 bg-primary/5 text-primary" ? "border-primary bg-accent text-primary"
: "border-border/80 bg-white text-foreground hover:border-primary/20", : "border-border bg-background text-foreground hover:bg-muted"
].join(" ")} }`}
> >
{size.label} {size.label}
</button> </button>
...@@ -216,89 +184,106 @@ export function AIWatermarkSettings() { ...@@ -216,89 +184,106 @@ export function AIWatermarkSettings() {
</div> </div>
</div> </div>
<div> {/* Opacity */}
<label className="mb-2 block text-sm font-medium text-foreground">透明度:{config.opacity}%</label> <div className="rounded-xl border border-border bg-card p-5">
<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((current) => ({ ...current, opacity: Number(e.target.value) }))} onChange={(e) => setConfig({ ...config, opacity: Number(e.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-muted accent-primary" className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer 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="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" 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"
> >
{saveSetting.isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />} {saveSetting.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{saved ? "已保存" : "保存设置"} {saved ? "已保存" : "保存设置"}
</button> </button>
</div> </div>
</div>
</section>
<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"> {/* Preview Panel */}
<div className="mb-5 flex items-center gap-2"> <div className="col-span-1">
<Eye className="h-5 w-5 text-primary" /> <div className="rounded-xl border border-border bg-card p-5 sticky top-6">
<h3 className="text-2xl font-semibold text-foreground">预览效果</h3> <div className="flex items-center gap-2 mb-4">
<Eye className="w-4 h-4 text-primary" />
<h3 className="font-medium text-foreground">预览效果</h3>
</div> </div>
<div className="grid gap-5 xl:grid-cols-[1fr_320px]"> <div className="aspect-video rounded-lg bg-gradient-to-br from-purple-100 to-blue-100 relative overflow-hidden">
<div className="relative aspect-video overflow-hidden rounded-[28px] bg-[linear-gradient(145deg,#dbeafe_0%,#eff6ff_48%,#bfdbfe_100%)]">
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<Droplet className="mx-auto h-16 w-16 text-sky-900/20" /> <Droplet className="w-16 h-16 text-muted-foreground/30 mx-auto mb-2" />
<div className="mt-3 text-sm text-sky-900/40">示例内容预览区</div> <p className="text-sm text-muted-foreground/50">示例内容</p>
</div> </div>
</div> </div>
{config.enabled ? ( {config.enabled && (
<div <div
className={[ className={`absolute ${
"absolute", config.position === "top-left" ? "top-3 left-3"
config.position === "top-left" : config.position === "top-right" ? "top-3 right-3"
? "left-5 top-5" : config.position === "bottom-left" ? "bottom-3 left-3"
: config.position === "top-right" : config.position === "bottom-right" ? "bottom-3 right-3"
? "right-5 top-5" : "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
: config.position === "bottom-left" }`}
? "bottom-5 left-5"
: config.position === "center"
? "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
: "bottom-5 right-5",
].join(" ")}
style={{ opacity: config.opacity / 100 }} style={{ opacity: config.opacity / 100 }}
> >
<div <div
className={[ className={`px-3 py-1.5 rounded bg-black/20 backdrop-blur-sm text-white ${
"rounded-2xl bg-slate-950/24 px-4 py-2 text-white backdrop-blur-sm", config.size === "small" ? "text-xs"
config.size === "small" : config.size === "large" ? "text-base"
? "text-xs" : "text-sm"
: config.size === "large" }`}
? "text-base"
: "text-sm",
].join(" ")}
> >
{config.text} {config.text}
</div> </div>
</div> </div>
) : null} )}
</div> </div>
<div className="rounded-[28px] border border-border/80 bg-white/78 p-5 shadow-sm"> <div className="mt-4 p-3 rounded-lg bg-muted space-y-2 text-sm">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Preview Summary</div> <div className="flex justify-between">
<div className="mt-4 space-y-3 text-sm text-muted-foreground"> <span className="text-muted-foreground">状态:</span>
<div>状态:{config.enabled ? "启用" : "关闭"}</div> <span className="text-foreground">{config.enabled ? "已启用" : "已禁用"}</span>
<div>位置:{positions.find((item) => item.value === config.position)?.label ?? config.position}</div> </div>
<div>大小:{sizes.find((item) => item.value === config.size)?.label ?? config.size}</div> <div className="flex justify-between">
<div>透明度:{config.opacity}%</div> <span className="text-muted-foreground">位置:</span>
<div>Logo:{customLogo ? "已上传" : "未上传"}</div> <span className="text-foreground">
{positions.find((p) => p.value === config.position)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">大小:</span>
<span className="text-foreground">
{sizes.find((s) => s.value === config.size)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">透明度:</span>
<span className="text-foreground">{config.opacity}%</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</section>
</div> </div>
); );
} }
import { useMemo, useState } from "react"; import { useState } from "react";
import { useParams, useNavigate } from "react-router";
import { import {
Box, Users as UsersIcon,
Heart,
Image as ImageIcon,
Loader2,
MapPin, MapPin,
Box,
Plus, Plus,
Search, Search,
Sparkles, Star,
Trash2, Trash2,
Users as UsersIcon, Filter,
X, User,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useCreateAsset, useDeleteAsset, useGlobalAssets } from "../../hooks/useAssets"; import { useGlobalAssets, useCreateAsset, useToggleFavorite, useDeleteAsset } from "../../hooks/useAssets";
import type { GlobalAsset } from "../../lib/api/assets";
type AssetType = "character" | "scene" | "prop"; type AssetTypeStr = "character" | "scene" | "prop";
type LibraryType = "personal" | "team";
const assetTypeMeta: Record<AssetType, { label: string; icon: typeof UsersIcon }> = { const assetTypes = [
character: { label: "角色资产", icon: UsersIcon }, { id: "character" as AssetTypeStr, label: "角色库", icon: UsersIcon },
scene: { label: "场景资产", icon: MapPin }, { id: "scene" as AssetTypeStr, label: "场景库", icon: MapPin },
prop: { label: "道具资产", icon: Box }, { id: "prop" as AssetTypeStr, label: "道具库", icon: Box },
}; ];
export function AssetsManagement() { export function AssetsManagement() {
const [activeType, setActiveType] = useState<AssetType>("character"); const navigate = useNavigate();
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 [showModal, setShowModal] = useState(false); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [form, setForm] = useState({ const [showCreateModal, setShowCreateModal] = useState(false);
name: "",
tags: "",
description: "",
imageUrl: "",
});
const { data: assets = [], isLoading } = useGlobalAssets(activeType, "personal"); // create form
const createAsset = useCreateAsset(activeType, "personal"); const [formName, setFormName] = useState("");
const deleteAsset = useDeleteAsset(activeType, "personal"); const [formDesc, setFormDesc] = useState("");
const [formTags, setFormTags] = useState("");
const [formLibrary, setFormLibrary] = useState<LibraryType>("personal");
const filteredAssets = useMemo(() => { const { data: assets = [], isLoading } = useGlobalAssets(activeType, libraryType);
return assets.filter((asset) => { const createAsset = useCreateAsset(activeType, libraryType);
if (!searchTerm) return true; const toggleFavorite = useToggleFavorite(activeType, libraryType);
return asset.name.toLowerCase().includes(searchTerm.toLowerCase()); 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;
}); });
}, [assets, searchTerm]);
const ActiveIcon = assetTypeMeta[activeType].icon; const allTags = Array.from(new Set(assets.flatMap((a) => a.tags)));
const toggleTag = (tag: string) =>
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
const handleCreate = async () => { const handleCreate = async () => {
if (!form.name.trim()) return;
await createAsset.mutateAsync({ await createAsset.mutateAsync({
name: form.name, name: formName,
assetType: activeType, assetType: activeType,
libraryType: "personal", libraryType: formLibrary,
tags: form.tags, tags: formTags,
description: form.description, description: formDesc,
imageUrl: form.imageUrl || undefined,
}); });
setShowModal(false); setFormName(""); setFormDesc(""); setFormTags(""); setFormLibrary("personal");
setForm({ name: "", tags: "", description: "", imageUrl: "" }); setShowCreateModal(false);
};
const handleDelete = async (asset: GlobalAsset) => {
if (!confirm(`确定要删除资产“${asset.name}”吗?`)) return;
await deleteAsset.mutateAsync(asset.id);
}; };
return ( return (
<div className="mx-auto max-w-[1440px]"> <div className="h-full flex flex-col bg-background">
<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"> {/* Sub Navigation */}
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="border-b border-border bg-card px-6 py-4">
<div> <div className="flex items-center gap-4 mb-4">
<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-2 p-1 rounded-lg bg-muted">
<Sparkles className="h-3.5 w-3.5" />
Asset Center
</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 <button
onClick={() => setShowModal(true)} onClick={() => setLibraryType("personal")}
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]" 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"}`}
> >
<Plus className="h-4 w-4" /> <div className="flex items-center gap-2"><User className="w-3 h-3" />个人库</div>
新建资产
</button> </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 <button
key={type} onClick={() => setLibraryType("team")}
onClick={() => setActiveType(type)} 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"}`}
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" /> <div className="flex items-center gap-2"><UsersIcon className="w-3 h-3" />团队库</div>
{assetTypeMeta[type].label}
</button> </button>
);
})}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<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>
<div className="mt-3 text-2xl font-semibold text-foreground">{assetTypeMeta[activeType].label}</div>
</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>
<div className="mt-3 text-2xl font-semibold text-foreground">{assets.length}</div>
</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>
<div className="mt-3 text-2xl font-semibold text-foreground">
{assets.filter((asset) => !!asset.imageUrl).length}
</div> </div>
</div> </div>
<div className="flex items-center gap-1 mb-4">
{assetTypes.map((type) => (
<button
key={type.id}
onClick={() => navigate(`/assets/${type.id}`)}
className={`px-4 py-2 rounded-lg text-sm flex items-center gap-2 transition-all ${
activeType === type.id
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<type.icon className="w-4 h-4" />
{type.label}
</button>
))}
</div> </div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-4"> <div className="flex items-center gap-4">
<div className="relative w-full max-w-md"> <div className="flex-1 max-w-md relative">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} 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="搜索资源名称..."
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 className="inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm text-primary"> <button
<ActiveIcon className="h-4 w-4" /> onClick={() => setShowCreateModal(true)}
{filteredAssets.length} 项 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"
>
<Plus className="w-4 h-4" />
新建{assetTypes.find((t) => t.id === activeType)?.label.replace("库", "")}
</button>
</div> </div>
</div> </div>
{isLoading ? ( {/* Content */}
<div className="flex items-center justify-center py-24"> <div className="flex-1 overflow-auto p-6">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <div className="max-w-7xl mx-auto">
</div> {allTags.length > 0 && (
) : filteredAssets.length === 0 ? ( <div className="mb-6 p-4 rounded-xl border border-border bg-card">
<div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"> <div className="flex items-center gap-2 mb-3">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Filter className="w-4 h-4 text-muted-foreground" />
<ActiveIcon className="h-7 w-7" /> <span className="text-sm font-medium text-foreground">标签筛选</span>
</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>
<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-4">
{isLoading && (
<div className="text-center py-16"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground mx-auto" /></div>
)}
{!isLoading && filteredAssets.length > 0 ? (
<div className={`grid gap-4 ${activeType === "character" ? "grid-cols-5" : "grid-cols-4"}`}>
{filteredAssets.map((asset) => ( {filteredAssets.map((asset) => (
<div <div key={asset.id} className="rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all group">
key={asset.id} <div className={`${activeType === "character" ? "aspect-[3/4]" : "aspect-video"} bg-muted relative overflow-hidden`}>
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 ? ( {asset.imageUrl ? (
<img src={asset.imageUrl} alt={asset.name} className="h-full w-full object-cover" /> <img src={asset.imageUrl} alt={asset.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" />
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="w-full h-full flex items-center justify-center text-muted-foreground">
<ImageIcon className="h-10 w-10 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>
)} )}
<button
onClick={() => toggleFavorite.mutate(asset.id)}
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"
>
<Star className={`w-4 h-4 ${asset.favorite ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"}`} />
</button>
</div> </div>
<div className="p-5"> <div className="p-3">
<div className="flex items-start justify-between gap-3"> <h3 className="font-medium text-foreground mb-2 line-clamp-1">{asset.name}</h3>
<div> <div className="flex flex-wrap gap-1 mb-3">
<div className="text-lg font-semibold text-foreground">{asset.name}</div> {asset.tags.slice(0, 2).map((tag) => (
<div className="mt-2 text-sm text-muted-foreground"> <span key={tag} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">{tag}</span>
{asset.tags.join(" / ") || "未分类"} ))}
</div> {asset.tags.length > 2 && (
</div> <span className="px-2 py-0.5 rounded bg-muted text-xs text-muted-foreground">+{asset.tags.length - 2}</span>
<span className="rounded-full bg-accent px-3 py-1 text-xs text-primary"> )}
{assetTypeMeta[asset.assetType].label}
</span>
</div>
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">
{asset.description || "待补充资产描述。"}
</p>
<div className="mt-5 flex items-center justify-between gap-3">
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Heart className={asset.favorite ? "h-4 w-4 text-red-500" : "h-4 w-4"} />
{asset.favorite ? "已收藏" : "未收藏"}
</div> </div>
{asset.description && <div className="text-xs text-muted-foreground mb-3 line-clamp-1">{asset.description}</div>}
<div className="flex gap-2">
<button <button
onClick={() => handleDelete(asset)} onClick={() => deleteAsset.mutate(asset.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" 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="h-4 w-4" /> <Trash2 className="w-3 h-3" />
删除 删除
</button> </button>
</div> </div>
...@@ -211,86 +197,68 @@ export function AssetsManagement() { ...@@ -211,86 +197,68 @@ export function AssetsManagement() {
</div> </div>
))} ))}
</div> </div>
)} ) : !isLoading && (
</section> <div className="text-center py-16">
{activeType === "character" && <UsersIcon className="w-16 h-16 text-muted-foreground mx-auto mb-4" />}
{showModal ? ( {activeType === "scene" && <MapPin className="w-16 h-16 text-muted-foreground mx-auto mb-4" />}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> {activeType === "prop" && <Box className="w-16 h-16 text-muted-foreground mx-auto mb-4" />}
<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"> <p className="text-muted-foreground mb-2">
<div className="mb-6 flex items-start justify-between gap-4"> 暂无{assetTypes.find((t) => t.id === activeType)?.label.replace("库", "")}
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Asset Editor</div>
<h3 className="mt-2 text-2xl font-semibold text-foreground">新建资产</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
填写名称、标签、描述和图片链接,将素材沉淀到企业资产中心。
</p> </p>
<button onClick={() => setShowCreateModal(true)} className="text-sm text-primary hover:underline">立即创建</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>
</div>
{/* Create Modal */}
{showCreateModal && (
<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">
<h2 className="text-xl font-semibold text-foreground mb-6">
新建{assetTypes.find((t) => t.id === activeType)?.label.replace("库", "")}
</h2>
<div className="space-y-5"> <div className="space-y-4 mb-6">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">资产名称</label> <label className="block text-sm font-medium text-foreground mb-2">名称</label>
<input <input value={formName} onChange={(e) => setFormName(e.target.value)} type="text" placeholder="输入名称"
value={form.name} 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" />
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="mb-2 block text-sm font-medium text-foreground">标签</label> <label className="block text-sm font-medium text-foreground mb-2">标签(逗号分隔)</label>
<input <input value={formTags} onChange={(e) => setFormTags(e.target.value)} type="text" placeholder="如:女主角,都市,现代"
value={form.tags} 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" />
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="mb-2 block text-sm font-medium text-foreground">描述</label> <label className="block text-sm font-medium text-foreground mb-2">描述</label>
<textarea <textarea value={formDesc} onChange={(e) => setFormDesc(e.target.value)} rows={3} placeholder="输入描述"
value={form.description} 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" />
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="mb-2 block text-sm font-medium text-foreground">图片链接</label> <label className="block text-sm font-medium text-foreground mb-2">库类型</label>
<input <select value={formLibrary} onChange={(e) => setFormLibrary(e.target.value as LibraryType)}
value={form.imageUrl} 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">
onChange={(e) => setForm((current) => ({ ...current, imageUrl: e.target.value }))} <option value="personal">个人库</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)]" <option value="team">团队库</option>
placeholder="https://example.com/image.jpg" </select>
/>
</div> </div>
</div> </div>
<div className="mt-6 flex items-center justify-end gap-3"> <div className="flex gap-3">
<button <button onClick={() => setShowCreateModal(false)}
onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 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={handleCreate} disabled={createAsset.isPending || !formName.trim()}
onClick={handleCreate} 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">
disabled={createAsset.isPending || !form.name.trim()} {createAsset.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
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 {
Box, ArrowRight,
Image as ImageIcon, Check,
Edit2,
ImageIcon,
Loader2, Loader2,
MapPin, MapPin,
Plus, Plus,
...@@ -11,8 +13,8 @@ import { ...@@ -11,8 +13,8 @@ import {
Trash2, Trash2,
Upload, Upload,
Users, Users,
WandSparkles,
X, X,
Box,
} from "lucide-react"; } from "lucide-react";
import { import {
useCharacters, useCharacters,
...@@ -36,20 +38,12 @@ const ROLE_TYPES = [ ...@@ -36,20 +38,12 @@ const ROLE_TYPES = [
{ value: "supporting", label: "配角" }, { value: "supporting", label: "配角" },
]; ];
const CHARACTER_VIEWS: Array<{ type: CharacterViewType; label: string }> = [ const CHARACTER_VIEWS: Array<{ type: CharacterViewType; label: string; shortLabel: string }> = [
{ type: "front", label: "正面" }, { type: "front", label: "正面", shortLabel: "正" },
{ type: "side", label: "侧面" }, { type: "side", label: "侧面", shortLabel: "侧" },
{ type: "back", label: "背面" }, { type: "back", label: "背面", shortLabel: "背" },
]; ];
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",
...@@ -58,16 +52,15 @@ const emptyForm = (): Partial<Character> => ({ ...@@ -58,16 +52,15 @@ const emptyForm = (): Partial<Character> => ({
personality: "", personality: "",
costume: "", costume: "",
visualHint: "", visualHint: "",
status: "draft",
}); });
const emptyImages = (): ImageState => ({ const emptyImageState = (): ImageState => ({
front: null, front: null,
side: null, side: null,
back: null, back: null,
}); });
const emptyFiles = (): PendingFiles => ({ const emptyPendingFiles = (): PendingFiles => ({
front: null, front: null,
side: null, side: null,
back: null, back: null,
...@@ -83,96 +76,8 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV ...@@ -83,96 +76,8 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV
return character.backImageUrl ?? null; return character.backImageUrl ?? null;
} }
function CharacterCard({ function hasCompleteThreeViews(character: Partial<Character>) {
character, return CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type));
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() {
...@@ -185,17 +90,17 @@ export function CharacterGeneration() { ...@@ -185,17 +90,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 deleteCharacter = useDeleteCharacter(pid); const deleteChar = useDeleteCharacter(pid);
const [activeTab] = useState<SettingsTab>("characters"); const [activeTab] = useState<SettingsTab>("characters");
const [showModal, setShowModal] = useState(false);
const [editingCharacter, setEditingCharacter] = useState<Partial<Character>>(emptyForm());
const [imagePreviews, setImagePreviews] = useState<ImageState>(emptyImages());
const [pendingFiles, setPendingFiles] = useState<PendingFiles>(emptyFiles());
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null); const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingChar, setEditingChar] = useState<Partial<Character>>(emptyForm());
const [imagePreviews, setImagePreviews] = useState<ImageState>(emptyImageState());
const [pendingFiles, setPendingFiles] = useState<PendingFiles>(emptyPendingFiles());
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const fileRefs = useRef<Record<CharacterViewType, HTMLInputElement | null>>({ const fileInputRefs = useRef<Record<CharacterViewType, HTMLInputElement | null>>({
front: null, front: null,
side: null, side: null,
back: null, back: null,
...@@ -207,245 +112,434 @@ export function CharacterGeneration() { ...@@ -207,245 +112,434 @@ 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 = () => {
setEditingCharacter(emptyForm()); setEditingChar(emptyForm());
setImagePreviews(emptyImages()); setImagePreviews(emptyImageState());
setPendingFiles(emptyFiles()); setPendingFiles(emptyPendingFiles());
setShowModal(true); setShowModal(true);
}; };
const openEdit = (character: Character) => { const openEdit = (character: Character) => {
setEditingCharacter({ ...character }); setEditingChar({ ...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(emptyFiles()); setPendingFiles(emptyPendingFiles());
setShowModal(true); setShowModal(true);
}; };
const handleFileSelect = (viewType: CharacterViewType, event: ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (viewType: CharacterViewType, e: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = e.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 (!editingCharacter.name?.trim()) return; if (!editingChar.name?.trim()) return;
setSaving(true);
setSaving(true);
try { try {
const saved = await saveCharacter.mutateAsync({ const saved = await saveCharacter.mutateAsync({ ...editingChar, status: editingChar.status ?? "draft" });
...editingCharacter, for (const view of CHARACTER_VIEWS) {
status: editingCharacter.status ?? "draft", const pendingFile = pendingFiles[view.type];
}); 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 = (character: Character) => { const handleGenerateImage = async (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;
deleteCharacter.mutate(character.id, { deleteChar.mutate(character.id, {
onError: (error) => alert(`删除角色失败:${(error as Error).message}`), onError: (error) => alert(`删除失败: ${(error as Error).message}`),
}); });
}; };
return ( return (
<div className="mx-auto max-w-[1440px]"> <div className="h-full overflow-auto bg-background relative">
<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="p-6 pb-24">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="max-w-7xl mx-auto">
<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">
Character Setup <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">角色设定工作台</h1> <h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
从剧本中提取角色、补充人物信息、上传三视图素材或直接使用 AI 生成形象,让后续分镜与视频生成具备统一角色基础。
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={handleExtract}
disabled={extract.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"
>
{extract.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
从剧本提取角色
</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>
</div> </div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定。角色形象已升级为标准三视图。</p>
</div> </div>
<div className="mt-6 flex flex-wrap gap-3"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
{settingsTabs.map((tab) => { {settingsTabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const active = activeTab === tab.id;
return ( return (
<button <button
key={tab.id} key={tab.id}
onClick={() => navigate(tab.path)} onClick={() => navigate(tab.path)}
className={[ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all", activeTab === tab.id
active ? "bg-accent text-accent-foreground font-medium"
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]" : "text-muted-foreground hover:text-foreground hover:bg-muted"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground", }`}
].join(" ")}
> >
<Icon className="h-4 w-4" /> <Icon className="w-4 h-4" />
{tab.label} {tab.label}
</button> </button>
); );
})} })}
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-3"> <div className="flex items-center gap-2">
{[ <button
{ label: "角色总数", value: `${characters.length}` }, 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"
label: "已具备正面图", >
value: `${characters.filter((character) => !!getCharacterViewUrl(character, "front")).length}`, <Plus className="w-4 h-4" />
}, 手动添加
{ </button>
label: "三视图完整", <button
value: `${characters.filter((character) => CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type))).length}`, onClick={handleExtract}
}, disabled={extract.isPending}
].map((item) => ( 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"
<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> {extract.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div> AI提取角色
</button>
</div> </div>
))}
</div> </div>
{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">
<span className="text-sm text-muted-foreground">角色总数</span>
<span className="text-lg font-semibold text-foreground">{characters.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-green-600">{generatedCount}</span>
</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>
) : 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> </div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">还没有角色设定</h3> </div>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
你可以直接新建角色,或者先从剧本里自动提取角色基础信息,再继续完善视觉和三视图素材。 {!isLoading && characters.length === 0 && (
<div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
<Users className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">暂无角色</h3>
<p className="text-sm text-muted-foreground mb-6">
角色设定页现在支持三视图展示。你可以手动添加角色,或从大纲和分集里自动提取。
</p> </p>
<div className="mt-6 flex flex-wrap justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<button
onClick={openCreate}
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"
>
<Plus className="w-4 h-4" />
手动添加
</button>
<button <button
onClick={handleExtract} onClick={handleExtract}
disabled={extract.isPending} disabled={extract.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:bg-muted disabled:cursor-not-allowed disabled:opacity-60" 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 && (
<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"
> >
{extract.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />} <Edit2 className="w-4 h-4" />
从剧本提取
</button> </button>
<button <button
onClick={openCreate} onClick={() => handleDelete(character)}
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]" className="p-2 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors"
> >
<Plus className="h-4 w-4" /> <Trash2 className="w-4 h-4" />
新建角色
</button> </button>
</div> </div>
</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="flex items-center justify-between mb-3">
<div>
<span className="text-xs text-muted-foreground font-medium">角色三视图</span>
<p className="text-[11px] text-muted-foreground mt-0.5">正面 + 侧面 + 背面,用于完整展示角色造型。</p>
</div>
<button
onClick={() => handleGenerateImage(character)}
disabled={isGenerating}
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"
>
{isGenerating ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{hasCompleteThreeViews(character) ? "重新生成三视图" : "AI生成三视图"}
</button>
</div>
<div className="grid grid-cols-3 gap-3">
{CHARACTER_VIEWS.map((view) => {
const imageUrl = getCharacterViewUrl(character, view.type);
return (
<div key={view.type}>
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border">
{imageUrl ? (
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" />
) : ( ) : (
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4"> <div className="w-full h-full flex flex-col items-center justify-center gap-2">
{characters.map((character) => ( {isGenerating ? (
<CharacterCard <>
key={character.id} <Loader2 className="w-6 h-6 text-primary animate-spin" />
character={character} <span className="text-xs text-muted-foreground">生成中</span>
onEdit={openEdit} </>
onGenerateImage={handleGenerateImage} ) : (
onDelete={handleDelete} <>
generating={generatingImageId === character.id} <ImageIcon className="w-6 h-6 text-muted-foreground" />
/> <span className="text-xs text-muted-foreground">待补充</span>
))} </>
)}
</div> </div>
)} )}
</section> </div>
</div>
);
})}
</div>
{showModal ? ( {character.imagePrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> <p className="mt-3 text-xs text-muted-foreground line-clamp-3">{character.imagePrompt}</p>
<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"> )}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Character Editor</div>
<h3 className="mt-2 text-2xl font-semibold text-foreground">
{editingCharacter.id ? "编辑角色资料" : "新建角色"}
</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
补充角色名称、定位、视觉描述和三视图素材,为后续分镜与视频生成提供统一角色基础。
</p>
</div> </div>
</div>
);
})}
</div>
)}
</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 className="max-w-7xl mx-auto flex items-center justify-end gap-3">
<button <button
onClick={() => setShowModal(false)} onClick={() => navigate(`/project/${projectId}/scenes`)}
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="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>
{showModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<div>
<h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2>
<p className="text-sm text-muted-foreground mt-1">支持上传正面、侧面、背面三张标准视角图。</p>
</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>
<label className="block text-sm font-medium text-foreground mb-3">角色三视图上传(可选)</label>
<div className="grid grid-cols-3 gap-4">
{CHARACTER_VIEWS.map((view) => (
<div key={view.type} className="rounded-xl border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">{view.label}</span>
<span className="text-[11px] text-muted-foreground">{view.shortLabel}视图</span>
</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"
onClick={() => fileInputRefs.current[view.type]?.click()}
> >
<X className="h-4 w-4" /> {imagePreviews[view.type] ? (
<img src={imagePreviews[view.type] ?? ""} alt={`${view.label}预览`} className="w-full h-full object-cover" />
) : (
<div className="text-center px-4">
<Upload className="w-5 h-5 text-muted-foreground mx-auto mb-2" />
<span className="text-xs text-muted-foreground">点击上传{view.label}</span>
</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> </button>
)}
</div>
<input
ref={(node) => {
fileInputRefs.current[view.type] = node;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFileSelect(view.type, e)}
/>
</div>
))}
</div>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">角色名称</label> <label className="block text-sm font-medium text-foreground mb-1">
角色名称
<span className="text-destructive ml-1">*</span>
</label>
<input <input
value={editingCharacter.name ?? ""} type="text"
onChange={(e) => setEditingCharacter((current) => ({ ...current, name: e.target.value }))} value={editingChar.name ?? ""}
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)]" onChange={(e) => setEditingChar({ ...editingChar, name: e.target.value })}
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 className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">角色定位</label> <label className="block text-sm font-medium text-foreground mb-1">角色类型</label>
<select <select
value={editingCharacter.roleType ?? "supporting"} value={editingChar.roleType ?? "supporting"}
onChange={(e) => setEditingCharacter((current) => ({ ...current, roleType: e.target.value }))} onChange={(e) => setEditingChar({ ...editingChar, roleType: 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" 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) => ( {ROLE_TYPES.map((role) => (
<option key={role.value} value={role.value}> <option key={role.value} value={role.value}>
...@@ -454,126 +548,83 @@ export function CharacterGeneration() { ...@@ -454,126 +548,83 @@ export function CharacterGeneration() {
))} ))}
</select> </select>
</div> </div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">性别</label> <label className="block text-sm font-medium text-foreground mb-1">性别</label>
<select <select
value={editingCharacter.gender ?? "female"} value={editingChar.gender ?? "female"}
onChange={(e) => setEditingCharacter((current) => ({ ...current, gender: e.target.value }))} onChange={(e) => setEditingChar({ ...editingChar, 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" 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="female">女</option>
<option value="male">男性</option> <option value="male">男</option>
</select> </select>
</div> </div>
</div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">年龄</label> <label className="block text-sm font-medium text-foreground mb-1">年龄</label>
<input <input
value={editingCharacter.age ?? ""} type="text"
onChange={(e) => setEditingCharacter((current) => ({ ...current, age: e.target.value }))} value={editingChar.age ?? ""}
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)]" onChange={(e) => setEditingChar({ ...editingChar, age: e.target.value })}
placeholder="例如:28 岁" 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>
</div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">性格特征</label> <label className="block text-sm font-medium text-foreground mb-1">性格描述</label>
<textarea <textarea
value={editingCharacter.personality ?? ""} value={editingChar.personality ?? ""}
onChange={(e) => setEditingCharacter((current) => ({ ...current, personality: e.target.value }))} onChange={(e) => setEditingChar({ ...editingChar, 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)]"
placeholder="例如:善良坚韧,外柔内刚,遇事很有主见。" 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>
<label className="mb-2 block text-sm font-medium text-foreground">外观描述</label> <label className="block text-sm font-medium text-foreground mb-1">外貌特征</label>
<textarea <textarea
value={editingCharacter.visualHint ?? ""} value={editingChar.visualHint ?? ""}
onChange={(e) => setEditingCharacter((current) => ({ ...current, visualHint: e.target.value }))} onChange={(e) => setEditingChar({ ...editingChar, visualHint: 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="例如:黑长直,杏眼,气质清冷,身形修长。" 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>
<label className="mb-2 block text-sm font-medium text-foreground">服装与造型</label> <label className="block text-sm font-medium text-foreground mb-1">服装描述</label>
<textarea <textarea
value={editingCharacter.costume ?? ""} value={editingChar.costume ?? ""}
onChange={(e) => setEditingCharacter((current) => ({ ...current, costume: e.target.value }))} onChange={(e) => setEditingChar({ ...editingChar, 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="例如:浅色风衣搭配衬衫长裤,整体简洁利落。" 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>
<div className="rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"> <div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<div className="mb-4 text-base font-medium text-foreground">三视图素材</div>
<div className="space-y-4">
{CHARACTER_VIEWS.map((view) => (
<div key={view.type} className="rounded-[24px] border border-border/80 bg-white/80 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium text-foreground">{view.label}</div>
<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 className="aspect-[4/5] overflow-hidden rounded-[20px] bg-muted">
{imagePreviews[view.type] ? (
<img
src={imagePreviews[view.type] ?? ""}
alt={view.label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)}
</div>
<input
ref={(element) => {
fileRefs.current[view.type] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => handleFileSelect(view.type, event)}
/>
</div>
))}
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button <button
onClick={() => setShowModal(false)} 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" className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || !editingCharacter.name?.trim()} disabled={!editingChar.name?.trim() || saving}
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" 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"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {saving ? <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>
); );
} }
import { useRef, useState } from "react"; import { useState, useRef } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { import {
Calendar, Plus, Film, Users, Clock, Play, Search, Calendar,
Clock3, Filter, Loader2, Pencil, Trash2, X, Check, Upload,
Film,
Loader2,
Pencil,
Plus,
Search,
Sparkles,
Trash2,
TrendingUp,
Upload,
Users,
Video,
WandSparkles,
X,
} from "lucide-react"; } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query"; import { useProjects, useUpdateProject, useDeleteProject } from "../../hooks/useProjects";
import { useDeleteProject, useProjects, useUpdateProject } from "../../hooks/useProjects";
import { useTeamMembers } from "../../hooks/useTeam"; import { useTeamMembers } from "../../hooks/useTeam";
import { projectsApi, type ProjectDTO } from "../../lib/api/projects"; import { projectsApi } from "../../lib/api/projects";
import { useQueryClient } from "@tanstack/react-query";
import type { ProjectDTO } from "../../lib/api/projects";
import { import {
aspectRatioOptions, aspectRatioOptions,
getAspectRatioLabel, getAspectRatioLabel,
...@@ -29,101 +17,44 @@ import { ...@@ -29,101 +17,44 @@ 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 hash = 0; let h = 0;
for (let i = 0; i < name.length; i += 1) { for (let i = 0; i < name.length; i++) {
hash = Math.imul(31, hash) + name.charCodeAt(i); h = (Math.imul(31, h) + name.charCodeAt(i)) | 0;
}
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%))`;
} }
const statusMeta: Record<string, { label: string; chip: string }> = { function ProjectCover({ url, name, className = "" }: { url?: string; name: string; className?: string }) {
draft: { const initials = name.slice(0, 2);
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={`h-full w-full object-cover ${className}`} />; return <img src={url} alt={name} className={`w-full h-full object-cover ${className}`} />;
} }
return ( return (
<div <div
className={`flex h-full w-full items-center justify-center ${className}`} className={`w-full h-full flex items-center justify-center ${className}`}
style={{ background: getProjectGradient(name) }} style={{ background: getProjectGradient(name) }}
> >
<div className="rounded-3xl border border-white/16 bg-white/8 px-4 py-3 text-center backdrop-blur-sm"> <span className="text-white text-2xl font-bold tracking-wider select-none opacity-80">
<div className="text-[11px] uppercase tracking-[0.32em] text-white/76">Project</div> {initials}
<div className="mt-2 text-3xl font-semibold tracking-[0.2em] text-white"> </span>
{name.slice(0, 2).toUpperCase()}
</div>
</div>
</div> </div>
); );
} }
function EditProjectDialog({ // ── Edit Modal ───────────────────────────────────────────────────────────────
function EditModal({
project, project,
onClose, onClose,
}: { }: {
project: ProjectDTO; project: ProjectDTO;
onClose: () => void; onClose: () => void;
}) { }) {
const queryClient = useQueryClient(); const qc = 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 ?? "");
...@@ -131,6 +62,7 @@ function EditProjectDialog({ ...@@ -131,6 +62,7 @@ function EditProjectDialog({
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];
...@@ -142,7 +74,6 @@ function EditProjectDialog({ ...@@ -142,7 +74,6 @@ function EditProjectDialog({
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(),
...@@ -150,13 +81,11 @@ function EditProjectDialog({ ...@@ -150,13 +81,11 @@ function EditProjectDialog({
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");
await queryClient.invalidateQueries({ queryKey: ["projects"] }); qc.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["projects", project.id] }); qc.invalidateQueries({ queryKey: ["projects", project.id] });
} }
onClose(); onClose();
} finally { } finally {
setSaving(false); setSaving(false);
...@@ -164,42 +93,41 @@ function EditProjectDialog({ ...@@ -164,42 +93,41 @@ function EditProjectDialog({
}; };
return ( return (
<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 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<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"> <div className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div className="mb-6 flex items-start justify-between gap-4"> {/* Header */}
<div> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Project Edit</div> <h3 className="text-base font-semibold text-foreground">编辑项目</h3>
<h3 className="mt-2 text-2xl font-semibold text-foreground">编辑项目资料</h3> <button onClick={onClose} className="p-1 rounded-lg hover:bg-muted transition">
<p className="mt-2 text-sm leading-6 text-muted-foreground"> <X className="w-4 h-4 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="grid gap-6 lg:grid-cols-[240px_1fr]"> <div className="px-6 py-5 space-y-5">
{/* Cover image */}
<div> <div>
<label className="block text-xs text-muted-foreground mb-2">项目封面</label>
<div className="flex items-center gap-4">
{/* Preview */}
<div <div
className="group relative aspect-[3/4] overflow-hidden rounded-[28px] border border-border/80 bg-muted shadow-sm" className="w-24 h-16 rounded-lg overflow-hidden border border-border flex-shrink-0 cursor-pointer"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
> >
<ProjectCover name={name || project.name} url={coverPreview || undefined} /> <ProjectCover url={coverPreview || undefined} name={name || project.name} className="group-hover:scale-105 transition" />
<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">
点击上传新封面
</div>
</div> </div>
<div className="flex-1">
<button <button
onClick={() => fileRef.current?.click()} 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" 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="h-4 w-4" /> <Upload className="w-3.5 h-3.5" />
上传封面 {coverPreview ? "更换封面图" : "上传封面图"}
</button> </button>
<p className="text-[11px] text-muted-foreground mt-1.5 text-center">
支持 JPG、PNG,建议 16:9
</p>
</div>
</div>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
...@@ -209,26 +137,26 @@ function EditProjectDialog({ ...@@ -209,26 +137,26 @@ function EditProjectDialog({
/> />
</div> </div>
<div className="space-y-5"> {/* Project name */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label> <label className="block text-xs text-muted-foreground mb-2">项目名称</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} 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)]" 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="请输入项目名称" placeholder="请输入项目名称"
maxLength={200} maxLength={200}
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">视觉风格</label> <label className="block text-xs text-muted-foreground mb-2">视觉风格</label>
<select <select
value={style} value={style}
onChange={(e) => setStyle(e.target.value)} onChange={(e) => setStyle(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" 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"
> >
<option value="">未设置</option> <option value="">未设置</option>
{projectStyleOptions.map((option) => ( {projectStyleOptions.map((option) => (
...@@ -238,13 +166,12 @@ function EditProjectDialog({ ...@@ -238,13 +166,12 @@ function EditProjectDialog({
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">画幅比例</label> <label className="block text-xs text-muted-foreground mb-2">画幅比例</label>
<select <select
value={aspectRatio} value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)} 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" 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"
> >
<option value="">未设置</option> <option value="">未设置</option>
{aspectRatioOptions.map((option) => ( {aspectRatioOptions.map((option) => (
...@@ -254,13 +181,12 @@ function EditProjectDialog({ ...@@ -254,13 +181,12 @@ function EditProjectDialog({
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">清晰度</label> <label className="block text-xs text-muted-foreground mb-2">清晰度</label>
<select <select
value={resolution} value={resolution}
onChange={(e) => setResolution(e.target.value)} 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" 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"
> >
<option value="">未设置</option> <option value="">未设置</option>
{resolutionOptions.map((option) => ( {resolutionOptions.map((option) => (
...@@ -271,30 +197,23 @@ function EditProjectDialog({ ...@@ -271,30 +197,23 @@ function EditProjectDialog({
</select> </select>
</div> </div>
</div> </div>
<div className="rounded-[28px] border border-primary/10 bg-primary/5 px-5 py-4">
<div className="text-sm font-medium text-foreground">品牌展示建议</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
封面、风格和分辨率会直接影响项目工作台卡片、详情页头图和后续生成流程的展示一致性。
</p>
</div>
</div>
</div> </div>
<div className="mt-6 flex items-center justify-end gap-3"> {/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
<button <button
onClick={onClose} onClick={onClose}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted" className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || !name.trim()} disabled={saving || !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" 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"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
保存更新 保存
</button> </button>
</div> </div>
</div> </div>
...@@ -302,7 +221,8 @@ function EditProjectDialog({ ...@@ -302,7 +221,8 @@ function EditProjectDialog({
); );
} }
function DeleteConfirmDialog({ // ── Delete Confirm ────────────────────────────────────────────────────────────
function DeleteConfirm({
project, project,
onClose, onClose,
}: { }: {
...@@ -310,6 +230,7 @@ function DeleteConfirmDialog({ ...@@ -310,6 +230,7 @@ function DeleteConfirmDialog({
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);
...@@ -317,30 +238,29 @@ function DeleteConfirmDialog({ ...@@ -317,30 +238,29 @@ function DeleteConfirmDialog({
}; };
return ( return (
<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 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<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="bg-card border border-border rounded-2xl shadow-xl w-full max-w-sm mx-4 p-6">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-3xl bg-red-100 text-red-600"> <div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="h-6 w-6" /> <Trash2 className="w-6 h-6 text-red-500" />
</div> </div>
<h3 className="mt-5 text-center text-2xl font-semibold text-foreground">删除项目</h3> <h3 className="text-base font-semibold text-foreground text-center mb-2">删除项目</h3>
<p className="mt-3 text-center text-sm leading-6 text-muted-foreground"> <p className="text-sm text-muted-foreground text-center mb-6">
删除后,项目“{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 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted" className="flex-1 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition"
> >
取消 取消
</button> </button>
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={deleteProject.isPending} disabled={deleteProject.isPending}
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" 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"
> >
{deleteProject.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {deleteProject.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
确认删除 删除
</button> </button>
</div> </div>
</div> </div>
...@@ -348,454 +268,308 @@ function DeleteConfirmDialog({ ...@@ -348,454 +268,308 @@ function DeleteConfirmDialog({
); );
} }
// ── 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: teamMembers = [] } = useTeamMembers(); const { data: teamMembersData = [] } = useTeamMembers();
const allMembers = teamMembersData.map((m) => m.username).filter(Boolean);
const projects = (apiProjects ?? []).map((project) => {
const status = project.status === "draft" ? "outline" : project.status; const projects = (apiProjects ?? []).map((p) => ({
const progress = getProjectProgress(project.status); raw: p,
id: p.id,
return { title: p.name,
raw: project, description: p.description ?? "",
id: project.id, status: p.status === "draft" ? "outline" : p.status,
title: project.name, progress: p.status === "completed" ? 100 : p.status === "draft" ? 0 : 50,
description: project.description ?? "", updatedAt: p.updatedAt?.slice(0, 10) ?? "",
status, createdAt: p.createdAt?.slice(0, 10) ?? "",
progress, coverUrl: p.coverUrl,
updatedAt: project.updatedAt?.slice(0, 10) ?? "",
createdAt: project.createdAt?.slice(0, 10) ?? "",
coverUrl: project.coverUrl,
tags: [ tags: [
getProjectStyleLabel(project.style), { key: "style", label: getProjectStyleLabel(p.style) },
getAspectRatioLabel(project.aspectRatio), { key: "aspectRatio", label: getAspectRatioLabel(p.aspectRatio) },
getResolutionLabel(project.resolution), { key: "resolution", label: getResolutionLabel(p.resolution) },
].filter((item) => item !== "未设置"), ].filter((tag) => tag.label !== "未设置"),
}; }));
});
const filtered = projects.filter((p) => {
const filtered = projects.filter((project) => { if (searchTerm && !p.title.toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (searchTerm && !project.title.toLowerCase().includes(searchTerm.toLowerCase())) { if (dateRange.start && p.createdAt < dateRange.start) return false;
return false; if (dateRange.end && p.createdAt > dateRange.end) 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="mx-auto max-w-[1440px]"> <div className="h-full overflow-auto bg-background p-6">
<section className="grid gap-5 xl:grid-cols-[1.25fr_0.75fr]"> <div className="max-w-7xl mx-auto">
<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>
<div className="mt-8 flex flex-wrap gap-3">
<Link
to="/new-project"
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]"
>
<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>
<div className="grid gap-5"> {/* Header */}
<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="flex items-center justify-between mb-6">
<div className="mb-4 flex items-center justify-between">
<div> <div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Today Focus</div> <h1 className="text-2xl font-semibold text-foreground mb-1">我的项目</h1>
<div className="mt-2 text-2xl font-semibold text-foreground">创作节奏总览</div> <p className="text-sm text-muted-foreground">管理和创作您的AI短剧项目</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<Clock3 className="h-5 w-5" />
</div>
</div>
<div className="space-y-3">
{[
"优先跟进处于“剧本拆解中”和“分镜推进中”的项目,能最快带动整条链路推进。",
"将项目封面、风格和分辨率补全,有利于工作台和详情页展示一致性。",
"团队成员与资产中心已就绪时,可直接进入角色、场景与视频流程。",
].map((item, index) => (
<div
key={item}
className="rounded-2xl border border-border/70 bg-white/74 px-4 py-4 shadow-sm"
>
<div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">
0{index + 1}
</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>
{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>
</>
) : (
<div className="text-sm leading-6 text-muted-foreground">
还没有项目时,建议先从“创建短剧项目”开始,系统会自动串起后续工作流。
</div>
)}
</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
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"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{stat.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 className="mt-3 text-sm leading-6 text-muted-foreground">{stat.hint}</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 <Link
to="/new-project" 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]" 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="h-4 w-4" /> <Plus className="w-4 h-4" />
新建项目 新建项目
</Link> </Link>
</div> </div>
<div className="mt-6 grid gap-4 xl:grid-cols-[1fr_auto_auto_auto]"> {/* Search & Filters */}
<div className="mb-6 p-4 rounded-xl border border-border bg-card">
<div className="grid grid-cols-5 gap-4">
<div className="col-span-2">
<label className="block text-xs text-muted-foreground mb-2">搜索项目</label>
<div className="relative"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索项目名称" 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)]" 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>
<label className="block text-xs text-muted-foreground mb-2">开始日期</label>
<div className="relative"> <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" /> <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.start} value={dateRange.start}
onChange={(e) => setDateRange((current) => ({ ...current, start: e.target.value }))} onChange={(e) => setDateRange({ ...dateRange, 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" 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>
<label className="block text-xs text-muted-foreground mb-2">结束日期</label>
<div className="relative"> <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" /> <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.end} value={dateRange.end}
onChange={(e) => setDateRange((current) => ({ ...current, end: e.target.value }))} onChange={(e) => setDateRange({ ...dateRange, 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" 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>
<label className="block text-xs text-muted-foreground mb-2">按成员筛选</label>
<div className="relative">
<button <button
onClick={() => { onClick={() => setShowMemberFilter(!showMemberFilter)}
setSearchTerm(""); 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"
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"
> >
清空筛选 <span className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
{selectedMembers.length > 0 ? `${selectedMembers.length}个成员` : "选择成员"}
</span>
<span className="text-xs text-muted-foreground">{showMemberFilter ? "▲" : "▼"}</span>
</button> </button>
</div> {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="mt-6 flex items-center justify-between gap-4"> <div className="flex flex-wrap gap-2">
<div className="text-sm text-muted-foreground"> {allMembers.map((member) => (
共 {filtered.length} 个项目,已接入 {teamMembers.length} 位团队成员协作。 <button
</div> key={member}
<div className="hidden items-center gap-2 md:flex"> onClick={() => setSelectedMembers((prev) =>
{["企业展示", "统一视觉", "流程驱动"].map((chip) => ( prev.includes(member) ? prev.filter((m) => m !== member) : [...prev, member]
<span )}
key={chip} className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
className="rounded-full bg-accent px-3 py-1 text-xs text-primary" selectedMembers.includes(member) ? "bg-primary text-white" : "bg-muted text-foreground hover:bg-muted-foreground/10"
}`}
> >
{chip} {member}
</span> </button>
))} ))}
</div> </div>
{selectedMembers.length > 0 && (
<button onClick={() => setSelectedMembers([])} className="mt-3 text-xs text-destructive hover:underline">
清除筛选
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{ label: "总项目", value: filtered.length, icon: Film },
{ label: "进行中", value: filtered.filter((p) => p.progress < 100).length, icon: Play },
{ label: "团队成员", value: allMembers.length, icon: Users },
{ label: "已完成", value: filtered.filter((p) => p.progress === 100).length, icon: Clock },
].map((s) => (
<div key={s.label} className="rounded-xl border border-border bg-card p-4">
<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 className="text-2xl font-semibold text-foreground">{s.value}</div>
<div className="text-xs text-muted-foreground">{s.label}</div>
</div>
</div>
</div>
))}
</div> </div>
{/* Projects Grid */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-24"> <div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
</div> </div>
) : filtered.length > 0 ? ( ) : filtered.length > 0 ? (
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-3 gap-4">
{filtered.map((project) => { {filtered.map((project) => (
const meta = statusMeta[project.status] ?? statusMeta.outline; <div key={project.id} className="group relative rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all">
return (
<div {/* Cover */}
key={project.id} <Link to={`/project/${project.id}`} className="block aspect-video bg-muted overflow-hidden">
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)]"
>
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
<Link to={`/project/${project.id}`} className="block h-full w-full">
<ProjectCover <ProjectCover
name={project.title}
url={project.coverUrl} url={project.coverUrl}
className="transition duration-500 group-hover:scale-105" name={project.title}
className="group-hover:scale-105 transition-transform duration-300"
/> />
{/* Status badge */}
<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"}`}>
{statusText[project.status] ?? project.status}
</div>
</Link> </Link>
<div className="absolute left-4 top-4 flex gap-2 opacity-0 transition group-hover:opacity-100"> {/* 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 <button
onClick={(e) => { onClick={(e) => { e.preventDefault(); setEditingProject(project.raw); }}
e.preventDefault(); className="w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-black/80 transition"
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="编辑项目" title="编辑项目"
> >
<Pencil className="h-4 w-4" /> <Pencil className="w-3.5 h-3.5 text-white" />
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => { e.preventDefault(); setDeletingProject(project.raw); }}
e.preventDefault(); className="w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-red-600/80 transition"
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="删除项目" title="删除项目"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="w-3.5 h-3.5 text-white" />
</button> </button>
</div> </div>
<div className={`absolute right-4 top-4 rounded-full px-3 py-1 text-xs font-medium ${meta.chip}`}> {/* Card body */}
{meta.label} <Link to={`/project/${project.id}`} className="block p-4">
</div> <h3 className="font-medium text-foreground mb-1.5 line-clamp-1">{project.title}</h3>
</div> {project.description && (
<p className="text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed">
<div className="p-5"> {project.description}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Link
to={`/project/${project.id}`}
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>
)}
<div className="mt-4 flex flex-wrap gap-2"> {project.tags.length > 0 && (
{project.tags.length > 0 ? ( <div className="flex flex-wrap gap-1.5 mb-3">
project.tags.slice(0, 3).map((tag) => ( {project.tags.slice(0, 3).map((tag) => (
<span <span key={tag.key} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">
key={tag} {tag.label}
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="mt-5"> <div className="space-y-1.5">
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>生产完成度</span> <span>完成度</span>
<span>{project.progress}%</span> <span>{project.progress}%</span>
</div> </div>
<div className="h-2.5 overflow-hidden rounded-full bg-muted"> <div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div <div
className="h-full rounded-full bg-[linear-gradient(90deg,#0f74d8_0%,#1ea5ff_100%)] transition-all" className="h-full bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] rounded-full transition-all"
style={{ width: `${project.progress}%` }} style={{ width: `${project.progress}%` }}
/> />
</div> </div>
</div> </div>
</Link>
</div> </div>
</div> ))}
);
})}
</div> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="mt-6 rounded-[30px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"> <div className="text-center py-16">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Film className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<Film className="h-7 w-7" /> <p className="text-muted-foreground mb-4">还没有项目,开始创建第一个吧</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>
<Link <Link
to="/new-project" to="/new-project"
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]" 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"
> >
<Plus className="h-4 w-4" /> <Plus className="w-4 h-4" />
创建第一个项目 新建项目
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="mt-6 rounded-[30px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"> <div className="text-center py-16">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Film className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<Search className="h-7 w-7" /> <p className="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>
<button <button
onClick={() => { onClick={() => { setSearchTerm(""); setDateRange({ start: "", end: "" }); setSelectedMembers([]); }}
setSearchTerm(""); className="mt-4 text-sm text-primary hover:underline"
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>
)} )}
</section> </div>
{editingProject ? ( {/* Modals */}
<EditProjectDialog project={editingProject} onClose={() => setEditingProject(null)} /> {editingProject && (
) : null} <EditModal
{deletingProject ? ( project={editingProject}
<DeleteConfirmDialog project={deletingProject} onClose={() => setDeletingProject(null)} /> onClose={() => setEditingProject(null)}
) : null} />
)}
{deletingProject && (
<DeleteConfirm
project={deletingProject}
onClose={() => setDeletingProject(null)}
/>
)}
</div> </div>
); );
} }
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import { ArrowRight, Film, Loader2, AlertCircle, RefreshCw } from "lucide-react";
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() {
...@@ -19,14 +11,10 @@ export function EpisodeGeneration() { ...@@ -19,14 +11,10 @@ export function EpisodeGeneration() {
if (isLoading || (isFetching && episodes.length === 0)) { if (isLoading || (isFetching && episodes.length === 0)) {
return ( return (
<div className="mx-auto max-w-[1180px]"> <div className="h-full flex items-center justify-center bg-background">
<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">
<div className="text-center"> <div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[28px] bg-primary/10 text-primary"> <Loader2 className="w-10 h-10 text-primary animate-spin mx-auto mb-4" />
<Loader2 className="h-8 w-8 animate-spin" /> <p className="text-sm text-muted-foreground">加载分集数据...</p>
</div>
<p className="mt-5 text-sm text-muted-foreground">正在加载分集数据...</p>
</div>
</div> </div>
</div> </div>
); );
...@@ -34,42 +22,35 @@ export function EpisodeGeneration() { ...@@ -34,42 +22,35 @@ export function EpisodeGeneration() {
if (error) { if (error) {
return ( return (
<div className="mx-auto max-w-[1180px]"> <div className="h-full flex items-center justify-center p-6">
<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"> <div className="flex items-center gap-3 text-red-600">
<div className="flex items-center gap-3 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-red-700"> <AlertCircle className="w-5 h-5" />
<AlertCircle className="h-5 w-5" />
<span className="text-sm">{(error as Error).message}</span> <span className="text-sm">{(error as Error).message}</span>
</div> </div>
</div> </div>
</div>
); );
} }
if (episodes.length === 0) { if (episodes.length === 0) {
return ( return (
<div className="mx-auto max-w-[1180px]"> <div className="h-full flex items-center justify-center bg-background p-6">
<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"> <div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[28px] bg-primary/10 text-primary"> <Film className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<Film className="h-8 w-8" /> <h3 className="text-lg font-medium text-foreground mb-2">暂无分集</h3>
</div> <p className="text-sm text-muted-foreground mb-6">请先在大纲页面生成分集内容</p>
<h3 className="mt-5 text-2xl font-semibold text-foreground">暂时还没有分集内容</h3> <div className="flex gap-3 justify-center">
<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="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" 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"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="w-4 h-4" />
刷新 刷新
</button> </button>
<button <button
onClick={() => navigate(`/project/${projectId}/outline`)} 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]" className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm"
> >
<Sparkles className="h-4 w-4" /> 返回大纲页面
返回大纲页
</button> </button>
</div> </div>
</div> </div>
...@@ -78,70 +59,57 @@ export function EpisodeGeneration() { ...@@ -78,70 +59,57 @@ export function EpisodeGeneration() {
} }
return ( return (
<div className="mx-auto max-w-[1180px]"> <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-6xl mx-auto">
<div className="mb-6 flex flex-wrap items-start justify-between gap-4"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Episodes</div> <h1 className="text-2xl font-semibold text-foreground mb-1">分集列表</h1>
<h1 className="mt-2 text-3xl font-semibold text-foreground">分集列表</h1> <p className="text-sm text-muted-foreground">AI已生成 {episodes.length} 集内容</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
AI 已生成 {episodes.length} 集内容,建议按顺序进入分镜工作台继续推进镜头设计。
</p>
</div> </div>
{isFetching && (
{isFetching ? ( <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm text-primary"> <Loader2 className="w-4 h-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" /> 刷新中...
正在刷新
</div> </div>
) : null} )}
</div> </div>
<div className="grid gap-4"> <div className="grid grid-cols-1 gap-3 mb-8">
{episodes.map((episode) => ( {episodes.map((ep) => (
<button <div
key={episode.id} key={ep.id}
onClick={() => navigate(`/project/${projectId}/storyboard/${episode.id}`)} onClick={() => navigate(`/project/${projectId}/storyboard/${ep.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" className="rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<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)]"> <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">
{episode.episodeNumber} {ep.episodeNumber}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Clapperboard className="h-4 w-4 text-primary" />
<span className="text-lg font-medium text-foreground">
{episode.title || `第 ${episode.episodeNumber} 集`}
</span>
</div> </div>
<p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground"> <div className="flex-1 min-w-0">
{episode.summary} <div className="flex items-center gap-2 mb-1">
</p> <Film className="w-3.5 h-3.5 text-primary flex-shrink-0" />
</div> <span className="font-medium text-foreground text-sm">{ep.title || `第${ep.episodeNumber}集`}</span>
<span </div>
className={[ <p className="text-xs text-muted-foreground line-clamp-2">{ep.summary}</p>
"rounded-full px-3 py-1 text-xs", </div>
episode.status === "ready" <span className={`text-xs px-2 py-1 rounded-full flex-shrink-0 ${
? "bg-emerald-100 text-emerald-700" ep.status === "ready" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
: "bg-slate-100 text-slate-600", }`}>
].join(" ")} {ep.status === "ready" ? "已就绪" : "草稿"}
>
{episode.status === "ready" ? "已就绪" : "草稿"}
</span> </span>
</div> </div>
</button> </div>
))} ))}
</div> </div>
<button <button
onClick={() => navigate(`/project/${projectId}/storyboard/${episodes[0].id}`)} onClick={() => navigate(`/project/${projectId}/storyboard/${episodes[0].id}`)}
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]" 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"
> >
进入分镜工作台 <span>进入分镜工作台</span>
<ArrowRight className="h-5 w-5" /> <ArrowRight className="w-5 h-5" />
</button> </button>
</section> </div>
</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();
...@@ -17,31 +16,16 @@ export function LoginPage() { ...@@ -17,31 +16,16 @@ export function LoginPage() {
}; };
return ( return (
<AuthShell <div className="min-h-screen flex items-center justify-center bg-background px-4">
title="登录企业工作台" <Card className="w-full max-w-sm">
description="使用企业账号进入中科集团 AI 短剧工业化平台,继续推进剧本、设定、分镜与视频任务。" <CardHeader className="text-center">
footer={ <CardTitle className="text-2xl">YaoAI Comic Studio</CardTitle>
<div className="flex items-center justify-between gap-3"> <CardDescription>登录您的账号</CardDescription>
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground"> </CardHeader>
<ShieldCheck className="h-4 w-4 text-primary" /> <CardContent>
统一身份认证与团队空间隔离 <form onSubmit={handleSubmit} className="space-y-4">
</div>
<div>
没有账号?
<Link to="/register" className="ml-1 font-medium text-primary hover:underline">
立即注册
</Link>
</div>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm text-foreground"> <Label htmlFor="email">邮箱</Label>
邮箱账号
</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 <Input
id="email" id="email"
type="email" type="email"
...@@ -49,20 +33,10 @@ export function LoginPage() { ...@@ -49,20 +33,10 @@ export function LoginPage() {
required required
value={form.email} value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} 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>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <Label htmlFor="password">密码</Label>
<Label htmlFor="password" className="text-sm text-foreground">
登录密码
</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 <Input
id="password" id="password"
type="password" type="password"
...@@ -70,25 +44,28 @@ export function LoginPage() { ...@@ -70,25 +44,28 @@ export function LoginPage() {
required required
value={form.password} value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} 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>
</div>
{loginError ? ( {loginError && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <p className="text-sm text-destructive">
{loginError instanceof Error ? loginError.message : "登录失败,请检查账号和密码。"} {loginError instanceof Error ? loginError.message : "登录失败"}
</div> </p>
) : null} )}
<Button <Button type="submit" className="w-full" disabled={loginPending}>
type="submit" {loginPending ? "登录中..." : "登录"}
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}
>
{loginPending ? "正在登录..." : "进入工作台"}
</Button> </Button>
<p className="text-center text-sm text-muted-foreground">
没有账号?{" "}
<Link to="/register" className="text-primary underline-offset-4 hover:underline">
立即注册
</Link>
</p>
</form> </form>
</AuthShell> </CardContent>
</Card>
</div>
); );
} }
import { useEffect, useMemo, useState } from "react"; import { useState, useEffect } from "react";
import { import {
Check,
Image as ImageIcon,
Loader2,
MessageSquare,
Plus,
Save,
Search,
Settings2,
Sparkles, Sparkles,
Plus,
Trash2, Trash2,
Video,
X, X,
Check,
Search,
MessageSquare,
Image as ImageIcon,
Video,
Zap,
Settings2,
Loader2,
} 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 { import type { ModelProvider, ModelDefaults, CreateProviderPayload } from "../../lib/api/modelProviders";
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 capabilityMeta = { const protocolOptions: { value: Protocol; label: string; capabilities: Capability[] }[] = [
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: "阿里云百炼", capabilities: ["text", "image"] }, { value: "dashscope", label: "阿里云百炼 (DashScope)", 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 [showModal, setShowModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<EditingProvider | null>(null); const [editingProvider, setEditingProvider] = useState<EditingProvider | null>(null);
const [modelSearch, setModelSearch] = useState(""); const [modelSearch, setModelSearch] = useState("");
...@@ -74,72 +69,70 @@ export function ModelSettings() { ...@@ -74,72 +69,70 @@ export function ModelSettings() {
const [localDefaults, setLocalDefaults] = useState<ModelDefaults>({}); const [localDefaults, setLocalDefaults] = useState<ModelDefaults>({});
useEffect(() => { useEffect(() => {
if (!loadingDefaults) { if (!loadingDefaults) setLocalDefaults(defaults);
setLocalDefaults(defaults);
}
}, [loadingDefaults, defaults]); }, [loadingDefaults, defaults]);
const providersByCapability = useMemo( const getProvidersByCapability = (cap: Capability) =>
() => providers.filter((provider) => provider.capability === activeCapability), providers.filter((p) => p.capability === cap);
[providers, activeCapability],
);
const checkedModels = useMemo(() => {
return providers.map((provider) => ({
provider,
models: provider.models.filter((model) => model.checked),
}));
}, [providers]);
const openCreate = (capability: Capability) => { const getCheckedModels = (cap: Capability) => {
const protocol = protocolOptions.find((item) => item.capabilities.includes(capability)); const result: { providerId: number; providerName: string; modelId: string; modelName: string }[] = [];
setEditingProvider({ providers
name: "", .filter((p) => p.capability === cap)
protocol: protocol?.value ?? "openai", .forEach((p) => {
capability, (p.models ?? []).filter((m) => m.checked).forEach((m) => {
baseUrl: "", result.push({ providerId: p.id, providerName: p.name, modelId: m.id, modelName: m.name });
apiKey: "", });
secretKey: "",
models: [],
}); });
setShowModal(true); return result;
}; };
const handleToggleModel = async (provider: ModelProvider, modelId: string) => { const handleToggleModel = async (provider: ModelProvider, modelId: string) => {
const patch = { const updated = {
...provider, ...provider,
models: provider.models.map((model) => models: provider.models.map((m) =>
model.id === modelId ? { ...model, checked: !model.checked } : model, m.id === modelId ? { ...m, checked: !m.checked } : m
), ),
}; };
await updateProvider.mutateAsync({ id: provider.id, patch }); await updateProvider.mutateAsync({ id: provider.id, patch: updated });
}; };
const handleDeleteProvider = async (id: number) => { const handleDeleteProvider = async (id: number) => {
if (!confirm("确定要删除该模型提供商配置吗?")) return; if (!confirm("确定要删除该供应商配置吗?")) return;
await deleteProvider.mutateAsync(id); await deleteProvider.mutateAsync(id);
}; };
const openAddProvider = (capability: Capability) => {
const proto = protocolOptions.find((p) => p.capabilities.includes(capability));
setEditingProvider({
name: "",
protocol: proto?.value || "openai",
capability,
baseUrl: "",
apiKey: "",
secretKey: "",
models: [],
});
setShowAddModal(true);
};
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, capability: editingProvider.capability as 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);
}; };
...@@ -149,340 +142,394 @@ export function ModelSettings() { ...@@ -149,340 +142,394 @@ export function ModelSettings() {
if (loadingProviders) { if (loadingProviders) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="w-6 h-6 animate-spin text-primary" />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-[1440px]"> <div className="h-full overflow-auto bg-background">
<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="p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="max-w-5xl mx-auto">
<div> {/* Header */}
<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="mb-8">
<Settings2 className="h-3.5 w-3.5" /> <div className="flex items-center gap-3 mb-2">
Model Hub <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
</div> <Settings2 className="w-4 h-4 text-white" />
<h1 className="mt-4 text-3xl font-semibold text-foreground">模型配置中心</h1> </div>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"> <h1 className="text-2xl font-semibold text-foreground">模型配置</h1>
管理语言、图像与视频模型提供商,并为每类能力设置默认模型,保证生产流程的调用一致性。 </div>
<p className="text-sm text-muted-foreground">
配置 AI 模型供应商和默认模型,支持语言模型、图片模型和视频模型的独立配置
</p> </p>
</div> </div>
<button {/* Default Model Selection */}
onClick={() => openCreate(activeCapability)} <div className="rounded-xl border border-border bg-card p-5 mb-6">
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]" <h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
> <Zap className="w-4 h-4 text-yellow-500" />
<Plus className="h-4 w-4" /> 默认模型
新建模型提供商 </h2>
</button> <p className="text-xs text-muted-foreground mb-4">
</div> 选择每种任务类型的默认模型,生成时可临时切换。仅显示已勾选的模型。
</p>
<div className="mt-6 grid gap-6 xl:grid-cols-[1fr_0.96fr]"> <div className="grid grid-cols-3 gap-4">
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> {(["text", "image", "video"] as Capability[]).map((cap) => {
<div className="mb-4 flex items-center justify-between"> const config = capabilityConfig[cap];
<div> const Icon = config.icon;
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Default Models</div> const checked = getCheckedModels(cap);
<h2 className="mt-2 text-2xl font-semibold text-foreground">默认模型</h2> const current = localDefaults[cap];
</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 <div key={cap} className="rounded-lg border border-border p-4">
key={capability} <div className="flex items-center gap-2 mb-3">
className="rounded-[28px] border border-border/80 bg-white px-4 py-5 shadow-sm" <div className={`w-7 h-7 rounded-md flex items-center justify-center ${bgColorMap[config.color]}`}>
> <Icon className="w-3.5 h-3.5" />
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"> </div>
<Icon className="h-5 w-5" /> <span className="text-sm font-medium text-foreground">{config.label}</span>
</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={ value={current ? `${current.providerId}:${current.modelId}` : ""}
localDefaults[capability]
? `${localDefaults[capability]?.providerId}:${localDefaults[capability]?.modelId}`
: ""
}
onChange={(e) => { onChange={(e) => {
if (!e.target.value) { if (!e.target.value) {
setLocalDefaults((current) => ({ ...current, [capability]: null })); setLocalDefaults({ ...localDefaults, [cap]: null });
} else { } else {
const [providerId, modelId] = e.target.value.split(":"); const [pid, mid] = e.target.value.split(":");
setLocalDefaults((current) => ({ setLocalDefaults({ ...localDefaults, [cap]: { providerId: Number(pid), modelId: mid } });
...current,
[capability]: { providerId: Number(providerId), modelId },
}));
} }
}} }}
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" 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"
> >
<option value="">未选择</option> <option value="">未选择</option>
{available.map((model) => ( {checked.map((m) => (
<option <option key={`${m.providerId}:${m.modelId}`} value={`${m.providerId}:${m.modelId}`}>
key={`${model.providerId}:${model.modelId}`} {m.providerName} / {m.modelName}
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> <div className="mt-4 flex justify-end">
<button
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> onClick={handleSaveDefaults}
<div className="mb-4 flex items-center justify-between"> disabled={saveDefaults.isPending}
<div> 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"
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Provider Overview</div> >
<h2 className="mt-2 text-2xl font-semibold text-foreground">能力分类</h2> {saveDefaults.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
保存默认模型
</button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> {/* Capability Tabs */}
{(["text", "image", "video"] as Capability[]).map((capability) => { <div className="flex items-center gap-2 mb-6">
const meta = capabilityMeta[capability]; {(["text", "image", "video"] as Capability[]).map((cap) => {
const Icon = meta.icon; const config = capabilityConfig[cap];
const active = activeCapability === capability; const Icon = config.icon;
const count = getProvidersByCapability(cap).length;
return ( return (
<button <button
key={capability} key={cap}
onClick={() => setActiveCapability(capability)} onClick={() => setActiveCapability(cap)}
className={[ className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm transition-all ${
"rounded-[28px] border px-5 py-5 text-left transition-all", activeCapability === cap
active ? `bg-gradient-to-r ${colorMap[config.color]} text-white shadow-sm`
? "border-primary/20 bg-primary/5 shadow-[0_16px_28px_rgba(15,116,216,0.10)]" : "bg-card border border-border text-foreground hover:bg-muted"
: "border-border/80 bg-white shadow-sm hover:border-primary/20", }`}
].join(" ")}
> >
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"> <Icon className="w-4 h-4" />
<Icon className="h-5 w-5" /> {config.label}
</div> <span className={`px-1.5 py-0.5 rounded text-xs ${
<div className="text-base font-medium text-foreground">{meta.label}</div> activeCapability === cap ? "bg-white/20" : "bg-muted text-muted-foreground"
<div className="mt-2 text-sm text-muted-foreground"> }`}>
{providers.filter((provider) => provider.capability === capability).length} 个提供商 {count}
</div> </span>
</button> </button>
); );
})} })}
</div> </div>
</div>
</div>
<div className="mt-6 rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> <div className="rounded-lg border border-border bg-muted/30 p-3 mb-4 flex items-start gap-2">
<div className="mb-4 flex flex-wrap items-center justify-between gap-4"> <Sparkles className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<div> <span className="text-xs text-muted-foreground">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Provider List</div> {capabilityConfig[activeCapability].description}
<h2 className="mt-2 text-2xl font-semibold text-foreground"> </span>
{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">
{providersByCapability.length === 0 ? ( {getProvidersByCapability(activeCapability).map((provider) => (
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"> <div key={provider.id} className="rounded-xl border border-border bg-card overflow-hidden">
当前能力下还没有配置提供商,请先新增一个模型提供商。 <div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${bgColorMap[capabilityConfig[activeCapability].color]}`}>
<span className="text-xs font-bold">{provider.name[0]}</span>
</div> </div>
) : (
providersByCapability.map((provider) => (
<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>
<div className="text-lg font-medium text-foreground">{provider.name}</div> <h3 className="text-sm font-semibold text-foreground">{provider.name}</h3>
<div className="mt-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 mt-0.5">
协议:{protocolOptions.find((option) => option.value === provider.protocol)?.label ?? provider.protocol} <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 className="mt-2 text-sm text-muted-foreground">
接口地址:{provider.baseUrl || "未设置"}
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<button
onClick={() => { setEditingProvider({ ...provider }); setShowAddModal(true); }}
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"
>
<Settings2 className="w-3 h-3" />
配置
</button>
<button <button
onClick={() => handleDeleteProvider(provider.id)} onClick={() => handleDeleteProvider(provider.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" 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="h-4 w-4" /> <Trash2 className="w-3 h-3" />
删除
</button> </button>
</div> </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 className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3"> {/* Model List */}
{provider.models <div className="p-4">
.filter((model) => !modelSearch || model.name.toLowerCase().includes(modelSearch.toLowerCase())) <div className="flex items-center justify-between mb-3">
.map((model) => ( <span className="text-xs text-muted-foreground font-medium">可用模型</span>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
<input
type="text"
placeholder="搜索模型..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
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"
/>
</div>
</div>
<div className="space-y-1">
{(provider.models ?? [])
.filter((m) => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()) || m.id.toLowerCase().includes(modelSearch.toLowerCase()))
.map((model) => {
const isDefault = localDefaults[provider.capability as Capability]?.providerId === provider.id &&
localDefaults[provider.capability as Capability]?.modelId === model.id;
return (
<button <button
key={model.id} key={model.id}
onClick={() => handleToggleModel(provider, model.id)} onClick={() => handleToggleModel(provider, model.id)}
className={[ className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all text-left ${
"rounded-[20px] border px-4 py-4 text-left transition-all", model.checked ? "bg-accent border border-primary/30" : "border border-transparent hover:bg-muted"
model.checked }`}
? "border-primary/20 bg-primary/5"
: "border-border/80 bg-white hover:border-primary/20",
].join(" ")}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-center gap-3">
<div> <div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${
<div className="text-sm font-medium text-foreground">{model.name}</div> model.checked ? "border-primary bg-primary" : "border-muted-foreground"
<div className="mt-1 text-xs text-muted-foreground">{model.id}</div> }`}>
{model.checked && <Check className="w-3 h-3 text-white" />}
</div> </div>
{model.checked ? ( <div>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white"> <span className="text-sm text-foreground">{model.name}</span>
<Check className="h-3.5 w-3.5" /> <span className="text-xs text-muted-foreground ml-2 font-mono">{model.id}</span>
</div> </div>
) : null}
</div> </div>
{isDefault && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary font-medium">
默认
</span>
)}
</button> </button>
))} );
</div> })}
</div> {(provider.models ?? []).length === 0 && (
)) <p className="text-xs text-muted-foreground py-2 text-center">暂无模型,请编辑供应商添加</p>
)} )}
</div> </div>
</div> </div>
</section> </div>
))}
{showModal && editingProvider ? ( <button
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> onClick={() => openAddProvider(activeCapability)}
<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"> 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"
<div className="mb-6 flex items-start justify-between gap-4"> >
<div> <Plus className="w-4 h-4" />
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Provider Editor</div> 添加{capabilityConfig[activeCapability].label}供应商
<h3 className="mt-2 text-2xl font-semibold text-foreground">新建模型提供商</h3> </button>
</div>
</div> </div>
</div>
{/* Add/Edit Provider Modal */}
{showAddModal && editingProvider && (
<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 max-h-[85vh] overflow-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-foreground">
{editingProvider.id ? "编辑供应商" : "添加供应商"}
</h2>
<button <button
onClick={() => setShowModal(false)} onClick={() => { setShowAddModal(false); setEditingProvider(null); }}
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="p-2 hover:bg-muted rounded-lg transition-colors"
> >
<X className="h-4 w-4" /> <X className="w-5 h-5 text-muted-foreground" />
</button> </button>
</div> </div>
<div className="space-y-5"> <div className="space-y-4">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">提供商名称</label> <label className="block text-sm font-medium text-foreground mb-2">供应商名称</label>
<input <input
type="text"
value={editingProvider.name} value={editingProvider.name}
onChange={(e) => setEditingProvider((current) => current ? { ...current, name: e.target.value } : current)} onChange={(e) => setEditingProvider({ ...editingProvider, 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="例如:通义千问、OpenAI、Gemini"
placeholder="例如:OpenAI、Gemini、可灵" 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="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">协议</label> <label className="block text-sm font-medium text-foreground mb-2">协议</label>
<select <select
value={editingProvider.protocol} value={editingProvider.protocol}
onChange={(e) => setEditingProvider((current) => current ? { ...current, protocol: e.target.value as Protocol } : current)} onChange={(e) => setEditingProvider({ ...editingProvider, protocol: 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" 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"
> >
{protocolOptions.map((option) => ( {protocolOptions
<option key={option.value} value={option.value}> .filter((p) => p.capabilities.includes(editingProvider.capability as Capability))
{option.label} .map((p) => (
</option> <option key={p.value} value={p.value}>{p.label}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">能力类型</label> <label className="block text-sm font-medium text-foreground mb-2">Base URL</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>
<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((current) => current ? { ...current, baseUrl: e.target.value } : current)} onChange={(e) => setEditingProvider({ ...editingProvider, baseUrl: 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="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="mb-2 block text-sm font-medium text-foreground">API Key</label> <label className="block text-sm font-medium text-foreground mb-2">API Key</label>
<input <input
type="password"
value={editingProvider.apiKey} value={editingProvider.apiKey}
onChange={(e) => setEditingProvider((current) => current ? { ...current, apiKey: e.target.value } : current)} onChange={(e) => setEditingProvider({ ...editingProvider, apiKey: 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="sk-xxxxxxxxxxxxxxxx(留空保持不变)"
placeholder="sk-xxxxxxxx" 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>
{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> <div>
<label className="mb-2 block text-sm font-medium text-foreground">Secret Key</label> <label className="block text-sm font-medium text-foreground mb-2">模型 ID(回车添加)</label>
<input <input
value={editingProvider.secretKey ?? ""} type="text"
onChange={(e) => setEditingProvider((current) => current ? { ...current, secretKey: e.target.value } : current)} placeholder="输入模型 ID,例如 gpt-4o"
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="如需额外 Secret Key,可在这里填写" onKeyDown={(e) => {
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="mt-6 flex items-center justify-end gap-3"> <div className="flex gap-3 mt-6 pt-6 border-t border-border">
<button <button
onClick={() => setShowModal(false)} onClick={() => { setShowAddModal(false); setEditingProvider(null); }}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted" className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSaveProvider} onClick={handleSaveProvider}
disabled={createProvider.isPending || updateProvider.isPending} disabled={!editingProvider.name.trim() || createProvider.isPending || updateProvider.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" 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"
> >
{createProvider.isPending || updateProvider.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {(createProvider.isPending || updateProvider.isPending) ? (
保存配置 <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,8 +3,6 @@ import { useNavigate } from "react-router"; ...@@ -3,8 +3,6 @@ import { useNavigate } from "react-router";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
CheckCircle2,
Clapperboard,
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
...@@ -16,7 +14,6 @@ import { Progress } from "../components/ui/progress"; ...@@ -16,7 +14,6 @@ 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";
...@@ -25,26 +22,24 @@ const submitStageMeta: Record< ...@@ -25,26 +22,24 @@ 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);
...@@ -54,13 +49,14 @@ export function NewProject() { ...@@ -54,13 +49,14 @@ 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 = Boolean(scriptFile || coverFile); const hasUpload = !!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);
...@@ -71,10 +67,11 @@ export function NewProject() { ...@@ -71,10 +67,11 @@ 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((previous) => { setCoverPreview((prev) => {
if (previous) URL.revokeObjectURL(previous); if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file); return URL.createObjectURL(file);
}); });
}; };
...@@ -128,39 +125,34 @@ export function NewProject() { ...@@ -128,39 +125,34 @@ 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="mx-auto max-w-[1380px]"> <div className="relative h-full overflow-auto bg-background">
{activeSubmitMeta ? ( {activeSubmitMeta && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-md"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-sm">
<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="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-2xl">
<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)]"> <div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]">
<Loader2 className="h-8 w-8 animate-spin text-white" /> <Loader2 className="h-7 w-7 animate-spin text-white" />
</div> </div>
<h2 className="text-2xl font-semibold text-foreground">{activeSubmitMeta.title}</h2> <h2 className="mb-2 text-xl font-semibold text-foreground">{activeSubmitMeta.title}</h2>
<p className="mt-3 text-sm leading-7 text-muted-foreground"> <p className="mb-5 text-sm leading-relaxed text-muted-foreground">
{activeSubmitMeta.description} {activeSubmitMeta.description}
</p> </p>
<Progress value={activeSubmitMeta.progress} className="mb-4 h-2.5" />
<div className="mt-6 rounded-[28px] border border-primary/10 bg-primary/5 p-5"> <div className="space-y-2 text-sm">
<div className="mb-3 flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-foreground">
<span className="font-medium text-foreground">流程进度</span> <span>创建项目</span>
<span className="text-primary">{activeSubmitMeta.progress}%</span> <span>{submitStage === "creating" ? "进行中" : "已准备"}</span>
</div> </div>
<Progress value={activeSubmitMeta.progress} className="h-2.5" /> <div className="flex items-center justify-between text-foreground">
<div className="mt-5 space-y-3 text-sm"> <span>上传素材</span>
<div className="flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3"> <span>
<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 {!hasUpload
? "已跳过" ? "已跳过"
: submitStage === "uploading" : submitStage === "uploading"
...@@ -170,113 +162,34 @@ export function NewProject() { ...@@ -170,113 +162,34 @@ export function NewProject() {
: "等待中"} : "等待中"}
</span> </span>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3"> <div className="flex items-center justify-between text-foreground">
<span className="text-foreground">进入剧本拆解</span> <span>进入大纲页</span>
<span className="text-muted-foreground"> <span>{submitStage === "redirecting" ? "进行中" : "等待中"}</span>
{submitStage === "redirecting" ? "进行中" : "等待中"}
</span>
</div>
</div>
</div> </div>
<p className="mt-5 text-xs leading-6 text-muted-foreground">
当前为前端阶段性反馈,后续可继续接入后端真实进度接口,显示更精确的任务状态。
</p>
</div> </div>
</div> <p className="mt-4 text-xs text-muted-foreground">
) : null} 处理中已锁定按钮,避免重复点击创建多个项目。
<section className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]">
<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-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">
<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> </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>
<div className="mt-8 rounded-[28px] border border-white/14 bg-white/10 p-5 backdrop-blur-sm">
<div className="mb-3 text-xs uppercase tracking-[0.24em] text-white/64">
Recommended Flow
</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> </div>
)}
<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="mx-auto max-w-4xl p-8">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="mb-8 text-center">
<div> <h1 className="mb-2 text-3xl font-semibold text-foreground">创建短剧项目</h1>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Project Setup</div> <p className="text-sm text-muted-foreground">
<h2 className="mt-2 text-3xl font-semibold text-foreground">项目基础配置</h2> 可以先上传剧本直接进入解析流程,也可以跳过上传,在下一页手动输入内容生成大纲。
<p className="mt-3 text-sm leading-6 text-muted-foreground">
定义项目名称、剧本素材、封面与视觉参数,这些配置会贯穿整个创作流程。
</p> </p>
</div> </div>
<div className="flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary">
<Clapperboard className="h-6 w-6" />
</div>
</div>
{submitError ? ( {submitError && (
<div className="mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4"> <div className="mb-5 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" /> <AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" />
<p className="text-sm leading-6 text-red-700">{submitError}</p> <p className="text-sm text-red-700">{submitError}</p>
</div> </div>
) : null} )}
<div className="space-y-6"> <div className="mb-5">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label> <label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<input <input
type="text" type="text"
...@@ -284,24 +197,23 @@ export function NewProject() { ...@@ -284,24 +197,23 @@ export function NewProject() {
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
placeholder="输入项目名称,或上传剧本后自动带入文件名" 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" 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="grid gap-5 md:grid-cols-[220px_1fr]"> <div className="mb-6 flex gap-4">
<div> <div className="flex-shrink-0">
<div className="mb-2 flex items-center justify-between"> <label className="mb-2 block text-sm font-medium text-foreground">
<label className="block text-sm font-medium text-foreground">项目封面</label> 封面图片
<span className="text-xs text-muted-foreground">可选</span> <span className="ml-1 font-normal text-muted-foreground">(可选)</span>
</div> </label>
<div className="relative flex h-40 w-28 items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-card">
<div className="relative flex aspect-[3/4] items-center justify-center overflow-hidden rounded-[28px] border-2 border-dashed border-border bg-muted">
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleCoverChange} onChange={handleCoverChange}
disabled={isSubmitting} disabled={isSubmitting}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/> />
{coverPreview ? ( {coverPreview ? (
<> <>
...@@ -309,45 +221,41 @@ export function NewProject() { ...@@ -309,45 +221,41 @@ export function NewProject() {
<button <button
onClick={removeCover} onClick={removeCover}
disabled={isSubmitting} 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" 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-4 w-4" /> <X className="h-3 w-3 text-white" />
</button> </button>
</> </>
) : ( ) : (
<div className="px-4 text-center"> <div className="p-2 text-center">
<ImageIcon className="mx-auto mb-3 h-7 w-7 text-muted-foreground" /> <ImageIcon className="mx-auto mb-1 h-6 w-6 text-muted-foreground" />
<div className="text-sm text-foreground">点击上传封面</div> <span className="text-xs text-muted-foreground">点击上传</span>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
建议使用竖版海报或封面图,用于项目卡片与详情页头图展示。
</div>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div> <div className="flex-1">
<div className="mb-2 flex items-center justify-between"> <label className="mb-2 block text-sm font-medium text-foreground">
<label className="block text-sm font-medium text-foreground">剧本文件</label> 剧本文件
<span className="text-xs text-muted-foreground">可选</span> <span className="ml-1 font-normal text-muted-foreground">(可选)</span>
</div> </label>
<div className="relative flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-border bg-card">
<div className="relative flex min-h-[300px] items-center justify-center rounded-[28px] border-2 border-dashed border-border bg-white">
<input <input
type="file" type="file"
accept=".txt,.doc,.docx,.pdf,.md" accept=".txt,.doc,.docx,.pdf,.md"
onChange={handleScriptChange} onChange={handleScriptChange}
disabled={isSubmitting} disabled={isSubmitting}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/> />
{scriptFile ? ( {scriptFile ? (
<div className="flex flex-col items-center gap-4 px-8 py-8 text-center"> <div className="flex flex-col items-center gap-3">
<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)]"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]">
<FileText className="h-8 w-8" /> <FileText className="h-6 w-6 text-white" />
</div> </div>
<div> <div className="text-center">
<div className="text-base font-medium text-foreground">{scriptFile.name}</div> <div className="mb-0.5 text-sm font-medium text-foreground">{scriptFile.name}</div>
<div className="mt-2 text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground">
{(scriptFile.size / 1024).toFixed(1)} KB {(scriptFile.size / 1024).toFixed(1)} KB
</div> </div>
</div> </div>
...@@ -357,23 +265,20 @@ export function NewProject() { ...@@ -357,23 +265,20 @@ export function NewProject() {
if (!isSubmitting) setScriptFile(null); if (!isSubmitting) setScriptFile(null);
}} }}
disabled={isSubmitting} disabled={isSubmitting}
className="text-sm font-medium text-primary transition hover:underline disabled:no-underline disabled:opacity-50" className="text-xs text-primary hover:underline disabled:no-underline disabled:opacity-50"
> >
重新上传 重新上传
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-4 px-8 py-8 text-center"> <div className="flex flex-col items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-[28px] bg-muted text-muted-foreground"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
<Upload className="h-8 w-8" /> <Upload className="h-6 w-6 text-muted-foreground" />
</div> </div>
<div> <div className="text-center">
<div className="text-base font-medium text-foreground"> <div className="mb-0.5 text-sm text-foreground">点击或拖拽剧本文件到这里</div>
点击或拖拽剧本文件到这里 <div className="text-xs text-muted-foreground">
</div> 支持 doc、docx、txt、pdf、md,大小不超过 20M;也可以先跳过,下一页手动输入剧本。
<div className="mt-2 text-sm leading-6 text-muted-foreground">
支持 doc、docx、txt、pdf、md,建议单个文件不超过 20 MB。
如果先不上传,也可以在下一页手动输入剧本文本。
</div> </div>
</div> </div>
</div> </div>
...@@ -382,15 +287,14 @@ export function NewProject() { ...@@ -382,15 +287,14 @@ export function NewProject() {
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="mb-6 grid grid-cols-3 gap-4">
<div className="rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm"> <div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">Aspect Ratio</div> <div className="mb-2 text-xs text-muted-foreground">画幅比例</div>
<div className="mb-3 text-lg font-medium text-foreground">画幅比例</div>
<select <select
value={aspectRatio} value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)} onChange={(e) => setAspectRatio(e.target.value)}
disabled={isSubmitting} 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" 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"
> >
{aspectRatioOptions.map((option) => ( {aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
...@@ -400,14 +304,13 @@ export function NewProject() { ...@@ -400,14 +304,13 @@ export function NewProject() {
</select> </select>
</div> </div>
<div className="rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm"> <div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">Resolution</div> <div className="mb-2 text-xs text-muted-foreground">清晰度</div>
<div className="mb-3 text-lg font-medium text-foreground">清晰度</div>
<select <select
value={resolution} value={resolution}
onChange={(e) => setResolution(e.target.value)} onChange={(e) => setResolution(e.target.value)}
disabled={isSubmitting} 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" 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) => ( {resolutionOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
...@@ -417,14 +320,13 @@ export function NewProject() { ...@@ -417,14 +320,13 @@ export function NewProject() {
</select> </select>
</div> </div>
<div className="rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm"> <div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs uppercase tracking-[0.24em] text-primary/70">Style</div> <div className="mb-2 text-xs text-muted-foreground">视觉风格</div>
<div className="mb-3 text-lg font-medium text-foreground">视觉风格</div>
<select <select
value={style} value={style}
onChange={(e) => setStyle(e.target.value)} onChange={(e) => setStyle(e.target.value)}
disabled={isSubmitting} 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" 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"
> >
{projectStyleOptions.map((option) => ( {projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
...@@ -435,43 +337,29 @@ export function NewProject() { ...@@ -435,43 +337,29 @@ export function NewProject() {
</div> </div>
</div> </div>
<div className="rounded-[28px] border border-primary/12 bg-primary/5 p-5"> <div className="mb-6 rounded-xl border border-primary/20 bg-accent/50 p-4">
<div className="mb-3 flex items-center gap-3"> <div className="flex items-start gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10">
<CheckCircle2 className="h-5 w-5" /> <Sparkles className="h-5 w-5 text-primary" />
</div> </div>
<div> <div>
<div className="text-base font-medium text-foreground">创建后会发生什么?</div> <h3 className="mb-1 text-sm font-medium text-foreground">AI 智能处理</h3>
<div className="text-sm text-muted-foreground">项目会立刻接入后续 AI 剧本拆解流程。</div> <p className="text-sm leading-relaxed text-muted-foreground">
</div> 如果上传了剧本,下一页会继续自动解析;如果没有上传,也可以直接手动输入剧本内容生成大纲。这里选择的视觉风格会继续影响后续人物与场景的 AI 生成效果。
</div> </p>
<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>
</div> </div>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={!canSubmit}
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" 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> <span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建新短剧项目"}</span>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ArrowRight className="h-5 w-5" />} {isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ArrowRight className="h-5 w-5" />}
</button> </button>
</div> </div>
</div> </div>
</section>
</div>
); );
} }
...@@ -11,13 +11,11 @@ import { ...@@ -11,13 +11,11 @@ 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;
...@@ -38,50 +36,50 @@ type OutlineLocationState = { ...@@ -38,50 +36,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,
}, },
], ],
...@@ -89,21 +87,21 @@ const readingScriptState: ProcessingState = { ...@@ -89,21 +87,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,
}, },
], ],
...@@ -111,37 +109,33 @@ const importingScriptState: ProcessingState = { ...@@ -111,37 +109,33 @@ 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 minutes = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const remainSeconds = seconds % 60; const secs = seconds % 60;
return minutes > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${remainSeconds} 秒`; return mins > 0 ? `${mins}分 ${secs}秒` : `${secs}秒`;
} }
function ProcessingScreen({ function ProcessingScreen({ state, elapsedSeconds, projectName }: {
state,
elapsedSeconds,
projectName,
}: {
state: ProcessingState; state: ProcessingState;
elapsedSeconds: number; elapsedSeconds: number;
projectName?: string; projectName?: string;
...@@ -159,95 +153,66 @@ function ProcessingScreen({ ...@@ -159,95 +153,66 @@ function ProcessingScreen({
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [state]); }, [state]);
const currentStep = state.steps[Math.min(activeStep, state.steps.length - 1)]; const progressValue = state.steps[Math.min(activeStep, state.steps.length - 1)]?.progress ?? 0;
return ( return (
<div className="mx-auto max-w-[1380px]"> <div className="h-full bg-background flex items-center justify-center p-6">
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <div className="w-full max-w-2xl rounded-3xl border border-border bg-card shadow-xl p-8">
<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="flex items-start gap-4 mb-6">
<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="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 -right-10 top-6 h-52 w-52 rounded-full border border-white/14" /> <Loader2 className="w-8 h-8 text-white animate-spin" />
<div className="absolute right-20 top-20 h-32 w-32 rounded-full border border-white/10" /> </div>
<div className="flex-1">
<div className="relative"> <div className="flex items-center justify-between gap-4 mb-2">
<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"> <h1 className="text-2xl font-semibold text-foreground">{state.title}</h1>
<WandSparkles className="h-4 w-4" /> <span className="text-xs text-muted-foreground whitespace-nowrap">已用时 {formatElapsed(elapsedSeconds)}</span>
Outline Processing </div>
</div> <p className="text-sm text-muted-foreground leading-relaxed">{state.description}</p>
<h1 className="mt-5 max-w-2xl text-4xl font-semibold leading-tight"> {projectName && (
{state.title} <p className="text-xs text-primary mt-2">当前项目:{projectName}</p>
</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>
</div>
<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="mb-5 flex items-center justify-between">
<div>
<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> </div>
<div className="rounded-[28px] border border-primary/10 bg-primary/5 p-5"> <div className="rounded-2xl border border-primary/15 bg-primary/5 p-5 mb-6">
<div className="mb-3 flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm mb-3">
<span className="font-medium text-foreground">{currentStep?.label}</span> <span className="text-foreground font-medium">{state.steps[Math.min(activeStep, state.steps.length - 1)]?.label}</span>
<span className="text-primary">{currentStep?.progress ?? 0}%</span> <span className="text-primary">{progressValue}%</span>
</div> </div>
<Progress value={currentStep?.progress ?? 0} className="h-2.5" /> <Progress value={progressValue} className="h-2.5 mb-3" />
<p className="mt-4 text-sm leading-6 text-muted-foreground">{currentStep?.hint}</p> <p className="text-sm text-muted-foreground">{state.steps[Math.min(activeStep, state.steps.length - 1)]?.hint}</p>
</div> </div>
<div className="mt-5 space-y-3"> <div className="space-y-3">
{state.steps.map((step, index) => { {state.steps.map((step, index) => {
const done = index < activeStep; const isDone = index < activeStep;
const current = index === activeStep; const isCurrent = index === activeStep;
return ( return (
<div <div key={step.label} className="flex items-start gap-3 rounded-xl border border-border/70 bg-background px-4 py-3">
key={step.label}
className="flex items-start gap-3 rounded-[24px] border border-border/70 bg-white/78 px-4 py-4 shadow-sm"
>
<div className="mt-0.5"> <div className="mt-0.5">
{done ? ( {isDone ? (
<CheckCircle2 className="h-5 w-5 text-emerald-500" /> <CheckCircle2 className="w-5 h-5 text-green-500" />
) : current ? ( ) : isCurrent ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="w-5 h-5 text-primary animate-spin" />
) : ( ) : (
<div className="h-5 w-5 rounded-full border-2 border-border" /> <div className="w-5 h-5 rounded-full border-2 border-border" />
)} )}
</div> </div>
<div> <div>
<div className="text-sm font-medium text-foreground">{step.label}</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 className="text-xs text-muted-foreground mt-0.5">{step.hint}</div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
<p className="mt-5 text-xs leading-6 text-muted-foreground"> <p className="text-xs text-muted-foreground mt-5">
当前为阶段性前端反馈,后续如果接入后端实时进度接口,这里可以展示更准确的百分比与步骤状态。 当前为前端阶段性实时反馈。如果后端后续提供解析进度接口,这里可以进一步切成真实百分比。
</p> </p>
</div> </div>
</div> </div>
</div>
); );
} }
...@@ -256,7 +221,7 @@ export function OutlineGeneration() { ...@@ -256,7 +221,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 queryClient = useQueryClient(); const qc = useQueryClient();
const outlineState = location.state as OutlineLocationState | null; const outlineState = location.state as OutlineLocationState | null;
const projectNameFromState = outlineState?.projectName; const projectNameFromState = outlineState?.projectName;
...@@ -281,8 +246,7 @@ export function OutlineGeneration() { ...@@ -281,8 +246,7 @@ export function OutlineGeneration() {
setPreparingState("parsing"); setPreparingState("parsing");
setImportError(null); setImportError(null);
projectsApi projectsApi.parseScript(pid)
.parseScript(pid)
.then((info) => { .then((info) => {
if (cancelled) return; if (cancelled) return;
setScriptInfo(info); setScriptInfo(info);
...@@ -301,7 +265,7 @@ export function OutlineGeneration() { ...@@ -301,7 +265,7 @@ export function OutlineGeneration() {
setScript(text.slice(0, 8000)); setScript(text.slice(0, 8000));
} }
} catch { } catch {
// Keep manual editor empty so users can paste script content directly. // Keep manual editor empty so the user can paste script content manually.
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoadingScript(false); setLoadingScript(false);
...@@ -327,13 +291,13 @@ export function OutlineGeneration() { ...@@ -327,13 +291,13 @@ export function OutlineGeneration() {
try { try {
await projectsApi.importScript(pid); await projectsApi.importScript(pid);
await queryClient.invalidateQueries({ queryKey: ["outline", pid] }); await qc.invalidateQueries({ queryKey: ["outline", pid] });
await queryClient.invalidateQueries({ queryKey: ["episodes", pid] }); await qc.invalidateQueries({ queryKey: ["episodes", pid] });
await queryClient.invalidateQueries({ queryKey: ["characters", pid] }); await qc.invalidateQueries({ queryKey: ["characters", pid] });
await queryClient.invalidateQueries({ queryKey: ["scenes", pid] }); await qc.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);
} }
...@@ -341,6 +305,7 @@ export function OutlineGeneration() { ...@@ -341,6 +305,7 @@ 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);
...@@ -348,6 +313,7 @@ export function OutlineGeneration() { ...@@ -348,6 +313,7 @@ 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`);
}; };
...@@ -387,371 +353,239 @@ export function OutlineGeneration() { ...@@ -387,371 +353,239 @@ export function OutlineGeneration() {
if (!outline) { if (!outline) {
return ( return (
<div className="mx-auto max-w-[1380px]"> <div className="h-full overflow-auto bg-background p-6">
<div className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]"> <div className="max-w-3xl mx-auto">
<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="mb-6 flex items-center gap-3">
<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="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
<div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" /> <Sparkles className="w-4 h-4 text-white" />
<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">
<Sparkles className="h-4 w-4" />
Outline Stage
</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">
你可以直接导入平台识别到的剧本摘要,也可以手动粘贴剧本文本,让 AI 生成适合企业工作流的大纲结果。
</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>
<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 flex items-start justify-between gap-4">
<div> <div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Outline Builder</div> <h1 className="text-2xl font-semibold text-foreground">生成剧本大纲</h1>
<h2 className="mt-2 text-3xl font-semibold text-foreground">剧本拆解入口</h2> <p className="text-sm text-muted-foreground">先预览剧本解析结果,再决定直接导入或手动生成大纲。</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
先预览自动识别结果,再决定一键导入还是手动编辑生成大纲。
</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> </div>
{importError || generateOutline.isError ? ( {(importError || generateOutline.isError) && (
<div className="mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4"> <div className="mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" /> <AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm leading-6 text-red-700"> <p className="text-sm text-red-700">
{importError ?? (generateOutline.error as Error)?.message} {importError ?? (generateOutline.error as Error)?.message}
</p> </p>
</div> </div>
) : null} )}
{scriptInfo && !showManual ? ( {scriptInfo && !showManual ? (
<div className="space-y-5"> <div className="rounded-xl border border-primary/40 bg-primary/5 p-6 mb-4">
<div className="rounded-[30px] border border-primary/12 bg-primary/5 p-6"> <div className="flex items-start justify-between mb-4">
<div className="mb-4 flex items-center justify-between"> <div className="flex items-center gap-2">
<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="w-5 h-5 text-primary" />
<FileText className="h-3.5 w-3.5" /> <span className="text-sm font-medium text-primary">已识别上传剧本</span>
Script Summary Detected
</div> </div>
<CheckCircle2 className="h-5 w-5 text-emerald-500" /> <CheckCircle2 className="w-5 h-5 text-green-500" />
</div> </div>
<h3 className="text-2xl font-semibold text-foreground"> <h2 className="text-xl font-bold text-foreground mb-1">{scriptInfo.title || "未识别标题"}</h2>
{scriptInfo.title || "已识别上传剧本"} <p className="text-sm text-muted-foreground mb-4">{scriptInfo.genre || "待补充题材"}</p>
</h3>
<p className="mt-3 text-sm text-muted-foreground">
{scriptInfo.genre || "题材待补充"}
</p>
{scriptInfo.synopsis ? ( {scriptInfo.synopsis && (
<p className="mt-4 rounded-[24px] border border-border/70 bg-white/78 p-4 text-sm leading-7 text-foreground/84"> <p className="text-sm text-foreground/80 bg-background/60 rounded-lg p-3 mb-4 leading-relaxed">
{scriptInfo.synopsis.length > 160 {scriptInfo.synopsis.length > 120 ? `${scriptInfo.synopsis.slice(0, 120)}...` : scriptInfo.synopsis}
? `${scriptInfo.synopsis.slice(0, 160)}...`
: scriptInfo.synopsis}
</p> </p>
) : null} )}
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-4 gap-3 mb-5">
{[ {[
{ { icon: <Film className="w-4 h-4" />, label: "集数", value: `${scriptInfo.episodeCount} 集` },
icon: <Film className="h-4 w-4" />, { icon: <Users className="w-4 h-4" />, label: "角色", value: `${scriptInfo.characterCount} 个` },
label: "分集规模", { icon: <MapPin className="w-4 h-4" />, label: "场景", value: `${scriptInfo.sceneCount} 个` },
value: `${scriptInfo.episodeCount} 集`, { icon: <Sparkles className="w-4 h-4" />, label: "分镜脚本", value: `${scriptInfo.storyboardCount} 条` },
}, ].map(({ icon, label, value }) => (
{ <div key={label} className="rounded-lg bg-background border border-border p-3 text-center">
icon: <Users className="h-4 w-4" />, <div className="flex justify-center mb-1 text-primary">{icon}</div>
label: "角色数量", <div className="text-lg font-bold text-foreground">{value}</div>
value: `${scriptInfo.characterCount} 个`, <div className="text-xs text-muted-foreground">{label}</div>
},
{
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="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" 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"
> >
{importing ? ( {importing ? (
<> <>
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
正在导入剧本骨架... <span>导入中...</span>
</> </>
) : ( ) : (
<> <>
<Sparkles className="h-5 w-5" /> <Sparkles className="w-5 h-5" />
一键导入大纲、分集、角色与分镜骨架 <span>一键导入大纲 · 分集 · 角色 · 分镜</span>
<ArrowRight className="h-5 w-5" /> <ArrowRight className="w-4 h-4" />
</> </>
)} )}
</button> </button>
<button <button
onClick={() => setShowManual(true)} 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" className="w-full mt-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
改为手动编辑剧本文本,再由 AI 生成大纲 手动编辑剧本内容,用 AI 生成大纲
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-5"> <div className="rounded-xl border border-border bg-card p-6">
<div className="rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"> <div className="flex items-center justify-between mb-3">
<div className="mb-3 flex items-center justify-between gap-4"> <label className="block text-sm font-medium text-foreground">剧本内容</label>
<label className="text-sm font-medium text-foreground">剧本文本</label> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> {loadingScript && (
{loadingScript ? ( <span className="text-xs text-muted-foreground flex items-center gap-1">
<> <Loader2 className="w-3 h-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 正在加载上传剧本...
正在加载已上传剧本... </span>
</> )}
) : script ? ( {!loadingScript && script && (
<span className="text-emerald-600">已自动带入剧本文本</span> <span className="text-xs text-green-600">已自动带入上传剧本文本</span>
) : null} )}
{scriptInfo && (
<button
onClick={() => setShowManual(false)}
className="text-xs text-primary underline"
>
返回文档预览
</button>
)}
</div> </div>
</div> </div>
<textarea <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)]" 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 会为你生成结构化大纲..." placeholder="将您的剧本文本粘贴到此处,AI 将自动生成大纲..."
value={script} value={script}
onChange={(e) => setScript(e.target.value)} onChange={(e) => setScript(e.target.value)}
/> />
<p className="mt-3 text-xs leading-6 text-muted-foreground"> <p className="text-xs text-muted-foreground mt-2">支持中英文剧本,建议正文长度 500 到 5000 字。</p>
支持中英文剧本,建议正文长度在 500 到 5000 字之间,便于生成更稳定的大纲结果。
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending} 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" 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 ? ( {generateOutline.isPending ? (
<> <>
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
正在生成大纲... <span>生成中...</span>
</> </>
) : ( ) : (
<> <>
<Sparkles className="h-5 w-5" /> <Sparkles className="w-5 h-5" />
AI 生成大纲 <span>AI 生成大纲</span>
</> </>
)} )}
</button> </button>
{scriptInfo ? (
<button
onClick={() => setShowManual(false)}
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>
) : null}
</div>
</div> </div>
)} )}
</section>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-[1380px]"> <div className="h-full overflow-auto bg-background p-6">
<section className="grid gap-6 xl:grid-cols-[1.02fr_0.98fr]"> <div className="max-w-5xl mx-auto">
<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="mb-6 flex items-start justify-between">
<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>
<div className="absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14" /> <div className="flex items-center gap-3 mb-2">
<div className="absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10" /> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
<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">
<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> </div>
<h1 className="text-2xl font-semibold text-foreground">剧本大纲</h1>
</div> </div>
<p className="text-sm text-muted-foreground">AI 已为您生成当前项目的大纲结果。</p>
<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 flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Outline Result</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">大纲结果确认</h2>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
你可以在这里快速检查大纲内容,确认无误后继续生成分集;如需修改,也可以重新提交剧本文本。
</p>
</div> </div>
<button <button
onClick={() => setShowManual(true)} 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" 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="h-4 w-4" /> <RefreshCw className="w-4 h-4" />
重新生成 重新生成
</button> </button>
</div> </div>
{showManual ? ( {showManual && (
<div className="mb-5 rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"> <div className="mb-6 rounded-xl border border-border bg-card p-6">
<label className="mb-3 block text-sm font-medium text-foreground">输入新的剧本文本</label> <label className="block text-sm font-medium text-foreground mb-3">输入新的剧本内容</label>
<textarea <textarea
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)]" 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"
value={script} value={script}
onChange={(e) => setScript(e.target.value)} onChange={(e) => setScript(e.target.value)}
/> />
<div className="mt-4 flex gap-3"> <div className="flex gap-2 mt-3">
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={!script.trim() || generateOutline.isPending} 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" 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="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {generateOutline.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{generateOutline.isPending ? "生成中..." : "确认生成"} {generateOutline.isPending ? "生成中..." : "确认生成"}
</button> </button>
<button <button
onClick={() => setShowManual(false)} 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" className="px-4 py-2 rounded-lg border border-border text-sm"
> >
取消 取消
</button> </button>
</div> </div>
</div> </div>
) : null} )}
{generateEpisodes.isError ? ( {generateEpisodes.isError && (
<div className="mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4"> <div className="mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" /> <AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm leading-6 text-red-700">{(generateEpisodes.error as Error)?.message}</p> <p className="text-sm text-red-700">{(generateEpisodes.error as Error)?.message}</p>
</div> </div>
) : null} )}
<div className="rounded-[30px] border border-border/80 bg-white/70 p-6 shadow-sm"> <div className="rounded-xl border border-border bg-card p-6 mb-4">
<div className="grid gap-5 md:grid-cols-2"> <div className="grid grid-cols-2 gap-6 mb-6">
<div> <div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Title</div> <label className="block text-xs text-muted-foreground mb-2">剧名</label>
<div className="mt-2 text-2xl font-semibold text-foreground">{outline.title}</div> <div className="text-xl font-semibold text-foreground">{outline.title}</div>
</div> </div>
<div> <div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Genre</div> <label className="block text-xs text-muted-foreground mb-2">类型</label>
<div className="mt-2 text-2xl font-semibold text-foreground">{outline.genre}</div> <div className="text-xl font-semibold text-foreground">{outline.genre}</div>
</div> </div>
</div> </div>
<div className="mb-4">
<div className="mt-6"> <label className="block text-xs text-muted-foreground mb-2">故事梗概</label>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Synopsis</div> <p className="text-sm text-foreground leading-relaxed">{outline.synopsis}</p>
<p className="mt-3 text-sm leading-7 text-foreground/84">{outline.synopsis}</p>
</div> </div>
<div>
<div className="mt-6 inline-flex rounded-full bg-accent px-4 py-2 text-sm text-primary"> <label className="block text-xs text-muted-foreground mb-2">集数</label>
共 {outline.episodeCount} 集 <span className="px-3 py-1 rounded-lg bg-accent text-primary text-sm">{outline.episodeCount} 集</span>
</div> </div>
</div> </div>
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={generateEpisodes.isPending} disabled={generateEpisodes.isPending}
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" 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"
> >
{generateEpisodes.isPending ? ( {generateEpisodes.isPending ? (
<> <>
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
正在生成分集内容... <span>生成分集中...</span>
</> </>
) : ( ) : (
<> <>
继续生成分集 <span>生成分集内容</span>
<ArrowRight className="h-5 w-5" /> <ArrowRight className="w-5 h-5" />
</> </>
)} )}
</button> </button>
</section> </div>
</section>
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
ArrowRight,
BookOpenText,
ChevronRight,
Clapperboard,
Film, Film,
Play,
ChevronRight,
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 { useCharacters, useEpisodes, useOutline, useScenes, useVideoTasks } from "../../hooks/useAi"; import { useOutline, useEpisodes, useCharacters, 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();
...@@ -79,220 +34,156 @@ export function ProjectDetail() { ...@@ -79,220 +34,156 @@ 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: BookOpenText }, { id: "outline" as Tab, label: "大纲", icon: BookOpen },
{ 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="flex h-full items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-[1440px]"> <div className="h-full overflow-auto bg-background relative">
<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"> {/* Header */}
<div className="grid gap-6 xl:grid-cols-[320px_1fr]"> <div className="border-b border-border bg-card px-6 py-3">
<div className="overflow-hidden rounded-[30px] border border-white/70 bg-muted shadow-sm"> <div className="flex gap-4 items-center">
<div className="aspect-[3/4]"> <div className="w-20 aspect-[2/3] rounded-lg overflow-hidden bg-muted flex-shrink-0 flex items-center justify-center">
<ProjectCover coverUrl={project?.coverUrl} name={project?.name ?? "项目"} /> {project?.coverUrl ? (
</div> <img src={project.coverUrl} alt="封面" className="w-full h-full object-cover" />
) : (
<Film className="w-8 h-8 text-muted-foreground" />
)}
</div> </div>
<div className="flex flex-col"> <div className="flex-1">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex items-center gap-3 mb-2">
<div> <h1 className="text-lg font-semibold text-foreground">
<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"> {project?.name ?? "加载中..."}
<Film className="h-3.5 w-3.5" />
Project Overview
</div>
<h1 className="mt-4 text-4xl font-semibold text-foreground">
{project?.name ?? "当前项目"}
</h1> </h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-muted-foreground"> {outline && (
这里是项目的流程总控台,可以统一查看剧本拆解、角色场景、分镜与视频任务,并从任意阶段继续推进。 <span className="px-2 py-0.5 rounded bg-accent text-foreground text-xs border border-border">
</p> {outline.genre}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{outline && <span>{outline.episodeCount}集</span>}
{episodes.length > 0 && <span>已生成 {episodes.length} 集分集</span>}
{characters.length > 0 && <span>{characters.length} 个角色</span>}
{scenes.length > 0 && <span>{scenes.length} 个场景</span>}
{project?.updatedAt && (
<span>更新于 {new Date(project.updatedAt).toLocaleDateString("zh-CN")}</span>
)}
</div>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex items-center gap-2 flex-shrink-0">
{episodes.length === 0 ? ( {episodes.length === 0 ? (
<button <button
onClick={() => navigate(`/project/${projectId}/outline`)} 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]" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="w-4 h-4" />
开始剧本拆解 生成大纲
</button> </button>
) : ( ) : (
<button <button
onClick={() => navigate(`/project/${projectId}/storyboard`)} 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]" 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="h-4 w-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> {/* Tabs */}
{[ <div className="flex items-center gap-1 mt-3">
{ {tabs.map((tab) => {
label: "题材类型", const Icon = tab.icon;
value: outline?.genre ?? "待生成", return (
}, <button
{
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 className="mt-6 rounded-[30px] border border-primary/12 bg-primary/5 p-5">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<PlayCircle className="h-5 w-5" />
</div>
<div>
<div className="text-base font-medium text-foreground">项目推进节奏</div>
<div className="text-sm text-muted-foreground">
按“剧本拆解 → 设定 → 分镜 → 视频”顺序推进,当前工作台会持续沉淀项目资产与状态。
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
{[
{
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>
</section>
<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 flex-wrap gap-3">
{tabs.map((tab) => (
<SectionTab
key={tab.id} key={tab.id}
active={activeTab === tab.id}
label={tab.label}
icon={tab.icon}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
/> className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-all ${
))} 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>
<div className="mt-6"> {/* Content */}
{activeTab === "outline" ? ( <div className="p-6">
<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={(episodeId) => navigate(`/project/${projectId}/storyboard/${episodeId}`)} onNavigateEpisode={(epId) => navigate(`/project/${projectId}/storyboard/${epId}`)}
/> />
) : 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" && (
{activeTab === "storyboard" ? ( <StoryboardTab episodes={episodes} projectId={projectId} />
<StoryboardTab )}
episodes={episodes} {activeTab === "video" && (
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: (episodeId: string) => void; onNavigateEpisode: (epId: string) => void;
}) { }) {
if (!outline) { if (!outline) {
return ( return (
<div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center"> <div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<BookOpenText className="h-7 w-7" /> <h3 className="text-lg font-medium text-foreground mb-2">尚未生成大纲</h3>
</div> <p className="text-sm text-muted-foreground mb-6">上传剧本后,AI 可自动生成大纲和分集</p>
<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="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]" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="w-4 h-4" />
前往生成大纲 前往生成大纲
</button> </button>
</div> </div>
...@@ -300,73 +191,65 @@ function OutlineTab({ ...@@ -300,73 +191,65 @@ function OutlineTab({
} }
return ( return (
<div className="space-y-6">
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Outline</div> {/* Outline Card */}
<h3 className="mt-2 text-3xl font-semibold text-foreground">{outline.title}</h3> <div className="mb-8">
<div className="mt-3 flex flex-wrap gap-2"> <div className="flex items-center justify-between mb-4">
<span className="rounded-full bg-accent px-3 py-1 text-sm text-primary"> <h2 className="text-lg font-semibold text-foreground">剧本大纲</h2>
{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="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="text-sm text-primary hover:underline flex items-center gap-1"
> >
编辑大纲 <Edit2 className="w-3 h-3" />
编辑
</button> </button>
</div> </div>
<p className="mt-5 text-sm leading-7 text-foreground/84">{outline.synopsis}</p> <div className="rounded-xl border border-border bg-card p-6">
<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>
{episodes.length > 0 ? ( {/* Episode List */}
{episodes.length > 0 && (
<div> <div>
<div className="mb-4 flex items-center justify-between"> <h2 className="text-lg font-semibold text-foreground mb-4">
<h3 className="text-2xl font-semibold text-foreground">分集总览</h3> 分集列表 ({episodes.length} 集)
<span className="rounded-full bg-accent px-3 py-1 text-sm text-primary"> </h2>
{episodes.length} 集 <div className="grid grid-cols-3 gap-4">
</span> {episodes.map((ep) => (
</div> <div
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> key={ep.id}
{episodes.map((episode) => ( onClick={() => onNavigateEpisode(ep.id)}
<button className="rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer"
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="inline-flex rounded-full bg-primary/8 px-3 py-1 text-xs text-primary"> <div className="flex items-center gap-2 mb-2">
第 {episode.episodeNumber} 集 <span className="text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded">
第 {ep.episodeNumber} 集
</span>
</div> </div>
<div className="mt-4 text-lg font-medium text-foreground">{episode.title}</div> <h3 className="font-medium text-foreground text-sm mb-1">{ep.title}</h3>
<p className="mt-3 line-clamp-4 text-sm leading-6 text-muted-foreground"> <p className="text-xs text-muted-foreground line-clamp-3">{ep.summary}</p>
{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> </div>
</button>
))} ))}
</div> </div>
</div> </div>
) : ( )}
<div className="rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center">
<h3 className="text-2xl font-semibold text-foreground">大纲已准备好,下一步生成分集</h3> {episodes.length === 0 && outline && (
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"> <div className="rounded-xl border border-dashed border-border bg-card p-8 text-center">
继续生成分集后,就可以进入角色设定与分镜制作阶段。 <p className="text-sm text-muted-foreground mb-4">大纲已生成,点击前往生成分集内容</p>
</p>
<button <button
onClick={onNavigateOutline} onClick={onNavigateOutline}
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]" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="w-4 h-4" />
前往生成分集 生成分集
</button> </button>
</div> </div>
)} )}
...@@ -374,133 +257,118 @@ function OutlineTab({ ...@@ -374,133 +257,118 @@ 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 getCharacterPreview = (character: import("../../lib/api/ai").Character) => const getMainCharacterImage = (char: import("../../lib/api/ai").Character) =>
character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null; char.frontImageUrl ?? char.imageUrl ?? char.sideImageUrl ?? char.backImageUrl ?? null;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Characters */}
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Users className="w-5 h-5 text-primary" />
<Users className="h-5 w-5" /> <h2 className="text-lg font-semibold text-foreground">角色设定</h2>
</div> <span className="text-sm text-muted-foreground">({characters.length})</span>
<div>
<h3 className="text-2xl font-semibold text-foreground">角色设定</h3>
<div className="text-sm text-muted-foreground">累计 {characters.length} 个角色资产</div>
</div>
</div> </div>
<button <button onClick={onNavigateChars} className="text-sm text-primary hover:underline">
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-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center"> <div className="rounded-xl border border-dashed border-border bg-card p-8 text-center">
<p className="text-sm leading-6 text-muted-foreground">还没有角色设定,建议先从剧本中提取主要人物。</p> <p className="text-sm text-muted-foreground mb-4">暂无角色,去角色页面提取</p>
<button <button
onClick={onNavigateChars} onClick={onNavigateChars}
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]" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="w-4 h-4" />
AI 提取角色 AI 提取角色
</button> </button>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-4 gap-4">
{characters.slice(0, 8).map((character) => ( {characters.slice(0, 8).map((char) => (
<button <div
key={character.id} key={char.id}
onClick={onNavigateChars} onClick={onNavigateChars}
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" className="rounded-xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors"
> >
<div className="flex h-24 items-center justify-center overflow-hidden rounded-[24px] bg-muted"> <div className="w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center">
{getCharacterPreview(character) ? ( {getMainCharacterImage(char) ? (
<img <img src={getMainCharacterImage(char) ?? ""} alt={char.name} className="w-full h-full object-cover" />
src={getCharacterPreview(character) ?? ""}
alt={character.name}
className="h-full w-full object-cover"
/>
) : ( ) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" /> <ImageIcon className="w-8 h-8 text-muted-foreground" />
)} )}
</div> </div>
<div className="mt-4 text-lg font-medium text-foreground">{character.name}</div> <div className="font-medium text-foreground text-sm mb-1">{char.name}</div>
<div className="mt-2 text-sm text-muted-foreground">{character.roleType}</div> <div className="text-xs text-muted-foreground">{char.roleType}</div>
</button> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Scenes */}
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <MapPin className="w-5 h-5 text-primary" />
<MapPin className="h-5 w-5" /> <h2 className="text-lg font-semibold text-foreground">场景设定</h2>
</div> <span className="text-sm text-muted-foreground">({scenes.length})</span>
<div>
<h3 className="text-2xl font-semibold text-foreground">场景设定</h3>
<div className="text-sm text-muted-foreground">累计 {scenes.length} 个场景资产</div>
</div>
</div> </div>
<button <button onClick={onNavigateScenes} className="text-sm text-primary hover:underline">
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-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center"> <div className="rounded-xl border border-dashed border-border bg-card p-8 text-center">
<p className="text-sm leading-6 text-muted-foreground">还没有场景设定,建议在角色之后继续提取关键场景。</p> <p className="text-sm text-muted-foreground mb-4">暂无场景,去场景页面提取</p>
<button <button
onClick={onNavigateScenes} onClick={onNavigateScenes}
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]" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="w-4 h-4" />
AI 提取场景 AI 提取场景
</button> </button>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-3 gap-4">
{scenes.slice(0, 6).map((scene) => ( {scenes.slice(0, 6).map((scene) => (
<button <div
key={scene.id} key={scene.id}
onClick={onNavigateScenes} onClick={onNavigateScenes}
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" className="rounded-xl border border-border bg-card overflow-hidden cursor-pointer hover:border-primary transition-colors"
> >
<div className="aspect-video bg-muted"> <div className="aspect-video bg-muted flex items-center justify-center">
{scene.imageUrl ? ( {scene.imageUrl ? (
<img src={scene.imageUrl} alt={scene.name} className="h-full w-full object-cover" /> <img src={scene.imageUrl} alt={scene.name} className="w-full h-full object-cover" />
) : ( ) : (
<div className="flex h-full items-center justify-center"> <ImageIcon className="w-8 h-8 text-muted-foreground" />
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)} )}
</div> </div>
<div className="p-4"> <div className="p-3 flex items-center justify-between">
<div className="text-lg font-medium text-foreground">{scene.name}</div> <div className="font-medium text-foreground text-sm">{scene.name}</div>
<div className="mt-2 text-sm text-muted-foreground"> <span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted">
{scene.sceneType === "indoor" ? "室内场景" : "室外场景"} {scene.sceneType === "indoor" ? "室内" : "室外"}
</span>
</div> </div>
</div> </div>
</button>
))} ))}
</div> </div>
)} )}
...@@ -509,6 +377,7 @@ function CharactersTab({ ...@@ -509,6 +377,7 @@ function CharactersTab({
); );
} }
// ---- Storyboard Tab ----
function StoryboardTab({ function StoryboardTab({
episodes, episodes,
projectId, projectId,
...@@ -517,67 +386,65 @@ function StoryboardTab({ ...@@ -517,67 +386,65 @@ function StoryboardTab({
projectId?: string; projectId?: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>( const [selectedEpId, setSelectedEpId] = 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-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center"> <div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Clapperboard className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<Clapperboard className="h-7 w-7" /> <h3 className="text-lg font-medium text-foreground mb-2">尚未生成分集</h3>
</div> <p className="text-sm text-muted-foreground">先生成大纲和分集,再进入分镜工作台</p>
<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>
); );
} }
const selectedEpisode = episodes.find((episode) => episode.id === selectedEpisodeId) ?? episodes[0];
return ( return (
<div className="space-y-6"> <div>
<div className="flex flex-wrap gap-3"> <div className="flex items-center justify-between mb-4">
{episodes.map((episode) => ( <h2 className="text-lg font-semibold text-foreground">分镜工作台</h2>
<button <button
key={episode.id} onClick={() => navigate(`/project/${projectId}/storyboard/${selectedEpId ?? episodes[0].id}`)}
onClick={() => setSelectedEpisodeId(episode.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"
className={[
"rounded-2xl border px-4 py-3 text-sm transition-all",
selectedEpisodeId === 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} 集 进入分镜工作台
<ChevronRight className="w-4 h-4" />
</button> </button>
))}
</div> </div>
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> <div className="flex items-center gap-2 mb-6 flex-wrap">
<div className="flex flex-wrap items-start justify-between gap-4"> {episodes.map((ep) => (
<div>
<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>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
{selectedEpisode.summary}
</p>
</div>
<button <button
onClick={() => navigate(`/project/${projectId}/storyboard/${selectedEpisode.id}`)} key={ep.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]" onClick={() => setSelectedEpId(ep.id)}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
selectedEpId === ep.id
? "bg-primary text-white"
: "bg-card border border-border text-foreground hover:border-primary"
}`}
> >
进入分镜工作台 第 {ep.episodeNumber} 集
<ChevronRight className="h-4 w-4" />
</button> </button>
))}
</div> </div>
{selectedEpId && (
<div
onClick={() => navigate(`/project/${projectId}/storyboard/${selectedEpId}`)}
className="rounded-xl border border-border bg-card p-6 text-center hover:border-primary transition-colors cursor-pointer"
>
<Clapperboard className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
点击进入第 {episodes.find(e => e.id === selectedEpId)?.episodeNumber} 集分镜工作台
</p>
</div> </div>
)}
</div> </div>
); );
} }
// ---- Video Tab ----
function VideoTab({ function VideoTab({
videoTasks, videoTasks,
projectId, projectId,
...@@ -587,77 +454,70 @@ function VideoTab({ ...@@ -587,77 +454,70 @@ 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-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center"> <div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Video className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<Video className="h-7 w-7" /> <h3 className="text-lg font-medium text-foreground mb-2">尚无视频任务</h3>
</div> <p className="text-sm text-muted-foreground mb-6">在视频生成页面提交生成任务</p>
<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="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]" 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"
> >
<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 className="space-y-6"> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-semibold text-foreground">视频任务总览</h3> <h2 className="text-lg font-semibold text-foreground">
视频任务 ({videoTasks.length})
</h2>
<button <button
onClick={() => navigate(`/project/${projectId}/video`)} onClick={() => navigate(`/project/${projectId}/video`)}
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="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> </button>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-3 gap-4">
{videoTasks.map((task) => { {videoTasks.map((task) => {
const status = statusMap[task.status] ?? { label: task.status, dot: "bg-slate-400" }; const cfg = statusConfig[task.status] ?? { label: task.status, color: "bg-gray-400" };
return ( return (
<div <div key={task.id} className="rounded-xl border border-border bg-card overflow-hidden">
key={task.id} <div className="aspect-video bg-muted flex items-center justify-center relative">
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="h-full w-full object-cover" controls /> <video src={task.resultVideoUrl} className="w-full h-full object-cover" controls />
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="text-center">
{task.status === "running" ? ( {task.status === "running" ? (
<Loader2 className="h-10 w-10 animate-spin text-primary" /> <Loader2 className="w-10 h-10 text-primary animate-spin mx-auto mb-2" />
) : ( ) : (
<Film className="h-10 w-10 text-muted-foreground" /> <Film className="w-10 h-10 text-muted-foreground mx-auto mb-2" />
)} )}
</div> </div>
)} )}
</div> </div>
<div className="p-3 flex items-center justify-between">
<div className="p-4"> <span className="text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{new Date(task.createdAt).toLocaleDateString("zh-CN")} {new Date(task.createdAt).toLocaleDateString("zh-CN")}
</div> </span>
<div className="inline-flex items-center gap-2 rounded-full bg-accent px-3 py-1 text-xs text-primary"> <div className="flex items-center gap-1">
<span className={`h-2 w-2 rounded-full ${status.dot}`} /> <div className={`w-2 h-2 rounded-full ${cfg.color}`} />
{status.label} <span className="text-xs text-muted-foreground">{cfg.label}</span>
</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 {
Check, Users, UserPlus, Shield, Trash2, Loader2, X, Check, Settings,
Loader2,
Settings,
Shield,
Trash2,
UserPlus,
Users,
X,
} from "lucide-react"; } from "lucide-react";
import { useAddMember, useRemoveMember, useTeamMembers, useUpdateMember } from "../../hooks/useTeam"; import { useTeamMembers, useUpdateMember, useRemoveMember, useAddMember } 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<Role, string> = { const roleLabels: Record<string, string> = {
owner: "所有者", owner: "所有者",
admin: "管理员", admin: "管理员",
member: "成员", member: "成员",
}; };
const roleDescriptions: Record<Role, string> = { const rolePermissions: Record<string, string[]> = {
owner: "可管理项目设置、成员与全部流程。", owner: ["完全控制", "删除项目", "管理成员", "编辑内容", "查看内容"],
admin: "可协作推进项目内容并管理部分成员。", admin: ["管理成员", "编辑内容", "审核分镜", "生成视频", "查看内容"],
member: "参与内容生产与流程执行。", member: ["编辑内容", "创建分镜", "修改分镜", "查看内容"],
}; };
export function ProjectSettings() { export function ProjectSettings() {
const { projectId } = useParams(); const { projectId } = useParams();
const pid = projectId ?? ""; const numId = projectId ?? "";
const { data: project, isLoading: loadingProject } = useProject(pid); const { data: project, isLoading: loadingProject } = useProject(numId);
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(pid); const updateProject = useUpdateProject(numId);
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,15 +53,6 @@ export function ProjectSettings() { ...@@ -53,15 +53,6 @@ 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 ?? "",
...@@ -78,364 +69,303 @@ export function ProjectSettings() { ...@@ -78,364 +69,303 @@ 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 handleRoleChange = async (userId: number, role: string) => { const roleCounts = (role: string) => members.filter((m) => m.role === role).length;
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="flex h-full items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="w-6 h-6 animate-spin text-primary" />
</div> </div>
); );
} }
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-6xl mx-auto">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="mb-6">
<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>
<Settings className="h-3.5 w-3.5" /> </div>
Project Settings
</div> {/* Project Info */}
<h1 className="mt-4 text-3xl font-semibold text-foreground">项目设置与成员权限</h1> <div className="rounded-xl border border-border bg-card p-5 mb-6">
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"> <div className="flex items-center justify-between mb-4">
统一维护项目基础参数、创作风格和协作成员权限,让项目配置与生产流程保持一致。 <h2 className="font-semibold text-foreground flex items-center gap-2">
</p> <Settings className="w-4 h-4" /> 项目信息
</div> </h2>
{!editingProject && (
<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 className="mt-6 grid gap-4 md:grid-cols-4">
<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>
<div className="mt-3 text-2xl font-semibold text-foreground">{members.length}</div>
</div>
{roles.map((role) => (
<div
key={role}
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">
{roleLabels[role]}
</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 className="text-xs uppercase tracking-[0.24em] text-primary/70">Project Profile</div>
<h2 className="mt-2 text-2xl font-semibold text-foreground">项目基础信息</h2>
</div>
{!editingProject ? (
<button <button
onClick={openEditProject} onClick={openEditProject}
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="px-3 py-1.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors"
> >
编辑项目 编辑
</button> </button>
) : null} )}
</div> </div>
{editingProject ? ( {editingProject ? (
<div className="space-y-5"> <div className="space-y-3">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label> <label className="block text-xs text-muted-foreground mb-1">项目名称</label>
<input <input
type="text"
value={projectForm.name} value={projectForm.name}
onChange={(e) => setProjectForm((current) => ({ ...current, name: e.target.value }))} onChange={(e) => setProjectForm({ ...projectForm, 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)]" 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> <div>
<label className="mb-2 block text-sm font-medium text-foreground">项目描述</label> <label className="block text-xs text-muted-foreground mb-1">项目描述</label>
<textarea <textarea
value={projectForm.description} value={projectForm.description}
onChange={(e) => setProjectForm((current) => ({ ...current, description: e.target.value }))} onChange={(e) => setProjectForm({ ...projectForm, description: e.target.value })}
rows={4} rows={2}
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)]" 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">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">视觉风格</label>
<select
value={projectForm.style}
onChange={(e) => setProjectForm((current) => ({ ...current, style: 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>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">画幅比例</label>
<select
value={projectForm.aspectRatio}
onChange={(e) => setProjectForm((current) => ({ ...current, aspectRatio: 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={projectForm.resolution}
onChange={(e) => setProjectForm((current) => ({ ...current, resolution: 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 className="flex items-center justify-end gap-3">
<button <button
onClick={() => setEditingProject(false)} onClick={() => setEditingProject(false)}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted" className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSaveProject} onClick={handleSaveProject}
disabled={updateProject.isPending} 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" 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="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />} {updateProject.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
保存更新 保存
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2"> <div className="space-y-2 text-sm">
{[ <div>
{ label: "项目名称", value: project?.name || "未设置" }, <span className="text-muted-foreground">名称:</span>
{ label: "当前状态", value: project?.status || "未设置" }, <span className="text-foreground font-medium">{project?.name}</span>
{ label: "视觉风格", value: project?.style || "未设置" }, </div>
{ label: "画幅比例", value: project?.aspectRatio || "未设置" }, <div>
{ label: "清晰度", value: project?.resolution || "未设置" }, <span className="text-muted-foreground">描述:</span>
{ label: "项目描述", value: project?.description || "暂无描述" }, <span className="text-foreground">{project?.description || "—"}</span>
].map((item) => ( </div>
<div <div>
key={item.label} <span className="text-muted-foreground">状态:</span>
className="rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm" <span className="text-foreground">{project?.status}</span>
>
<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> </div>
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> {/* Team Overview */}
<div className="mb-4"> <div className="grid grid-cols-4 gap-4 mb-6">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Team Access</div> <div className="rounded-xl border border-border bg-card p-4">
<h2 className="mt-2 text-2xl font-semibold text-foreground">成员权限管理</h2> <div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center mb-3">
<Users className="w-5 h-5 text-primary" />
</div>
<div className="text-2xl font-semibold text-foreground mb-0.5">{members.length}</div>
<div className="text-xs text-muted-foreground">团队成员</div>
</div>
{["owner", "admin", "member"].map((role) => (
<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> </div>
<div className="space-y-4"> {/* Invite Button */}
<div className="mb-6">
<button
onClick={() => setShowInviteModal(true)}
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"
>
<UserPlus className="w-4 h-4" />
添加成员
</button>
</div>
{/* Members List */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-foreground mb-4">团队成员</h2>
{members.length === 0 ? ( {members.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 className="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground">
当前还没有成员,点击右上角“添加成员”即可邀请新成员加入项目协作。 暂无成员
</div> </div>
) : ( ) : (
members.map((member) => ( <div className="space-y-3">
{members.map((member) => (
<div <div
key={member.userId} key={member.userId}
className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm" className="rounded-xl border border-border bg-card p-4 flex items-center justify-between"
> >
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex items-center gap-4">
<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>
<div className="text-lg font-medium text-foreground">{member.username}</div> <div className="text-sm font-medium text-foreground mb-0.5">{member.username}</div>
<div className="mt-2 text-sm text-muted-foreground">{member.email}</div> <div className="text-xs text-muted-foreground">{member.email}</div>
</div> </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>
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_1fr]"> <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={member.role}
onChange={(e) => handleRoleChange(member.userId, e.target.value)} 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" disabled={member.role === "owner" || updateMember.isPending}
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((role) => ( {ROLES.map((r) => (
<option key={role} value={role}> <option key={r} value={r}>{roleLabels[r]}</option>
{roleLabels[role]}
</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 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>
<div className="mt-2 text-sm leading-6 text-muted-foreground"> ))}
{roleDescriptions[(member.role as Role) ?? "member"]}
</div> </div>
)}
</div> </div>
{/* Role Permissions */}
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">角色权限说明</h2>
<div className="grid grid-cols-3 gap-4">
{Object.entries(rolePermissions).map(([key, perms]) => (
<div key={key} className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
<Shield className="w-4 h-4 text-primary" />
</div>
<h3 className="font-medium text-foreground">{roleLabels[key]}</h3>
</div>
<div className="space-y-2">
{perms.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="w-1 h-1 rounded-full bg-primary" />
{p}
</div> </div>
))}
</div> </div>
))
)}
</div> </div>
))}
</div> </div>
</div> </div>
</section>
{showInviteModal ? ( {/* Invite Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> {showInviteModal && (
<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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="bg-card rounded-2xl border border-border p-6 max-w-md w-full">
<div> <div className="flex items-center justify-between mb-6">
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Invite Member</div> <h2 className="text-xl font-semibold text-foreground">添加成员</h2>
<h3 className="mt-2 text-2xl font-semibold text-foreground">添加项目成员</h3> <button onClick={() => setShowInviteModal(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={() => 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> </button>
</div> </div>
<div className="space-y-5"> <div className="space-y-4 mb-6">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">用户名</label> <label className="block text-sm font-medium text-foreground mb-2">用户名</label>
<input <input
type="text"
value={inviteForm.username} value={inviteForm.username}
onChange={(e) => setInviteForm((current) => ({ ...current, username: e.target.value }))} onChange={(e) => setInviteForm({ ...inviteForm, 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="输入用户名" 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="mb-2 block text-sm font-medium text-foreground">邮箱</label> <label className="block text-sm font-medium text-foreground mb-2">邮箱</label>
<input <input
type="email"
value={inviteForm.email} value={inviteForm.email}
onChange={(e) => setInviteForm((current) => ({ ...current, email: e.target.value }))} onChange={(e) => setInviteForm({ ...inviteForm, 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" 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>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">初始密码</label> <label className="block text-sm font-medium text-foreground mb-2">初始密码</label>
<input <input
type="password" type="password"
value={inviteForm.password} value={inviteForm.password}
onChange={(e) => setInviteForm((current) => ({ ...current, password: e.target.value }))} onChange={(e) => setInviteForm({ ...inviteForm, 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="设置初始登录密码" 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="mb-2 block text-sm font-medium text-foreground">角色权限</label> <label className="block text-sm font-medium text-foreground mb-2">角色</label>
<select <select
value={inviteForm.role} value={inviteForm.role}
onChange={(e) => setInviteForm((current) => ({ ...current, role: e.target.value }))} onChange={(e) => setInviteForm({ ...inviteForm, 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" 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"
> >
{roles.map((role) => ( <option value="admin">管理员</option>
<option key={role} value={role}> <option value="member">成员</option>
{roleLabels[role]}
</option>
))}
</select> </select>
</div> </div>
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>}
{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>
<div className="mt-6 flex items-center justify-end gap-3"> <div className="flex gap-3">
<button <button
onClick={() => setShowInviteModal(false)} 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" className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleInvite} onClick={handleInvite}
disabled={addMember.isPending} 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" 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"
> >
{addMember.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />} {addMember.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
确认添加 添加
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) : null} )}
</div>
</div> </div>
); );
} }
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
Box, ArrowRight, Box, Sparkles, Users, MapPin, Plus,
Image as ImageIcon, Edit2, Trash2, Upload, X, Check, RefreshCw, Loader2,
Link2,
Loader2,
MapPin,
Plus,
Sparkles,
Trash2,
Users,
X,
} from "lucide-react"; } from "lucide-react";
import { useCreateAsset, useDeleteAsset, useGlobalAssets } from "../../hooks/useAssets"; import { useGlobalAssets, useCreateAsset, useDeleteAsset } 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 currentProjectId = projectId ?? ""; const numProjectId = 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] = useState<SettingsTab>("props"); const [activeTab, setActiveTab] = useState<SettingsTab>("props");
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState<EditForm>({ const [editForm, setEditForm] = useState<EditForm | null>(null);
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` },
...@@ -50,240 +38,263 @@ export function PropsGeneration() { ...@@ -50,240 +38,263 @@ 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 handleCreate = async () => { const openAdd = () => {
if (!form.name.trim()) return; setEditForm({ name: "", category: "配饰", description: "", imageUrl: "" });
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",
tags: form.category, name: editForm.name,
description: form.description, description: editForm.description,
imageUrl: form.imageUrl || undefined, tags: editForm.category,
sourceProjectId: currentProjectId || undefined, imageUrl: editForm.imageUrl || undefined,
sourceProjectId: numProjectId || undefined,
}); });
setShowModal(false); closeModal();
setForm({ name: "", category: "配件", description: "", imageUrl: "" });
}; };
const handleDelete = async (asset: GlobalAsset) => { const handleDelete = async (id: string) => {
if (!confirm(`确定要删除道具“${asset.name}”吗?`)) return; if (!confirm("确定要删除这个道具吗?")) return;
await deleteAsset.mutateAsync(asset.id); await deleteAsset.mutateAsync(id);
}; };
const withImage = props.filter((p) => p.imageUrl).length;
return ( return (
<div className="mx-auto max-w-[1440px]"> <div className="h-full overflow-auto bg-background relative">
<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="p-6 pb-24">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="max-w-7xl mx-auto">
<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">
Prop Setup <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">道具资产工作台</h1> <h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
管理项目中复用频率较高的道具与物件资产,可以按类别沉淀,也可通过图片链接快速补充参考。
</p>
</div> </div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p>
<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>
<div className="mt-6 flex flex-wrap gap-3"> {/* Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
{settingsTabs.map((tab) => { {settingsTabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const active = activeTab === tab.id;
return ( return (
<button <button
key={tab.id} key={tab.id}
onClick={() => navigate(tab.path)} onClick={() => { setActiveTab(tab.id); navigate(tab.path); }}
className={[ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all", activeTab === tab.id
active ? "bg-accent text-accent-foreground font-medium"
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]" : "text-muted-foreground hover:text-foreground hover:bg-muted"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground", }`}
].join(" ")}
> >
<Icon className="h-4 w-4" /> <Icon className="w-4 h-4" />
{tab.label} {tab.label}
</button> </button>
); );
})} })}
</div> </div>
<button
<div className="mt-6 grid gap-4 md:grid-cols-3"> onClick={openAdd}
{[ 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"
{ 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"
> >
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div> <Plus className="w-4 h-4" />
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div> 新增道具
</button>
</div> </div>
))}
{/* Stats bar */}
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">总计:</span>
<span className="text-lg font-semibold text-foreground">{props.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-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>
<button
onClick={() => {}}
className="p-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
</div> </div>
{/* Grid */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-24"> <div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
</div> </div>
) : props.length === 0 ? ( ) : props.length === 0 ? (
<div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <Box className="w-12 h-12 text-muted-foreground/30 mb-4" />
<Box className="h-7 w-7" /> <p className="text-muted-foreground mb-4">暂无道具,点击"新增道具"开始添加</p>
</div> <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">
<h3 className="mt-5 text-2xl font-semibold text-foreground">还没有道具资产</h3> <Plus className="w-4 h-4" />新增道具
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"> </button>
为常用道具建立统一资产库,可以让后续人物、场景和分镜流程复用同一套视觉素材。
</p>
</div> </div>
) : ( ) : (
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-6 gap-4 mb-8">
{props.map((asset) => ( {props.map((prop) => (
<div <div key={prop.id} className="rounded-xl border border-border bg-card overflow-hidden group">
key={asset.id} <div className="aspect-square relative bg-muted">
className="overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]" {prop.imageUrl ? (
> <img src={prop.imageUrl} alt={prop.name} className="w-full h-full object-cover" />
<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"> <div className="w-full h-full flex items-center justify-center">
<ImageIcon className="h-10 w-10 text-muted-foreground" /> <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-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-semibold text-foreground">{asset.name}</div>
<div className="mt-2 text-sm text-muted-foreground">
{asset.tags[0] ?? "未分类"}
</div>
</div>
<span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
道具
</span>
</div> </div>
<div className="p-3">
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground"> <div className="flex items-center justify-between mb-1">
{asset.description || "待补充道具说明、用途和适用场景。"} <h3 className="font-medium text-foreground text-sm truncate flex-1">{prop.name}</h3>
</p>
<button <button
onClick={() => handleDelete(asset)} onClick={() => handleDelete(prop.id)}
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" disabled={deleteAsset.isPending}
className="p-0.5 rounded hover:bg-destructive/10 transition-colors flex-shrink-0 disabled:opacity-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="w-3 h-3 text-destructive" />
删除道具
</button> </button>
</div> </div>
{prop.tags?.[0] && (
<div className="mb-2">
<span className="px-2 py-0.5 rounded-md bg-accent text-primary text-xs">{prop.tags[0]}</span>
</div>
)}
{prop.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{prop.description}</p>
)}
</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</section>
{showModal ? (
<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-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="mb-6 flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Prop Editor</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>
</div>
{/* 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 <button
onClick={() => setShowModal(false)} onClick={() => navigate(`/project/${projectId}/storyboard`)}
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="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"
> >
<X className="h-4 w-4" /> <ArrowRight className="w-4 h-4" />
进入分镜
</button> </button>
</div> </div>
</div>
<div className="space-y-5"> {/* Add / Edit Modal */}
<div className="grid gap-4 md:grid-cols-2"> {showModal && editForm && (
<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">
{editForm.id ? "编辑道具" : "新增道具"}
</h2>
<button onClick={closeModal} 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">
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">道具名称</label> <label className="block text-sm font-medium text-foreground mb-2">道具名称</label>
<input <input
value={form.name} type="text"
onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))} value={editForm.name}
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)]" onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
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="mb-2 block text-sm font-medium text-foreground">分类</label> <label className="block text-sm font-medium text-foreground mb-2">道具分类</label>
<select <select
value={form.category} value={editForm.category}
onChange={(e) => setForm((current) => ({ ...current, category: e.target.value }))} onChange={(e) => setEditForm({ ...editForm, category: 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" 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"
> >
{categories.map((category) => ( {CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
<option key={category} value={category}>
{category}
</option>
))}
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">描述</label> <label className="block text-sm font-medium text-foreground mb-2">道具描述</label>
<textarea <textarea
value={form.description} value={editForm.description}
onChange={(e) => setForm((current) => ({ ...current, description: e.target.value }))} onChange={(e) => setEditForm({ ...editForm, 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="描述道具的外观、用途和重要性"
placeholder="描述道具的外观、用途和重要性。" rows={3}
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="mb-2 block text-sm font-medium text-foreground">图片链接</label> <label className="block text-sm font-medium text-foreground mb-2">图片 URL(可选)</label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
value={form.imageUrl} type="text"
onChange={(e) => setForm((current) => ({ ...current, imageUrl: e.target.value }))} value={editForm.imageUrl}
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)]" onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
placeholder="https://example.com/prop.jpg" placeholder="https://example.com/prop.jpg"
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>
</div>
<div className="mt-6 flex items-center justify-end gap-3"> <div className="flex gap-3 mt-6 pt-6 border-t border-border">
<button <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">
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={handleCreate} onClick={handleSave}
disabled={createAsset.isPending || !form.name.trim()} disabled={!editForm.name.trim() || createAsset.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" 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"
> >
{createAsset.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {createAsset.isPending ? <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>
); );
} }
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();
...@@ -27,45 +26,26 @@ export function RegisterPage() { ...@@ -27,45 +26,26 @@ export function RegisterPage() {
}; };
return ( return (
<AuthShell <div className="min-h-screen flex items-center justify-center bg-background px-4">
title="创建企业账号" <Card className="w-full max-w-sm">
description="注册后即可创建团队空间,统一管理短剧项目、成员权限、资产沉淀与 AI 生成流程。" <CardHeader className="text-center">
footer={ <CardTitle className="text-2xl">创建账号</CardTitle>
<div className="flex items-center justify-between gap-3"> <CardDescription>免费开始使用 YaoAI Comic Studio</CardDescription>
<span className="text-xs text-muted-foreground">支持个人试用,也支持企业团队空间接入</span> </CardHeader>
<div> <CardContent>
已有账号? <form onSubmit={handleSubmit} className="space-y-4">
<Link to="/login" className="ml-1 font-medium text-primary hover:underline">
立即登录
</Link>
</div>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username" className="text-sm text-foreground"> <Label htmlFor="username">用户名</Label>
用户名
</Label>
<div className="relative">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="username" id="username"
placeholder="your-name" placeholder="your-name"
required required
value={form.username} value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
className="h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm text-foreground"> <Label htmlFor="email">邮箱</Label>
邮箱
</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 <Input
id="email" id="email"
type="email" type="email"
...@@ -73,17 +53,10 @@ export function RegisterPage() { ...@@ -73,17 +53,10 @@ export function RegisterPage() {
required required
value={form.email} value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} 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>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm text-foreground"> <Label htmlFor="password">密码</Label>
密码
</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 <Input
id="password" id="password"
type="password" type="password"
...@@ -92,44 +65,39 @@ export function RegisterPage() { ...@@ -92,44 +65,39 @@ export function RegisterPage() {
minLength={8} minLength={8}
value={form.password} value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} 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>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <Label htmlFor="tenantName">
<Label htmlFor="tenantName" className="text-sm text-foreground"> 工作空间名称 <span className="text-muted-foreground text-xs">(可选)</span>
工作空间名称
</Label> </Label>
<span className="text-xs text-muted-foreground">可选</span>
</div>
<div className="relative">
<Building2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="tenantName" id="tenantName"
placeholder="我的工作室" placeholder="我的工作室"
value={form.tenantName} value={form.tenantName}
onChange={(e) => setForm((f) => ({ ...f, tenantName: e.target.value }))} 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>
</div>
{registerError ? ( {registerError && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <p className="text-sm text-destructive">
{registerError instanceof Error ? registerError.message : "注册失败,请稍后重试。"} {registerError instanceof Error ? registerError.message : "注册失败"}
</div> </p>
) : null} )}
<Button <Button type="submit" className="w-full" disabled={registerPending}>
type="submit" {registerPending ? "注册中..." : "注册"}
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> </Button>
<p className="text-center text-sm text-muted-foreground">
已有账号?{" "}
<Link to="/login" className="text-primary underline-offset-4 hover:underline">
立即登录
</Link>
</p>
</form> </form>
</AuthShell> </CardContent>
</Card>
</div>
); );
} }
import { useRef, useState } from "react"; import { useState, useRef } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import { ArrowRight, Sparkles, Plus, Edit2, Users, MapPin, Box, Trash2, X, Check, RefreshCw, ImageIcon, Loader2, Upload } from "lucide-react";
Box, import { useScenes, useExtractScenes, useGenerateSceneImage, useDeleteScene, useSaveScene, useUploadSceneImage } from "../../hooks/useAi";
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: "", name: "", sceneType: "indoor", description: "",
sceneType: "indoor",
description: "",
status: "draft",
}); });
export function SceneGeneration() { export function SceneGeneration() {
...@@ -46,13 +23,15 @@ export function SceneGeneration() { ...@@ -46,13 +23,15 @@ 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 [generatingImageId, setGeneratingImageId] = useState<string | null>(null); const fileInputRef = useRef<HTMLInputElement>(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` },
...@@ -61,40 +40,41 @@ export function SceneGeneration() { ...@@ -61,40 +40,41 @@ export function SceneGeneration() {
]; ];
const handleExtract = () => { const handleExtract = () => {
extract.mutate(undefined, { extract.mutate(undefined, { onError: (e) => alert("提取失败: " + (e as Error).message) });
onError: (error) => alert(`提取场景失败:${(error as Error).message}`),
});
}; };
const openCreate = () => { const openCreate = () => {
setEditingScene(emptyForm()); setEditingScene(emptyForm());
setPendingFile(null);
setImagePreview(null); setImagePreview(null);
setPendingFile(null);
setShowModal(true); setShowModal(true);
}; };
const openEdit = (scene: Scene) => { const openEdit = (scene: Scene) => {
setEditingScene({ ...scene }); setEditingScene({ ...scene });
setPendingFile(null);
setImagePreview(scene.imageUrl ?? null); setImagePreview(scene.imageUrl ?? null);
setPendingFile(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({ const saved = await saveScene.mutateAsync({ ...editingScene, status: editingScene.status ?? "draft" });
...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 (error) { } catch (e) {
alert(`保存场景失败:${(error as Error).message}`); alert("保存失败: " + (e as Error).message);
} finally { } finally {
setSaving(false); setSaving(false);
} }
...@@ -104,287 +84,328 @@ export function SceneGeneration() { ...@@ -104,287 +84,328 @@ export function SceneGeneration() {
setGeneratingImageId(scene.id); setGeneratingImageId(scene.id);
generateImage.mutate(scene.id, { generateImage.mutate(scene.id, {
onSettled: () => setGeneratingImageId(null), onSettled: () => setGeneratingImageId(null),
onError: (error) => alert(`场景图生成失败:${(error as Error).message}`), onError: (e) => alert("图片生成失败: " + (e 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, { deleteScene.mutate(scene.id, { onError: (e) => alert("删除失败: " + (e as Error).message) });
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="mx-auto max-w-[1440px]"> <div className="h-full overflow-auto bg-background relative">
<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="p-6 pb-24">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="max-w-7xl mx-auto">
<div> {/* Header */}
<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="mb-6">
<Sparkles className="h-3.5 w-3.5" /> <div className="flex items-center gap-3 mb-2">
Scene Setup <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div> </div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">场景设定工作台</h1> <h1 className="text-2xl font-semibold text-foreground">项目设定</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
管理项目场景资产,支持从剧本中提取场景、补充环境描述、上传参考图或直接由 AI 生成场景图。
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={handleExtract}
disabled={extract.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"
>
{extract.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
从剧本提取场景
</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>
</div> </div>
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p>
</div> </div>
<div className="mt-6 flex flex-wrap gap-3"> {/* Settings Navigation Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
{settingsTabs.map((tab) => { {settingsTabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const active = activeTab === tab.id;
return ( return (
<button <button
key={tab.id} key={tab.id}
onClick={() => navigate(tab.path)} onClick={() => navigate(tab.path)}
className={[ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all", activeTab === tab.id
active ? "bg-accent text-accent-foreground font-medium"
? "border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]" : "text-muted-foreground hover:text-foreground hover:bg-muted"
: "border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground", }`}
].join(" ")}
> >
<Icon className="h-4 w-4" /> <Icon className="w-4 h-4" />
{tab.label} {tab.label}
</button> </button>
); );
})} })}
</div> </div>
<div className="flex items-center gap-2">
<div className="mt-6 grid gap-4 md:grid-cols-3"> <button
{[ onClick={openCreate}
{ label: "场景总数", value: `${scenes.length}` }, 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"
{ >
label: "室内场景", <Plus className="w-4 h-4" />
value: `${scenes.filter((scene) => scene.sceneType === "indoor").length}`, 手动添加
}, </button>
{ <button
label: "已有参考图", onClick={handleExtract}
value: `${scenes.filter((scene) => !!scene.imageUrl).length}`, 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"
].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> {extract.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div> AI提取场景
</button>
</div> </div>
))}
</div> </div>
{isLoading ? ( {/* Statistics */}
<div className="flex items-center justify-center py-24"> <div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">总计:</span>
<span className="text-lg font-semibold text-foreground">{scenes.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-green-600">
{scenes.filter(s => s.status === "ready").length}
</span>
</div> </div>
) : scenes.length === 0 ? ( <div className="flex items-center gap-2">
<div className="mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"> <span className="text-sm text-muted-foreground">室内:</span>
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> <span className="text-lg font-semibold text-foreground">
<MapPin className="h-7 w-7" /> {scenes.filter(s => s.sceneType === "indoor").length}
</span>
</div> </div>
<h3 className="mt-5 text-2xl font-semibold text-foreground">还没有场景设定</h3> <div className="flex items-center gap-2">
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"> <span className="text-sm text-muted-foreground">室外:</span>
先从剧本里提取关键场景,或手动创建基础场景,后续再补充参考图和环境描述。 <span className="text-lg font-semibold text-foreground">
</p> {scenes.filter(s => s.sceneType === "outdoor").length}
</span>
</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>
)}
</div> </div>
<div className="p-5"> {/* Empty State */}
<div className="flex items-start justify-between gap-3"> {!isLoading && scenes.length === 0 && (
<div> <div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
<div className="text-lg font-semibold text-foreground">{scene.name}</div> <MapPin className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<div className="mt-2 text-sm text-muted-foreground"> <h3 className="text-lg font-medium text-foreground mb-2">暂无场景</h3>
{scene.sceneType === "indoor" ? "室内场景" : "室外场景"} <p className="text-sm text-muted-foreground mb-6">手动添加场景,或点击「AI提取场景」从大纲和分集自动提取主要场景</p>
</div> <div className="flex items-center justify-center gap-3">
<button
onClick={openCreate}
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"
>
<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>
<span className="rounded-full bg-accent px-3 py-1 text-xs text-primary">
{scene.status === "ready" ? "已完成" : "待完善"}
</span>
</div> </div>
)}
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground"> {isLoading && (
{scene.description || "待补充环境氛围、陈设与镜头相关描述。"} <div className="flex items-center justify-center py-16">
</p> <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)}
<div className="mt-5 grid gap-2"> {/* Scene Grid */}
{scenes.length > 0 && (
<div className="grid grid-cols-3 gap-4 mb-8">
{scenes.map((scene) => (
<div key={scene.id} className="rounded-xl border border-border bg-card overflow-hidden group">
{/* Image */}
<div className="aspect-video relative overflow-hidden bg-muted">
{scene.imageUrl ? (
<img src={scene.imageUrl} alt={scene.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center 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={() => handleGenerateImage(scene)}
disabled={generatingImageId === scene.id} disabled={generatingImageId === scene.id || scene.status === "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" className="p-2 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title="AI重新生成"
> >
{generatingImageId === scene.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <WandSparkles className="h-4 w-4" />} <RefreshCw className={`w-5 h-5 text-white ${generatingImageId === scene.id ? "animate-spin" : ""}`} />
{generatingImageId === scene.id ? "正在生成..." : "AI 生成场景图"}
</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>
<div className="grid grid-cols-2 gap-2"> {/* 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 <button
onClick={() => openEdit(scene)} onClick={() => openEdit(scene)}
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-1 rounded hover:bg-muted transition-colors"
> >
编辑场景 <Edit2 className="w-3.5 h-3.5 text-muted-foreground" />
</button> </button>
<button <button
onClick={() => handleDelete(scene)} onClick={() => handleDelete(scene)}
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="p-1 rounded hover:bg-destructive/10 transition-colors"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="w-3.5 h-3.5 text-destructive" />
删除
</button> </button>
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{scene.description}</p>
{!scene.imageUrl && scene.status !== "generating" && (
<button
onClick={() => handleGenerateImage(scene)}
disabled={generatingImageId === scene.id}
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" />
生成场景图片
</button>
)}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</section> </div>
</div>
{showModal ? ( {/* Floating Bottom Bar */}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> <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="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="max-w-7xl mx-auto flex items-center justify-end gap-3">
<div className="mb-6 flex items-start justify-between gap-4"> <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 && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-md max-h-[90vh] flex flex-col">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold text-foreground">
{editingScene.id ? "编辑场景" : "手动添加场景"}
</h2>
<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>
{/* Modal Body */}
<div className="overflow-auto p-6 space-y-4 flex-1">
{/* Image Upload */}
<div> <div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Scene Editor</div> <label className="block text-sm font-medium text-foreground mb-2">场景图片(可选)</label>
<h3 className="mt-2 text-2xl font-semibold text-foreground"> <div
{editingScene.id ? "编辑场景" : "新建场景"} 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"
</h3> onClick={() => fileInputRef.current?.click()}
<p className="mt-2 text-sm leading-6 text-muted-foreground"> >
补充场景名称、环境描述和参考图,为角色互动和镜头设计提供统一空间背景。 {imagePreview ? (
</p> <img src={imagePreview} alt="预览" className="w-full h-full object-cover" />
) : (
<div className="text-center">
<Upload className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<span className="text-sm text-muted-foreground">点击上传场景参考图</span>
</div> </div>
)}
</div>
{imagePreview && (
<button <button
onClick={() => setShowModal(false)} onClick={() => { setImagePreview(null); setPendingFile(null); }}
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="mt-1 text-xs text-destructive hover:underline"
> >
<X className="h-4 w-4" /> 移除图片
</button> </button>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1fr_320px]"> {/* Name */}
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">场景名称</label> <label className="block text-sm font-medium text-foreground mb-1">场景名称 <span className="text-destructive">*</span></label>
<input <input
type="text"
value={editingScene.name ?? ""} value={editingScene.name ?? ""}
onChange={(e) => setEditingScene((current) => ({ ...current, name: e.target.value }))} onChange={(e) => setEditingScene({ ...editingScene, 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>
{/* Scene Type */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">场景类型</label> <label className="block text-sm font-medium text-foreground mb-1">场景类型</label>
<select <select
value={editingScene.sceneType ?? "indoor"} value={editingScene.sceneType ?? "indoor"}
onChange={(e) => setEditingScene((current) => ({ ...current, sceneType: e.target.value }))} onChange={(e) => setEditingScene({ ...editingScene, 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" 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> <option value="indoor">室内</option>
<option value="outdoor">室外</option> <option value="outdoor">室外</option>
</select> </select>
</div> </div>
</div>
{/* Description */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">环境描述</label> <label className="block text-sm font-medium text-foreground mb-1">场景描述</label>
<textarea <textarea
value={editingScene.description ?? ""} value={editingScene.description ?? ""}
onChange={(e) => setEditingScene((current) => ({ ...current, description: e.target.value }))} onChange={(e) => setEditingScene({ ...editingScene, 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="描述场景的环境、氛围、陈设等"
placeholder="描述场景的环境、氛围、陈设和镜头需要注意的空间信息。" rows={3}
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>
<div className="rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"> {/* Modal Footer */}
<div className="mb-4 text-base font-medium text-foreground">场景参考图</div> <div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<div className="aspect-[4/5] overflow-hidden rounded-[24px] bg-muted">
{imagePreview ? (
<img src={imagePreview} alt="场景参考" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)}
</div>
<button
onClick={() => fileRef.current?.click()}
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"
>
<Upload className="h-4 w-4" />
上传参考图
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setPendingFile(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button <button
onClick={() => setShowModal(false)} 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" className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || !editingScene.name?.trim()} disabled={!editingScene.name?.trim() || saving}
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" 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"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {saving ? <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>
); );
} }
import { useEffect, useMemo, useState } from "react"; import { useState, useEffect, useMemo } from "react";
import { useNavigate, useParams } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
ArrowRight, Plus, Wand2, Play, Trash2, Copy,
Clapperboard, Loader2, ArrowRight, Layers, Download, Check, X,
Copy, ChevronDown, Sparkles,
Image as ImageIcon,
Layers,
Loader2,
Plus,
Sparkles,
Trash2,
Wand2,
} from "lucide-react"; } from "lucide-react";
import { import {
useCharacters, useStoryboards,
useCreateStoryboard, useCreateStoryboard,
useDeleteStoryboard,
useEpisodes,
useGenerateStoryboardPrompt,
useGenerateStoryboards, useGenerateStoryboards,
useGenerateVideo,
useScenes,
useStoryboards,
useUpdateStoryboard, useUpdateStoryboard,
useDeleteStoryboard,
useAssemblyTask,
useStartAssembly,
useGenerateVideo,
useVideoTasks, useVideoTasks,
useGenerateStoryboardPrompt,
useCharacters,
useScenes,
useEpisodes,
} from "../../hooks/useAi"; } from "../../hooks/useAi";
import type { Storyboard } from "../../lib/api/ai"; import type { Storyboard, Character, Scene, Episode } from "../../lib/api/ai";
const videoModels = [ const VIDEO_MODELS = [
{ 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 durationOptions = [5, 10, 15, 20, 30]; const DURATION_OPTIONS = [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: loadingEpisodes } = useEpisodes(pid); const { data: episodes = [], isLoading: episodesLoading } = useEpisodes(pid);
const [activeEpisodeId, setActiveEpisodeId] = useState(""); const [activeEpisodeId, setActiveEpisodeId] = useState<string>("");
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, activeEpisodeId]); }, [urlEpisodeId, episodes.length]);
const eid = activeEpisodeId; const eid = activeEpisodeId;
const { data: storyboards = [], isLoading: loadingStoryboards } = useStoryboards(pid, eid); const { data: storyboards = [], isLoading } = 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 generateStoryboards = useGenerateStoryboards(pid); const generateSbs = useGenerateStoryboards(pid);
const createStoryboard = useCreateStoryboard(pid, eid); const createSb = useCreateStoryboard(pid, eid);
const updateStoryboard = useUpdateStoryboard(pid, eid); const updateSb = useUpdateStoryboard(pid, eid);
const deleteStoryboard = useDeleteStoryboard(pid, eid); const deleteSb = useDeleteStoryboard(pid, eid);
const generatePrompt = useGenerateStoryboardPrompt(pid);
const generateVideo = useGenerateVideo(pid); const generateVideo = useGenerateVideo(pid);
const startAssembly = useStartAssembly(pid, eid);
const generatePrompt = useGenerateStoryboardPrompt(pid);
useEffect(() => { const [selected, setSelected] = useState<Storyboard | null>(null);
if (storyboards.length > 0 && !selectedStoryboardId) { const [promptDraft, setPromptDraft] = useState("");
setSelectedStoryboardId(storyboards[0].id); const [promptChanged, setPromptChanged] = useState(false);
} const [selectedModel, setSelectedModel] = useState(VIDEO_MODELS[0].id);
}, [storyboards, selectedStoryboardId]); const [selectedDuration, setSelectedDuration] = useState(15);
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
const [batchGenerating, setBatchGenerating] = useState(false);
const selectedStoryboard = useMemo( const getCharacterPrimaryImage = (character: Character) =>
() => storyboards.find((storyboard) => storyboard.id === selectedStoryboardId) ?? null, character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null;
[storyboards, selectedStoryboardId],
);
// Reset selection when episode changes
useEffect(() => { useEffect(() => {
if (!selectedStoryboard) { setSelected(null);
setPromptDraft(""); setPromptDraft("");
setSelectedDraft(null); setPromptChanged(false);
return; }, [activeEpisodeId]);
// Sync prompt draft when selected storyboard changes
useEffect(() => {
if (selected) {
const draft = selected.startFramePrompt || selected.detailedDescription || "";
setPromptDraft(draft);
setPromptChanged(false);
} }
setSelectedDraft(selectedStoryboard); }, [selected?.id]);
setPromptDraft(selectedStoryboard.startFramePrompt || selectedStoryboard.detailedDescription || "");
}, [selectedStoryboard?.id]);
const taskMap = useMemo(() => {
const map = new Map<string, (typeof videoTasks)[number]>();
storyboards.forEach((storyboard) => {
const task = videoTasks.find((item) => item.storyboardId === storyboard.id);
if (task) map.set(storyboard.id, task);
});
return map;
}, [storyboards, videoTasks]);
const handleGenerateStoryboards = async () => { // Auto-select first storyboard when list loads
if (!eid) return; useEffect(() => {
const created = await generateStoryboards.mutateAsync(eid); if (storyboards.length > 0 && !selected) {
if (created.length > 0) { setSelected(storyboards[0]);
setSelectedStoryboardId(created[0].id);
} }
}, [storyboards.length]);
const getTaskForSb = (sb: Storyboard) => {
const byId = videoTasks.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]
?? videoTasks.find((t) => t.episodeId === sb.episodeId && t.storyboardId == null);
};
const getDotColor = (sb: Storyboard) => {
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 () => {
if (!eid) return;
const result = await generateSbs.mutateAsync(eid);
if (result.length > 0) setSelected(result[0]);
}; };
const handleCreateStoryboard = async () => { const handleCreate = async () => {
const created = await createStoryboard.mutateAsync({ const newSb = await createSb.mutateAsync({
sequenceNum: storyboards.length + 1, sequenceNum: storyboards.length + 1,
sceneNumber: `场景 ${storyboards.length + 1}`, sceneNumber: `场景${storyboards.length + 1}`,
shortDescription: "新镜头", shortDescription: "新镜头",
detailedDescription: "", detailedDescription: "",
characters: "", characters: "",
...@@ -152,56 +138,143 @@ export function StoryboardWorkspace() { ...@@ -152,56 +138,143 @@ export function StoryboardWorkspace() {
motionScript: "", motionScript: "",
notes: "", notes: "",
status: "draft", status: "draft",
} as Storyboard); } as unknown as Storyboard);
setSelectedStoryboardId(created.id); setSelected(newSb);
}; };
const handleDeleteStoryboard = async (storyboard: Storyboard) => { const handleDelete = async (sb: Storyboard) => {
if (!confirm(`确定要删除分镜 #${storyboard.sequenceNum} 吗?`)) return; if (!confirm(`确定删除分镜 #${sb.sequenceNum}?`)) return;
await deleteStoryboard.mutateAsync(storyboard.id); await deleteSb.mutateAsync(sb.id);
if (selectedStoryboardId === storyboard.id) { if (selected?.id === sb.id) setSelected(storyboards.find((s) => s.id !== sb.id) ?? null);
setSelectedStoryboardId("");
}
}; };
const handleCopyStoryboard = async () => { const handleCopy = async () => {
if (!selectedStoryboard) return; if (!selected) return;
const copy = await createStoryboard.mutateAsync({ const copy = await createSb.mutateAsync({
...selectedStoryboard, ...selected,
id: undefined, id: undefined,
sequenceNum: storyboards.length + 1, sequenceNum: storyboards.length + 1,
sceneNumber: `${selectedStoryboard.sceneNumber}-副本`, sceneNumber: `${selected.sceneNumber}-副本`,
status: "draft", status: "draft",
} as unknown as Storyboard); } as unknown as Storyboard);
setSelectedStoryboardId(copy.id); setSelected(copy);
};
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 () => {
if (!selected) return;
const generatedPrompt = await generatePrompt.mutateAsync(selected.id);
setPromptDraft(generatedPrompt);
setPromptChanged(true);
}; };
const handleSaveStoryboard = async () => { // 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。
if (!selectedDraft) return; const refImages = useMemo<Array<{
await updateStoryboard.mutateAsync({ label: string;
id: selectedDraft.id, name: string;
patch: { imageUrl: string | null;
...selectedDraft, imageTosKey: string;
startFramePrompt: promptDraft, 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 handleGeneratePrompt = async () => { const insertRefImage = (idx: number) => {
if (!selectedStoryboard) return; const tag = `@图${idx + 1}`;
const generated = await generatePrompt.mutateAsync(selectedStoryboard.id); setPromptDraft((prev) => (prev ? prev + " " + tag : tag));
setPromptDraft(generated); setPromptChanged(true);
};
// 从 prompt 解析所有 @图N 引用(按数字顺序去重),返回对应的 imageTosKey 列表
const parseRefImageKeys = (text: string): string[] => {
const matches = [...text.matchAll(/@图(\d+)/g)];
const seen = new Set<number>();
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 (!selectedDraft || !promptDraft.trim()) return; if (!selected) return;
setGeneratingVideoId(selectedDraft.id); setGeneratingVideoId(selected.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: eid, episodeId: selected.episodeId,
storyboardId: selectedDraft.id, storyboardId: selected.id,
prompt: promptDraft, prompt: currentPrompt,
videoPrompt: promptDraft, videoPrompt: selected.motionScript || currentPrompt,
imageKeys: imageKeys.length > 0 ? imageKeys : null,
duration: selectedDuration, duration: selectedDuration,
}); });
} finally { } finally {
...@@ -209,308 +282,454 @@ export function StoryboardWorkspace() { ...@@ -209,308 +282,454 @@ export function StoryboardWorkspace() {
} }
}; };
const updateSelected = (patch: Partial<Storyboard>) => { const handleBatchGenerate = async () => {
if (!selectedDraft) return; setBatchGenerating(true);
setSelectedDraft((current) => (current ? { ...current, ...patch } : current)); const pending = storyboards.filter((sb) => {
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);
}; };
if (loadingEpisodes) { const selectedTask = selected ? getTaskForSb(selected) : null;
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (episodes.length === 0) {
return ( return (
<div className="mx-auto max-w-[1180px]"> <div className="h-full flex overflow-hidden bg-background">
<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">
<Clapperboard 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>
<button
onClick={() => navigate(`/project/${projectId}/outline`)}
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]"
>
返回大纲页
<ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
return ( {/* ─── Far Left: Episode list strip ─── */}
<div className="mx-auto max-w-[1480px]"> <div className="flex-shrink-0 border-r border-border bg-muted/20 flex flex-col overflow-hidden" style={{ width: 52 }}>
<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="h-14 border-b border-border flex items-center justify-center flex-shrink-0">
<div className="flex flex-wrap items-start justify-between gap-4"> <span className="text-[10px] text-muted-foreground font-medium">集数</span>
<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">
<Layers className="h-3.5 w-3.5" />
Storyboard Workspace
</div> </div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">分镜工作台</h1> <div className="flex-1 overflow-y-auto py-1">
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"> {episodesLoading ? (
选择分集后统一管理镜头描述、角色与场景线索、提示词和视频生成入口。 <div className="flex items-center justify-center pt-4">
</p> <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />
</div> </div>
) : episodes.length === 0 ? (
<div className="flex flex-wrap gap-3"> <div className="px-1 pt-4 text-center">
<span className="text-[9px] text-muted-foreground leading-tight">暂无集数</span>
</div>
) : (
episodes.map((ep) => (
<button <button
onClick={handleGenerateStoryboards} key={ep.id}
disabled={!eid || generateStoryboards.isPending} onClick={() => handleEpisodeSelect(ep)}
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" 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"
}`}
> >
{generateStoryboards.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} <span className="text-[11px] font-semibold leading-none">第</span>
AI 生成分镜 <span className="text-base font-bold leading-tight">{ep.episodeNumber}</span>
<span className="text-[11px] font-semibold leading-none">集</span>
</button> </button>
))
)}
</div>
</div>
{/* ─── Left: storyboard list ─── */}
<div className="flex flex-col flex-shrink-0 border-r border-border overflow-hidden" style={{ width: 240 }}>
{/* Header */}
<div className="h-14 px-3 border-b border-border flex items-center gap-2 flex-shrink-0">
<button <button
onClick={handleCreateStoryboard} onClick={handleCreate}
disabled={!eid} disabled={createSb.isPending || !eid}
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" 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"
> >
<Plus className="h-4 w-4" /> {createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
新建分镜 新建分镜
</button> </button>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
{episodes.map((episode) => (
<button <button
key={episode.id} onClick={handleGenerate}
onClick={() => { disabled={generateSbs.isPending || !eid}
setActiveEpisodeId(episode.id); title="AI批量生成分镜"
setSelectedStoryboardId(""); className="p-1.5 rounded-lg border border-border hover:bg-muted transition text-muted-foreground disabled:opacity-50"
navigate(`/project/${pid}/storyboard/${episode.id}`, { replace: true });
}}
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} 集 {generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
</button> </button>
))}
</div> </div>
<div className="mt-6 grid gap-6 xl:grid-cols-[420px_1fr]"> {/* List */}
<div className="rounded-[32px] border border-white/80 bg-white/78 p-5 shadow-sm"> <div className="flex-1 overflow-y-auto py-1">
<div className="mb-4 flex items-center justify-between"> {!eid ? (
<div> <div className="px-3 py-8 text-center">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Storyboard List</div> <p className="text-xs text-muted-foreground">请从左侧选择集数</p>
<div className="mt-2 text-xl font-semibold text-foreground">{storyboards.length} 个镜头</div>
</div> </div>
{loadingStoryboards ? <Loader2 className="h-5 w-5 animate-spin text-primary" /> : null} ) : isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div> </div>
) : storyboards.length === 0 ? (
<div className="space-y-3"> <div className="px-3 py-8 text-center">
{storyboards.length === 0 ? ( <p className="text-xs text-muted-foreground">点击「AI生成」或「新建分镜」开始</p>
<div className="rounded-[24px] border border-dashed border-border bg-white px-4 py-12 text-center text-sm text-muted-foreground">
当前分集还没有分镜,可以先通过 AI 自动生成,或手动新建一个镜头。
</div> </div>
) : ( ) : (
storyboards.map((storyboard) => { storyboards.map((sb, i) => (
const task = taskMap.get(storyboard.id); <div
return ( key={sb.id}
<button onClick={() => setSelected(sb)}
key={storyboard.id} className={`mx-2 my-0.5 rounded-lg px-3 py-2.5 cursor-pointer transition-colors border ${
onClick={() => setSelectedStoryboardId(storyboard.id)} selected?.id === sb.id
className={[ ? "border-primary bg-accent"
"w-full rounded-[24px] border px-4 py-4 text-left transition-all", : "border-transparent hover:bg-muted"
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(" ")}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-center justify-between mb-1">
<div> <span className="text-xs font-semibold text-foreground">
<div className="text-sm font-medium text-foreground"> 分镜 {String(sb.sequenceNum).padStart(3, "0")}
#{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> </span>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${getDotColor(sb)}`} />
</div> </div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground"> <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{storyboard.detailedDescription || "待补充镜头描述。"} {sb.shortDescription || sb.sceneNumber}
</p>
</div> </div>
</button> ))
);
})
)} )}
</div> </div>
{/* Bottom: batch generate */}
<div className="p-3 border-t border-border flex-shrink-0">
<button
onClick={handleBatchGenerate}
disabled={batchGenerating || storyboards.length === 0}
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"
>
{batchGenerating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
批量生成视频
</button>
</div>
</div> </div>
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> {/* ─── Middle: storyboard detail + prompt edit ─── */}
{selectedDraft ? ( <div className="flex flex-col border-r border-border overflow-hidden" style={{ width: 400 }}>
<div className="space-y-5"> {!selected ? (
<div className="flex flex-wrap items-start justify-between gap-4"> <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>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Selected Shot</div> <h2 className="text-base font-semibold text-foreground">
<h2 className="mt-2 text-2xl font-semibold text-foreground"> 分镜 {String(selected.sequenceNum).padStart(3, "0")}
#{selectedDraft.sequenceNum} {selectedDraft.shortDescription || "未命名镜头"}
</h2> </h2>
<p className="text-xs text-muted-foreground">编辑分镜详细信息</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleCopyStoryboard} onClick={handleCopy}
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={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="h-4 w-4" /> <Copy className="w-3.5 h-3.5" />
复制镜头 复制
</button> </button>
<button <button
onClick={() => handleDeleteStoryboard(selectedDraft)} onClick={() => handleDelete(selected)}
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" 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="h-4 w-4" /> <Trash2 className="w-3.5 h-3.5" />
删除 删除
</button> </button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> {/* Prompt edit area */}
<div> <div className="flex-1 overflow-y-auto px-5 py-4">
<label className="mb-2 block text-sm font-medium text-foreground">镜头标题</label> <div className="flex items-center justify-between mb-2">
<input <label className="text-xs font-medium text-muted-foreground">分镜描述</label>
value={selectedDraft.shortDescription || ""} <div className="flex items-center gap-2">
onChange={(e) => updateSelected({ shortDescription: 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)]" onClick={handleGeneratePrompt}
/> disabled={generatePrompt.isPending}
</div> className="text-xs text-violet-600 dark:text-violet-400 flex items-center gap-1 hover:underline disabled:opacity-50"
<div> title="根据分镜剧本自动生成描述"
<label className="mb-2 block text-sm font-medium text-foreground">场景编号</label> >
<input {generatePrompt.isPending
value={selectedDraft.sceneNumber || ""} ? <Loader2 className="w-3 h-3 animate-spin" />
onChange={(e) => updateSelected({ sceneNumber: e.target.value })} : <Sparkles className="w-3 h-3" />}
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)]" AI生成描述
/> </button>
{promptChanged && (
<button
onClick={handleSavePrompt}
disabled={updateSb.isPending}
className="text-xs text-primary flex items-center gap-1 hover:underline"
>
{updateSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3 h-3" />}
保存
</button>
)}
</div> </div>
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">详细描述</label>
<textarea <textarea
value={selectedDraft.detailedDescription || ""} 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"
onChange={(e) => updateSelected({ detailedDescription: e.target.value })} value={promptDraft}
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) => {
setPromptDraft(e.target.value);
setPromptChanged(e.target.value !== (selected.startFramePrompt || selected.detailedDescription || ""));
}}
placeholder="在此输入或编辑分镜描述/提示词,AI 将根据此内容生成视频..."
/> />
</div>
<div className="grid gap-4 md:grid-cols-2"> {/* shortDescription */}
<div> {selected.shortDescription && (
<label className="mb-2 block text-sm font-medium text-foreground">角色线索</label> <p className="mt-3 text-xs text-muted-foreground border-l-2 border-primary/30 pl-2">
<textarea {selected.shortDescription}
value={selectedDraft.characters || ""} </p>
onChange={(e) => updateSelected({ characters: 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={characters.map((character) => character.name).join("、") || "可填写角色名称或动作描述"} {/* @提及:角色/场景名 */}
/> {(characters.length > 0 || scenes.length > 0) && (
</div> <div className="mt-3">
<div> <div className="text-[11px] text-muted-foreground mb-1.5">@ 提及角色/场景</div>
<label className="mb-2 block text-sm font-medium text-foreground">场景线索</label> <div className="flex flex-wrap gap-1.5">
<textarea {characters.map((c) => (
value={selectedDraft.notes || ""} <button
onChange={(e) => updateSelected({ notes: e.target.value })} key={c.id}
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)]" onClick={() => insertMention(c.name)}
placeholder={scenes.map((scene) => scene.name).join("、") || "可填写场景、机位或备注信息"} 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"
/> >
{getCharacterPrimaryImage(c) && <img src={getCharacterPrimaryImage(c) ?? ""} className="w-3.5 h-3.5 rounded-full object-cover" />}
@{c.name}
</button>
))}
{scenes.map((s) => (
<button
key={s.id}
onClick={() => insertMention(s.name)}
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"
>
{s.imageUrl && <img src={s.imageUrl} className="w-3.5 h-3.5 rounded object-cover" />}
@{s.name}
</button>
))}
</div> </div>
</div> </div>
)}
<div className="rounded-[28px] border border-primary/10 bg-primary/5 p-5"> {/* @图N:参考图面板 */}
<div className="mb-3 flex items-center justify-between gap-4"> {refImages.length > 0 && (
<div> <div className="mt-3">
<div className="text-sm font-medium text-foreground">视频提示词</div> <div className="text-[11px] text-muted-foreground mb-1.5">
<div className="mt-1 text-sm text-muted-foreground">可手动编辑,也可基于当前分镜内容自动生成。</div> @图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。
</div> </div>
<div className="flex gap-2 flex-wrap">
{refImages.map((img, idx) => (
<button <button
onClick={handleGeneratePrompt} key={img.imageTosKey}
disabled={generatePrompt.isPending} onClick={() => insertRefImage(idx)}
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" className="flex flex-col items-center gap-0.5 group"
title={`点击插入 @图${idx + 1}(${img.label})`}
> >
{generatePrompt.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />} <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> </button>
))}
</div> </div>
<textarea </div>
value={promptDraft} )}
onChange={(e) => setPromptDraft(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 className="grid gap-4 md:grid-cols-[1fr_160px_160px]"> {/* 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 */}
<div className="relative flex-1">
<select <select
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => setSelectedModel(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" 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"
> >
{videoModels.map((model) => ( {VIDEO_MODELS.map((m) => (
<option key={model.id} value={model.id}> <option key={m.id} value={m.id}>{m.label}</option>
{model.label}
</option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">时长</label> {/* Duration */}
<div className="relative">
<select <select
value={selectedDuration} value={selectedDuration}
onChange={(e) => setSelectedDuration(Number(e.target.value))} onChange={(e) => setSelectedDuration(Number(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" 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"
> >
{durationOptions.map((duration) => ( {DURATION_OPTIONS.map((d) => (
<option key={duration} value={duration}> <option key={d} value={d}>{d}</option>
{duration} 秒
</option>
))} ))}
</select> </select>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
</div> </div>
<div className="flex items-end">
{/* Generate video */}
<button <button
onClick={handleSaveStoryboard} onClick={handleGenerateVideo}
disabled={updateStoryboard.isPending} disabled={generatingVideoId === selected.id || !promptDraft}
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" 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"
> >
{updateStoryboard.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {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> </button>
</div> </div>
</>
)}
</div> </div>
{/* ─── Right: video preview ─── */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<div className="h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0">
<span className="text-sm font-medium text-foreground">视频预览</span>
{/* Assembly controls */}
<div className="flex items-center gap-2">
{assemblyTask?.status === "succeeded" && assemblyTask.downloadUrl && (
<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>
)}
<button
onClick={() => startAssembly.mutate()}
disabled={startAssembly.isPending || assemblyTask?.status === "running" || assemblyTask?.status === "pending" || storyboards.length === 0}
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">
{!selected ? (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">选择分镜后查看视频</p>
</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>
))}
</div>
<a
href={selectedTask.resultVideoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<Download className="w-3.5 h-3.5" />
下载此镜头视频
</a>
</div>
) : selectedTask?.status === "running" || selectedTask?.status === "submitted" || selectedTask?.status === "pending" ? (
/* Generating */
<div className="h-full flex flex-col items-center justify-center gap-4">
<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" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-foreground mb-1">视频生成中...</p>
<p className="text-xs text-muted-foreground">通常需要 30-60 秒,请稍候</p>
</div>
</div>
) : selectedTask?.status === "failed" ? (
/* Failed */
<div className="rounded-xl border border-red-200 bg-red-50 p-6">
<div className="flex items-center gap-2 mb-2">
<X className="w-4 h-4 text-red-500" />
<span className="text-sm font-medium text-red-700">生成失败</span>
</div>
<p className="text-xs text-red-600">{selectedTask.errorMessage || "未知错误"}</p>
<button <button
onClick={handleGenerateVideo} onClick={handleGenerateVideo}
disabled={!promptDraft.trim() || generatingVideoId === selectedDraft.id} className="mt-3 text-xs text-red-600 hover:underline"
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> </button>
</div> </div>
) : ( ) : (
<div className="rounded-[24px] border border-dashed border-border bg-white px-4 py-16 text-center text-sm text-muted-foreground"> /* 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,9 +20,10 @@ export function StyleSelection() { ...@@ -20,9 +20,10 @@ 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">
杩欓噷閫夋嫨鐨勬槸瑙嗚鍛堢幇鏂瑰紡锛屼笉鏄鏉愮被鍨嬨€侫I 浼氭牴鎹墍閫夐鏍煎奖鍝嶄汉鐗┿€佸満鏅笌鍚庣画鍥惧儚鐢熸垚銆? </p> 这里选择的是视觉呈现方式,不是题材类型。AI 会根据所选风格影响人物、场景与后续图像生成。
</p>
</div> </div>
<div className="mb-8 grid grid-cols-3 gap-4"> <div className="mb-8 grid grid-cols-3 gap-4">
...@@ -60,13 +61,13 @@ export function StyleSelection() { ...@@ -60,13 +61,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-[#0f74d8] to-[#1ea5ff] 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-[#5b5ff9] to-[#8b5ff9] 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" />
</> </>
)} )}
...@@ -75,4 +76,3 @@ export function StyleSelection() { ...@@ -75,4 +76,3 @@ export function StyleSelection() {
</div> </div>
); );
} }
import { useMemo, useState } from "react"; import { useState } from "react";
import { import {
FolderKanban, Users,
Loader2, UserPlus,
Mail,
Pencil,
Plus,
Search, Search,
Shield, Edit2,
Trash2, Trash2,
Users, Shield,
Check,
Loader2,
X, X,
UserMinus,
} from "lucide-react"; } from "lucide-react";
import { import {
useTeamMembers,
useAddMember, useAddMember,
useCreateGroup, useUpdateMember,
useDeleteGroup,
useRemoveMember, useRemoveMember,
useTeamGroups, useTeamGroups,
useTeamMembers, useCreateGroup,
useUpdateGroup, useUpdateGroup,
useUpdateMember, useDeleteGroup,
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: "所有者",
...@@ -29,450 +34,488 @@ const roleLabels: Record<string, string> = { ...@@ -29,450 +34,488 @@ 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 [searchTerm, setSearchTerm] = useState(""); const filteredGroups = groups.filter((g) =>
const [showMemberModal, setShowMemberModal] = useState(false); g.name.toLowerCase().includes(searchTerm.toLowerCase())
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 openEditMember = (member: (typeof members)[number]) => { const memberById = Object.fromEntries(members.map((m) => [m.userId, m]));
setEditingMemberId(member.userId); const managingGroup = managingGroupId !== null ? groups.find((g) => g.id === managingGroupId) ?? null : null;
setMemberForm({
username: member.username,
email: member.email,
password: "",
role: member.role,
position: member.position ?? "",
});
setShowMemberModal(true);
};
const openCreateGroup = () => { // handlers
setEditingGroupId(null); const handleAddMember = async () => {
setGroupForm({ name: "", description: "" }); await addMember.mutateAsync({ username: formUsername, email: formEmail, password: formPassword, role: formRole, position: formPosition });
setShowGroupModal(true); setFormUsername(""); setFormEmail(""); setFormPassword(""); setFormRole("member"); setFormPosition("");
setShowAddMemberModal(false);
}; };
const openEditGroup = (group: (typeof groups)[number]) => { const openEditMember = (m: TeamMember) => {
setEditingGroupId(group.id); setEditingMember(m);
setGroupForm({ setEditRole(m.role);
name: group.name, setEditPosition(m.position ?? "");
description: group.description ?? "",
});
setShowGroupModal(true);
}; };
const handleSaveMember = async () => { const handleEditMember = async () => {
if (!memberForm.username.trim() || !memberForm.email.trim()) return; if (!editingMember) return;
await updateMember.mutateAsync({ userId: editingMember.userId, patch: { role: editRole, position: editPosition } });
if (editingMemberId) { setEditingMember(null);
await updateMember.mutateAsync({
userId: editingMemberId,
patch: { role: memberForm.role, position: memberForm.position },
});
} else {
await addMember.mutateAsync(memberForm);
}
setShowMemberModal(false);
}; };
const handleRemoveMember = async (userId: number) => { const handleAddGroup = async () => {
if (!confirm("确定要移除该成员吗?")) return; await createGroup.mutateAsync({ name: formGroupName, description: formGroupDesc });
await removeMember.mutateAsync(userId); setFormGroupName(""); setFormGroupDesc("");
setShowAddGroupModal(false);
}; };
const handleSaveGroup = async () => { const openEditGroup = (g: TeamGroup) => {
if (!groupForm.name.trim()) return; setEditingGroup(g);
setEditGroupName(g.name);
if (editingGroupId) { setEditGroupDesc(g.description ?? "");
await updateGroup.mutateAsync({
groupId: editingGroupId,
patch: groupForm,
});
} else {
await createGroup.mutateAsync(groupForm);
}
setShowGroupModal(false);
}; };
const handleDeleteGroup = async (groupId: number) => { const handleEditGroup = async () => {
if (!confirm("确定要删除该小组吗?")) return; if (!editingGroup) return;
await deleteGroup.mutateAsync(groupId); await updateGroup.mutateAsync({ groupId: editingGroup.id, patch: { name: editGroupName, description: editGroupDesc } });
setEditingGroup(null);
}; };
const loading = loadingMembers || loadingGroups; const addBtnLabel = activeTab === "employees" ? "添加成员" : activeTab === "groups" ? "创建小组" : "";
if (loading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="h-full flex flex-col bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> {/* Header */}
</div> <div className="border-b border-border bg-card px-6 py-4">
); <div className="flex items-center justify-between mb-4">
}
return (
<div className="mx-auto max-w-[1440px]">
<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>
<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 className="text-2xl font-semibold text-foreground mb-1">团队管理</h1>
<Users className="h-3.5 w-3.5" /> <p className="text-sm text-muted-foreground">管理团队成员、小组和权限</p>
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
onClick={openCreateGroup}
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"
>
<FolderKanban className="h-4 w-4" />
新建小组
</button>
<button <button
onClick={openCreateMember} onClick={() => activeTab === "employees" ? setShowAddMemberModal(true) : setShowAddGroupModal(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]" 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"
> >
<Plus className="h-4 w-4" /> <UserPlus className="w-4 h-4" />
添加成员 {addBtnLabel}
</button> </button>
</div> )}
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-4"> <div className="flex gap-1">
{[ {tabs.map((tab) => (
{ label: "团队成员", value: `${members.length}` }, <button key={tab.id} onClick={() => setActiveTab(tab.id)}
{ label: "管理员", value: `${members.filter((member) => member.role === "admin").length}` }, className={`px-4 py-2 rounded-lg text-sm flex items-center gap-2 transition-all ${
{ label: "所有者", value: `${members.filter((member) => member.role === "owner").length}` }, activeTab === tab.id ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted"
{ label: "小组数量", value: `${groups.length}` }, }`}>
].map((item) => ( <tab.icon className="w-4 h-4" />
<div {tab.label}
key={item.label} </button>
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 className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Members</div>
<h2 className="mt-2 text-2xl font-semibold text-foreground">成员列表</h2>
</div> </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" /> {/* Content */}
<input <div className="flex-1 overflow-auto p-6">
value={searchTerm} <div className="max-w-7xl mx-auto">
onChange={(e) => setSearchTerm(e.target.value)} <div className="mb-6">
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 className="relative max-w-md">
placeholder="搜索成员" <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-card text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" />
</div> </div>
</div> </div>
<div className="space-y-4"> {/* Employees Tab */}
{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 <div key={member.userId} className="rounded-xl border border-border bg-card p-4 flex items-center justify-between">
key={member.userId} <div className="flex items-center gap-4">
className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm" <div className="w-12 h-12 rounded-full bg-accent flex items-center justify-center text-primary font-semibold text-lg">
> {member.username.charAt(0).toUpperCase()}
<div className="flex flex-wrap items-start justify-between gap-4"> </div>
<div> <div>
<div className="text-lg font-medium text-foreground">{member.username}</div> <div className="flex items-center gap-3 mb-1">
<div className="mt-2 inline-flex items-center gap-2 text-sm text-muted-foreground"> <span className="font-medium text-foreground">{member.username}</span>
<Mail className="h-4 w-4" /> {member.position && <span className="text-xs text-muted-foreground">{member.position}</span>}
{member.email} <span className="px-2 py-0.5 rounded bg-accent text-primary text-xs">{roleLabels[member.role] ?? member.role}</span>
{member.status === 1
? <span className="px-2 py-0.5 rounded bg-green-100 text-green-700 text-xs">已启用</span>
: <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-xs">已禁用</span>}
</div> </div>
<div className="mt-2 text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">{member.email}</div>
岗位:{member.position || "未设置"}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-2 rounded-full bg-accent px-3 py-1 text-xs text-primary"> <div className="flex items-center gap-2">
<Shield className="h-3.5 w-3.5" /> {member.role !== "owner" && (
{roleLabels[member.role] ?? member.role} <>
</span> <button onClick={() => openEditMember(member)}
<button 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">
onClick={() => openEditMember(member)} <Edit2 className="w-3 h-3" />编辑
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 <button onClick={() => removeMember.mutate(member.userId)}
onClick={() => handleRemoveMember(member.userId)} className="p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors">
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="w-4 h-4" />
>
<Trash2 className="h-4 w-4" />
移除
</button> </button>
</div> </>
)}
</div> </div>
</div> </div>
))} ))}
{!loadingMembers && filteredMembers.length === 0 && (
<div className="text-center py-16 text-muted-foreground">暂无成员</div>
)}
</div>
)}
{filteredMembers.length === 0 ? ( {/* Groups Tab */}
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"> {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>
) : null} <div className="flex items-center gap-1">
<button onClick={() => openEditGroup(group)}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => deleteGroup.mutate(group.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-destructive transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<div className="mb-4"> <div className="mb-4">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Groups</div> <div className="flex items-center justify-between mb-2">
<h2 className="mt-2 text-2xl font-semibold text-foreground">小组结构</h2> <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>
))}
{!loadingGroups && filteredGroups.length === 0 && (
<div className="col-span-2 text-center py-16 text-muted-foreground">暂无小组</div>
)}
</div> </div>
)}
<div className="space-y-4"> {/* Permissions Tab */}
{groups.length === 0 ? ( {activeTab === "permissions" && (
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"> <div className="grid grid-cols-2 gap-4">
当前还没有小组,建议按职能或项目阶段划分小组,便于成员协作。 {[
{ name: "owner(所有者)", description: "最高权限,可管理成员、计费、所有功能", includes: ["成员管理", "计费管理", "项目管理", "资产库", "全部操作"] },
{ name: "admin(管理员)", description: "可管理成员和项目,但不能操作计费", includes: ["成员管理", "项目管理", "资产库", "生成操作"] },
{ name: "member(成员)", description: "可操作已授权的项目功能", includes: ["项目查看", "分镜编辑", "视频生成", "资产库查看"] },
].map((role, idx) => (
<div key={idx} className="rounded-xl border border-border bg-card p-5">
<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">
<Shield className="w-5 h-5 text-primary" />
</div> </div>
) : (
groups.map((group) => (
<div
key={group.id}
className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
>
<div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-lg font-medium text-foreground">{group.name}</div> <h3 className="font-medium text-foreground mb-1">{role.name}</h3>
<div className="mt-2 text-sm leading-6 text-muted-foreground"> <p className="text-sm text-muted-foreground">{role.description}</p>
{group.description || "暂无描述"}
</div> </div>
<div className="mt-2 text-sm text-muted-foreground">
成员数:{group.memberCount ?? 0}
</div> </div>
<div>
<div className="text-xs text-muted-foreground mb-2">包含权限</div>
<div className="space-y-2">
{role.includes.map((item) => (
<div key={item} className="flex items-center gap-2 text-sm text-foreground">
<Check className="w-4 h-4 text-green-500" />{item}
</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>
{showMemberModal ? ( {/* ---- Add Member Modal ---- */}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> {showAddMemberModal && (
<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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full">
<h2 className="text-xl font-semibold text-foreground mb-6">添加团队成员</h2>
<div className="space-y-4 mb-6">
<div> <div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Member Editor</div> <label className="block text-sm font-medium text-foreground mb-2">用户名</label>
<h3 className="mt-2 text-2xl font-semibold text-foreground"> <input value={formUsername} onChange={(e) => setFormUsername(e.target.value)} type="text" placeholder="输入用户名"
{editingMemberId ? "编辑成员" : "添加成员"} 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" />
</h3>
</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>
<div className="space-y-5">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">用户名</label> <label className="block text-sm font-medium text-foreground mb-2">邮箱</label>
<input <input value={formEmail} onChange={(e) => setFormEmail(e.target.value)} type="email" placeholder="输入邮箱"
value={memberForm.username} 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" />
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="mb-2 block text-sm font-medium text-foreground">邮箱</label> <label className="block text-sm font-medium text-foreground mb-2">初始密码</label>
<input <input value={formPassword} onChange={(e) => setFormPassword(e.target.value)} type="password" placeholder="设置初始密码"
value={memberForm.email} 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" />
onChange={(e) => setMemberForm((current) => ({ ...current, email: 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)]" <div className="grid grid-cols-2 gap-4">
placeholder="输入邮箱"
disabled={editingMemberId !== null}
/>
</div>
{editingMemberId === null ? (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">初始密码</label>
<input
type="password"
value={memberForm.password}
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> <div>
<label className="mb-2 block text-sm font-medium text-foreground">角色权限</label> <label className="block text-sm font-medium text-foreground mb-2">角色</label>
<select <select value={formRole} onChange={(e) => setFormRole(e.target.value)}
value={memberForm.role} 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">
onChange={(e) => setMemberForm((current) => ({ ...current, role: e.target.value }))} <option value="member">成员</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" <option value="admin">管理员</option>
>
{Object.entries(roleLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">岗位</label> <label className="block text-sm font-medium text-foreground mb-2">职位</label>
<input <input value={formPosition} onChange={(e) => setFormPosition(e.target.value)} type="text" placeholder="如:分镜师"
value={memberForm.position} 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" />
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">
<div className="mt-6 flex items-center justify-end gap-3"> <button onClick={() => setShowAddMemberModal(false)}
<button className="flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">取消</button>
onClick={() => setShowMemberModal(false)} <button onClick={handleAddMember} disabled={addMember.isPending || !formUsername || !formEmail || !formPassword}
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted" 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">
> {addMember.isPending && <Loader2 className="w-4 h-4 animate-spin" />}确定
取消
</button> </button>
<button </div>
onClick={handleSaveMember} </div>
disabled={addMember.isPending || updateMember.isPending} </div>
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 || updateMember.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />} {/* ---- Edit Member Modal ---- */}
保存成员 {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} )}
{showGroupModal ? ( {/* ---- Add Group Modal ---- */}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"> {showAddGroupModal && (
<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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="mb-6 flex items-start justify-between gap-4"> <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>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Group Editor</div> <label className="block text-sm font-medium text-foreground mb-2">小组名称</label>
<h3 className="mt-2 text-2xl font-semibold text-foreground"> <input value={formGroupName} onChange={(e) => setFormGroupName(e.target.value)} type="text" placeholder="输入小组名称"
{editingGroupId ? "编辑小组" : "新建小组"} 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" />
</h3>
</div> </div>
<button <div>
onClick={() => setShowGroupModal(false)} <label className="block text-sm font-medium text-foreground mb-2">描述</label>
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary" <textarea value={formGroupDesc} onChange={(e) => setFormGroupDesc(e.target.value)} 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" />
<X className="h-4 w-4" /> </div>
</div>
<div className="flex gap-3">
<button onClick={() => setShowAddGroupModal(false)}
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()}
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="mb-2 block text-sm font-medium text-foreground">小组名称</label> <label className="block text-sm font-medium text-foreground mb-2">小组名称</label>
<input <input value={editGroupName} onChange={(e) => setEditGroupName(e.target.value)} type="text"
value={groupForm.name} 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" />
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="mb-2 block text-sm font-medium text-foreground">小组描述</label> <label className="block text-sm font-medium text-foreground mb-2">描述</label>
<textarea <textarea value={editGroupDesc} onChange={(e) => setEditGroupDesc(e.target.value)} rows={3}
value={groupForm.description} 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" />
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>
)}
<div className="mt-6 flex items-center justify-end gap-3"> {/* ---- Manage Group Members Modal ---- */}
<button {managingGroup && (
onClick={() => setShowGroupModal(false)} <div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
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="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> </button>
<button </div>
onClick={handleSaveGroup}
disabled={createGroup.isPending || updateGroup.isPending} <div className="mb-4">
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" <div className="text-sm font-medium text-foreground mb-3">当前成员</div>
> {managingGroup.memberIds.length === 0
{createGroup.isPending || updateGroup.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />} ? <p className="text-sm text-muted-foreground">暂无成员</p>
保存小组 : (
<div className="space-y-2">
{managingGroup.memberIds.map((uid) => {
const m = memberById[uid];
return (
<div key={uid} className="flex items-center justify-between rounded-lg bg-muted px-3 py-2">
<span className="text-sm text-foreground">{m ? `${m.username} (${m.email})` : `#${uid}`}</span>
<button onClick={() => removeGroupMember.mutate({ groupId: managingGroup.id, userId: uid })}
className="p-1 rounded hover:bg-destructive/10 text-destructive transition-colors">
<UserMinus className="w-4 h-4" />
</button> </button>
</div> </div>
);
})}
</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> </div>
) : null} ))}
{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>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
import { useMemo, useState } from "react"; import { useState, useMemo } from "react";
import { import {
Calendar,
Filter,
Image as ImageIcon,
Loader2,
Sparkles, Sparkles,
TrendingUp, TrendingUp,
Calendar,
Filter,
Users, Users,
Video, Film,
WandSparkles, Image as ImageIcon,
X, X,
Check,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { import {
CartesianGrid,
Legend,
Line,
LineChart, LineChart,
ResponsiveContainer, Line,
Tooltip,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts"; } from "recharts";
import { useBalance, usePurchase, useRecords } from "../../hooks/useUsage"; import { useBalance, useRecords, usePurchase } from "../../hooks/useUsage";
import type { BillingRecord } from "../../lib/api/usage"; import type { BillingRecord } from "../../lib/api/usage";
const operationLabels: Record<string, string> = { const opLabels: Record<string, string> = {
outline_generate: "大纲生成", outline_generate: "大纲生成",
episode_generate: "分集生成", episode_generate: "分集生成",
image_generate: "图片生成", image_generate: "图片生成",
video_generate: "视频生成", video_generate: "视频生成",
assembly: "视频合成", assembly: "视频合成",
recharge: "积分充值", recharge: "充值",
}; };
const operationIcons: Record<string, typeof Sparkles> = { const opIcons: Record<string, typeof Film> = {
outline_generate: WandSparkles, outline_generate: Film,
episode_generate: Sparkles, episode_generate: Film,
image_generate: ImageIcon, image_generate: ImageIcon,
video_generate: Video, video_generate: Film,
assembly: Video, assembly: Film,
recharge: Sparkles, recharge: Sparkles,
}; };
const packages = [ const PACKAGES = [
{ id: "starter", label: "入门包", credits: 1000, price: "¥9.9", desc: "适合个人试用" }, { id: "starter", label: "入门包", credits: 1_000, price: "¥9.9", desc: "适合个人试用" },
{ id: "basic", label: "基础包", credits: 5000, price: "¥39", desc: "适合小团队" }, { id: "basic", label: "基础包", credits: 5_000, price: "¥39", desc: "适合小团队" },
{ id: "pro", label: "专业包", credits: 20000, price: "¥129", desc: "适合持续生产" }, { id: "pro", label: "专业包", credits: 20_000, price: "¥129", desc: "最受欢迎" },
{ id: "enterprise", label: "企业包", credits: 100000, price: "¥499", desc: "适合大规模内容制作" }, { id: "enterprise", label: "企业包", credits: 100_000, 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 pageSize = 20; const PAGE_SIZE = 20;
const { data: balance } = useBalance(); const { data: balance } = useBalance();
const { data: recordsPage } = useRecords(pageSize, page * pageSize); const { data: recordsPage } = useRecords(PAGE_SIZE, page * PAGE_SIZE);
const purchase = usePurchase(); const purchase = usePurchase();
const records = recordsPage?.items ?? []; const records: BillingRecord[] = recordsPage?.items ?? [];
const totalRecords = recordsPage?.total ?? 0; const totalRecords = recordsPage?.total ?? 0;
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((r) => {
if (selectedType && record.operation !== selectedType) { if (selectedType && r.operation !== selectedType) return false;
return false; if (dateRange.start && r.createdAt < dateRange.start + "T00:00:00") 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;
}); });
const chartData = useMemo(() => buildDailyConsumptionData(records), [records]); // Generate daily consumption data for the past 30 days
const dailyConsumptionData = useMemo(() => {
const data = [];
const baseDate = new Date("2026-04-12");
const totalRecharged = Number(balance?.totalRecharged ?? 0); for (let i = 29; i >= 0; i--) {
const totalSpent = Number(balance?.totalSpent ?? 0); const date = new Date(baseDate);
const remaining = Number(balance?.balance ?? 0); date.setDate(baseDate.getDate() - i);
const handlePurchase = async () => { const month = date.getMonth() + 1;
await purchase.mutateAsync(selectedPackage); const day = date.getDate();
setShowPurchaseModal(false); const dateStr = `${month}/${day}`;
};
// Deterministic generation based on index to ensure uniqueness
const baseConsumption = 50000 + (i * 1234) % 30000;
const variance = ((i * 7) % 10000) - 5000;
const actual = baseConsumption + variance;
const predicted = baseConsumption + ((i * 3) % 5000) - 2500;
data.push({
date: dateStr,
actual,
predicted,
});
}
return data;
}, []);
const remainingCredits = balance?.balance ?? 0;
const usedCredits = balance?.totalSpent ?? 0;
const totalRecharged = balance?.totalRecharged ?? 0;
return ( return (
<div className="mx-auto max-w-[1440px]"> <>
<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="h-full overflow-auto bg-background p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<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"> <h1 className="text-2xl font-semibold text-foreground mb-1">用量统计</h1>
<TrendingUp className="h-3.5 w-3.5" /> <p className="text-sm text-muted-foreground">查看和管理积分使用情况</p>
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)} onClick={() => { setShowPurchaseModal(true); setPurchased(false); }}
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]" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="w-4 h-4" />
购买积分资源包 购买积分资源包
</button> </button>
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-3"> {/* Overview Cards */}
{[ <div className="grid grid-cols-3 gap-4 mb-6">
{ label: "剩余积分", value: remaining.toLocaleString(), hint: `累计充值 ${totalRecharged.toLocaleString()}` }, <div className="rounded-xl border border-border bg-card p-5">
{ label: "已消耗积分", value: totalSpent.toLocaleString(), hint: `共 ${totalRecords} 条记录` }, <div className="flex items-center gap-3 mb-3">
{ label: "本页记录数", value: `${records.length}`, hint: `第 ${page + 1} / ${Math.max(1, Math.ceil(totalRecords / pageSize))} 页` }, <div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center">
].map((item, index) => ( <Sparkles className="w-5 h-5 text-primary" />
<div
key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary">
{index === 0 ? <Sparkles className="h-5 w-5" /> : index === 1 ? <TrendingUp className="h-5 w-5" /> : <Users className="h-5 w-5" />}
</div> </div>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div> <div className="text-xs text-muted-foreground">剩余积分</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div> </div>
<div className="mt-2 text-sm text-muted-foreground">{item.hint}</div> <div className="text-2xl font-semibold text-foreground mb-1">
{remainingCredits.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">
累计充值 {Number(totalRecharged).toLocaleString()} 积分
</div> </div>
))}
</div> </div>
<div className="mt-6 rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> <div className="rounded-xl border border-border bg-card p-5">
<div className="mb-4 flex items-center gap-2"> <div className="flex items-center gap-3 mb-3">
<Filter className="h-4 w-4 text-muted-foreground" /> <div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center">
<span className="text-sm font-medium text-foreground">筛选条件</span> <TrendingUp className="w-5 h-5 text-red-600" />
</div>
<div className="text-xs text-muted-foreground">已消耗</div>
</div>
<div className="text-2xl font-semibold text-foreground mb-1">
{Number(usedCredits).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">
共 {totalRecords} 条记录
</div>
</div> </div>
<div className="grid gap-4 xl:grid-cols-3"> <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>
{/* Filters */}
<div className="mb-6 p-4 rounded-xl border border-border bg-card">
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">筛选条件</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground">开始日期</label> <label className="block text-xs text-muted-foreground mb-2">开始日期</label>
<div className="relative"> <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" /> <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.start} value={dateRange.start}
onChange={(e) => setDateRange((current) => ({ ...current, start: e.target.value }))} onChange={(e) => setDateRange({ ...dateRange, start: 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)]" 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>
<label className="mb-2 block text-sm font-medium text-foreground">结束日期</label> <label className="block text-xs text-muted-foreground mb-2">结束日期</label>
<div className="relative"> <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" /> <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.end} value={dateRange.end}
onChange={(e) => setDateRange((current) => ({ ...current, end: e.target.value }))} onChange={(e) => setDateRange({ ...dateRange, end: 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)]" 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>
<label className="mb-2 block text-sm font-medium text-foreground">操作类型</label> <label className="block text-xs text-muted-foreground mb-2">操作类型</label>
<select <select
value={selectedType} value={selectedType}
onChange={(e) => { onChange={(e) => { setSelectedType(e.target.value); setPage(0); }}
setSelectedType(e.target.value); 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"
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(operationLabels).map(([value, label]) => ( {Object.entries(opLabels).map(([k, v]) => (
<option key={value} value={value}> <option key={k} value={k}>{v}</option>
{label}
</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> {/* Credits Consumption Chart */}
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> <div className="mb-6 p-6 rounded-xl border border-border bg-card">
<div className="mb-5"> <div className="flex items-center justify-between mb-6">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Consumption Trend</div> <div>
<h2 className="mt-2 text-2xl font-semibold text-foreground">积分消耗趋势</h2> <h2 className="font-medium text-foreground mb-1">积分消耗趋势</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground"> <p className="text-xs text-muted-foreground">每日积分消耗对比(最近30天)</p>
基于最近 30 天的消耗记录,观察项目生产过程中积分使用的波动情况。 </div>
</p>
</div> </div>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}> <LineChart
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" /> data={dailyConsumptionData}
<XAxis dataKey="date" tick={{ fill: "#6b7280", fontSize: 12 }} stroke="#9ca3af" /> margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
<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: "12px", borderRadius: "8px",
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="实际消耗"
stroke="#0f74d8" dot={{ fill: "#5b5ff9", r: 3 }}
strokeWidth={3} activeDot={{ r: 5 }}
dot={{ fill: "#0f74d8", r: 3 }} isAnimationActive={false}
/> />
<Line <Line
key="predicted-line"
type="monotone" type="monotone"
dataKey="predicted" dataKey="predicted"
name="预测趋势" stroke="#94a3b8"
stroke="#1ea5ff"
strokeWidth={2} strokeWidth={2}
strokeDasharray="6 6" strokeDasharray="5 5"
dot={false} name="预计消耗"
dot={{ fill: "#94a3b8", r: 3 }}
activeDot={{ r: 5 }}
isAnimationActive={false}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> {/* Usage Table */}
<div className="mb-5"> <div className="rounded-xl border border-border bg-card overflow-hidden">
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Billing Records</div> <div className="p-4 border-b border-border">
<h2 className="mt-2 text-2xl font-semibold text-foreground">最近记录</h2> <h2 className="font-medium text-foreground">消耗明细</h2>
</div> </div>
<div className="space-y-3"> <div className="overflow-x-auto">
{filteredRecords.length === 0 ? ( <table className="w-full">
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"> <thead className="bg-muted/50">
当前筛选条件下暂无记录。 <tr>
</div> <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">操作类型</th>
) : ( <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">项目ID</th>
filteredRecords.map((record) => { <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">消耗积分</th>
const Icon = operationIcons[record.operation] ?? Sparkles; <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">模式</th>
const credits = Number(record.credits); <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">时间</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredRecords.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">暂无数据</td>
</tr>
)}
{filteredRecords.map((record) => {
const Icon = opIcons[record.operation] ?? Film;
const isCharge = record.operation !== "recharge";
return ( return (
<div <tr key={record.id} className="hover:bg-muted/30 transition-colors">
key={record.id} <td className="px-4 py-3">
className="rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm" <div className="flex items-center gap-2">
> <Icon className="w-4 h-4 text-muted-foreground" />
<div className="flex items-start justify-between gap-4"> <span className="text-sm text-foreground">{opLabels[record.operation] ?? record.operation}</span>
<div className="flex items-center gap-3"> </div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"> </td>
<Icon className="h-4 w-4" /> <td className="px-4 py-3 text-sm text-muted-foreground">{record.projectId ?? "—"}</td>
</div> <td className="px-4 py-3">
<div> <span className={`text-sm font-medium ${isCharge ? "text-destructive" : "text-green-600"}`}>
<div className="text-sm font-medium text-foreground"> {isCharge ? "-" : "+"}{Number(record.credits).toLocaleString()}
{operationLabels[record.operation] ?? record.operation} </span>
</div> </td>
<div className="mt-1 text-xs text-muted-foreground"> <td className="px-4 py-3 text-xs text-muted-foreground">{record.billingMode}</td>
{new Date(record.createdAt).toLocaleString("zh-CN")} <td className="px-4 py-3 text-sm text-muted-foreground">{record.createdAt?.replace("T", " ").slice(0, 16)}</td>
</div> </tr>
</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>
</div>
); );
}) })}
)} </tbody>
</table>
</div> </div>
<div className="mt-5 flex items-center justify-between"> {totalRecords > PAGE_SIZE && (
<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}
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" onClick={() => setPage(p => p - 1)}
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>
<div className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
第 {page + 1} 页 / 共 {Math.max(1, Math.ceil(totalRecords / pageSize))} 页 {page + 1} / {Math.ceil(totalRecords / PAGE_SIZE)}
</div> </span>
<button <button
onClick={() => setPage((current) => (current + 1 < Math.ceil(totalRecords / pageSize) ? current + 1 : current))} disabled={(page + 1) * PAGE_SIZE >= totalRecords}
disabled={page + 1 >= Math.ceil(totalRecords / pageSize)} 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>
</div> </div>
)}
</div>
</div> </div>
</div> </div>
</section>
{showPurchaseModal ? ( {/* Purchase Modal */}
<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-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"> {showPurchaseModal && (
<div className="mb-6 flex items-start justify-between gap-4"> <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> <div>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Purchase Credits</div> <h2 className="text-xl font-semibold text-foreground">购买积分资源包</h2>
<h3 className="mt-2 text-2xl font-semibold text-foreground">选择积分资源包</h3> <p className="text-sm text-muted-foreground mt-0.5">积分到账后立即可用,永久有效</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground"> </div>
选择适合团队当前阶段的积分包,平台会在购买完成后立即同步余额。 <button onClick={() => setShowPurchaseModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors">
</p> <X className="w-5 h-5 text-muted-foreground" />
</button>
</div> </div>
{purchased ? (
<div className="py-12 text-center">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<Check className="w-8 h-8 text-green-600" />
</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="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary" className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm"
> >
<X className="h-4 w-4" /> 完成
</button> </button>
</div> </div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <>
{packages.map((pkg) => ( <div className="grid grid-cols-2 gap-3 mb-6">
{PACKAGES.map((pkg) => (
<button <button
key={pkg.id} key={pkg.id}
onClick={() => setSelectedPackage(pkg.id)} onClick={() => setSelectedPackage(pkg.id)}
className={[ className={`rounded-xl border-2 p-4 text-left transition-all ${
"rounded-[28px] border p-5 text-left transition-all",
selectedPackage === pkg.id selectedPackage === pkg.id
? "border-primary/20 bg-primary/5 shadow-[0_20px_40px_rgba(15,116,216,0.12)]" ? "border-primary bg-accent"
: "border-border/80 bg-white shadow-sm hover:border-primary/20", : "border-border bg-background hover:border-primary/40"
].join(" ")} }`}
> >
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{pkg.label}</div> <div className="flex items-center justify-between mb-2">
<div className="mt-3 text-3xl font-semibold text-foreground"> <span className="font-semibold text-foreground">{pkg.label}</span>
{pkg.credits.toLocaleString()} {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> </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"> <div className="flex items-center gap-3 p-4 rounded-xl bg-muted mb-6">
<Sparkles className="w-5 h-5 text-primary flex-shrink-0" />
<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 <button
onClick={() => setShowPurchaseModal(false)} 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" 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 <button
onClick={handlePurchase} onClick={async () => {
await purchase.mutateAsync(selectedPackage);
setPurchased(true);
}}
disabled={purchase.isPending} 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" 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"
> >
{purchase.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} {purchase.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
确认购买 确认购买 {PACKAGES.find(p => p.id === selectedPackage)?.price}
</button> </button>
</div> </div>
</>
)}
</div> </div>
</div> </div>
) : null} )}
</div> </>
); );
} }
import { useEffect, useMemo, useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
Clapperboard, Loader2, Play, Pause, Download, Trash2, Edit2,
Download, SkipBack, SkipForward, Check, Film,
Film,
Loader2,
PlayCircle,
Trash2,
Video,
} from "lucide-react"; } from "lucide-react";
import { import {
useAssemblyTask, useVideoTasks,
useDeleteVideoTask,
useEpisodes, useEpisodes,
useStartAssembly,
useStoryboards, useStoryboards,
useVideoTasks, useAssemblyTask,
useStartAssembly,
useDeleteVideoTask,
} from "../../hooks/useAi"; } from "../../hooks/useAi";
import type { AiTask } from "../../lib/api/ai"; import type { AiTask, Episode, Storyboard } from "../../lib/api/ai";
function getTaskLabel(status: string) { function fmt(secs: number) {
switch (status) { const m = Math.floor(secs / 60);
case "succeeded": const s = secs % 60;
return "已完成"; return `${m}:${String(s).padStart(2, "0")}`;
case "running":
case "submitted":
case "pending":
return "生成中";
case "failed":
return "失败";
default:
return status;
}
} }
function timeAgo(dateStr: string) {
function getTaskClass(status: string) { const diff = Date.now() - new Date(dateStr).getTime();
switch (status) { const h = Math.floor(diff / 3600000);
case "succeeded": if (h < 1) return "刚刚";
return "bg-emerald-100 text-emerald-700"; if (h < 24) return `${h}小时前`;
case "running": return `${Math.floor(h / 24)}天前`;
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() {
...@@ -54,223 +32,439 @@ export function VideoGeneration() { ...@@ -54,223 +32,439 @@ export function VideoGeneration() {
const navigate = useNavigate(); const navigate = useNavigate();
const pid = projectId ?? ""; const pid = projectId ?? "";
const { data: episodes = [], isLoading: loadingEpisodes } = useEpisodes(pid); const { data: episodes = [], isLoading: epLoading } = 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 && !activeEpisodeId) { if (episodes.length > 0 && !activeEpId) {
setActiveEpisodeId(episodes[0].id); setActiveEpId(episodes[0].id);
} }
}, [episodes, activeEpisodeId]); }, [episodes.length]);
const { data: storyboards = [] } = useStoryboards(pid, activeEpisodeId); const { data: storyboards = [] } = useStoryboards(pid, activeEpId);
const { data: assemblyTask } = useAssemblyTask(pid, activeEpisodeId); const { data: assemblyTask } = useAssemblyTask(pid, activeEpId);
const startAssembly = useStartAssembly(pid, activeEpisodeId); const startAssembly = useStartAssembly(pid, activeEpId);
const deleteTask = useDeleteVideoTask(pid); const deleteTask = useDeleteVideoTask(pid);
const episodeTasks = useMemo( // Tasks for active episode
() => allTasks.filter((task) => task.episodeId === activeEpisodeId), const epTasks = allTasks.filter((t) => t.episodeId === activeEpId);
[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);
const completedTasks = episodeTasks.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);
const stats = [ // ─── Player state ───
{ label: "分镜数量", value: `${storyboards.length}` }, // "assembly" = show assembled video, "shot" = show individual storyboard video
{ label: "已完成视频", value: `${completedTasks.length}` }, const videoRef = useRef<HTMLVideoElement>(null);
{ const [playing, setPlaying] = useState(false);
label: "合成状态", const [playerMode, setPlayerMode] = useState<"assembly" | "shot">("assembly");
value: const [currentShotIdx, setCurrentShotIdx] = useState(0);
assemblyTask?.status === "succeeded"
? "已完成"
: assemblyTask?.status === "failed"
? "失败"
: assemblyTask?.status === "running"
? "进行中"
: "未开始",
},
];
const handleDelete = async (task: AiTask) => { const hasAssembly = assemblyTask?.status === "succeeded" && !!assemblyTask.downloadUrl;
if (!confirm("确定要删除该视频任务吗?")) return;
await deleteTask.mutateAsync(task.id); // 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
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.pause();
setPlaying(false);
v.load();
}, [currentVideoUrl]);
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) { v.play(); setPlaying(true); }
else { v.pause(); setPlaying(false); }
};
const selectShot = (idx: number) => {
setCurrentShotIdx(idx);
setPlayerMode("shot");
}; };
if (loadingEpisodes) { const handleDelete = async (taskId: string) => {
if (!confirm("确定删除此视频?")) return;
await deleteTask.mutateAsync(taskId);
};
// Episode stats helper
const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0;
const epDoneCount = (ep: Episode) =>
allTasks.filter((t) => t.episodeId === ep.id && t.status === "succeeded").length;
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" />
</div>
);
}
if (episodes.length === 0) { {/* ─── Left: Episode list ─── */}
<div className="flex-shrink-0 border-r border-border flex flex-col" style={{ width: 180 }}>
<div className="h-14 px-4 border-b border-border flex items-center flex-shrink-0">
<span className="text-sm font-medium text-foreground">选集</span>
</div>
<div className="flex-1 overflow-y-auto py-2">
{epLoading ? (
<div className="flex justify-center pt-6">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : episodes.length === 0 ? (
<p className="text-xs text-muted-foreground text-center pt-6 px-3">暂无分集</p>
) : (
episodes.map((ep) => {
const total = epShotCount(ep);
const done = epDoneCount(ep);
const isActive = ep.id === activeEpId;
return ( return (
<div className="mx-auto max-w-[1180px]"> <button
<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"> key={ep.id}
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"> onClick={() => { setActiveEpId(ep.id); setPlayerMode("assembly"); setCurrentShotIdx(0); }}
<Video className="h-7 w-7" /> className={`w-full text-left px-4 py-3 border-b border-border/50 transition-colors ${
</div> isActive ? "bg-accent" : "hover:bg-muted"
<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"> >
请先完成分集和分镜流程,再在这里查看视频任务与成片合成结果。 <div className="flex items-center justify-between mb-0.5">
</p> <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>
<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> </div>
</button>
); );
} })
)}
return (
<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> </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>
{/* ─── Right: Main content ─── */}
<div className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{/* Section 1: 视频播放器 */}
<div>
<div className="flex items-center justify-between mb-1">
<div>
<h2 className="text-lg font-semibold text-foreground">整集视频</h2>
<p className="text-xs text-muted-foreground">点击下方分镜缩略图可切换单镜预览</p>
</div>
<div className="flex items-center gap-2">
{/* Assembly controls */}
{hasAssembly && (
<button <button
onClick={() => navigate(`/project/${projectId}/storyboard/${activeEpisodeId}`)} onClick={() => setPlayerMode("assembly")}
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" 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"
}`}
> >
<Clapperboard className="h-4 w-4" /> <Film className="w-3.5 h-3.5" />
返回分镜工作台 整集
</button> </button>
</div> )}
{(!assemblyTask || assemblyTask.status === "failed") && (
<div className="mt-6 flex flex-wrap gap-3">
{episodes.map((episode) => (
<button <button
key={episode.id} onClick={() => startAssembly.mutate()}
onClick={() => setActiveEpisodeId(episode.id)} disabled={startAssembly.isPending || doneShots === 0}
className={[ 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"
"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} 集 {startAssembly.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Film className="w-3.5 h-3.5" />}
合成整集
</button> </button>
))} )}
</div> {(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<div className="mt-6 grid gap-4 md:grid-cols-3"> <Loader2 className="w-3.5 h-3.5 animate-spin" />合成中...
{stats.map((stat) => ( </span>
<div )}
key={stat.label} {hasAssembly && (
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm" <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"
> >
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{stat.label}</div> <Download className="w-3.5 h-3.5" />
<div className="mt-3 text-2xl font-semibold text-foreground">{stat.value}</div> 导出整集
</a>
)}
</div> </div>
))}
</div> </div>
<div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> {/* Player */}
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> <div className="rounded-xl overflow-hidden bg-black relative" style={{ aspectRatio: "16/9", maxHeight: 440 }}>
<div className="mb-4 flex items-center justify-between"> {currentVideoUrl ? (
<div> <>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Shot Videos</div> {/* Badge */}
<h2 className="mt-2 text-2xl font-semibold text-foreground">镜头视频任务</h2> <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">
{playerMode === "assembly" ? "整集合成" : "分镜预览"}
</div> </div>
<div className="font-medium">{currentShotLabel}</div>
</div> </div>
{/* Duration badge */}
<div className="space-y-4"> <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">
{episodeTasks.length === 0 ? ( {fmt(totalDuration)}
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
当前分集还没有视频任务,请先回到分镜工作台生成镜头视频。
</div> </div>
) : ( <video
episodeTasks.map((task) => ( ref={videoRef}
<div src={currentVideoUrl}
key={task.id} className="w-full h-full object-contain"
className="rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm" 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="flex items-start justify-between gap-4"> <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> {playing
<div className="text-sm font-medium text-foreground"> ? <Pause className="w-7 h-7 text-white" />
任务 {task.id.slice(0, 8)} : <Play className="w-7 h-7 text-white ml-0.5" />}
</div> </div>
<div className="mt-2 text-xs text-muted-foreground"> </button>
{new Date(task.createdAt).toLocaleString("zh-CN")} </>
) : (
<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>
)}
</div> </div>
<span className={`rounded-full px-3 py-1 text-xs ${getTaskClass(task.status)}`}>
{getTaskLabel(task.status)} {/* Controls row */}
<div className="flex items-center gap-3 mt-3">
<button
onClick={() => {
const idx = Math.max(0, currentShotIdx - 1);
selectShot(idx);
}}
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> </span>
</div> </div>
{task.resultVideoUrl ? ( {/* Thumbnail strip — click to switch player to that shot */}
<video src={task.resultVideoUrl} controls className="mt-4 aspect-video w-full rounded-2xl bg-muted object-cover" /> {orderedShots.length > 0 && (
) : ( <div className="flex gap-2 mt-3 overflow-x-auto pb-1">
<div className="mt-4 flex aspect-video items-center justify-center rounded-2xl bg-muted"> {orderedShots.map(({ sb, task }, i) => (
<PlayCircle className="h-10 w-10 text-muted-foreground" /> <button
key={sb.id}
onClick={() => selectShot(i)}
className={`flex-shrink-0 relative rounded-lg overflow-hidden border-2 transition ${
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>
</button>
))}
</div> </div>
)} )}
</div>
<div className="mt-4 flex justify-end"> {/* Section 2: Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "总分镜数", value: totalShots, color: "text-foreground" },
{ label: "已完成", value: doneShots, color: "text-green-600" },
{ 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>
<h3 className="text-base font-semibold text-foreground mb-4">分镜管理</h3>
{storyboards.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground">
{!activeEpId ? "请从左侧选择集数" : "该集暂无分镜"}
</div>
) : (
<div className="grid grid-cols-3 gap-4">
{storyboards.map((sb) => {
const task = getTaskForSb(sb);
const hasVideo = task?.status === "succeeded" && task.resultVideoUrl;
const isPending = task?.status === "pending" || task?.status === "submitted" || task?.status === "running";
return (
<div key={sb.id} className="rounded-xl border border-border bg-card overflow-hidden">
{/* Thumbnail — click plays in top player */}
<button <button
onClick={() => handleDelete(task)} className="relative bg-black w-full"
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" 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" });
}}
> >
<Trash2 className="h-4 w-4" /> {hasVideo ? (
删除任务 <video
</button> 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>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-8 h-8 text-white/20" />
</div> </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> </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> </div>
)}
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"> {hasVideo && <div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-green-400" />}
<div className="mb-4"> {isPending && <div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-orange-400 animate-pulse" />}
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Assembly</div> {/* Play hint overlay */}
<h2 className="mt-2 text-2xl font-semibold text-foreground">整集合成</h2> {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> </div>
)}
</button>
<div className="rounded-[28px] border border-primary/10 bg-primary/5 p-5"> {/* Footer */}
<div className="text-sm leading-6 text-muted-foreground"> <div className="px-3 py-2.5 flex items-center justify-between">
当前分集下共有 {completedTasks.length} 个可用镜头视频。确认镜头视频已经生成完成后,可以发起整集合成任务。 <span className="text-[11px] text-muted-foreground">
</div> {task ? timeAgo(task.createdAt) : "未生成"}
</span>
<div className="flex items-center gap-3">
<button <button
onClick={() => startAssembly.mutate()} onClick={() => navigate(`/project/${pid}/storyboard/${activeEpId}`)}
disabled={completedTasks.length === 0 || startAssembly.isPending} className="flex items-center gap-1 text-[11px] text-primary hover:underline"
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"
> >
{startAssembly.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Film className="h-4 w-4" />} <Edit2 className="w-3 h-3" />
发起整集合成 编辑
</button> </button>
</div> {hasVideo && (
<div className="mt-5 rounded-[28px] border border-border/80 bg-white p-5 shadow-sm">
<div className="text-sm font-medium text-foreground">当前状态</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">
{assemblyTask?.status ? getTaskLabel(assemblyTask.status) : "尚未开始合成"}
</div>
{assemblyTask?.downloadUrl ? (
<a <a
href={assemblyTask.downloadUrl} href={task!.resultVideoUrl!}
target="_blank" target="_blank"
rel="noreferrer" 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" className="flex items-center gap-1 text-[11px] text-foreground hover:text-primary"
> >
<Download className="h-4 w-4" /> <Download className="w-3 h-3" />
下载合成结果 下载
</a> </a>
) : null} )}
{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> </div>
</section> );
})}
</div>
)}
</div>
</div>
</div>
</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: #eef4f9; --background: #f8f9fa;
--foreground: #12263f; --foreground: #1a1a1a;
--card: rgba(255, 255, 255, 0.92); --card: #ffffff;
--card-foreground: #12263f; --card-foreground: #1a1a1a;
--popover: #ffffff; --popover: #ffffff;
--popover-foreground: #12263f; --popover-foreground: #1a1a1a;
--primary: #0f74d8; --primary: #5b5ff9;
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--secondary: #d9e7f4; --secondary: #f1f3f5;
--secondary-foreground: #163b62; --secondary-foreground: #1a1a1a;
--muted: #f4f8fc; --muted: #f8f9fa;
--muted-foreground: #5d6f84; --muted-foreground: #6c757d;
--accent: #e5f1fb; --accent: #e7f5ff;
--accent-foreground: #0f4d8f; --accent-foreground: #1971c2;
--destructive: #d95050; --destructive: #f03e3e;
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--border: rgba(16, 74, 132, 0.12); --border: rgba(0, 0, 0, 0.06);
--input: rgba(16, 74, 132, 0.12); --input: rgba(0, 0, 0, 0.06);
--input-background: rgba(255, 255, 255, 0.92); --input-background: #ffffff;
--switch-background: #c3d4e5; --switch-background: #dee2e6;
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-normal: 400; --font-weight-normal: 400;
--ring: #0f74d8; --ring: #5b5ff9;
--chart-1: #0f74d8; --chart-1: #5b5ff9;
--chart-2: #28a5ea; --chart-2: #22b8cf;
--chart-3: #2fbe93; --chart-3: #51cf66;
--chart-4: #ff8b52; --chart-4: #ff6b6b;
--chart-5: #f2c84b; --chart-5: #fcc419;
--radius: 1rem; --radius: 0.75rem;
--sidebar: rgba(255, 255, 255, 0.86); --sidebar: #ffffff;
--sidebar-foreground: #12263f; --sidebar-foreground: #1a1a1a;
--sidebar-primary: #0f74d8; --sidebar-primary: #5b5ff9;
--sidebar-primary-foreground: #ffffff; --sidebar-primary-foreground: #ffffff;
--sidebar-accent: #eff5fb; --sidebar-accent: #f8f9fa;
--sidebar-accent-foreground: #123c67; --sidebar-accent-foreground: #1a1a1a;
--sidebar-border: rgba(16, 74, 132, 0.12); --sidebar-border: rgba(0, 0, 0, 0.06);
--sidebar-ring: #0f74d8; --sidebar-ring: #5b5ff9;
} }
.dark { .dark {
...@@ -126,26 +126,6 @@ ...@@ -126,26 +126,6 @@
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;
} }
/** /**
...@@ -198,9 +178,4 @@ ...@@ -198,9 +178,4 @@
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