Commit fe614843 authored by 黄同智's avatar 黄同智
parents bc0bcd4b 2cdd280b
import { Outlet, Link, useLocation, useParams, useNavigate } from "react-router";
import { Link, Outlet, useLocation, useNavigate, useParams } from "react-router";
import {
Bell,
BookOpen,
Bot,
ChevronDown,
Clapperboard,
Coins,
FileText,
Film,
Users,
Sparkles,
FolderOpen,
Settings,
Search,
Bell,
Globe,
ChevronDown,
BarChart3,
Headphones,
HelpCircle,
Image as ImageIcon,
FileText,
Sliders,
Clapperboard,
Video,
Bot,
Cpu,
LayoutDashboard,
LogOut,
User,
Menu,
Search,
Settings,
ShieldCheck,
Sparkles,
Users,
Video,
Wand2,
} from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useAuthStore } from "../../stores/authStore";
import { useBalance } from "../../hooks/useUsage";
import { useProject } from "../../hooks/useProjects";
import { useTeamGroups } from "../../hooks/useTeam";
import logoImage from "../../imports/ab7d1ce5faf061d2af2b02d93e11935.png";
import { useAuth } from "../../hooks/useAuth";
const consoleLinks = ["企业", "工具", "费用", "支持", "备案", "文档"];
export function Layout() {
const location = useLocation();
......@@ -34,14 +40,17 @@ export function Layout() {
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 { user, clearAuth } = useAuthStore();
const { user } = useAuthStore();
const { logout } = useAuth();
const { data: balance } = useBalance();
const { data: teamGroups = [] } = useTeamGroups();
// Close user menu on outside click
const projectId = params.projectId;
const isProjectRoute = projectId !== undefined;
const { data: currentProject } = useProject(projectId ?? "");
useEffect(() => {
const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
......@@ -52,363 +61,281 @@ export function Layout() {
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 isProjectRoute = projectId !== undefined;
const { data: currentProject } = useProject(projectId ?? "");
// Auto-collapse sidebar when in project detail routes
useEffect(() => {
setSidebarCollapsed(isProjectRoute);
}, [isProjectRoute]);
// Determine active section based on current path
const getActiveSection = () => {
const path = location.pathname;
if (path.includes("/agent")) return "agent";
if (path.includes("/storyboard")) return "storyboard";
if (path.includes("/video")) return "video";
if (
path.includes("/characters") ||
path.includes("/scenes") ||
path.includes("/props") ||
path.includes("/settings")
) {
if (path.includes("/characters") || path.includes("/scenes") || path.includes("/props") || path.includes("/settings")) {
return "settings";
}
if (
path.includes("/outline") ||
path.includes("/episodes") ||
path === `/project/${projectId}`
) {
return "script";
}
return "script";
};
const activeSection = getActiveSection();
const projectSections = [
{ id: "agent", label: "Agent制作", icon: Bot, path: `/project/${projectId}/agent` },
{ id: "script", label: "剧本", icon: FileText, path: `/project/${projectId}` },
{
id: "settings",
label: "设定",
icon: Sliders,
path: `/project/${projectId}/characters`,
},
{
id: "storyboard",
label: "分镜",
icon: Clapperboard,
path: `/project/${projectId}/storyboard`,
},
{ id: "settings", label: "设定", icon: Wand2, path: `/project/${projectId}/characters` },
{ id: "storyboard", label: "分镜", icon: Clapperboard, path: `/project/${projectId}/storyboard` },
{ id: "video", label: "视频", icon: Video, path: `/project/${projectId}/video` },
];
const notifications = [
{ id: 1, title: "项目《重生之豪门千金》第3集分镜待审核", time: "5分钟前" },
{ id: 2, title: "系统更新:新增AI水印功能", time: "1小时前" },
{ id: 3, title: "张三提交的角色设定已通过审核", time: "2小时前" },
];
const primaryMenu = [
{ label: "剧本生短剧", icon: Sparkles, path: "/new-project", active: location.pathname === "/new-project" },
{ label: "项目", icon: FolderOpen, path: "/", active: location.pathname === "/" },
{ label: "资产", icon: ImageIcon, path: "/assets/character", active: location.pathname.startsWith("/assets") },
];
const enterpriseMenu = [
{ label: "团队管理", icon: Users, path: "/team" },
{ label: "用量统计", icon: LayoutDashboard, path: "/usage" },
{ label: "模型配置", icon: Settings, path: "/models" },
{ label: "AI 生成水印设置", icon: ShieldCheck, path: "/settings" },
];
return (
<div className="h-screen flex bg-background">
{/* Left Sidebar */}
<aside className={`border-r border-border bg-card flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-56'}`}>
{/* Logo */}
<div className="h-14 border-b border-border flex items-center justify-center px-4">
<div className="h-screen w-full max-w-full overflow-hidden bg-[#f6f7fb] text-[#0f172a]">
<header className="relative z-40 flex h-12 w-full max-w-full min-w-0 items-center overflow-visible border-b border-[#dfe3ee] bg-white shadow-[0_1px_5px_rgba(15,23,42,0.06)]">
<div className="flex h-full w-auto flex-shrink-0 items-center gap-3 px-4 md:w-[200px]">
<button className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-[#f1f4fb]" aria-label="展开产品服务">
<Menu className="h-5 w-5 text-[#1f2937]" />
</button>
<Link to="/" className="flex items-center gap-2">
{/* ZK Logo with Film Icon */}
<div className="relative">
<Film className="w-8 h-8 text-primary" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[10px] 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">Dramix</span>
<span className="text-xs text-muted-foreground">V1</span>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-[#0073ff] to-[#7b2cff]">
<Film className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold tracking-normal text-[#111827]">Dramix</span>
</Link>
</div>
<nav className="flex-1 p-3 overflow-auto">
{/* 小组板块 */}
<div className="text-xs text-muted-foreground px-3 mb-2">小组</div>
<div className="mb-6 px-3">
<div className="relative">
<select
value={currentGroupId}
onChange={(e) => setCurrentGroupId(Number(e.target.value) || "")}
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>
{teamGroups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<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 className="text-xs text-muted-foreground px-3 mb-2">创作</div>
<Link
to="/new-project"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/new-project"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<Sparkles className="w-4 h-4" />
剧本生成短剧
</Link>
{/* 我的板块 */}
<div className="text-xs text-muted-foreground px-3 mt-6 mb-2">我的</div>
<Link
to="/"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<FolderOpen className="w-4 h-4" />
项目
</Link>
<Link
to="/assets/character"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname.startsWith("/assets")
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<ImageIcon className="w-4 h-4" />
资产
</Link>
<Link
to="/"
className="mr-3 hidden h-8 flex-shrink-0 items-center gap-1.5 whitespace-nowrap rounded bg-[#eef3ff] px-3 text-sm font-medium text-[#1664ff] sm:flex md:mr-6"
>
<LayoutDashboard className="h-4 w-4" />
总览
</Link>
{/* 企业配置 */}
<div className="text-xs text-muted-foreground px-3 mt-6 mb-2">企业配置</div>
<Link
to="/team"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors mb-1"
>
<Users className="w-4 h-4" />
团队管理
</Link>
<Link
to="/usage"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors mb-1"
>
<BarChart3 className="w-4 h-4" />
用量统计
</Link>
<Link
to="/models"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/models"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<Cpu className="w-4 h-4" />
模型配置
</Link>
<Link
to="/settings"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === "/settings"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`}
>
<Settings className="w-4 h-4" />
AI生成水印
</Link>
</nav>
<div className="relative hidden w-[min(28vw,420px)] min-w-[220px] flex-shrink lg:block">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#8a94a6]" />
<input
className="h-8 w-full rounded border border-[#d6dbe7] bg-white pl-9 pr-3 text-sm outline-none transition focus:border-[#1664ff] focus:ring-2 focus:ring-[#1664ff]/10"
placeholder="搜索产品或文档"
/>
</div>
{/* Credits Display */}
<div className="p-3 border-t border-border">
<div className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2 text-primary">
<Sparkles className="w-4 h-4" />
<span className="font-medium">
{Number(balance?.balance ?? 0).toLocaleString()}
</span>
</span>
<div className="ml-auto flex h-full flex-shrink-0 items-center gap-1 pr-4 text-sm text-[#202b3d]">
<nav className="hidden h-full max-w-[56vw] flex-shrink items-center justify-end gap-1 overflow-hidden xl:flex">
{consoleLinks.map((item) => (
<button key={item} className="flex h-9 flex-shrink-0 items-center gap-1 whitespace-nowrap rounded px-2.5 hover:bg-[#f3f6fb]">
{item}
{["企业", "工具", "费用", "支持"].includes(item) && <ChevronDown className="h-3.5 w-3.5" />}
</button>
))}
</nav>
<div className="relative">
<button
onClick={() => navigate("/usage")}
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowNotifications((show) => !show)}
className="relative flex h-9 w-9 flex-shrink-0 items-center justify-center rounded hover:bg-[#f3f6fb]"
aria-label="通知"
>
购买
<Bell className="h-4 w-4" />
<span className="absolute right-1.5 top-1 rounded-full bg-[#f43f5e] px-1 text-[10px] leading-4 text-white">9</span>
</button>
{showNotifications && (
<div className="absolute right-0 top-11 z-50 w-80 overflow-hidden rounded-lg border border-[#dfe3ee] bg-white shadow-xl">
<div className="border-b border-[#edf0f6] px-4 py-3 text-sm font-medium text-[#111827]">通知</div>
{notifications.map((item) => (
<div key={item.id} className="border-b border-[#f1f3f8] px-4 py-3 last:border-b-0">
<div className="text-sm text-[#1f2937]">{item.title}</div>
<div className="mt-1 text-xs text-[#8a94a6]">{item.time}</div>
</div>
))}
</div>
)}
</div>
<button className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded hover:bg-[#f3f6fb]" aria-label="语言">
<Globe className="h-4 w-4 text-[#1664ff]" />
</button>
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu((show) => !show)}
className="flex h-9 items-center gap-2 rounded-full bg-[#eef4ff] px-2.5 text-sm font-medium text-[#2662d9]"
>
{user?.username?.[0]?.toUpperCase() ?? "U"}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{showUserMenu && (
<div className="absolute right-0 top-11 z-50 w-52 overflow-hidden rounded-lg border border-[#dfe3ee] bg-white shadow-xl">
<div className="border-b border-[#edf0f6] px-4 py-3 text-sm">
<div className="font-medium text-[#111827]">{user?.username ?? "用户"}</div>
<div className="mt-0.5 text-xs text-[#7b8496]">Dramix 控制台</div>
</div>
<button
onClick={() => {
logout();
setShowUserMenu(false);
}}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-[#d92d20] hover:bg-[#fff1f0]"
>
<LogOut className="h-4 w-4" />
退出登录
</button>
</div>
)}
</div>
</div>
</aside>
</header>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header */}
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-6">
<div className="flex items-center gap-4 flex-1">
{/* Logo - Show when sidebar is collapsed */}
{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 className="flex h-[calc(100vh-48px)] w-full min-w-0 overflow-hidden">
{!isProjectRoute && (
<aside className="hidden w-[200px] flex-col border-r border-[#dfe3ee] bg-[#fbfcff] md:flex">
<div className="px-4 pb-5 pt-7">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#e8eaff] text-[#5a50ff]">
<Sparkles className="h-4 w-4" />
</div>
{/* Product Name and Version */}
<div className="flex items-baseline gap-1">
<span className="text-sm font-semibold text-foreground">Dramix</span>
<span className="text-xs text-muted-foreground">V1</span>
<div className="flex items-center gap-1.5">
<span className="text-lg font-semibold text-[#111827]">Dramix</span>
<span className="rounded bg-[#eef1f7] px-1.5 py-0.5 text-[10px] text-[#6b7280]">Beta</span>
</div>
</Link>
)}
</div>
{/* 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
type="text"
placeholder="搜索项目或文档"
className="w-full pl-9 pr-4 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
<select
value={currentGroupId}
onChange={(e) => setCurrentGroupId(Number(e.target.value) || "")}
className="h-9 w-full appearance-none rounded-md border border-[#dfe3ee] bg-white px-9 pr-8 text-sm text-[#1f2937] outline-none focus:border-[#1664ff]"
>
<option value="">默认团队</option>
{teamGroups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
<Users className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6f65ff]" />
<ChevronDown className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#7b8496]" />
</div>
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Top Nav Menu */}
<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>
<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>
<nav className="flex-1 overflow-auto px-4 text-sm">
<div className="mb-3 text-xs text-[#667085]">创作</div>
{primaryMenu.slice(0, 1).map((item) => (
<Link
key={item.label}
to={item.path}
className={`mb-5 flex h-10 items-center gap-3 rounded-lg px-3 font-medium transition ${
item.active ? "bg-[#eceeff] text-[#5547ff]" : "text-[#344054] hover:bg-[#f0f3fa]"
}`}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
{/* Notifications */}
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 hover:bg-muted rounded-lg transition-colors"
>
<Bell className="w-4 h-4 text-muted-foreground" />
<span className="absolute top-1 right-1 w-2 h-2 bg-destructive rounded-full"></span>
</button>
<div className="mb-3 text-xs text-[#667085]">我的</div>
{primaryMenu.slice(1).map((item) => (
<Link
key={item.label}
to={item.path}
className={`mb-1 flex h-10 items-center gap-3 rounded-lg px-3 transition ${
item.active ? "bg-[#eceeff] text-[#5547ff]" : "text-[#344054] hover:bg-[#f0f3fa]"
}`}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
{showNotifications && (
<div className="absolute right-0 top-12 w-80 bg-card border border-border rounded-xl shadow-lg z-50">
<div className="p-4 border-b border-border">
<h3 className="font-medium text-foreground">通知中心</h3>
</div>
<div className="max-h-96 overflow-auto">
{notifications.map((notif) => (
<button
key={notif.id}
className="w-full p-4 text-left hover:bg-muted transition-colors border-b border-border last:border-b-0"
>
<div className="text-sm text-foreground mb-1">{notif.title}</div>
<div className="text-xs text-muted-foreground">{notif.time}</div>
</button>
))}
</div>
<div className="p-3 border-t border-border text-center">
<button className="text-sm text-primary hover:underline">查看全部</button>
</div>
</div>
)}
</div>
<div className="my-5 h-px bg-[#e5e8f0]" />
<div className="mb-3 text-xs text-[#667085]">企业配置</div>
{enterpriseMenu.map((item) => {
const active = location.pathname === item.path;
return (
<Link
key={item.label}
to={item.path}
className={`mb-1 flex h-10 items-center gap-3 rounded-lg px-3 transition ${
active ? "bg-[#eceeff] text-[#5547ff]" : "text-[#344054] hover:bg-[#f0f3fa]"
}`}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
{/* Language Switcher */}
<button className="p-2 hover:bg-muted rounded-lg transition-colors">
<Globe className="w-4 h-4 text-muted-foreground" />
<button
onClick={() => navigate("/usage")}
className="m-3 flex h-10 items-center justify-between rounded-lg border border-[#dfe3ee] bg-white px-3 text-sm hover:border-[#b7c4dc]"
>
<span className="flex items-center gap-2 font-medium text-[#5547ff]">
<Coins className="h-4 w-4" />
{Number(balance?.balance ?? 0).toLocaleString()}
</span>
<span className="flex items-center gap-1 text-[#5547ff]">
购买
<ChevronDown className="h-3.5 w-3.5 -rotate-90" />
</span>
</button>
</aside>
)}
{/* User Avatar */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-muted rounded-lg transition-colors"
>
<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"}
</span>
</div>
<ChevronDown className="w-3 h-3 text-muted-foreground" />
</button>
{showUserMenu && (
<div className="absolute right-0 top-12 w-48 bg-card border border-border rounded-xl shadow-lg z-50">
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground truncate">{user?.username}</span>
</div>
</div>
<button
onClick={() => { clearAuth(); navigate("/login"); }}
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"
>
<LogOut className="w-4 h-4" />
退出登录
</button>
</div>
)}
</div>
</div>
</header>
{/* Project Navigation */}
{isProjectRoute && (
<div className="border-b border-border bg-card px-6 py-3">
<div className="flex items-center gap-6">
{/* Project Name */}
<div className="flex items-center gap-2 pr-6 border-r border-border">
<Film className="w-4 h-4 text-primary" />
<span className="font-medium text-foreground">{currentProject?.name ?? projectId}</span>
<section className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{isProjectRoute && (
<div className="flex h-12 items-center gap-5 border-b border-[#dfe3ee] bg-white px-6">
<div className="flex min-w-0 items-center gap-2 border-r border-[#dfe3ee] pr-5">
<Film className="h-4 w-4 flex-shrink-0 text-[#1664ff]" />
<span className="max-w-[240px] truncate text-sm font-medium text-[#111827]">
{currentProject?.name ?? projectId}
</span>
</div>
{/* Section Tabs */}
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-1.5 overflow-x-auto">
{projectSections.map((section) => {
const Icon = section.icon;
const active = getActiveSection() === section.id;
return (
<button
key={section.id}
onClick={() => navigate(section.path)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm transition-all ${
activeSection === section.id
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
className={`flex h-8 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm transition ${
active ? "bg-[#eef3ff] text-[#1664ff]" : "text-[#475467] hover:bg-[#f2f4f8]"
}`}
>
<Icon className="w-4 h-4" />
<Icon className="h-4 w-4" />
{section.label}
</button>
);
})}
</div>
</div>
</div>
)}
)}
{/* Page Content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
<main className="min-h-0 w-full min-w-0 flex-1 overflow-auto overflow-x-hidden bg-[#f6f7fb]">
<Outlet />
</main>
<div className="pointer-events-none fixed bottom-7 right-3 z-30 flex flex-col gap-3">
{[Sparkles, Headphones, BookOpen, HelpCircle].map((Icon, index) => (
<button
key={index}
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-white text-[#344054] shadow-[0_6px_20px_rgba(15,23,42,0.16)] transition hover:text-[#5547ff]"
aria-label="快捷入口"
>
<Icon className="h-5 w-5" />
</button>
))}
</div>
</section>
</div>
</div>
);
}
\ No newline at end of file
}
import { useState, useRef } from "react";
import { useEffect, useState, useRef } from "react";
import { Link, useNavigate } from "react-router";
import {
Plus, Film, Users, Clock, Play, Search, Calendar,
Filter, Loader2, Pencil, Trash2, X, Check, Upload,
Plus, Film, Search, Calendar,
Filter, Loader2, Pencil, Trash2, X, Check, Upload, Sparkles, MoreHorizontal,
} from "lucide-react";
import { useProjects, useUpdateProject, useDeleteProject } from "../../hooks/useProjects";
import { useTeamMembers } from "../../hooks/useTeam";
......@@ -11,36 +11,20 @@ import { useQueryClient } from "@tanstack/react-query";
import type { ProjectDTO } from "../../lib/api/projects";
import {
aspectRatioOptions,
getAspectRatioLabel,
getProjectStyleLabel,
getResolutionLabel,
projectStyleOptions,
resolutionOptions,
} from "../../lib/projectStyles";
// ── Default cover generator ─────────────────────────────────────────────────
function getProjectGradient(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) {
h = (Math.imul(31, h) + name.charCodeAt(i)) | 0;
}
const hue = ((h >>> 0) % 360);
return `linear-gradient(135deg, hsl(${hue},60%,40%), hsl(${(hue + 45) % 360},65%,55%))`;
}
function ProjectCover({ url, name, className = "" }: { url?: string; name: string; className?: string }) {
const initials = name.slice(0, 2);
if (url) {
return <img src={url} alt={name} className={`w-full h-full object-cover ${className}`} />;
}
return (
<div
className={`w-full h-full flex items-center justify-center ${className}`}
style={{ background: getProjectGradient(name) }}
>
<span className="text-white text-2xl font-bold tracking-wider select-none opacity-80">
{initials}
</span>
<div className={`flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,#e5fff2_0%,#b9d8ff_52%,#61d7ee_100%)] ${className}`}>
<div className="relative text-white">
<Film className="h-8 w-8 stroke-[2.2]" />
<Sparkles className="absolute -right-2 bottom-0 h-4 w-4 fill-white stroke-white" />
</div>
</div>
);
}
......@@ -268,24 +252,6 @@ function DeleteConfirm({
);
}
// ── Status helpers ────────────────────────────────────────────────────────────
const statusText: Record<string, string> = {
outline: "大纲阶段",
storyboard: "分镜制作",
video: "视频生成",
draft: "草稿",
processing: "处理中",
completed: "已完成",
};
const statusColor: Record<string, string> = {
outline: "bg-blue-100 text-blue-700",
storyboard: "bg-yellow-100 text-yellow-700",
video: "bg-green-100 text-green-700",
draft: "bg-gray-100 text-gray-600",
processing: "bg-orange-100 text-orange-700",
completed: "bg-green-100 text-green-700",
};
// ── Dashboard ─────────────────────────────────────────────────────────────────
export function Dashboard() {
const [searchTerm, setSearchTerm] = useState("");
......@@ -294,6 +260,8 @@ export function Dashboard() {
const [showMemberFilter, setShowMemberFilter] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectDTO | null>(null);
const [deletingProject, setDeletingProject] = useState<ProjectDTO | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const { data: apiProjects, isLoading } = useProjects();
const { data: teamMembersData = [] } = useTeamMembers();
......@@ -309,11 +277,6 @@ export function Dashboard() {
updatedAt: p.updatedAt?.slice(0, 10) ?? "",
createdAt: p.createdAt?.slice(0, 10) ?? "",
coverUrl: p.coverUrl,
tags: [
{ key: "style", label: getProjectStyleLabel(p.style) },
{ key: "aspectRatio", label: getAspectRatioLabel(p.aspectRatio) },
{ key: "resolution", label: getResolutionLabel(p.resolution) },
].filter((tag) => tag.label !== "未设置"),
}));
const filtered = projects.filter((p) => {
......@@ -323,19 +286,30 @@ export function Dashboard() {
return true;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const pagedProjects = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);
useEffect(() => {
setPage(1);
}, [searchTerm, dateRange.start, dateRange.end, selectedMembers.length, pageSize]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
return (
<div className="h-full overflow-auto bg-background p-6">
<div className="max-w-7xl mx-auto">
<div className="h-full overflow-auto bg-[#f6f7fb] p-5 text-[#0f172a]">
<div className="mx-auto max-w-[1440px]">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground mb-1">我的项目</h1>
<p className="text-sm text-muted-foreground">管理和创作您的AI短剧项目</p>
<h1 className="mb-1 text-2xl font-semibold text-[#111827]">剧本生短剧</h1>
</div>
<Link
to="/new-project"
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center gap-2 hover:shadow-md transition-all text-sm"
className="flex h-9 items-center gap-2 rounded-md bg-[#1664ff] px-4 text-sm font-medium text-white transition hover:bg-[#0f56d9]"
>
<Plus className="w-4 h-4" />
新建项目
......@@ -343,60 +317,54 @@ export function Dashboard() {
</div>
{/* 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="mb-4 rounded-lg border border-[#e1e6f0] bg-white p-4">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_minmax(260px,340px)_minmax(140px,160px)_auto]">
<div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#8a94a6]" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="输入项目名称搜索..."
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="搜索短剧项目名称"
className="h-9 w-full rounded-md border border-[#d6dbe7] bg-white pl-9 pr-3 text-sm text-[#111827] outline-none transition placeholder:text-[#98a2b3] focus:border-[#1664ff] focus:ring-2 focus:ring-[#1664ff]/10"
/>
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">开始日期</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<div className="flex h-9 items-center rounded-md border border-[#d6dbe7] bg-white px-3">
<div className="relative min-w-0 flex-1">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
className="h-7 w-full bg-transparent text-sm text-[#111827] outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">结束日期</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<span className="px-2 text-sm text-[#98a2b3]">-</span>
<div className="relative min-w-0 flex-1">
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
className="h-7 w-full bg-transparent text-sm text-[#111827] outline-none"
/>
</div>
<Calendar className="ml-2 h-4 w-4 text-[#667085]" />
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">按成员筛选</label>
<div className="relative">
<button
onClick={() => setShowMemberFilter(!showMemberFilter)}
className="w-full px-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground text-left flex items-center justify-between hover:bg-muted transition-colors"
className="flex h-9 w-full items-center justify-between rounded-md border border-[#d6dbe7] bg-white px-3 text-left text-sm text-[#111827] transition hover:border-[#b8c2d6]"
>
<span className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<Filter className="h-4 w-4 text-[#8a94a6]" />
{selectedMembers.length > 0 ? `${selectedMembers.length}个成员` : "选择成员"}
</span>
<span className="text-xs text-muted-foreground">{showMemberFilter ? "▲" : "▼"}</span>
<span className="text-xs text-[#8a94a6]">{showMemberFilter ? "▲" : "▼"}</span>
</button>
{showMemberFilter && (
<div className="absolute top-full left-0 right-0 mt-2 p-3 rounded-lg border border-border bg-card shadow-lg z-10">
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-lg border border-[#dfe3ee] bg-white p-3 shadow-lg">
<div className="flex flex-wrap gap-2">
{allMembers.map((member) => (
<button
......@@ -404,8 +372,8 @@ export function Dashboard() {
onClick={() => setSelectedMembers((prev) =>
prev.includes(member) ? prev.filter((m) => m !== member) : [...prev, member]
)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
selectedMembers.includes(member) ? "bg-primary text-white" : "bg-muted text-foreground hover:bg-muted-foreground/10"
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
selectedMembers.includes(member) ? "bg-[#1664ff] text-white" : "bg-[#f2f4f8] text-[#344054] hover:bg-[#e8edf6]"
}`}
>
{member}
......@@ -421,135 +389,128 @@ export function Dashboard() {
)}
</div>
</div>
<Link
to="/new-project"
className="flex h-9 items-center justify-center gap-2 rounded-md bg-[#5d5bff] px-4 text-sm font-medium text-white transition hover:bg-[#4d4af0]"
>
<Plus className="h-4 w-4" />
创建项目
</Link>
</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>
{/* Projects Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : filtered.length > 0 ? (
<div className="grid grid-cols-3 gap-4">
{filtered.map((project) => (
<div key={project.id} className="group relative rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all">
<>
<div className="grid grid-cols-1 gap-x-8 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{pagedProjects.map((project) => (
<div key={project.id} className="group relative">
{/* Cover */}
<Link to={`/project/${project.id}`} className="block aspect-video bg-muted overflow-hidden">
<Link to={`/project/${project.id}`} className="block aspect-[16/10] overflow-hidden rounded-lg bg-[#eef1f7]">
<ProjectCover
url={project.coverUrl}
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>
{/* Action buttons — visible on hover */}
<div className="absolute top-3 left-3 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.preventDefault(); setEditingProject(project.raw); }}
className="w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-black/80 transition"
title="编辑项目"
>
<Pencil className="w-3.5 h-3.5 text-white" />
</button>
<button
onClick={(e) => { e.preventDefault(); setDeletingProject(project.raw); }}
className="w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-red-600/80 transition"
title="删除项目"
>
<Trash2 className="w-3.5 h-3.5 text-white" />
</button>
</div>
{/* Card body */}
<Link to={`/project/${project.id}`} className="block p-4">
<h3 className="font-medium text-foreground mb-1.5 line-clamp-1">{project.title}</h3>
{project.description && (
<p className="text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed">
{project.description}
</p>
)}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{project.tags.slice(0, 3).map((tag) => (
<span key={tag.key} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">
{tag.label}
</span>
))}
<div className="relative pt-3">
<Link to={`/project/${project.id}`} className="block pr-9">
<div className="flex min-w-0 items-center gap-2">
<h3 className="line-clamp-1 font-medium text-[#111827]">{project.title}</h3>
<span className="shrink-0 rounded bg-[#ecebff] px-1.5 py-0.5 text-xs text-[#5d5bff]">
{project.progress === 100 ? "已完成" : "创作中"}
</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 className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>完成度</span>
<span>{project.progress}%</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] rounded-full transition-all"
style={{ width: `${project.progress}%` }}
/>
<div className="mt-2 text-xs text-[#a0a9bc]">
{project.createdAt ? `${project.createdAt.replace("T", " ").slice(0, 19)} 创建` : "—"}
</div>
</Link>
<div className="absolute right-0 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => { e.preventDefault(); setEditingProject(project.raw); }}
className="flex h-7 w-7 items-center justify-center rounded text-[#475467] hover:bg-[#eef1f7]"
title="编辑项目"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => { e.preventDefault(); setDeletingProject(project.raw); }}
className="flex h-7 w-7 items-center justify-center rounded text-[#475467] hover:bg-[#fff1f0] hover:text-[#d92d20]"
title="删除项目"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</Link>
</div>
</div>
))}
</div>
<div className="mt-12 flex items-center justify-end gap-2 text-sm text-[#344054]">
<span className="mr-1">{filtered.length}</span>
<button
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={safePage === 1}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#d6dbe7] bg-white disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<span className="flex h-8 min-w-8 items-center justify-center rounded-md border border-[#5d5bff] bg-white px-2 text-[#5d5bff]">
{safePage}
</span>
<button
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
disabled={safePage === totalPages}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#d6dbe7] bg-white disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="ml-2 h-8 rounded-md border border-[#d6dbe7] bg-white px-3 text-sm outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>{size} 条/页</option>
))}
</select>
<span className="ml-2">前往</span>
<input
value={safePage}
onChange={(e) => {
const value = Number(e.target.value);
if (Number.isFinite(value)) setPage(Math.min(Math.max(1, value), totalPages));
}}
className="h-8 w-14 rounded-md border border-[#d6dbe7] bg-white px-2 text-center outline-none"
/>
<span></span>
</div>
</>
) : projects.length === 0 ? (
<div className="text-center py-16">
<Film className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">还没有项目,开始创建第一个吧</p>
<div className="rounded-lg border border-dashed border-[#cfd6e4] bg-white py-16 text-center">
<Film className="mx-auto mb-4 h-14 w-14 text-[#98a2b3]" />
<p className="mb-4 text-[#667085]">还没有项目,开始创建第一个吧</p>
<Link
to="/new-project"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm hover:shadow-md transition-all"
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#1664ff] px-4 text-sm font-medium text-white transition hover:bg-[#0f56d9]"
>
<Plus className="w-4 h-4" />
<Plus className="h-4 w-4" />
新建项目
</Link>
</div>
) : (
<div className="text-center py-16">
<Film className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">没有找到符合条件的项目</p>
<div className="rounded-lg border border-dashed border-[#cfd6e4] bg-white py-16 text-center">
<Film className="mx-auto mb-4 h-14 w-14 text-[#98a2b3]" />
<p className="text-[#667085]">没有找到符合条件的项目</p>
<button
onClick={() => { setSearchTerm(""); setDateRange({ start: "", end: "" }); setSelectedMembers([]); }}
className="mt-4 text-sm text-primary hover:underline"
className="mt-4 text-sm text-[#1664ff] hover:underline"
>
清除所有筛选条件
</button>
......
......@@ -9,10 +9,20 @@ import { useAuth } from "../../hooks/useAuth";
export function LoginPage() {
const { login, loginPending, loginError } = useAuth();
const [form, setForm] = useState({ email: "", password: "" });
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login(form);
const email = form.email.trim();
const password = form.password.trim();
if (!email || !password) {
setFormError("请输入邮箱和密码");
return;
}
setFormError(null);
login({ email, password });
};
return (
......@@ -23,7 +33,7 @@ export function LoginPage() {
<CardDescription>登录您的账号</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">邮箱</Label>
<Input
......@@ -32,7 +42,10 @@ export function LoginPage() {
placeholder="you@example.com"
required
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
onChange={(e) => {
setFormError(null);
setForm((f) => ({ ...f, email: e.target.value }));
}}
/>
</div>
<div className="space-y-2">
......@@ -43,13 +56,16 @@ export function LoginPage() {
placeholder="••••••••"
required
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
onChange={(e) => {
setFormError(null);
setForm((f) => ({ ...f, password: e.target.value }));
}}
/>
</div>
{loginError && (
{(formError || loginError) && (
<p className="text-sm text-destructive">
{loginError instanceof Error ? loginError.message : "登录失败"}
{formError ?? (loginError instanceof Error ? loginError.message : "登录失败")}
</p>
)}
......
import { useState, type ChangeEvent, type MouseEvent } from "react";
import { useState, type ChangeEvent, type DragEvent } from "react";
import { useNavigate } from "react-router";
import {
AlertCircle,
ArrowRight,
BookOpen,
CheckCircle2,
FileText,
Image as ImageIcon,
Loader2,
Megaphone,
Monitor,
Sparkles,
Upload,
X,
Wand2,
} from "lucide-react";
import { Progress } from "../components/ui/progress";
import { useCreateProject } from "../../hooks/useProjects";
......@@ -16,6 +17,10 @@ import { projectsApi } from "../../lib/api/projects";
import { aspectRatioOptions, projectStyleOptions, resolutionOptions } from "../../lib/projectStyles";
type SubmitStage = "idle" | "creating" | "uploading" | "redirecting";
type CreationMode = "agent" | "manual";
const MAX_SCRIPT_SIZE = 20 * 1024 * 1024;
const SCRIPT_EXTENSIONS = [".txt", ".doc", ".docx", ".pdf", ".md"];
const submitStageMeta: Record<
Exclude<SubmitStage, "idle">,
......@@ -27,23 +32,30 @@ const submitStageMeta: Record<
progress: 28,
},
uploading: {
title: "正在上传素材",
description: "如果你上传了剧本或封面,系统会先完成上传再进入大纲页。",
title: "正在上传剧本",
description: "剧本文件上传完成后,会进入大纲生成页面继续处理。",
progress: 72,
},
redirecting: {
title: "准备进入剧本大纲",
description: "马上跳转到“生成剧本大纲”,可以继续解析已上传剧本,或手动输入内容。",
description: "马上跳转到剧本大纲页面,可以继续解析已上传剧本,或手动输入内容。",
progress: 96,
},
};
function getProjectNameFromFile(file: File | null) {
return file ? file.name.replace(/\.[^/.]+$/, "") : "新短剧项目";
}
function formatFileSize(size: number) {
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024).toFixed(1)} KB`;
}
export function NewProject() {
const navigate = useNavigate();
const [projectName, setProjectName] = useState("");
const [mode, setMode] = useState<CreationMode>("agent");
const [scriptFile, setScriptFile] = useState<File | null>(null);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [aspectRatio, setAspectRatio] = useState(aspectRatioOptions[0].value);
const [resolution, setResolution] = useState(resolutionOptions[0].value);
const [style, setStyle] = useState(projectStyleOptions[0].value);
......@@ -52,45 +64,47 @@ export function NewProject() {
const createProject = useCreateProject();
const isSubmitting = submitStage !== "idle";
const hasUpload = !!scriptFile || !!coverFile;
const hasUpload = !!scriptFile;
const handleScriptChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting) return;
const validateAndSetScript = (file: File) => {
const lowerName = file.name.toLowerCase();
const hasValidExtension = SCRIPT_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
const file = e.target.files[0];
setScriptFile(file);
if (!hasValidExtension) {
setSubmitError("请上传 doc、docx、txt、pdf 或 md 格式的剧本文件");
return;
}
if (!projectName.trim()) {
setProjectName(file.name.replace(/\.[^/.]+$/, ""));
if (file.size > MAX_SCRIPT_SIZE) {
setSubmitError("剧本文件大小不能超过 20M");
return;
}
};
const handleCoverChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting) return;
setSubmitError(null);
setScriptFile(file);
};
const file = e.target.files[0];
setCoverFile(file);
setCoverPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
const handleScriptChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0] || isSubmitting || mode !== "agent") return;
validateAndSetScript(e.target.files[0]);
};
const removeCover = (e: MouseEvent) => {
const handleDrop = (e: DragEvent<HTMLLabelElement>) => {
e.preventDefault();
if (isSubmitting) return;
setCoverFile(null);
if (coverPreview) URL.revokeObjectURL(coverPreview);
setCoverPreview(null);
if (isSubmitting || mode !== "agent") return;
const file = e.dataTransfer.files?.[0];
if (file) validateAndSetScript(file);
};
const handleSubmit = async () => {
if (isSubmitting) return;
const name =
projectName.trim() ||
(scriptFile ? scriptFile.name.replace(/\.[^/.]+$/, "") : "新短剧项目");
if (mode === "agent" && !scriptFile) {
setSubmitError("Agent模式需要先上传剧本文件");
return;
}
const name = getProjectNameFromFile(scriptFile);
try {
setSubmitError(null);
......@@ -103,13 +117,9 @@ export function NewProject() {
resolution,
});
if (hasUpload) {
if (scriptFile) {
setSubmitStage("uploading");
await Promise.all([
scriptFile ? projectsApi.uploadAsset(project.id, scriptFile, "script") : Promise.resolve(),
coverFile ? projectsApi.uploadAsset(project.id, coverFile, "cover") : Promise.resolve(),
]);
await projectsApi.uploadAsset(project.id, scriptFile, "script");
}
setSubmitStage("redirecting");
......@@ -121,6 +131,7 @@ export function NewProject() {
aspectRatio,
resolution,
style,
mode,
},
});
} catch (error) {
......@@ -129,29 +140,28 @@ export function NewProject() {
}
};
const canSubmit = !isSubmitting;
const activeSubmitMeta = submitStage === "idle" ? null : submitStageMeta[submitStage];
return (
<div className="relative h-full overflow-auto bg-background">
<div className="relative min-h-full overflow-auto bg-[#f6f7fb] text-[#0f172a]">
{activeSubmitMeta && (
<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-md rounded-2xl border border-border bg-card p-6 shadow-2xl">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 p-6 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-[#dfe6f3] bg-white p-6 shadow-2xl">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#1677ff] to-[#7c2dff]">
<Loader2 className="h-7 w-7 animate-spin text-white" />
</div>
<h2 className="mb-2 text-xl font-semibold text-foreground">{activeSubmitMeta.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-muted-foreground">
<h2 className="mb-2 text-xl font-semibold text-[#0f172a]">{activeSubmitMeta.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-[#667085]">
{activeSubmitMeta.description}
</p>
<Progress value={activeSubmitMeta.progress} className="mb-4 h-2.5" />
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between text-foreground">
<div className="flex items-center justify-between text-[#202b3d]">
<span>创建项目</span>
<span>{submitStage === "creating" ? "进行中" : "已准备"}</span>
<span>{submitStage === "creating" ? "进行中" : "已完成"}</span>
</div>
<div className="flex items-center justify-between text-foreground">
<span>上传素材</span>
<div className="flex items-center justify-between text-[#202b3d]">
<span>上传剧本</span>
<span>
{!hasUpload
? "已跳过"
......@@ -162,203 +172,201 @@ export function NewProject() {
: "等待中"}
</span>
</div>
<div className="flex items-center justify-between text-foreground">
<div className="flex items-center justify-between text-[#202b3d]">
<span>进入大纲页</span>
<span>{submitStage === "redirecting" ? "进行中" : "等待中"}</span>
</div>
</div>
<p className="mt-4 text-xs text-muted-foreground">
处理中已锁定按钮,避免重复点击创建多个项目。
</p>
<p className="mt-4 text-xs text-[#8a94a6]">处理过程中按钮已锁定,避免重复创建项目。</p>
</div>
</div>
)}
<div className="mx-auto max-w-4xl p-8">
<div className="mb-8 text-center">
<h1 className="mb-2 text-3xl font-semibold text-foreground">创建短剧项目</h1>
<p className="text-sm text-muted-foreground">
可以先上传剧本直接进入解析流程,也可以跳过上传,在下一页手动输入内容生成大纲。
</p>
<div className="flex min-h-full flex-col">
<div className="flex h-12 items-center justify-center gap-2 border-b border-[#e7ebf3] bg-[#f0f3ff] px-6 text-sm text-[#111827]">
<Megaphone className="h-4 w-4 text-[#1764ff]" />
<span>
公告:视频生成在使用视频素材作为输入时,积分计算逻辑已更新。针对不同的视频输出时长,现设有最低积分消耗限制,详情请参见
</span>
<span className="font-medium text-[#1764ff]">视频生成计费说明。</span>
</div>
{submitError && (
<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" />
<p className="text-sm text-red-700">{submitError}</p>
</div>
)}
<div className="mb-5">
<label className="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
disabled={isSubmitting}
placeholder="输入项目名称,或上传剧本后自动带入文件名"
className="w-full rounded-xl border border-border bg-card px-4 py-3 text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
<main className="mx-auto flex w-full max-w-[1280px] flex-1 flex-col items-center px-8 pb-10 pt-14">
<h1 className="text-center text-[40px] font-semibold leading-tight tracking-normal text-black">
翻开剧本,创作<span className="text-[#5b55ff]">精品短剧</span>
</h1>
<div className="mb-6 flex gap-4">
<div className="flex-shrink-0">
<label className="mb-2 block text-sm font-medium text-foreground">
封面图片
<span className="ml-1 font-normal text-muted-foreground">(可选)</span>
</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">
<input
type="file"
accept="image/*"
onChange={handleCoverChange}
disabled={isSubmitting}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{coverPreview ? (
<>
<img src={coverPreview} alt="封面预览" className="h-full w-full object-cover" />
<button
onClick={removeCover}
disabled={isSubmitting}
className="absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 transition-colors hover:bg-black/80 disabled:opacity-50"
>
<X className="h-3 w-3 text-white" />
</button>
</>
) : (
<div className="p-2 text-center">
<ImageIcon className="mx-auto mb-1 h-6 w-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">点击上传</span>
</div>
)}
{submitError && (
<div className="mt-8 flex w-full max-w-[1000px] 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" />
<p className="text-sm text-red-700">{submitError}</p>
</div>
</div>
)}
<div className="flex-1">
<label className="mb-2 block text-sm font-medium text-foreground">
剧本文件
<span className="ml-1 font-normal text-muted-foreground">(可选)</span>
</label>
<div className="relative flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-border bg-card">
<input
type="file"
accept=".txt,.doc,.docx,.pdf,.md"
onChange={handleScriptChange}
disabled={isSubmitting}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{scriptFile ? (
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]">
<FileText className="h-6 w-6 text-white" />
<section className="mt-12 w-full max-w-[1000px] rounded-[20px] border border-[#dde5f1] bg-white p-4 shadow-[0_22px_60px_rgba(38,51,77,0.08)]">
<label
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className={`flex min-h-[228px] items-center justify-center rounded-2xl border border-dashed border-[#d9e2f1] bg-[#f8faff] transition-colors ${
mode === "agent" && !isSubmitting
? "cursor-pointer hover:border-[#7b8cff] hover:bg-[#f5f7ff]"
: "cursor-default"
}`}
>
{mode === "agent" && (
<input
type="file"
accept=".txt,.doc,.docx,.pdf,.md"
onChange={handleScriptChange}
disabled={isSubmitting}
className="hidden"
/>
)}
{mode === "agent" ? (
scriptFile ? (
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border border-[#dfe6f3] bg-white shadow-sm">
<FileText className="h-8 w-8 text-[#5b55ff]" />
</div>
<div className="text-sm font-medium text-[#111827]">{scriptFile.name}</div>
<div className="mt-1 text-xs text-[#8a94a6]">{formatFileSize(scriptFile.size)}</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
if (!isSubmitting) setScriptFile(null);
}}
disabled={isSubmitting}
className="mt-4 rounded-lg border border-[#dfe6f3] bg-white px-3 py-1.5 text-xs text-[#4f46e5] transition-colors hover:bg-[#f5f7ff] disabled:cursor-not-allowed disabled:opacity-60"
>
重新上传
</button>
</div>
<div className="text-center">
<div className="mb-0.5 text-sm font-medium text-foreground">{scriptFile.name}</div>
<div className="text-xs text-muted-foreground">
{(scriptFile.size / 1024).toFixed(1)} KB
) : (
<div className="flex flex-col items-center text-center">
<div className="mb-6 flex items-center justify-center">
<div className="-mr-2 rotate-[-14deg] rounded-lg border border-[#dfe6f3] bg-white p-2 text-[#a8b3c7] shadow-sm">
<FileText className="h-5 w-5" />
</div>
<div className="z-10 rounded-xl border border-[#dfe6f3] bg-white p-3 text-[#69758a] shadow-md">
<BookOpen className="h-6 w-6" />
</div>
<div className="-ml-2 rotate-[14deg] rounded-lg border border-[#dfe6f3] bg-white p-2 text-[#a8b3c7] shadow-sm">
<FileText className="h-5 w-5" />
</div>
</div>
<div className="text-sm text-[#8a94a6]">
点击或拖拽剧本至此,支持 doc、docx、txt、pdf 和 md 格式,文件大小不超过 20M
</div>
</div>
<button
onClick={(e) => {
e.preventDefault();
if (!isSubmitting) setScriptFile(null);
}}
disabled={isSubmitting}
className="text-xs text-primary hover:underline disabled:no-underline disabled:opacity-50"
>
重新上传
</button>
</div>
)
) : (
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
<Upload className="h-6 w-6 text-muted-foreground" />
</div>
<div className="text-center">
<div className="mb-0.5 text-sm text-foreground">点击或拖拽剧本文件到这里</div>
<div className="text-xs text-muted-foreground">
支持 doc、docx、txt、pdf、md,大小不超过 20M;也可以先跳过,下一页手动输入剧本。
</div>
<div className="flex flex-col items-center text-center">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-[#dfe6f3] bg-white shadow-sm">
<Wand2 className="h-8 w-8 text-[#5b55ff]" />
</div>
<div className="text-sm font-medium text-[#111827]">人工模式将在下一页手动输入剧本和大纲</div>
<div className="mt-2 text-sm text-[#8a94a6]">这里不需要填写项目名称,也不需要上传图片。</div>
</div>
)}
</div>
</div>
</div>
</label>
<div className="mb-6 grid grid-cols-3 gap-4">
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">画幅比例</div>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} · {option.helper}
</option>
))}
</select>
</div>
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="inline-flex w-fit rounded-lg border border-[#dbe3f0] bg-white p-0.5">
<button
type="button"
onClick={() => {
setMode("agent");
setSubmitError(null);
}}
disabled={isSubmitting}
className={`h-9 rounded-md px-4 text-sm transition-colors ${
mode === "agent"
? "bg-[#eef0ff] text-[#5b55ff]"
: "text-[#111827] hover:bg-[#f6f7fb]"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
Agent模式
</button>
<button
type="button"
onClick={() => {
setMode("manual");
setSubmitError(null);
}}
disabled={isSubmitting}
className={`h-9 rounded-md px-4 text-sm transition-colors ${
mode === "manual"
? "bg-[#eef0ff] text-[#5b55ff]"
: "text-[#111827] hover:bg-[#f6f7fb]"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
人工模式
</button>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">清晰度</div>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} · {option.helper}
</option>
))}
</select>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="relative">
<Monitor className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#445066]" />
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={isSubmitting}
className="h-10 min-w-[126px] appearance-none rounded-lg border border-[#dbe3f0] bg-white py-0 pl-9 pr-8 text-sm text-[#111827] outline-none transition-colors focus:border-[#7b8cff] disabled:cursor-not-allowed disabled:opacity-60"
>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 text-xs text-muted-foreground">视觉风格</div>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
disabled={isSubmitting}
className="w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
disabled={isSubmitting}
className="h-10 min-w-[112px] rounded-lg border border-[#dbe3f0] bg-white px-3 text-sm text-[#111827] outline-none transition-colors focus:border-[#7b8cff] disabled:cursor-not-allowed disabled:opacity-60"
>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="mb-6 rounded-xl border border-primary/20 bg-accent/50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-foreground">AI 智能处理</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
如果上传了剧本,下一页会继续自动解析;如果没有上传,也可以直接手动输入剧本内容生成大纲。这里选择的视觉风格会继续影响后续人物与场景的 AI 生成效果。
</p>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
disabled={isSubmitting}
className="h-10 min-w-[150px] rounded-lg border border-[#dbe3f0] bg-white px-3 text-sm text-[#111827] outline-none transition-colors focus:border-[#7b8cff] disabled:cursor-not-allowed disabled:opacity-60"
>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
</div>
</section>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
>
<span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建新短剧项目"}</span>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ArrowRight className="h-5 w-5" />}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="mt-16 flex h-[52px] min-w-[250px] items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#1677ff] to-[#7c2dff] px-8 text-sm font-medium text-white shadow-[0_18px_38px_rgba(88,82,255,0.28)] transition-all hover:-translate-y-0.5 hover:shadow-[0_22px_46px_rgba(88,82,255,0.34)] disabled:cursor-not-allowed disabled:translate-y-0 disabled:opacity-60"
>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
<span>{isSubmitting ? activeSubmitMeta?.title ?? "处理中..." : "创建短剧项目"}</span>
</button>
<div className="mt-8 flex items-center gap-2 text-sm text-[#9aa4b8]">
<CheckCircle2 className="h-4 w-4" />
<span>平台内容均由人工智能模型生成,不代表平台立场</span>
</div>
</main>
</div>
</div>
);
......
......@@ -4,6 +4,7 @@ import {
AlertCircle,
ArrowRight,
CheckCircle2,
Edit2,
FileText,
Film,
Loader2,
......@@ -14,7 +15,7 @@ import {
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Progress } from "../components/ui/progress";
import { useGenerateEpisodes, useGenerateOutline, useOutline } from "../../hooks/useAi";
import { useGenerateEpisodes, useGenerateOutline, useOutline, useUpdateOutline } from "../../hooks/useAi";
import { projectsApi, type ScriptInfoDTO } from "../../lib/api/projects";
type ProcessingStep = {
......@@ -34,6 +35,13 @@ type OutlineLocationState = {
projectName?: string;
};
type OutlineDraft = {
title: string;
genre: string;
synopsis: string;
episodeCount: number;
};
const checkingOutlineState: ProcessingState = {
title: "正在检查项目状态",
description: "先确认这个项目是否已经有可用大纲。",
......@@ -228,6 +236,7 @@ export function OutlineGeneration() {
const { data: outline, isLoading: loadingOutline } = useOutline(pid);
const generateOutline = useGenerateOutline(pid);
const updateOutline = useUpdateOutline(pid);
const generateEpisodes = useGenerateEpisodes(pid);
const [scriptInfo, setScriptInfo] = useState<ScriptInfoDTO | null>(null);
......@@ -238,6 +247,24 @@ export function OutlineGeneration() {
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [editingOutline, setEditingOutline] = useState(false);
const [outlineDraft, setOutlineDraft] = useState<OutlineDraft>({
title: "",
genre: "",
synopsis: "",
episodeCount: 1,
});
const [outlineEditError, setOutlineEditError] = useState<string | null>(null);
useEffect(() => {
if (!outline) return;
setOutlineDraft({
title: outline.title ?? "",
genre: outline.genre ?? "",
synopsis: outline.synopsis ?? "",
episodeCount: outline.episodeCount ?? 1,
});
}, [outline]);
useEffect(() => {
if (!pid || outline || loadingOutline) return;
......@@ -311,6 +338,33 @@ export function OutlineGeneration() {
setShowManual(false);
};
const handleSaveOutline = async () => {
if (!outline || updateOutline.isPending) return;
const title = outlineDraft.title.trim();
const genre = outlineDraft.genre.trim();
const synopsis = outlineDraft.synopsis.trim();
const episodeCount = Number(outlineDraft.episodeCount) || 1;
if (!title || !genre || !synopsis) {
setOutlineEditError("请补全剧名、类型和故事梗概");
return;
}
try {
setOutlineEditError(null);
await updateOutline.mutateAsync({
title,
genre,
synopsis,
episodeCount,
});
setEditingOutline(false);
} catch (error) {
setOutlineEditError((error as Error)?.message ?? "保存大纲失败,请重试");
}
};
const handleContinue = async () => {
if (!outline) return;
......@@ -548,24 +602,131 @@ export function OutlineGeneration() {
)}
<div className="rounded-xl border border-border bg-card p-6 mb-4">
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-xs text-muted-foreground mb-2">剧名</label>
<div className="text-xl font-semibold text-foreground">{outline.title}</div>
</div>
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-2">类型</label>
<div className="text-xl font-semibold text-foreground">{outline.genre}</div>
<h2 className="text-base font-semibold text-foreground">大纲信息</h2>
<p className="mt-1 text-xs text-muted-foreground">
手动生成的大纲如果缺少剧名、类型或故事梗概,可以在这里补充。
</p>
</div>
{!editingOutline && (
<button
onClick={() => {
setOutlineEditError(null);
setEditingOutline(true);
}}
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
<Edit2 className="h-4 w-4" />
编辑大纲
</button>
)}
</div>
<div className="mb-4">
<label className="block text-xs text-muted-foreground mb-2">故事梗概</label>
<p className="text-sm text-foreground leading-relaxed">{outline.synopsis}</p>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">集数</label>
<span className="px-3 py-1 rounded-lg bg-accent text-primary text-sm">{outline.episodeCount}</span>
</div>
{outlineEditError && (
<div className="mb-4 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" />
<p className="text-sm text-red-700">{outlineEditError}</p>
</div>
)}
{editingOutline ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground">剧名</label>
<input
value={outlineDraft.title}
onChange={(e) => setOutlineDraft({ ...outlineDraft, title: e.target.value })}
placeholder="请输入剧名"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground">类型</label>
<input
value={outlineDraft.genre}
onChange={(e) => setOutlineDraft({ ...outlineDraft, genre: e.target.value })}
placeholder="如:都市爱情 / 悬疑短剧"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground">故事梗概</label>
<textarea
value={outlineDraft.synopsis}
onChange={(e) => setOutlineDraft({ ...outlineDraft, synopsis: e.target.value })}
rows={5}
placeholder="请输入故事梗概"
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground">集数</label>
<input
type="number"
min={1}
value={outlineDraft.episodeCount}
onChange={(e) => setOutlineDraft({ ...outlineDraft, episodeCount: Number(e.target.value) || 1 })}
className="w-32 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border pt-4">
<button
onClick={() => {
setEditingOutline(false);
setOutlineEditError(null);
setOutlineDraft({
title: outline.title ?? "",
genre: outline.genre ?? "",
synopsis: outline.synopsis ?? "",
episodeCount: outline.episodeCount ?? 1,
});
}}
disabled={updateOutline.isPending}
className="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
取消
</button>
<button
onClick={handleSaveOutline}
disabled={updateOutline.isPending}
className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] px-4 py-2 text-sm text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
>
{updateOutline.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
保存大纲
</button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-xs text-muted-foreground mb-2">剧名</label>
<div className="text-xl font-semibold text-foreground">
{outline.title || <span className="text-muted-foreground">待补充</span>}
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">类型</label>
<div className="text-xl font-semibold text-foreground">
{outline.genre || <span className="text-muted-foreground">待补充</span>}
</div>
</div>
</div>
<div className="mb-4">
<label className="block text-xs text-muted-foreground mb-2">故事梗概</label>
<p className="text-sm text-foreground leading-relaxed">
{outline.synopsis || <span className="text-muted-foreground">待补充</span>}
</p>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">集数</label>
<span className="px-3 py-1 rounded-lg bg-accent text-primary text-sm">{outline.episodeCount}</span>
</div>
</>
)}
</div>
<button
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiApi } from "../lib/api/ai";
import type { Character, Episode, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
import type { Character, Episode, Outline, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
// ---- Characters ----
const charactersKey = (pid: string) => ["characters", pid];
......@@ -84,6 +84,14 @@ export function useGenerateOutline(projectId: string) {
});
}
export function useUpdateOutline(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: Partial<Outline>) => aiApi.updateOutline(projectId, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: outlineKey(projectId) }),
});
}
// ---- Episodes ----
export function useEpisodes(projectId: string) {
return useQuery({
......
......@@ -152,6 +152,10 @@ export const aiApi = {
const r = await apiClient.get(`/projects/${projectId}/outline`);
return r.data.data;
},
updateOutline: async (projectId: string, patch: Partial<Outline>): Promise<Outline> => {
const r = await apiClient.put(`/projects/${projectId}/outline`, patch);
return r.data.data;
},
// ---- Episodes ----
generateEpisodes: async (projectId: string): Promise<Episode[]> => {
......
......@@ -137,6 +137,15 @@
font-size: var(--font-size);
}
html,
body,
#root {
width: 100%;
min-width: 0;
height: 100%;
margin: 0;
}
h1 {
font-size: var(--text-2xl);
font-weight: var(--font-weight-medium);
......
......@@ -52,6 +52,15 @@ public class OutlineController {
return ApiResponse.success(OutlineDTO.from(outline));
}
@Operation(summary = "编辑项目大纲")
@PutMapping("/outline")
public ApiResponse<OutlineDTO> updateOutline(@PathVariable Long projectId,
@RequestBody Outline req) {
Long tenantId = TenantContext.get();
Outline outline = outlinePipelineService.updateOutline(projectId, tenantId, req);
return ApiResponse.success(OutlineDTO.from(outline));
}
@Operation(summary = "生成分集内容(使用最新大纲)")
@PostMapping("/episodes/generate")
public ApiResponse<List<EpisodeDTO>> generateEpisodes(@PathVariable Long projectId) {
......
......@@ -19,6 +19,11 @@ public interface OutlinePipelineService {
Outline getOutline(Long projectId, Long tenantId);
/**
* 手动编辑大纲基础信息(title/genre/synopsis/episodeCount,非 null 字段才更新)。
*/
Outline updateOutline(Long projectId, Long tenantId, Outline req);
List<Episode> getEpisodes(Long projectId, Long tenantId);
/**
......
......@@ -205,6 +205,24 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
}
@Override
public Outline updateOutline(Long projectId, Long tenantId, Outline req) {
Outline existing = outlineMapper.findLatestByProject(projectId, tenantId);
if (existing == null) {
throw new BizException(ErrorCode.NOT_FOUND, "大纲不存在,请先生成或保存大纲");
}
if (req.getTitle() != null) existing.setTitle(req.getTitle());
if (req.getGenre() != null) existing.setGenre(req.getGenre());
if (req.getSynopsis() != null) existing.setSynopsis(req.getSynopsis());
if (req.getEpisodeCount() != null && req.getEpisodeCount() > 0) {
existing.setEpisodeCount(req.getEpisodeCount());
}
existing.setStatus("ready");
outlineMapper.updateById(existing);
log.info("Outline updated: id={}, projectId={}", existing.getId(), projectId);
return existing;
}
@Override
public List<Episode> getEpisodes(Long projectId, Long tenantId) {
return episodeMapper.findByProject(projectId, tenantId);
}
......
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