Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yaoai-video
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
姚珂
yaoai-video
Commits
75992d7b
Commit
75992d7b
authored
Apr 22, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
企业级工业风格
parent
175f406c
Hide whitespace changes
Inline
Side-by-side
Showing
25 changed files
with
5913 additions
and
5371 deletions
+5913
-5371
AuthShell.tsx
doc/html/src/app/components/AuthShell.tsx
+109
-0
BrandLogo.tsx
doc/html/src/app/components/BrandLogo.tsx
+58
-0
Layout.tsx
doc/html/src/app/components/Layout.tsx
+438
-310
AIWatermarkSettings.tsx
doc/html/src/app/pages/AIWatermarkSettings.tsx
+180
-165
AssetsManagement.tsx
doc/html/src/app/pages/AssetsManagement.tsx
+233
-201
CharacterGeneration.tsx
doc/html/src/app/pages/CharacterGeneration.tsx
+380
-431
Dashboard.tsx
doc/html/src/app/pages/Dashboard.tsx
+602
-376
EpisodeGeneration.tsx
doc/html/src/app/pages/EpisodeGeneration.tsx
+83
-51
LoginPage.tsx
doc/html/src/app/pages/LoginPage.tsx
+72
-49
ModelSettings.tsx
doc/html/src/app/pages/ModelSettings.tsx
+327
-374
NewProject.tsx
doc/html/src/app/pages/NewProject.tsx
+324
-212
OutlineGeneration.tsx
doc/html/src/app/pages/OutlineGeneration.tsx
+452
-286
ProjectDetail.tsx
doc/html/src/app/pages/ProjectDetail.tsx
+417
-277
ProjectSettings.tsx
doc/html/src/app/pages/ProjectSettings.tsx
+329
-259
PropsGeneration.tsx
doc/html/src/app/pages/PropsGeneration.tsx
+201
-212
RegisterPage.tsx
doc/html/src/app/pages/RegisterPage.tsx
+103
-71
SceneGeneration.tsx
doc/html/src/app/pages/SceneGeneration.tsx
+267
-288
StoryboardWorkspace.tsx
doc/html/src/app/pages/StoryboardWorkspace.tsx
+398
-617
StyleSelection.tsx
doc/html/src/app/pages/StyleSelection.tsx
+6
-6
TeamManagement.tsx
doc/html/src/app/pages/TeamManagement.tsx
+370
-413
UsageManagement.tsx
doc/html/src/app/pages/UsageManagement.tsx
+253
-327
VideoGeneration.tsx
doc/html/src/app/pages/VideoGeneration.tsx
+221
-415
brand.ts
doc/html/src/lib/brand.ts
+24
-0
fonts.css
doc/html/src/styles/fonts.css
+10
-0
theme.css
doc/html/src/styles/theme.css
+56
-31
No files found.
doc/html/src/app/components/AuthShell.tsx
0 → 100644
View file @
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
>
);
}
doc/html/src/app/components/BrandLogo.tsx
0 → 100644
View file @
75992d7b
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
);
}
doc/html/src/app/components/Layout.tsx
View file @
75992d7b
import
{
Outlet
,
Link
,
useLocation
,
useParams
,
useNavigate
}
from
"react-router"
;
import
{
Link
,
Outlet
,
useLocation
,
useNavigate
,
useParams
}
from
"react-router"
;
import
{
import
{
Film
,
BarChart3
,
Users
,
Sparkles
,
FolderOpen
,
Settings
,
Search
,
Bell
,
Bell
,
Globe
,
BookOpenText
,
Bot
,
ChevronDown
,
ChevronDown
,
BarChart3
,
Image
as
ImageIcon
,
FileText
,
Sliders
,
Clapperboard
,
Clapperboard
,
Video
,
Bot
,
Cpu
,
Cpu
,
Film
,
FolderOpen
,
Globe
,
Image
as
ImageIcon
,
LogOut
,
LogOut
,
PanelLeftClose
,
PanelLeftOpen
,
Search
,
Settings
,
ShieldCheck
,
Sliders
,
Sparkles
,
User
,
User
,
Users
,
Video
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
use
State
,
useEffect
,
useRef
}
from
"react"
;
import
{
use
Effect
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
{
useAuthStore
}
from
"../../stores/authStore"
;
import
{
useAuthStore
}
from
"../../stores/authStore"
;
import
{
useBalance
}
from
"../../hooks/useUsage"
;
import
{
useBalance
}
from
"../../hooks/useUsage"
;
import
{
useProject
}
from
"../../hooks/useProjects"
;
import
{
useProject
}
from
"../../hooks/useProjects"
;
import
{
useTeamGroups
}
from
"../../hooks/useTeam"
;
import
{
useTeamGroups
}
from
"../../hooks/useTeam"
;
import
logoImage
from
"../../imports/ab7d1ce5faf061d2af2b02d93e11935.png"
;
import
{
BrandLogo
}
from
"./BrandLogo"
;
import
{
brand
}
from
"../../lib/brand"
;
import
{
cn
}
from
"./ui/utils"
;
type
NavItem
=
{
label
:
string
;
icon
:
typeof
FolderOpen
;
path
:
string
;
match
:
(
pathname
:
string
)
=>
boolean
;
};
type
ProjectSection
=
{
id
:
string
;
label
:
string
;
icon
:
typeof
BookOpenText
;
path
:
string
;
};
const
notifications
=
[
{
id
:
1
,
title
:
"《归潮计划》大纲已完成解析,可继续生成分集。"
,
time
:
"5 分钟前"
,
},
{
id
:
2
,
title
:
"角色组“都市主角团”新增 3 个参考资产。"
,
time
:
"18 分钟前"
,
},
{
id
:
3
,
title
:
"平台已同步企业品牌主题与工作台布局。"
,
time
:
"1 小时前"
,
},
];
const
primaryNav
:
NavItem
[]
=
[
{
label
:
"项目工作台"
,
icon
:
FolderOpen
,
path
:
"/"
,
match
:
(
pathname
)
=>
pathname
===
"/"
,
},
{
label
:
"新建项目"
,
icon
:
Sparkles
,
path
:
"/new-project"
,
match
:
(
pathname
)
=>
pathname
===
"/new-project"
,
},
{
label
:
"资产中心"
,
icon
:
ImageIcon
,
path
:
"/assets/character"
,
match
:
(
pathname
)
=>
pathname
.
startsWith
(
"/assets"
),
},
];
const
manageNav
:
NavItem
[]
=
[
{
label
:
"团队管理"
,
icon
:
Users
,
path
:
"/team"
,
match
:
(
pathname
)
=>
pathname
===
"/team"
,
},
{
label
:
"用量统计"
,
icon
:
BarChart3
,
path
:
"/usage"
,
match
:
(
pathname
)
=>
pathname
===
"/usage"
,
},
{
label
:
"模型配置"
,
icon
:
Cpu
,
path
:
"/models"
,
match
:
(
pathname
)
=>
pathname
===
"/models"
,
},
{
label
:
"水印设置"
,
icon
:
Settings
,
path
:
"/settings"
,
match
:
(
pathname
)
=>
pathname
===
"/settings"
,
},
];
function
SectionLabel
({
children
,
collapsed
}:
{
children
:
string
;
collapsed
:
boolean
})
{
return
collapsed
?
null
:
(
<
div
className=
"px-3 text-[11px] font-semibold uppercase tracking-[0.28em] text-muted-foreground/78"
>
{
children
}
</
div
>
);
}
function
SidebarLink
({
item
,
pathname
,
collapsed
,
}:
{
item
:
NavItem
;
pathname
:
string
;
collapsed
:
boolean
;
})
{
const
active
=
item
.
match
(
pathname
);
const
Icon
=
item
.
icon
;
return
(
<
Link
to=
{
item
.
path
}
className=
{
cn
(
"group flex items-center rounded-2xl border px-3 py-3 text-sm transition-all"
,
collapsed
?
"justify-center"
:
"gap-3"
,
active
?
"border-primary/20 bg-[linear-gradient(135deg,rgba(15,116,216,0.14),rgba(30,165,255,0.05))] text-primary shadow-[0_10px_24px_rgba(15,116,216,0.10)]"
:
"border-transparent text-foreground/78 hover:border-border hover:bg-white/70 hover:text-foreground"
,
)
}
>
<
div
className=
{
cn
(
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-all"
,
active
?
"bg-primary text-white"
:
"bg-muted text-muted-foreground group-hover:bg-accent group-hover:text-primary"
,
)
}
>
<
Icon
className=
"h-4 w-4"
/>
</
div
>
{
collapsed
?
null
:
(
<
div
className=
"min-w-0"
>
<
div
className=
"truncate font-medium"
>
{
item
.
label
}
</
div
>
</
div
>
)
}
</
Link
>
);
}
export
function
Layout
()
{
export
function
Layout
()
{
const
location
=
useLocation
();
const
location
=
useLocation
();
const
params
=
useParams
();
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
[
showNotifications
,
setShowNotifications
]
=
useState
(
false
);
const
params
=
useParams
();
const
[
showUserMenu
,
setShowUserMenu
]
=
useState
(
false
);
const
[
currentGroupId
,
setCurrentGroupId
]
=
useState
<
number
|
""
>
(
""
);
const
[
sidebarCollapsed
,
setSidebarCollapsed
]
=
useState
(
false
);
const
userMenuRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
userMenuRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
notificationRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
{
user
,
clearAuth
}
=
useAuthStore
();
const
{
user
,
clearAuth
}
=
useAuthStore
();
const
{
data
:
balance
}
=
useBalance
();
const
{
data
:
balance
}
=
useBalance
();
const
{
data
:
teamGroups
=
[]
}
=
useTeamGroups
();
const
{
data
:
teamGroups
=
[]
}
=
useTeamGroups
();
// Close user menu on outside click
const
[
showNotifications
,
setShowNotifications
]
=
useState
(
false
);
useEffect
(()
=>
{
const
[
showUserMenu
,
setShowUserMenu
]
=
useState
(
false
);
const
handler
=
(
e
:
MouseEvent
)
=>
{
const
[
currentGroupId
,
setCurrentGroupId
]
=
useState
<
number
|
""
>
(
""
);
if
(
userMenuRef
.
current
&&
!
userMenuRef
.
current
.
contains
(
e
.
target
as
Node
))
{
const
[
sidebarCollapsed
,
setSidebarCollapsed
]
=
useState
(
false
);
setShowUserMenu
(
false
);
}
};
document
.
addEventListener
(
"mousedown"
,
handler
);
return
()
=>
document
.
removeEventListener
(
"mousedown"
,
handler
);
},
[]);
const
notifications
=
[
{
id
:
1
,
type
:
"task"
,
title
:
"项目《重生之豪门千金》第3集分镜待审核"
,
time
:
"5分钟前"
},
{
id
:
2
,
type
:
"system"
,
title
:
"系统更新:新增AI水印功能"
,
time
:
"1小时前"
},
{
id
:
3
,
type
:
"approval"
,
title
:
"张三提交的角色设定已通过审核"
,
time
:
"2小时前"
},
];
// Check if we're in a project route
const
projectId
=
params
.
projectId
;
const
projectId
=
params
.
projectId
;
const
isProjectRoute
=
projectId
!==
undefined
;
const
isProjectRoute
=
projectId
!==
undefined
;
const
{
data
:
currentProject
}
=
useProject
(
projectId
??
""
);
const
{
data
:
currentProject
}
=
useProject
(
projectId
??
""
);
// Auto-collapse sidebar when in project detail routes
useEffect
(()
=>
{
useEffect
(()
=>
{
setSidebarCollapsed
(
isProjectRoute
);
setSidebarCollapsed
(
isProjectRoute
);
},
[
isProjectRoute
]);
},
[
isProjectRoute
]);
// Determine active section based on current path
useEffect
(()
=>
{
const
getActiveSection
=
()
=>
{
const
handler
=
(
event
:
MouseEvent
)
=>
{
if
(
userMenuRef
.
current
&&
!
userMenuRef
.
current
.
contains
(
event
.
target
as
Node
))
{
setShowUserMenu
(
false
);
}
if
(
notificationRef
.
current
&&
!
notificationRef
.
current
.
contains
(
event
.
target
as
Node
))
{
setShowNotifications
(
false
);
}
};
document
.
addEventListener
(
"mousedown"
,
handler
);
return
()
=>
document
.
removeEventListener
(
"mousedown"
,
handler
);
},
[]);
const
activeSection
=
useMemo
(()
=>
{
const
path
=
location
.
pathname
;
const
path
=
location
.
pathname
;
if
(
path
.
includes
(
"/agent"
))
return
"agent"
;
if
(
path
.
includes
(
"/agent"
))
return
"agent"
;
if
(
path
.
includes
(
"/storyboard"
))
return
"storyboard"
;
if
(
path
.
includes
(
"/storyboard"
))
return
"storyboard"
;
...
@@ -82,333 +211,333 @@ export function Layout() {
...
@@ -82,333 +211,333 @@ export function Layout() {
)
{
)
{
return
"settings"
;
return
"settings"
;
}
}
if
(
path
.
includes
(
"/outline"
)
||
path
.
includes
(
"/episodes"
)
||
path
===
`/project/
${
projectId
}
`
)
{
return
"script"
;
}
return
"script"
;
return
"script"
;
};
},
[
location
.
pathname
]);
const
activeSection
=
getActiveSection
();
const
projectSections
:
ProjectSection
[]
=
[
{
id
:
"agent"
,
label
:
"Agent 制作"
,
icon
:
Bot
,
path
:
`/project/
${
projectId
}
/agent`
},
const
projectSections
=
[
{
id
:
"script"
,
label
:
"剧本拆解"
,
icon
:
BookOpenText
,
path
:
`/project/
${
projectId
}
`
},
{
id
:
"agent"
,
label
:
"Agent制作"
,
icon
:
Bot
,
path
:
`/project/
${
projectId
}
/agent`
},
{
id
:
"settings"
,
label
:
"人物设定"
,
icon
:
Sliders
,
path
:
`/project/
${
projectId
}
/characters`
},
{
id
:
"script"
,
label
:
"剧本"
,
icon
:
FileText
,
path
:
`/project/
${
projectId
}
`
},
{
id
:
"storyboard"
,
label
:
"分镜工作台"
,
icon
:
Clapperboard
,
path
:
`/project/
${
projectId
}
/storyboard`
},
{
{
id
:
"video"
,
label
:
"视频生成"
,
icon
:
Video
,
path
:
`/project/
${
projectId
}
/video`
},
id
:
"settings"
,
label
:
"设定"
,
icon
:
Sliders
,
path
:
`/project/
${
projectId
}
/characters`
,
},
{
id
:
"storyboard"
,
label
:
"分镜"
,
icon
:
Clapperboard
,
path
:
`/project/
${
projectId
}
/storyboard`
,
},
{
id
:
"video"
,
label
:
"视频"
,
icon
:
Video
,
path
:
`/project/
${
projectId
}
/video`
},
];
];
const
currentGroupName
=
currentGroupId
===
""
?
"全部小组"
:
teamGroups
.
find
((
group
)
=>
group
.
id
===
currentGroupId
)?.
name
??
"当前小组"
;
return
(
return
(
<
div
className=
"h-screen flex bg-background"
>
<
div
className=
"flex min-h-screen bg-transparent"
>
{
/* Left Sidebar */
}
<
aside
<
aside
className=
{
`border-r border-border bg-card flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-56'}`
}
>
className=
{
cn
(
{
/* Logo */
}
"relative flex h-screen flex-col border-r border-white/70 bg-sidebar/80 p-3 shadow-[20px_0_40px_rgba(11,44,81,0.06)] backdrop-blur-xl transition-all duration-300"
,
<
div
className=
"h-14 border-b border-border flex items-center justify-center px-4"
>
sidebarCollapsed
?
"w-24"
:
"w-80"
,
<
Link
to=
"/"
className=
"flex items-center gap-2"
>
)
}
{
/* ZK Logo with Film Icon */
}
>
<
div
className=
"relative"
>
<
div
className=
"rounded-[28px] border border-white/70 bg-white/60 p-3 shadow-sm"
>
<
Film
className=
"w-8 h-8 text-primary"
/>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
{
sidebarCollapsed
?
(
<
span
className=
"text-[10px] font-bold text-primary"
>
ZK
</
span
>
<
BrandLogo
compact
className=
"gap-0"
subtitle=
{
undefined
}
/>
)
:
(
<
BrandLogo
subtitle=
{
brand
.
tagline
}
/>
)
}
<
button
type=
"button"
onClick=
{
()
=>
setSidebarCollapsed
((
value
)
=>
!
value
)
}
className=
"flex h-10 w-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
{
sidebarCollapsed
?
(
<
PanelLeftOpen
className=
"h-4 w-4"
/>
)
:
(
<
PanelLeftClose
className=
"h-4 w-4"
/>
)
}
</
button
>
</
div
>
{
sidebarCollapsed
?
null
:
(
<
div
className=
"mt-4 rounded-3xl bg-[linear-gradient(160deg,#0d2c4f_0%,#11477f_54%,#1c94e8_100%)] p-5 text-white shadow-[0_24px_52px_rgba(12,52,96,0.18)]"
>
<
div
className=
"mb-2 inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white/72"
>
<
ShieldCheck
className=
"h-3.5 w-3.5"
/>
Enterprise
</
div
>
</
div
>
<
div
className=
"text-lg font-semibold leading-7"
>
中科集团统一创作空间
</
div
>
<
p
className=
"mt-2 text-sm leading-6 text-white/74"
>
在一套工作台里管理剧本、大纲、角色、场景、分镜和视频任务。
</
p
>
</
div
>
</
div
>
{
/* Product Name and Version */
}
)
}
<
div
className=
"flex items-baseline gap-1"
>
<
span
className=
"text-sm font-semibold text-foreground"
>
DramaStudio
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
V1
</
span
>
</
div
>
</
Link
>
</
div
>
</
div
>
<
nav
className=
"flex-1 p-3 overflow-auto"
>
<
div
className=
"mt-4 flex-1 overflow-y-auto rounded-[28px] border border-white/70 bg-white/56 p-3 shadow-sm"
>
{
/* 小组板块 */
}
<
SectionLabel
collapsed=
{
sidebarCollapsed
}
>
Workspace
</
SectionLabel
>
<
div
className=
"text-xs text-muted-foreground px-3 mb-2"
>
小组
</
div
>
<
div
className=
"mt-3 space-y-2"
>
<
div
className=
"mb-6 px-3"
>
{
primaryNav
.
map
((
item
)
=>
(
<
div
className=
"relative"
>
<
SidebarLink
<
select
key=
{
item
.
path
}
value=
{
currentGroupId
}
item=
{
item
}
onChange=
{
(
e
)
=>
setCurrentGroupId
(
Number
(
e
.
target
.
value
)
||
""
)
}
pathname=
{
location
.
pathname
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer appearance-none pr-8"
collapsed=
{
sidebarCollapsed
}
>
/>
<
option
value=
""
>
全部小组
</
option
>
))
}
{
teamGroups
.
map
((
g
)
=>
(
</
div
>
<
option
key=
{
g
.
id
}
value=
{
g
.
id
}
>
{
g
.
name
}
</
option
>
))
}
<
div
className=
"mt-6"
>
</
select
>
<
SectionLabel
collapsed=
{
sidebarCollapsed
}
>
Team Scope
</
SectionLabel
>
<
ChevronDown
className=
"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none"
/>
<
div
className=
"mt-3"
>
{
sidebarCollapsed
?
(
<
div
className=
"flex justify-center"
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-primary"
>
<
Users
className=
"h-5 w-5"
/>
</
div
>
</
div
>
)
:
(
<
div
className=
"rounded-3xl border border-border/80 bg-white/70 p-3"
>
<
div
className=
"mb-2 text-xs text-muted-foreground"
>
当前团队范围
</
div
>
<
div
className=
"relative"
>
<
select
value=
{
currentGroupId
}
onChange=
{
(
e
)
=>
setCurrentGroupId
(
Number
(
e
.
target
.
value
)
||
""
)
}
className=
"h-11 w-full appearance-none rounded-2xl border border-border/80 bg-background px-4 pr-10 text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<
option
value=
""
>
全部小组
</
option
>
{
teamGroups
.
map
((
group
)
=>
(
<
option
key=
{
group
.
id
}
value=
{
group
.
id
}
>
{
group
.
name
}
</
option
>
))
}
</
select
>
<
ChevronDown
className=
"pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
{
/* 创作板块 */
}
<
div
className=
"mt-6"
>
<
div
className=
"text-xs text-muted-foreground px-3 mb-2"
>
创作
</
div
>
<
SectionLabel
collapsed=
{
sidebarCollapsed
}
>
Management
</
SectionLabel
>
<
Link
<
div
className=
"mt-3 space-y-2"
>
to=
"/new-project"
{
manageNav
.
map
((
item
)
=>
(
className=
{
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
<
SidebarLink
location.pathname === "/new-project"
key=
{
item
.
path
}
? "bg-accent text-accent-foreground"
item=
{
item
}
: "text-foreground hover:bg-muted"
pathname=
{
location
.
pathname
}
}`
}
collapsed=
{
sidebarCollapsed
}
>
/>
<
Sparkles
className=
"w-4 h-4"
/>
))
}
剧本生成短剧
</
div
>
</
Link
>
{
/* 我的板块 */
}
<
div
className=
"text-xs text-muted-foreground px-3 mt-6 mb-2"
>
我的
</
div
>
<
Link
to=
"/"
className=
{
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`
}
>
<
FolderOpen
className=
"w-4 h-4"
/>
项目
</
Link
>
<
Link
to=
"/assets/character"
className=
{
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname.startsWith("/assets")
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`
}
>
<
ImageIcon
className=
"w-4 h-4"
/>
资产
</
Link
>
{
/* 企业配置 */
}
<
div
className=
"text-xs text-muted-foreground px-3 mt-6 mb-2"
>
企业配置
</
div
>
<
Link
to=
"/team"
className=
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors mb-1"
>
<
Users
className=
"w-4 h-4"
/>
团队管理
</
Link
>
<
Link
to=
"/usage"
className=
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors mb-1"
>
<
BarChart3
className=
"w-4 h-4"
/>
用量统计
</
Link
>
<
Link
to=
"/models"
className=
{
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
location.pathname === "/models"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`
}
>
<
Cpu
className=
"w-4 h-4"
/>
模型配置
</
Link
>
<
Link
to=
"/settings"
className=
{
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === "/settings"
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted"
}`
}
>
<
Settings
className=
"w-4 h-4"
/>
AI生成水印
</
Link
>
</
nav
>
{
/* Credits Display */
}
<
div
className=
"p-3 border-t border-border"
>
<
div
className=
"flex items-center justify-between px-3 py-2 text-sm"
>
<
span
className=
"flex items-center gap-2 text-primary"
>
<
Sparkles
className=
"w-4 h-4"
/>
<
span
className=
"font-medium"
>
{
Number
(
balance
?.
balance
??
0
).
toLocaleString
()
}
</
span
>
</
span
>
<
button
onClick=
{
()
=>
navigate
(
"/usage"
)
}
className=
"text-xs text-muted-foreground hover:text-foreground"
>
购买
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
aside
>
{
/* Main Content */
}
<
div
className=
"mt-4 rounded-[28px] border border-white/70 bg-white/64 p-3 shadow-sm"
>
<
div
className=
"flex-1 flex flex-col overflow-hidden"
>
<
div
{
/* Top Header */
}
className=
{
cn
(
<
header
className=
"h-14 border-b border-border bg-card flex items-center justify-between px-6"
>
"rounded-3xl border border-primary/10 bg-[linear-gradient(135deg,rgba(15,116,216,0.10),rgba(30,165,255,0.03))]"
,
<
div
className=
"flex items-center gap-4 flex-1"
>
sidebarCollapsed
?
"px-3 py-4 text-center"
:
"px-4 py-4"
,
{
/* Logo - Show when sidebar is collapsed */
}
)
}
{
sidebarCollapsed
&&
(
>
<
Link
to=
"/"
className=
"flex-shrink-0 flex items-center gap-2"
>
{
sidebarCollapsed
?
(
{
/* ZK Logo with Film Icon */
}
<>
<
div
className=
"relative"
>
<
div
className=
"mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-white"
>
<
Film
className=
"w-7 h-7 text-primary"
/>
<
Sparkles
className=
"h-4 w-4"
/>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
</
div
>
<
span
className=
"text-[9px] font-bold text-primary"
>
ZK
</
span
>
<
div
className=
"text-sm font-semibold text-foreground"
>
{
Number
(
balance
?.
balance
??
0
).
toLocaleString
()
}
</
div
>
</>
)
:
(
<>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Credits
</
div
>
<
div
className=
"mt-1 text-2xl font-semibold text-foreground"
>
{
Number
(
balance
?.
balance
??
0
).
toLocaleString
()
}
</
div
>
</
div
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-white shadow-[0_16px_28px_rgba(15,116,216,0.22)]"
>
<
Sparkles
className=
"h-5 w-5"
/>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Product Name and Version */
}
<
div
className=
"mt-3 text-sm text-muted-foreground"
>
<
div
className=
"flex items-baseline gap-1"
>
当前团队范围:
{
currentGroupName
}
<
span
className=
"text-sm font-semibold text-foreground"
>
DramaStudio
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
V1
</
span
>
</
div
>
</
div
>
</
Link
>
<
button
type=
"button"
onClick=
{
()
=>
navigate
(
"/usage"
)
}
className=
"mt-4 inline-flex w-full items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
查看用量与采购
</
button
>
</>
)
}
)
}
</
div
>
</
div
>
</
aside
>
{
/* Search Bar */
}
<
div
className=
"flex min-w-0 flex-1 flex-col"
>
<
div
className=
"flex-1 max-w-md"
>
<
header
className=
"sticky top-0 z-30 border-b border-white/70 bg-white/72 px-6 py-4 backdrop-blur-xl"
>
<
div
className=
"relative"
>
<
div
className=
"flex items-center gap-4"
>
<
Search
className=
"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<
div
className=
"flex min-w-0 flex-1 items-center gap-4"
>
{
sidebarCollapsed
?
<
BrandLogo
compact
subtitle=
{
undefined
}
/>
:
null
}
<
div
className=
"relative max-w-xl flex-1"
>
<
Search
className=
"pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
input
<
input
type=
"text"
type=
"text"
placeholder=
"搜索项目或文档"
placeholder=
"搜索项目
、资产、成员
或文档"
className=
"
w-full pl-9 pr-4 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20
"
className=
"
h-12 w-full rounded-2xl border border-white/80 bg-white/80 pl-11 pr-4 text-sm text-foreground shadow-sm outline-none ring-0 transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]
"
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Right Section */
}
<
div
className=
"hidden items-center gap-2 lg:flex"
>
<
div
className=
"flex items-center gap-4"
>
<
button
className=
"rounded-full border border-border/80 bg-white/80 px-4 py-2 text-sm text-muted-foreground shadow-sm transition hover:text-foreground"
>
{
/* Top Nav Menu */
}
操作指南
<
nav
className=
"flex items-center gap-1 text-sm text-muted-foreground"
>
</
button
>
<
button
className=
"px-3 py-1.5 hover:text-foreground transition-colors"
>
工具
</
button
>
<
button
className=
"rounded-full border border-border/80 bg-white/80 px-4 py-2 text-sm text-muted-foreground shadow-sm transition hover:text-foreground"
>
<
button
className=
"px-3 py-1.5 hover:text-foreground transition-colors"
>
费用
</
button
>
平台文档
<
button
className=
"px-3 py-1.5 hover:text-foreground transition-colors"
>
支持
</
button
>
</
button
>
<
button
className=
"px-3 py-1.5 hover:text-foreground transition-colors"
>
文档
</
button
>
</
div
>
</
nav
>
<
button
className=
"hidden h-11 items-center gap-2 rounded-full border border-border/80 bg-white/80 px-4 text-sm text-muted-foreground shadow-sm md:inline-flex"
>
{
/* Notifications */
}
<
Globe
className=
"h-4 w-4"
/>
<
div
className=
"relative"
>
中文
</
button
>
<
div
className=
"relative"
ref=
{
notificationRef
}
>
<
button
<
button
onClick=
{
()
=>
setShowNotifications
(
!
showNotifications
)
}
type=
"button"
className=
"relative p-2 hover:bg-muted rounded-lg transition-colors"
onClick=
{
()
=>
setShowNotifications
((
value
)
=>
!
value
)
}
className=
"relative flex h-11 w-11 items-center justify-center rounded-2xl border border-border/80 bg-white/80 text-muted-foreground shadow-sm transition hover:text-primary"
>
>
<
Bell
className=
"
w-4 h-4 text-muted-foreground
"
/>
<
Bell
className=
"
h-4 w-4
"
/>
<
span
className=
"absolute
top-1 right-1 w-2 h-2 bg-destructive rounded-full"
></
span
>
<
span
className=
"absolute
right-3 top-3 h-2 w-2 rounded-full bg-sky-500"
/
>
</
button
>
</
button
>
{
showNotifications
&&
(
{
showNotifications
?
(
<
div
className=
"absolute right-0 top-12 w-80 bg-card border border-border rounded-xl shadow-lg z-50"
>
<
div
className=
"absolute right-0 top-14 w-96 rounded-[28px] border border-white/80 bg-white/92 p-3 shadow-[0_26px_70px_rgba(10,42,76,0.16)] backdrop-blur-xl"
>
<
div
className=
"p-4 border-b border-border"
>
<
div
className=
"mb-2 flex items-center justify-between px-2 py-1"
>
<
h3
className=
"font-medium text-foreground"
>
通知中心
</
h3
>
<
div
>
<
div
className=
"text-sm font-semibold text-foreground"
>
消息中心
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
项目进度、资产更新与系统通知
</
div
>
</
div
>
<
span
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
{
notifications
.
length
}
条
</
span
>
</
div
>
</
div
>
<
div
className=
"
max-h-96 overflow-auto
"
>
<
div
className=
"
space-y-2
"
>
{
notifications
.
map
((
notif
)
=>
(
{
notifications
.
map
((
notif
ication
)
=>
(
<
button
<
button
key=
{
notif
.
id
}
key=
{
notif
ication
.
id
}
className=
"w-full
p-4 text-left hover:bg-muted transition-colors border-b border-border last:border-b-
0"
className=
"w-full
rounded-2xl border border-border/70 bg-white/70 px-4 py-3 text-left transition hover:border-primary/20 hover:bg-accent/6
0"
>
>
<
div
className=
"text-sm
text-foreground mb-1"
>
{
notif
.
title
}
</
div
>
<
div
className=
"text-sm
leading-6 text-foreground"
>
{
notification
.
title
}
</
div
>
<
div
className=
"
text-xs text-muted-foreground"
>
{
notif
.
time
}
</
div
>
<
div
className=
"
mt-1 text-xs text-muted-foreground"
>
{
notification
.
time
}
</
div
>
</
button
>
</
button
>
))
}
))
}
</
div
>
</
div
>
<
div
className=
"p-3 border-t border-border text-center"
>
<
button
className=
"text-sm text-primary hover:underline"
>
查看全部
</
button
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
{
/* Language Switcher */
}
<
button
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
<
Globe
className=
"w-4 h-4 text-muted-foreground"
/>
</
button
>
{
/* User Avatar */
}
<
div
className=
"relative"
ref=
{
userMenuRef
}
>
<
div
className=
"relative"
ref=
{
userMenuRef
}
>
<
button
<
button
onClick=
{
()
=>
setShowUserMenu
(
!
showUserMenu
)
}
type=
"button"
className=
"flex items-center gap-2 px-2 py-1.5 hover:bg-muted rounded-lg transition-colors"
onClick=
{
()
=>
setShowUserMenu
((
value
)
=>
!
value
)
}
className=
"flex items-center gap-3 rounded-2xl border border-border/80 bg-white/80 px-3 py-2 shadow-sm transition hover:border-primary/20"
>
>
<
div
className=
"w-7 h-7 rounded-full bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
<
div
className=
"flex h-10 w-10 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-sm font-semibold text-white shadow-[0_12px_24px_rgba(15,116,216,0.18)]"
>
<
span
className=
"text-[11px] font-bold text-white"
>
{
user
?.
username
?.[
0
]?.
toUpperCase
()
??
"U"
}
{
user
?.
username
?.[
0
]?.
toUpperCase
()
??
"U"
}
</
span
>
</
div
>
</
div
>
<
ChevronDown
className=
"w-3 h-3 text-muted-foreground"
/>
<
div
className=
"hidden text-left md:block"
>
<
div
className=
"max-w-36 truncate text-sm font-medium text-foreground"
>
{
user
?.
username
??
"当前用户"
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
企业创作成员
</
div
>
</
div
>
<
ChevronDown
className=
"h-4 w-4 text-muted-foreground"
/>
</
button
>
</
button
>
{
showUserMenu
&&
(
<
div
className=
"absolute right-0 top-12 w-48 bg-card border border-border rounded-xl shadow-lg z-50"
>
{
showUserMenu
?
(
<
div
className=
"px-4 py-3 border-b border-border"
>
<
div
className=
"absolute right-0 top-14 w-60 rounded-[28px] border border-white/80 bg-white/92 p-3 shadow-[0_24px_60px_rgba(10,42,76,0.14)] backdrop-blur-xl"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"rounded-3xl border border-border/70 bg-white/70 px-4 py-4"
>
<
User
className=
"w-4 h-4 text-muted-foreground"
/>
<
div
className=
"flex items-center gap-3"
>
<
span
className=
"text-sm font-medium text-foreground truncate"
>
{
user
?.
username
}
</
span
>
<
div
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-primary"
>
<
User
className=
"h-5 w-5"
/>
</
div
>
<
div
>
<
div
className=
"text-sm font-semibold text-foreground"
>
{
user
?.
username
??
"当前用户"
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
已加入企业创作空间
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
()
=>
{
clearAuth
();
navigate
(
"/login"
);
}
}
type=
"button"
className=
"w-full flex items-center gap-2 px-4 py-2.5 text-sm text-destructive hover:bg-muted transition-colors rounded-b-xl"
onClick=
{
()
=>
{
clearAuth
();
navigate
(
"/login"
);
}
}
className=
"mt-3 flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm text-destructive transition hover:bg-red-50"
>
>
<
LogOut
className=
"
w-4 h
-4"
/>
<
LogOut
className=
"
h-4 w
-4"
/>
退出登录
退出登录
</
button
>
</
button
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
</
div
>
</
div
>
</
header
>
{
/* Project Navigation */
}
{
isProjectRoute
?
(
{
isProjectRoute
&&
(
<
div
className=
"mt-4 rounded-[30px] border border-white/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.92),rgba(229,241,251,0.82))] p-5 shadow-sm"
>
<
div
className=
"border-b border-border bg-card px-6 py-3"
>
<
div
className=
"flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"
>
<
div
className=
"flex items-center gap-6"
>
<
div
>
{
/* Project Name */
}
<
div
className=
"mb-2 inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
div
className=
"flex items-center gap-2 pr-6 border-r border-border"
>
<
Film
className=
"h-3.5 w-3.5"
/>
<
Film
className=
"w-4 h-4 text-primary"
/>
Project Pipeline
<
span
className=
"font-medium text-foreground"
>
{
currentProject
?.
name
??
projectId
}
</
span
>
</
div
>
</
div
>
<
div
className=
"text-2xl font-semibold text-foreground"
>
{
currentProject
?.
name
??
"当前项目"
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
按照剧本拆解、设定、分镜、视频的标准链路推进企业级短剧生产。
</
div
>
</
div
>
{
/* Section Tabs */
}
<
div
className=
"flex flex-wrap gap-2"
>
<
div
className=
"flex items-center gap-2"
>
{
projectSections
.
map
((
section
)
=>
{
{
projectSections
.
map
((
section
)
=>
{
const
Icon
=
section
.
icon
;
const
Icon
=
section
.
icon
;
const
selected
=
activeSection
===
section
.
id
;
return
(
return
(
<
button
<
button
key=
{
section
.
id
}
key=
{
section
.
id
}
onClick=
{
()
=>
navigate
(
section
.
path
)
}
type=
"button"
className=
{
`flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm transition-all ${
onClick=
{
()
=>
navigate
(
section
.
path
)
}
activeSection === section.id
className=
{
cn
(
? "bg-accent text-accent-foreground font-medium"
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all"
,
: "text-muted-foreground hover:text-foreground hover:bg-muted"
selected
}`
}
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
>
:
"border-border/80 bg-white/78 text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
<
Icon
className=
"w-4 h-4"
/>
)
}
{
section
.
label
}
>
</
button
>
<
Icon
className=
"h-4 w-4"
/>
);
{
section
.
label
}
})
}
</
button
>
);
})
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
:
null
}
)
}
</
header
>
{
/* Page Content */
}
<
main
className=
"min-h-0 flex-1 overflow-auto px-6 py-6"
>
<
main
className=
"flex-1 overflow-auto"
>
<
Outlet
/>
<
Outlet
/>
</
main
>
</
main
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
\ No newline at end of file
doc/html/src/app/pages/AIWatermarkSettings.tsx
View file @
75992d7b
import
{
use
State
,
useEffect
}
from
"react"
;
import
{
use
Effect
,
useState
}
from
"react"
;
import
{
Droplet
,
Upload
,
Eye
,
Save
,
Loader2
}
from
"lucide-react"
;
import
{
Droplet
,
Eye
,
Loader2
,
Save
,
Upload
,
WandSparkles
}
from
"lucide-react"
;
import
{
useS
etting
,
useSav
eSetting
}
from
"../../hooks/useSettings"
;
import
{
useS
aveSetting
,
us
eSetting
}
from
"../../hooks/useSettings"
;
const
SETTING_KEY
=
"watermark"
;
const
settingKey
=
"watermark"
;
interface
WatermarkConfig
{
interface
WatermarkConfig
{
enabled
:
boolean
;
enabled
:
boolean
;
...
@@ -35,7 +35,7 @@ const sizes = [
...
@@ -35,7 +35,7 @@ const sizes = [
];
];
export
function
AIWatermarkSettings
()
{
export
function
AIWatermarkSettings
()
{
const
{
data
:
rawSetting
,
isLoading
}
=
useSetting
(
SETTING_KEY
);
const
{
data
:
rawSetting
,
isLoading
}
=
useSetting
(
settingKey
);
const
saveSetting
=
useSaveSetting
();
const
saveSetting
=
useSaveSetting
();
const
[
config
,
setConfig
]
=
useState
<
WatermarkConfig
>
(
defaultConfig
);
const
[
config
,
setConfig
]
=
useState
<
WatermarkConfig
>
(
defaultConfig
);
...
@@ -43,140 +43,172 @@ export function AIWatermarkSettings() {
...
@@ -43,140 +43,172 @@ export function AIWatermarkSettings() {
const
[
saved
,
setSaved
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
rawSetting
)
{
if
(
!
rawSetting
)
return
;
try
{
try
{
const
parsed
=
JSON
.
parse
(
rawSetting
)
as
WatermarkConfig
;
const
parsed
=
JSON
.
parse
(
rawSetting
)
as
WatermarkConfig
;
setConfig
({
...
defaultConfig
,
...
parsed
});
setConfig
({
...
defaultConfig
,
...
parsed
});
}
catch
{
}
catch
{
// keep default
setConfig
(
defaultConfig
);
}
}
}
},
[
rawSetting
]);
},
[
rawSetting
]);
const
handleSave
=
async
()
=>
{
const
handleSave
=
async
()
=>
{
await
saveSetting
.
mutateAsync
({
key
:
SETTING_KEY
,
value
:
JSON
.
stringify
(
config
)
});
await
saveSetting
.
mutateAsync
({
key
:
settingKey
,
value
:
JSON
.
stringify
(
config
)
});
setSaved
(
true
);
setSaved
(
true
);
setTimeout
(()
=>
setSaved
(
false
),
2000
);
window
.
setTimeout
(()
=>
setSaved
(
false
),
2000
);
};
};
if
(
isLoading
)
{
if
(
isLoading
)
{
return
(
return
(
<
div
className=
"
h-full flex
items-center justify-center"
>
<
div
className=
"
flex h-full
items-center justify-center"
>
<
Loader2
className=
"
w-6 h-6
animate-spin text-primary"
/>
<
Loader2
className=
"
h-8 w-8
animate-spin text-primary"
/>
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"mx-auto max-w-[1380px]"
>
<
div
className=
"max-w-5xl mx-auto"
>
<
section
className=
"grid gap-6 xl:grid-cols-[1.02fr_0.98fr]"
>
<
div
className=
"mb-6"
>
<
div
className=
"relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]"
>
<
h1
className=
"text-2xl font-semibold text-foreground mb-1"
>
AI生成水印设置
</
h1
>
<
div
className=
"absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]"
/>
<
p
className=
"text-sm text-muted-foreground"
>
配置AI生成内容的默认水印样式
</
p
>
<
div
className=
"absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14"
/>
<
div
className=
"absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10"
/>
<
div
className=
"relative"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74"
>
<
Droplet
className=
"h-4 w-4"
/>
Watermark Policy
</
div
>
<
h1
className=
"mt-5 max-w-2xl text-4xl font-semibold leading-tight"
>
配置 AI 生成内容的默认水印策略
</
h1
>
<
p
className=
"mt-4 max-w-2xl text-base leading-7 text-white/76"
>
统一控制默认水印开关、文字内容、位置、透明度和尺寸,帮助团队输出具备明确标识的 AI 生成内容。
</
p
>
<
div
className=
"mt-8 grid gap-4 sm:grid-cols-3"
>
{
[
{
title
:
"统一策略"
,
desc
:
"对 AI 生成内容保持一致的品牌和合规标识。"
},
{
title
:
"灵活预览"
,
desc
:
"直接在右侧面板中查看水印覆盖效果。"
},
{
title
:
"快速落地"
,
desc
:
"修改后立即保存为团队默认配置。"
},
].
map
((
item
,
index
)
=>
(
<
div
key=
{
item
.
title
}
className=
"rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<
div
className=
"mb-3 text-xs uppercase tracking-[0.24em] text-white/64"
>
0
{
index
+
1
}
</
div
>
<
div
className=
"text-lg font-medium"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-2 text-sm leading-6 text-white/74"
>
{
item
.
desc
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"grid grid-cols-3 gap-6"
>
<
div
className=
"rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"col-span-2 space-y-6"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
{
/* Enable/Disable */
}
<
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Watermark Config
</
div
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
h2
className=
"mt-2 text-3xl font-semibold text-foreground"
>
默认配置
</
h2
>
<
p
className=
"mt-3 text-sm leading-6 text-muted-foreground"
>
保存后会作为 AI 生成内容的默认水印策略。
</
p
>
</
div
>
<
div
className=
"flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary"
>
<
WandSparkles
className=
"h-6 w-6"
/>
</
div
>
</
div
>
<
div
className=
"space-y-5"
>
<
div
className=
"rounded-[28px] border border-border/80 bg-white/70 p-5 shadow-sm"
>
<
div
className=
"flex items-center justify-between gap-4"
>
<
div
>
<
div
>
<
h3
className=
"font-medium text-foreground mb-1"
>
启用水印
</
h3
>
<
div
className=
"text-base font-medium text-foreground"
>
启用水印
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
为所有AI生成的内容添加水印
</
p
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
为所有 AI 生成内容叠加默认水印标识。
</
div
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
()
=>
setConfig
({
...
config
,
enabled
:
!
config
.
enabled
})
}
onClick=
{
()
=>
setConfig
((
current
)
=>
({
...
current
,
enabled
:
!
current
.
enabled
}))
}
className=
{
`relative w-12 h-6 rounded-full transition-colors ${
className=
{
[
config.enabled ? "bg-primary" : "bg-muted"
"relative h-7 w-14 rounded-full transition-colors"
,
}`
}
config
.
enabled
?
"bg-primary"
:
"bg-muted"
,
].
join
(
" "
)
}
>
>
<
div
<
div
className=
{
`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
className=
{
[
config.enabled ? "translate-x-7" : "translate-x-1"
"absolute top-1 h-5 w-5 rounded-full bg-white transition-transform"
,
}`
}
config
.
enabled
?
"translate-x-8"
:
"translate-x-1"
,
].
join
(
" "
)
}
/>
/>
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Watermark Text */
}
<
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
水印文字
</
label
>
<
label
className=
"block font-medium text-foreground mb-3"
>
水印文字
</
label
>
<
input
<
input
type=
"text"
value=
{
config
.
text
}
value=
{
config
.
text
}
onChange=
{
(
e
)
=>
setConfig
({
...
config
,
text
:
e
.
target
.
value
})
}
onChange=
{
(
e
)
=>
setConfig
((
current
)
=>
({
...
current
,
text
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"输入水印文字"
placeholder=
"输入水印文字"
className=
"w-full px-4 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
/>
</
div
>
</
div
>
{
/* Custom Logo */
}
<
div
className=
"rounded-[28px] border border-border/80 bg-white/70 p-5 shadow-sm"
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
div
className=
"mb-3 text-sm font-medium text-foreground"
>
自定义 Logo
</
div
>
<
label
className=
"block font-medium text-foreground mb-3"
>
自定义水印Logo
</
label
>
<
label
className=
"flex cursor-pointer items-center justify-between rounded-2xl border border-dashed border-border px-4 py-4 transition hover:border-primary/20"
>
<
div
className=
"border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer"
>
<
div
>
<
div
className=
"text-sm text-foreground"
>
{
customLogo
?
customLogo
.
name
:
"点击上传 Logo 图片"
}
</
div
>
<
div
className=
"mt-1 text-xs text-muted-foreground"
>
支持 PNG、JPG,建议尺寸 200x200
</
div
>
</
div
>
<
div
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
<
Upload
className=
"h-5 w-5"
/>
</
div
>
<
input
<
input
type=
"file"
type=
"file"
accept=
"image/*"
accept=
"image/*"
onChange=
{
(
e
)
=>
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
[
0
])
setCustomLogo
(
e
.
target
.
files
[
0
]);
}
}
className=
"hidden"
className=
"hidden"
id=
"logo-upload"
onChange=
{
(
e
)
=>
setCustomLogo
(
e
.
target
.
files
?.[
0
]
??
null
)
}
/>
/>
<
label
htmlFor=
"logo-upload"
className=
"cursor-pointer"
>
</
label
>
{
customLogo
?
(
<
div
>
<
div
className=
"w-16 h-16 rounded-lg bg-muted mx-auto mb-3 flex items-center justify-center"
>
<
Droplet
className=
"w-8 h-8 text-primary"
/>
</
div
>
<
p
className=
"text-foreground mb-1"
>
{
customLogo
.
name
}
</
p
>
<
span
className=
"text-sm text-primary hover:underline"
>
重新上传
</
span
>
</
div
>
)
:
(
<
div
>
<
Upload
className=
"w-12 h-12 text-muted-foreground mx-auto mb-3"
/>
<
p
className=
"text-foreground mb-1"
>
点击上传Logo图片
</
p
>
<
p
className=
"text-sm text-muted-foreground"
>
支持 PNG、JPG 格式,建议尺寸 200x200
</
p
>
</
div
>
)
}
</
label
>
</
div
>
</
div
>
</
div
>
{
/* Position */
}
<
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
水印位置
</
label
>
<
label
className=
"block font-medium text-foreground mb-3"
>
水印位置
</
label
>
<
div
className=
"grid gap-3 sm:grid-cols-3"
>
<
div
className=
"grid grid-cols-3 gap-3"
>
{
positions
.
map
((
position
)
=>
(
{
positions
.
map
((
pos
)
=>
(
<
button
<
button
key=
{
pos
.
value
}
key=
{
position
.
value
}
onClick=
{
()
=>
setConfig
({
...
config
,
position
:
pos
.
value
})
}
onClick=
{
()
=>
setConfig
((
current
)
=>
({
...
current
,
position
:
position
.
value
}))
}
className=
{
`px-4 py-3 rounded-lg border transition-all text-sm ${
className=
{
[
config.position === pos.value
"rounded-2xl border px-4 py-3 text-sm transition-all"
,
? "border-primary bg-accent text-primary"
config
.
position
===
position
.
value
: "border-border bg-background text-foreground hover:bg-muted"
?
"border-primary/20 bg-primary/5 text-primary"
}`
}
:
"border-border/80 bg-white text-foreground hover:border-primary/20"
,
].
join
(
" "
)
}
>
>
{
pos
.
label
}
{
pos
ition
.
label
}
</
button
>
</
button
>
))
}
))
}
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Size */
}
<
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
水印大小
</
label
>
<
label
className=
"block font-medium text-foreground mb-3"
>
水印大小
</
label
>
<
div
className=
"grid gap-3 sm:grid-cols-3"
>
<
div
className=
"grid grid-cols-3 gap-3"
>
{
sizes
.
map
((
size
)
=>
(
{
sizes
.
map
((
size
)
=>
(
<
button
<
button
key=
{
size
.
value
}
key=
{
size
.
value
}
onClick=
{
()
=>
setConfig
({
...
config
,
size
:
size
.
value
})
}
onClick=
{
()
=>
setConfig
((
current
)
=>
({
...
current
,
size
:
size
.
value
}))
}
className=
{
`px-4 py-3 rounded-lg border transition-all text-sm ${
className=
{
[
"rounded-2xl border px-4 py-3 text-sm transition-all"
,
config
.
size
===
size
.
value
config
.
size
===
size
.
value
? "border-primary
bg-accent
text-primary"
?
"border-primary
/20 bg-primary/5
text-primary"
: "border-border
bg-background text-foreground hover:bg-muted"
:
"border-border
/80 bg-white text-foreground hover:border-primary/20"
,
}`
}
].
join
(
" "
)
}
>
>
{
size
.
label
}
{
size
.
label
}
</
button
>
</
button
>
...
@@ -184,106 +216,89 @@ export function AIWatermarkSettings() {
...
@@ -184,106 +216,89 @@ export function AIWatermarkSettings() {
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Opacity */
}
<
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
透明度:
{
config
.
opacity
}
%
</
label
>
<
label
className=
"block font-medium text-foreground mb-3"
>
透明度:
{
config
.
opacity
}
%
</
label
>
<
input
<
input
type=
"range"
type=
"range"
min=
"0"
min=
"0"
max=
"100"
max=
"100"
value=
{
config
.
opacity
}
value=
{
config
.
opacity
}
onChange=
{
(
e
)
=>
setConfig
(
{
...
config
,
opacity
:
Number
(
e
.
target
.
value
)
}
)
}
onChange=
{
(
e
)
=>
setConfig
(
(
current
)
=>
({
...
current
,
opacity
:
Number
(
e
.
target
.
value
)
})
)
}
className=
"
w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer
accent-primary"
className=
"
h-2 w-full cursor-pointer appearance-none rounded-lg bg-muted
accent-primary"
/>
/>
<
div
className=
"flex justify-between text-xs text-muted-foreground mt-2"
>
<
span
>
0%
</
span
>
<
span
>
50%
</
span
>
<
span
>
100%
</
span
>
</
div
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
handleSave
}
onClick=
{
handleSave
}
disabled=
{
saveSetting
.
isPending
}
disabled=
{
saveSetting
.
isPending
}
className=
"
w-full py-3 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-7
0"
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-6
0"
>
>
{
saveSetting
.
isPending
?
(
{
saveSetting
.
isPending
?
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
:
<
Save
className=
"h-5 w-5"
/>
}
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
)
:
(
<
Save
className=
"w-4 h-4"
/>
)
}
{
saved
?
"已保存"
:
"保存设置"
}
{
saved
?
"已保存"
:
"保存设置"
}
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
section
>
{
/* Preview Panel */
}
<
section
className=
"mt-6 rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"col-span-1"
>
<
div
className=
"mb-5 flex items-center gap-2"
>
<
div
className=
"rounded-xl border border-border bg-card p-5 sticky top-6"
>
<
Eye
className=
"h-5 w-5 text-primary"
/>
<
div
className=
"flex items-center gap-2 mb-4"
>
<
h3
className=
"text-2xl font-semibold text-foreground"
>
预览效果
</
h3
>
<
Eye
className=
"w-4 h-4 text-primary"
/>
</
div
>
<
h3
className=
"font-medium text-foreground"
>
预览效果
</
h3
>
</
div
>
<
div
className=
"aspect-video rounded-lg bg-gradient-to-br from-purple-100 to-blue-100 relative overflow-hidden"
>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
<
div
className=
"text-center"
>
<
Droplet
className=
"w-16 h-16 text-muted-foreground/30 mx-auto mb-2"
/>
<
p
className=
"text-sm text-muted-foreground/50"
>
示例内容
</
p
>
</
div
>
</
div
>
{
config
.
enabled
&&
(
<
div
className=
"grid gap-5 xl:grid-cols-[1fr_320px]"
>
<
div
<
div
className=
"relative aspect-video overflow-hidden rounded-[28px] bg-[linear-gradient(145deg,#dbeafe_0%,#eff6ff_48%,#bfdbfe_100%)]"
>
className=
{
`absolute ${
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
config.position === "top-left" ? "top-3 left-3"
<
div
className=
"text-center"
>
: config.position === "top-right" ? "top-3 right-3"
<
Droplet
className=
"mx-auto h-16 w-16 text-sky-900/20"
/>
: config.position === "bottom-left" ? "bottom-3 left-3"
<
div
className=
"mt-3 text-sm text-sky-900/40"
>
示例内容预览区
</
div
>
: config.position === "bottom-right" ? "bottom-3 right-3"
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
}`
}
style=
{
{
opacity
:
config
.
opacity
/
100
}
}
>
<
div
className=
{
`px-3 py-1.5 rounded bg-black/20 backdrop-blur-sm text-white ${
config.size === "small" ? "text-xs"
: config.size === "large" ? "text-base"
: "text-sm"
}`
}
>
{
config
.
text
}
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
<
div
className=
"mt-4 p-3 rounded-lg bg-muted space-y-2 text-sm"
>
{
config
.
enabled
?
(
<
div
className=
"flex justify-between"
>
<
div
<
span
className=
"text-muted-foreground"
>
状态:
</
span
>
className=
{
[
<
span
className=
"text-foreground"
>
{
config
.
enabled
?
"已启用"
:
"已禁用"
}
</
span
>
"absolute"
,
</
div
>
config
.
position
===
"top-left"
<
div
className=
"flex justify-between"
>
?
"left-5 top-5"
<
span
className=
"text-muted-foreground"
>
位置:
</
span
>
:
config
.
position
===
"top-right"
<
span
className=
"text-foreground"
>
?
"right-5 top-5"
{
positions
.
find
((
p
)
=>
p
.
value
===
config
.
position
)?.
label
}
:
config
.
position
===
"bottom-left"
</
span
>
?
"bottom-5 left-5"
</
div
>
:
config
.
position
===
"center"
<
div
className=
"flex justify-between"
>
?
"left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
<
span
className=
"text-muted-foreground"
>
大小:
</
span
>
:
"bottom-5 right-5"
,
<
span
className=
"text-foreground"
>
].
join
(
" "
)
}
{
sizes
.
find
((
s
)
=>
s
.
value
===
config
.
size
)?.
label
}
style=
{
{
opacity
:
config
.
opacity
/
100
}
}
</
span
>
>
</
div
>
<
div
<
div
className=
"flex justify-between"
>
className=
{
[
<
span
className=
"text-muted-foreground"
>
透明度:
</
span
>
"rounded-2xl bg-slate-950/24 px-4 py-2 text-white backdrop-blur-sm"
,
<
span
className=
"text-foreground"
>
{
config
.
opacity
}
%
</
span
>
config
.
size
===
"small"
?
"text-xs"
:
config
.
size
===
"large"
?
"text-base"
:
"text-sm"
,
].
join
(
" "
)
}
>
{
config
.
text
}
</
div
>
</
div
>
</
div
>
</
div
>
)
:
null
}
</
div
>
<
div
className=
"rounded-[28px] border border-border/80 bg-white/78 p-5 shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Preview Summary
</
div
>
<
div
className=
"mt-4 space-y-3 text-sm text-muted-foreground"
>
<
div
>
状态:
{
config
.
enabled
?
"启用"
:
"关闭"
}
</
div
>
<
div
>
位置:
{
positions
.
find
((
item
)
=>
item
.
value
===
config
.
position
)?.
label
??
config
.
position
}
</
div
>
<
div
>
大小:
{
sizes
.
find
((
item
)
=>
item
.
value
===
config
.
size
)?.
label
??
config
.
size
}
</
div
>
<
div
>
透明度:
{
config
.
opacity
}
%
</
div
>
<
div
>
Logo:
{
customLogo
?
"已上传"
:
"未上传"
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/AssetsManagement.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
useParams
,
useNavigate
}
from
"react-router"
;
import
{
import
{
Users
as
UsersIcon
,
MapPin
,
Box
,
Box
,
Heart
,
Image
as
ImageIcon
,
Loader2
,
MapPin
,
Plus
,
Plus
,
Search
,
Search
,
S
tar
,
S
parkles
,
Trash2
,
Trash2
,
Filter
,
Users
as
UsersIcon
,
User
,
X
,
Loader2
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
useGlobalAssets
,
useCreateAsset
,
useToggleFavorite
,
useDeleteAsset
}
from
"../../hooks/useAssets"
;
import
{
useCreateAsset
,
useDeleteAsset
,
useGlobalAssets
}
from
"../../hooks/useAssets"
;
import
type
{
GlobalAsset
}
from
"../../lib/api/assets"
;
type
AssetTypeStr
=
"character"
|
"scene"
|
"prop"
;
type
AssetType
=
"character"
|
"scene"
|
"prop"
;
type
LibraryType
=
"personal"
|
"team"
;
const
assetType
s
=
[
const
assetType
Meta
:
Record
<
AssetType
,
{
label
:
string
;
icon
:
typeof
UsersIcon
}
>
=
{
{
id
:
"character"
as
AssetTypeStr
,
label
:
"角色库
"
,
icon
:
UsersIcon
},
character
:
{
label
:
"角色资产
"
,
icon
:
UsersIcon
},
{
id
:
"scene"
as
AssetTypeStr
,
label
:
"场景库
"
,
icon
:
MapPin
},
scene
:
{
label
:
"场景资产
"
,
icon
:
MapPin
},
{
id
:
"prop"
as
AssetTypeStr
,
label
:
"道具库
"
,
icon
:
Box
},
prop
:
{
label
:
"道具资产
"
,
icon
:
Box
},
]
;
}
;
export
function
AssetsManagement
()
{
export
function
AssetsManagement
()
{
const
navigate
=
useNavigate
();
const
[
activeType
,
setActiveType
]
=
useState
<
AssetType
>
(
"character"
);
const
params
=
useParams
();
const
activeType
=
((
params
[
"*"
]?.
split
(
"/"
)[
0
])
||
"character"
)
as
AssetTypeStr
;
const
[
libraryType
,
setLibraryType
]
=
useState
<
LibraryType
>
(
"personal"
);
const
[
searchTerm
,
setSearchTerm
]
=
useState
(
""
);
const
[
searchTerm
,
setSearchTerm
]
=
useState
(
""
);
const
[
selectedTags
,
setSelectedTags
]
=
useState
<
string
[]
>
([]);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
showCreateModal
,
setShowCreateModal
]
=
useState
(
false
);
const
[
form
,
setForm
]
=
useState
({
name
:
""
,
// create form
tags
:
""
,
const
[
formName
,
setFormName
]
=
useState
(
""
);
description
:
""
,
const
[
formDesc
,
setFormDesc
]
=
useState
(
""
);
imageUrl
:
""
,
const
[
formTags
,
setFormTags
]
=
useState
(
""
);
const
[
formLibrary
,
setFormLibrary
]
=
useState
<
LibraryType
>
(
"personal"
);
const
{
data
:
assets
=
[],
isLoading
}
=
useGlobalAssets
(
activeType
,
libraryType
);
const
createAsset
=
useCreateAsset
(
activeType
,
libraryType
);
const
toggleFavorite
=
useToggleFavorite
(
activeType
,
libraryType
);
const
deleteAsset
=
useDeleteAsset
(
activeType
,
libraryType
);
const
filteredAssets
=
assets
.
filter
((
a
)
=>
{
if
(
searchTerm
&&
!
a
.
name
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
()))
return
false
;
if
(
selectedTags
.
length
>
0
&&
!
selectedTags
.
some
((
t
)
=>
a
.
tags
.
includes
(
t
)))
return
false
;
return
true
;
});
});
const
allTags
=
Array
.
from
(
new
Set
(
assets
.
flatMap
((
a
)
=>
a
.
tags
)));
const
{
data
:
assets
=
[],
isLoading
}
=
useGlobalAssets
(
activeType
,
"personal"
);
const
createAsset
=
useCreateAsset
(
activeType
,
"personal"
);
const
deleteAsset
=
useDeleteAsset
(
activeType
,
"personal"
);
const
toggleTag
=
(
tag
:
string
)
=>
const
filteredAssets
=
useMemo
(()
=>
{
setSelectedTags
((
prev
)
=>
(
prev
.
includes
(
tag
)
?
prev
.
filter
((
t
)
=>
t
!==
tag
)
:
[...
prev
,
tag
]));
return
assets
.
filter
((
asset
)
=>
{
if
(
!
searchTerm
)
return
true
;
return
asset
.
name
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
());
});
},
[
assets
,
searchTerm
]);
const
ActiveIcon
=
assetTypeMeta
[
activeType
].
icon
;
const
handleCreate
=
async
()
=>
{
const
handleCreate
=
async
()
=>
{
if
(
!
form
.
name
.
trim
())
return
;
await
createAsset
.
mutateAsync
({
await
createAsset
.
mutateAsync
({
name
:
form
N
ame
,
name
:
form
.
n
ame
,
assetType
:
activeType
,
assetType
:
activeType
,
libraryType
:
formLibrary
,
libraryType
:
"personal"
,
tags
:
formTags
,
tags
:
form
.
tags
,
description
:
formDesc
,
description
:
form
.
description
,
imageUrl
:
form
.
imageUrl
||
undefined
,
});
});
setFormName
(
""
);
setFormDesc
(
""
);
setFormTags
(
""
);
setFormLibrary
(
"personal"
);
setShowModal
(
false
);
setShowCreateModal
(
false
);
setForm
({
name
:
""
,
tags
:
""
,
description
:
""
,
imageUrl
:
""
});
};
const
handleDelete
=
async
(
asset
:
GlobalAsset
)
=>
{
if
(
!
confirm
(
`确定要删除资产“
${
asset
.
name
}
”吗?`
))
return
;
await
deleteAsset
.
mutateAsync
(
asset
.
id
);
};
};
return
(
return
(
<
div
className=
"h-full flex flex-col bg-background"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
{
/* Sub Navigation */
}
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"border-b border-border bg-card px-6 py-4"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
div
className=
"flex items-center gap-4 mb-4"
>
<
div
>
<
div
className=
"flex items-center gap-2 p-1 rounded-lg bg-muted"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
button
<
Sparkles
className=
"h-3.5 w-3.5"
/>
onClick=
{
()
=>
setLibraryType
(
"personal"
)
}
Asset Center
className=
{
`px-4 py-1.5 rounded-md text-sm transition-colors ${libraryType === "personal" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`
}
</
div
>
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
资产中心
</
h1
>
<
div
className=
"flex items-center gap-2"
><
User
className=
"w-3 h-3"
/>
个人库
</
div
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
</
button
>
统一沉淀角色、场景和道具资产,让项目流程中的引用与复用都来自同一套企业资产中心。
<
button
</
p
>
onClick=
{
()
=>
setLibraryType
(
"team"
)
}
className=
{
`px-4 py-1.5 rounded-md text-sm transition-colors ${libraryType === "team" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`
}
>
<
div
className=
"flex items-center gap-2"
><
UsersIcon
className=
"w-3 h-3"
/>
团队库
</
div
>
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
true
)
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<
Plus
className=
"h-4 w-4"
/>
新建资产
</
button
>
</
div
>
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
{
(
Object
.
keys
(
assetTypeMeta
)
as
AssetType
[]).
map
((
type
)
=>
{
const
Icon
=
assetTypeMeta
[
type
].
icon
;
const
active
=
activeType
===
type
;
return
(
<
button
key=
{
type
}
onClick=
{
()
=>
setActiveType
(
type
)
}
className=
{
[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all"
,
active
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
].
join
(
" "
)
}
>
<
Icon
className=
"h-4 w-4"
/>
{
assetTypeMeta
[
type
].
label
}
</
button
>
);
})
}
</
div
>
</
div
>
<
div
className=
"
flex items-center gap-1 mb-4
"
>
<
div
className=
"
mt-6 grid gap-4 md:grid-cols-3
"
>
{
assetTypes
.
map
((
type
)
=>
(
<
div
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<
button
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
当前分类
</
div
>
key=
{
type
.
id
}
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
assetTypeMeta
[
activeType
].
label
}
</
div
>
onClick=
{
()
=>
navigate
(
`/assets/${type.id}`
)
}
</
div
>
className=
{
`px-4 py-2 rounded-lg text-sm flex items-center gap-2 transition-all ${
<
div
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
activeType === type.id
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
资产总数
</
div
>
? "bg-accent text-accent-foreground font-medium"
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
assets
.
length
}
</
div
>
: "text-muted-foreground hover:text-foreground hover:bg-muted"
</
div
>
}`
}
<
div
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
含图片
</
div
>
<
type
.
icon
className=
"w-4 h-4"
/
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
type
.
label
}
{
assets
.
filter
((
asset
)
=>
!!
asset
.
imageUrl
).
length
}
</
button
>
</
div
>
))
}
</
div
>
</
div
>
</
div
>
<
div
className=
"
flex items-center
gap-4"
>
<
div
className=
"
mt-6 flex flex-wrap items-center justify-between
gap-4"
>
<
div
className=
"
flex-1 max-w-md relative
"
>
<
div
className=
"
relative w-full max-w-md
"
>
<
Search
className=
"
absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4
text-muted-foreground"
/>
<
Search
className=
"
pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2
text-muted-foreground"
/>
<
input
<
input
type=
"text"
value=
{
searchTerm
}
value=
{
searchTerm
}
onChange=
{
(
e
)
=>
setSearchTerm
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setSearchTerm
(
e
.
target
.
value
)
}
placeholder=
"搜索资源名称...
"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]
"
className=
"w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20
"
placeholder=
"搜索资产名称
"
/>
/>
</
div
>
</
div
>
<
button
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm text-primary"
>
onClick=
{
()
=>
setShowCreateModal
(
true
)
}
<
ActiveIcon
className=
"h-4 w-4"
/>
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
{
filteredAssets
.
length
}
项
>
</
div
>
<
Plus
className=
"w-4 h-4"
/>
新建
{
assetTypes
.
find
((
t
)
=>
t
.
id
===
activeType
)?.
label
.
replace
(
"库"
,
""
)
}
</
button
>
</
div
>
</
div
>
</
div
>
{
/* Content */
}
{
isLoading
?
(
<
div
className=
"flex-1 overflow-auto p-6"
>
<
div
className=
"flex items-center justify-center py-24"
>
<
div
className=
"max-w-7xl mx-auto"
>
<
Loader2
className=
"h-8 w-8 animate-spin text-primary"
/>
{
allTags
.
length
>
0
&&
(
</
div
>
<
div
className=
"mb-6 p-4 rounded-xl border border-border bg-card"
>
)
:
filteredAssets
.
length
===
0
?
(
<
div
className=
"flex items-center gap-2 mb-3"
>
<
div
className=
"mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"
>
<
Filter
className=
"w-4 h-4 text-muted-foreground"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
span
className=
"text-sm font-medium text-foreground"
>
标签筛选
</
span
>
<
ActiveIcon
className=
"h-7 w-7"
/>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
{
allTags
.
map
((
tag
)
=>
(
<
button
key=
{
tag
}
onClick=
{
()
=>
toggleTag
(
tag
)
}
className=
{
`px-3 py-1.5 rounded-lg text-xs transition-colors ${selectedTags.includes(tag) ? "bg-primary text-white" : "bg-muted text-foreground hover:bg-muted-foreground/10"}`
}
>
{
tag
}
</
button
>
))
}
{
selectedTags
.
length
>
0
&&
(
<
button
onClick=
{
()
=>
setSelectedTags
([])
}
className=
"px-3 py-1.5 rounded-lg text-xs text-destructive hover:bg-destructive/10"
>
清除
</
button
>
)
}
</
div
>
</
div
>
</
div
>
)
}
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有可展示的资产
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
{
isLoading
&&
(
当前分类下暂时没有资产,或筛选条件没有匹配结果。你可以直接创建一个新资产开始沉淀。
<
div
className=
"text-center py-16"
><
Loader2
className=
"w-8 h-8 animate-spin text-muted-foreground mx-auto"
/></
div
>
</
p
>
)
}
</
div
>
)
:
(
<
div
className=
"mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4"
>
{
filteredAssets
.
map
((
asset
)
=>
(
<
div
key=
{
asset
.
id
}
className=
"overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<
div
className=
"aspect-[4/5] bg-muted"
>
{
asset
.
imageUrl
?
(
<
img
src=
{
asset
.
imageUrl
}
alt=
{
asset
.
name
}
className=
"h-full w-full object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center"
>
<
ImageIcon
className=
"h-10 w-10 text-muted-foreground"
/>
</
div
>
)
}
</
div
>
{
!
isLoading
&&
filteredAssets
.
length
>
0
?
(
<
div
className=
"p-5"
>
<
div
className=
{
`grid gap-4 ${activeType === "character" ? "grid-cols-5" : "grid-cols-4"}`
}
>
<
div
className=
"flex items-start justify-between gap-3"
>
{
filteredAssets
.
map
((
asset
)
=>
(
<
div
>
<
div
key=
{
asset
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all group"
>
<
div
className=
"text-lg font-semibold text-foreground"
>
{
asset
.
name
}
</
div
>
<
div
className=
{
`${activeType === "character" ? "aspect-[3/4]" : "aspect-video"} bg-muted relative overflow-hidden`
}
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
asset
.
imageUrl
?
(
{
asset
.
tags
.
join
(
" / "
)
||
"未分类"
}
<
img
src=
{
asset
.
imageUrl
}
alt=
{
asset
.
name
}
className=
"w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
)
:
(
<
div
className=
"w-full h-full flex items-center justify-center text-muted-foreground"
>
{
activeType
===
"character"
?
<
UsersIcon
className=
"w-12 h-12"
/>
:
activeType
===
"scene"
?
<
MapPin
className=
"w-12 h-12"
/>
:
<
Box
className=
"w-12 h-12"
/>
}
</
div
>
</
div
>
)
}
</
div
>
<
button
<
span
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
onClick=
{
()
=>
toggleFavorite
.
mutate
(
asset
.
id
)
}
{
assetTypeMeta
[
asset
.
assetType
].
label
}
className=
"absolute top-2 right-2 w-7 h-7 rounded-full bg-card/80 backdrop-blur-sm flex items-center justify-center hover:bg-card transition-colors"
</
span
>
>
<
Star
className=
{
`w-4 h-4 ${asset.favorite ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"}`
}
/>
</
button
>
</
div
>
</
div
>
<
div
className=
"p-3"
>
<
p
className=
"mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground"
>
<
h3
className=
"font-medium text-foreground mb-2 line-clamp-1"
>
{
asset
.
name
}
</
h3
>
{
asset
.
description
||
"待补充资产描述。"
}
<
div
className=
"flex flex-wrap gap-1 mb-3"
>
</
p
>
{
asset
.
tags
.
slice
(
0
,
2
).
map
((
tag
)
=>
(
<
span
key=
{
tag
}
className=
"px-2 py-0.5 rounded bg-muted text-xs text-foreground"
>
{
tag
}
</
span
>
<
div
className=
"mt-5 flex items-center justify-between gap-3"
>
))
}
<
div
className=
"inline-flex items-center gap-2 text-sm text-muted-foreground"
>
{
asset
.
tags
.
length
>
2
&&
(
<
Heart
className=
{
asset
.
favorite
?
"h-4 w-4 text-red-500"
:
"h-4 w-4"
}
/>
<
span
className=
"px-2 py-0.5 rounded bg-muted text-xs text-muted-foreground"
>
+
{
asset
.
tags
.
length
-
2
}
</
span
>
{
asset
.
favorite
?
"已收藏"
:
"未收藏"
}
)
}
</
div
>
{
asset
.
description
&&
<
div
className=
"text-xs text-muted-foreground mb-3 line-clamp-1"
>
{
asset
.
description
}
</
div
>
}
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
deleteAsset
.
mutate
(
asset
.
id
)
}
className=
"flex-1 px-3 py-1.5 rounded-lg border border-border bg-background text-destructive hover:bg-destructive/10 transition-colors text-xs flex items-center justify-center gap-1"
>
<
Trash2
className=
"w-3 h-3"
/>
删除
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
handleDelete
(
asset
)
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<
Trash2
className=
"h-4 w-4"
/>
删除
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
))
}
</
div
>
</
div
>
))
}
)
:
!
isLoading
&&
(
</
div
>
<
div
className=
"text-center py-16"
>
)
}
{
activeType
===
"character"
&&
<
UsersIcon
className=
"w-16 h-16 text-muted-foreground mx-auto mb-4"
/>
}
</
section
>
{
activeType
===
"scene"
&&
<
MapPin
className=
"w-16 h-16 text-muted-foreground mx-auto mb-4"
/>
}
{
activeType
===
"prop"
&&
<
Box
className=
"w-16 h-16 text-muted-foreground mx-auto mb-4"
/>
}
<
p
className=
"text-muted-foreground mb-2"
>
暂无
{
assetTypes
.
find
((
t
)
=>
t
.
id
===
activeType
)?.
label
.
replace
(
"库"
,
""
)
}
</
p
>
<
button
onClick=
{
()
=>
setShowCreateModal
(
true
)
}
className=
"text-sm text-primary hover:underline"
>
立即创建
</
button
>
</
div
>
)
}
</
div
>
</
div
>
{
/* Create Modal */
}
{
showModal
?
(
{
showCreateModal
&&
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"w-full max-w-3xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-lg w-full"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
h2
className=
"text-xl font-semibold text-foreground mb-6"
>
<
div
>
新建
{
assetTypes
.
find
((
t
)
=>
t
.
id
===
activeType
)?.
label
.
replace
(
"库"
,
""
)
}
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Asset Editor
</
div
>
</
h2
>
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
新建资产
</
h3
>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
填写名称、标签、描述和图片链接,将素材沉淀到企业资产中心。
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</
div
>
<
div
className=
"space-y-
4 mb-6
"
>
<
div
className=
"space-y-
5
"
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
名称
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
资产名称
</
label
>
<
input
value=
{
formName
}
onChange=
{
(
e
)
=>
setFormName
(
e
.
target
.
value
)
}
type=
"text"
placeholder=
"输入名称"
<
input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
value=
{
form
.
name
}
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
name
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"输入名称"
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
标签(逗号分隔)
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
标签
</
label
>
<
input
value=
{
formTags
}
onChange=
{
(
e
)
=>
setFormTags
(
e
.
target
.
value
)
}
type=
"text"
placeholder=
"如:女主角,都市,现代"
<
input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
value=
{
form
.
tags
}
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
tags
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"如:都市、现代、主角"
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
描述
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
描述
</
label
>
<
textarea
value=
{
formDesc
}
onChange=
{
(
e
)
=>
setFormDesc
(
e
.
target
.
value
)
}
rows=
{
3
}
placeholder=
"输入描述"
<
textarea
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
value=
{
form
.
description
}
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
description
:
e
.
target
.
value
}))
}
className=
"min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"输入资产描述"
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
库类型
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
图片链接
</
label
>
<
select
value=
{
formLibrary
}
onChange=
{
(
e
)
=>
setFormLibrary
(
e
.
target
.
value
as
LibraryType
)
}
<
input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
value=
{
form
.
imageUrl
}
<
option
value=
"personal"
>
个人库
</
option
>
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
imageUrl
:
e
.
target
.
value
}))
}
<
option
value=
"team"
>
团队库
</
option
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
</
select
>
placeholder=
"https://example.com/image.jpg"
/>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex gap-3"
>
<
div
className=
"mt-6 flex items-center justify-end gap-3"
>
<
button
onClick=
{
()
=>
setShowCreateModal
(
false
)
}
<
button
className=
"flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
取消
</
button
>
</
button
>
<
button
onClick=
{
handleCreate
}
disabled=
{
createAsset
.
isPending
||
!
formName
.
trim
()
}
<
button
className=
"flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
onClick=
{
handleCreate
}
{
createAsset
.
isPending
&&
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
}
disabled=
{
createAsset
.
isPending
||
!
form
.
name
.
trim
()
}
创建
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
createAsset
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4"
/>
}
保存资产
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/CharacterGeneration.tsx
View file @
75992d7b
import
{
useRef
,
useState
,
type
ChangeEvent
}
from
"react"
;
import
{
useRef
,
useState
,
type
ChangeEvent
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
import
{
ArrowRight
,
Box
,
Check
,
Image
as
ImageIcon
,
Edit2
,
ImageIcon
,
Loader2
,
Loader2
,
MapPin
,
MapPin
,
Plus
,
Plus
,
...
@@ -13,8 +11,8 @@ import {
...
@@ -13,8 +11,8 @@ import {
Trash2
,
Trash2
,
Upload
,
Upload
,
Users
,
Users
,
WandSparkles
,
X
,
X
,
Box
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
import
{
useCharacters
,
useCharacters
,
...
@@ -38,12 +36,20 @@ const ROLE_TYPES = [
...
@@ -38,12 +36,20 @@ const ROLE_TYPES = [
{
value
:
"supporting"
,
label
:
"配角"
},
{
value
:
"supporting"
,
label
:
"配角"
},
];
];
const
CHARACTER_VIEWS
:
Array
<
{
type
:
CharacterViewType
;
label
:
string
;
shortLabel
:
string
}
>
=
[
const
CHARACTER_VIEWS
:
Array
<
{
type
:
CharacterViewType
;
label
:
string
}
>
=
[
{
type
:
"front"
,
label
:
"正面"
,
shortLabel
:
"正"
},
{
type
:
"front"
,
label
:
"正面"
},
{
type
:
"side"
,
label
:
"侧面"
,
shortLabel
:
"侧"
},
{
type
:
"side"
,
label
:
"侧面"
},
{
type
:
"back"
,
label
:
"背面"
,
shortLabel
:
"背"
},
{
type
:
"back"
,
label
:
"背面"
},
];
];
const
roleTypeLabel
:
Record
<
string
,
string
>
=
{
female_lead
:
"女主角"
,
male_lead
:
"男主角"
,
antagonist
:
"反派"
,
villain
:
"反派"
,
supporting
:
"配角"
,
};
const
emptyForm
=
():
Partial
<
Character
>
=>
({
const
emptyForm
=
():
Partial
<
Character
>
=>
({
name
:
""
,
name
:
""
,
roleType
:
"supporting"
,
roleType
:
"supporting"
,
...
@@ -52,15 +58,16 @@ const emptyForm = (): Partial<Character> => ({
...
@@ -52,15 +58,16 @@ const emptyForm = (): Partial<Character> => ({
personality
:
""
,
personality
:
""
,
costume
:
""
,
costume
:
""
,
visualHint
:
""
,
visualHint
:
""
,
status
:
"draft"
,
});
});
const
emptyImage
State
=
():
ImageState
=>
({
const
emptyImage
s
=
():
ImageState
=>
({
front
:
null
,
front
:
null
,
side
:
null
,
side
:
null
,
back
:
null
,
back
:
null
,
});
});
const
empty
Pending
Files
=
():
PendingFiles
=>
({
const
emptyFiles
=
():
PendingFiles
=>
({
front
:
null
,
front
:
null
,
side
:
null
,
side
:
null
,
back
:
null
,
back
:
null
,
...
@@ -76,8 +83,96 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV
...
@@ -76,8 +83,96 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV
return
character
.
backImageUrl
??
null
;
return
character
.
backImageUrl
??
null
;
}
}
function
hasCompleteThreeViews
(
character
:
Partial
<
Character
>
)
{
function
CharacterCard
({
return
CHARACTER_VIEWS
.
every
((
view
)
=>
!!
getCharacterViewUrl
(
character
,
view
.
type
));
character
,
onEdit
,
onGenerateImage
,
onDelete
,
generating
,
}:
{
character
:
Character
;
onEdit
:
(
character
:
Character
)
=>
void
;
onGenerateImage
:
(
character
:
Character
)
=>
void
;
onDelete
:
(
character
:
Character
)
=>
void
;
generating
:
boolean
;
})
{
const
preview
=
getCharacterViewUrl
(
character
,
"front"
);
return
(
<
div
className=
"overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<
div
className=
"aspect-[4/5] bg-muted"
>
{
preview
?
(
<
img
src=
{
preview
}
alt=
{
character
.
name
}
className=
"h-full w-full object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center"
>
<
ImageIcon
className=
"h-10 w-10 text-muted-foreground"
/>
</
div
>
)
}
</
div
>
<
div
className=
"p-5"
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
>
<
div
className=
"text-lg font-semibold text-foreground"
>
{
character
.
name
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
roleTypeLabel
[
character
.
roleType
]
??
character
.
roleType
}
</
div
>
</
div
>
<
span
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
{
character
.
gender
===
"male"
?
"男性"
:
"女性"
}
</
span
>
</
div
>
<
div
className=
"mt-4 flex flex-wrap gap-2"
>
{
CHARACTER_VIEWS
.
map
((
view
)
=>
{
const
exists
=
Boolean
(
getCharacterViewUrl
(
character
,
view
.
type
));
return
(
<
span
key=
{
view
.
type
}
className=
{
[
"rounded-full px-3 py-1 text-xs"
,
exists
?
"bg-emerald-100 text-emerald-700"
:
"bg-slate-100 text-slate-600"
,
].
join
(
" "
)
}
>
{
view
.
label
}
</
span
>
);
})
}
</
div
>
<
p
className=
"mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground"
>
{
character
.
visualHint
||
character
.
personality
||
"待补充角色视觉描述与性格特征。"
}
</
p
>
<
div
className=
"mt-5 grid gap-2"
>
<
button
onClick=
{
()
=>
onGenerateImage
(
character
)
}
disabled=
{
generating
}
className=
"inline-flex items-center justify-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
generating
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
WandSparkles
className=
"h-4 w-4"
/>
}
{
generating
?
"正在生成..."
:
"AI 生成形象"
}
</
button
>
<
div
className=
"grid grid-cols-2 gap-2"
>
<
button
onClick=
{
()
=>
onEdit
(
character
)
}
className=
"rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
编辑角色
</
button
>
<
button
onClick=
{
()
=>
onDelete
(
character
)
}
className=
"inline-flex items-center justify-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<
Trash2
className=
"h-4 w-4"
/>
删除
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
);
}
}
export
function
CharacterGeneration
()
{
export
function
CharacterGeneration
()
{
...
@@ -90,17 +185,17 @@ export function CharacterGeneration() {
...
@@ -90,17 +185,17 @@ export function CharacterGeneration() {
const
saveCharacter
=
useSaveCharacter
(
pid
);
const
saveCharacter
=
useSaveCharacter
(
pid
);
const
generateImage
=
useGenerateCharacterImage
(
pid
);
const
generateImage
=
useGenerateCharacterImage
(
pid
);
const
uploadImage
=
useUploadCharacterImage
(
pid
);
const
uploadImage
=
useUploadCharacterImage
(
pid
);
const
deleteChar
=
useDeleteCharacter
(
pid
);
const
deleteChar
acter
=
useDeleteCharacter
(
pid
);
const
[
activeTab
]
=
useState
<
SettingsTab
>
(
"characters"
);
const
[
activeTab
]
=
useState
<
SettingsTab
>
(
"characters"
);
const
[
generatingImageId
,
setGeneratingImageId
]
=
useState
<
string
|
null
>
(
null
);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
editingChar
,
setEditingChar
]
=
useState
<
Partial
<
Character
>>
(
emptyForm
());
const
[
editingCharacter
,
setEditingCharacter
]
=
useState
<
Partial
<
Character
>>
(
emptyForm
());
const
[
imagePreviews
,
setImagePreviews
]
=
useState
<
ImageState
>
(
emptyImageState
());
const
[
imagePreviews
,
setImagePreviews
]
=
useState
<
ImageState
>
(
emptyImages
());
const
[
pendingFiles
,
setPendingFiles
]
=
useState
<
PendingFiles
>
(
emptyPendingFiles
());
const
[
pendingFiles
,
setPendingFiles
]
=
useState
<
PendingFiles
>
(
emptyFiles
());
const
[
generatingImageId
,
setGeneratingImageId
]
=
useState
<
string
|
null
>
(
null
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
file
Input
Refs
=
useRef
<
Record
<
CharacterViewType
,
HTMLInputElement
|
null
>>
({
const
fileRefs
=
useRef
<
Record
<
CharacterViewType
,
HTMLInputElement
|
null
>>
({
front
:
null
,
front
:
null
,
side
:
null
,
side
:
null
,
back
:
null
,
back
:
null
,
...
@@ -112,519 +207,373 @@ export function CharacterGeneration() {
...
@@ -112,519 +207,373 @@ export function CharacterGeneration() {
{
id
:
"props"
as
SettingsTab
,
label
:
"道具"
,
icon
:
Box
,
path
:
`/project/
${
projectId
}
/props`
},
{
id
:
"props"
as
SettingsTab
,
label
:
"道具"
,
icon
:
Box
,
path
:
`/project/
${
projectId
}
/props`
},
];
];
const
roleTypeLabel
:
Record
<
string
,
string
>
=
{
female_lead
:
"女主角"
,
male_lead
:
"男主角"
,
antagonist
:
"反派"
,
villain
:
"反派"
,
supporting
:
"配角"
,
};
const
generatedCount
=
characters
.
filter
((
character
)
=>
hasCompleteThreeViews
(
character
)).
length
;
const
generatingCount
=
characters
.
filter
((
character
)
=>
character
.
status
===
"generating"
).
length
;
const
statusBadge
=
(
status
:
string
)
=>
{
if
(
status
===
"ready"
)
{
return
<
span
className=
"px-2 py-0.5 rounded text-xs bg-green-500/10 text-green-600"
>
三视图已完成
</
span
>;
}
if
(
status
===
"generating"
)
{
return
(
<
span
className=
"px-2 py-0.5 rounded text-xs bg-blue-500/10 text-blue-600 flex items-center gap-1"
>
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
生成中
</
span
>
);
}
if
(
status
===
"failed"
)
{
return
<
span
className=
"px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-600"
>
生成失败
</
span
>;
}
return
<
span
className=
"px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-700"
>
待生成
</
span
>;
};
const
handleExtract
=
()
=>
{
const
handleExtract
=
()
=>
{
extract
.
mutate
(
undefined
,
{
extract
.
mutate
(
undefined
,
{
onError
:
(
error
)
=>
alert
(
`提取
失败:
${(
error
as
Error
).
message
}
`
),
onError
:
(
error
)
=>
alert
(
`提取
角色失败:
${(
error
as
Error
).
message
}
`
),
});
});
};
};
const
openCreate
=
()
=>
{
const
openCreate
=
()
=>
{
setEditingChar
(
emptyForm
());
setEditingChar
acter
(
emptyForm
());
setImagePreviews
(
emptyImage
State
());
setImagePreviews
(
emptyImage
s
());
setPendingFiles
(
empty
Pending
Files
());
setPendingFiles
(
emptyFiles
());
setShowModal
(
true
);
setShowModal
(
true
);
};
};
const
openEdit
=
(
character
:
Character
)
=>
{
const
openEdit
=
(
character
:
Character
)
=>
{
setEditingChar
({
...
character
});
setEditingChar
acter
({
...
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
(
empty
Pending
Files
());
setPendingFiles
(
emptyFiles
());
setShowModal
(
true
);
setShowModal
(
true
);
};
};
const
handleFileSelect
=
(
viewType
:
CharacterViewType
,
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
handleFileSelect
=
(
viewType
:
CharacterViewType
,
e
vent
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
e
.
target
.
files
?.[
0
];
const
file
=
e
vent
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
if
(
!
file
)
return
;
setPendingFiles
((
current
)
=>
({
...
current
,
[
viewType
]:
file
}));
setPendingFiles
((
current
)
=>
({
...
current
,
[
viewType
]:
file
}));
setImagePreviews
((
current
)
=>
({
...
current
,
[
viewType
]:
URL
.
createObjectURL
(
file
)
}));
setImagePreviews
((
current
)
=>
({
...
current
,
[
viewType
]:
URL
.
createObjectURL
(
file
)
}));
};
};
const
clearImage
=
(
viewType
:
CharacterViewType
)
=>
{
setPendingFiles
((
current
)
=>
({
...
current
,
[
viewType
]:
null
}));
setImagePreviews
((
current
)
=>
({
...
current
,
[
viewType
]:
editingChar
.
id
?
getCharacterViewUrl
(
editingChar
,
viewType
)
:
null
,
}));
if
(
fileInputRefs
.
current
[
viewType
])
{
fileInputRefs
.
current
[
viewType
]
!
.
value
=
""
;
}
};
const
handleSave
=
async
()
=>
{
const
handleSave
=
async
()
=>
{
if
(
!
editingChar
.
name
?.
trim
())
return
;
if
(
!
editingCharacter
.
name
?.
trim
())
return
;
setSaving
(
true
);
setSaving
(
true
);
try
{
try
{
const
saved
=
await
saveCharacter
.
mutateAsync
({
...
editingChar
,
status
:
editingChar
.
status
??
"draft"
});
const
saved
=
await
saveCharacter
.
mutateAsync
({
for
(
const
view
of
CHARACTER_VIEWS
)
{
...
editingCharacter
,
const
pendingFile
=
pendingFiles
[
view
.
type
];
status
:
editingCharacter
.
status
??
"draft"
,
if
(
!
pendingFile
)
continue
;
});
await
uploadImage
.
mutateAsync
({
id
:
String
(
saved
.
id
),
file
:
pendingFile
,
viewType
:
view
.
type
});
}
const
uploads
=
CHARACTER_VIEWS
.
filter
((
view
)
=>
pendingFiles
[
view
.
type
]).
map
((
view
)
=>
uploadImage
.
mutateAsync
({
id
:
String
(
saved
.
id
),
file
:
pendingFiles
[
view
.
type
]
as
File
,
viewType
:
view
.
type
,
}),
);
await
Promise
.
all
(
uploads
);
setShowModal
(
false
);
setShowModal
(
false
);
}
catch
(
error
)
{
}
catch
(
error
)
{
alert
(
`保存
失败:
${(
error
as
Error
).
message
}
`
);
alert
(
`保存
角色失败:
${(
error
as
Error
).
message
}
`
);
}
finally
{
}
finally
{
setSaving
(
false
);
setSaving
(
false
);
}
}
};
};
const
handleGenerateImage
=
async
(
character
:
Character
)
=>
{
const
handleGenerateImage
=
(
character
:
Character
)
=>
{
setGeneratingImageId
(
character
.
id
);
setGeneratingImageId
(
character
.
id
);
generateImage
.
mutate
(
character
.
id
,
{
generateImage
.
mutate
(
character
.
id
,
{
onSettled
:
()
=>
setGeneratingImageId
(
null
),
onSettled
:
()
=>
setGeneratingImageId
(
null
),
onError
:
(
error
)
=>
alert
(
`
三视图生成失败:
${(
error
as
Error
).
message
}
`
),
onError
:
(
error
)
=>
alert
(
`
角色形象生成失败:
${(
error
as
Error
).
message
}
`
),
});
});
};
};
const
handleDelete
=
(
character
:
Character
)
=>
{
const
handleDelete
=
(
character
:
Character
)
=>
{
if
(
!
confirm
(
`确定要删除角色
「
${
character
.
name
}
」
吗?`
))
return
;
if
(
!
confirm
(
`确定要删除角色
“
${
character
.
name
}
”
吗?`
))
return
;
deleteChar
.
mutate
(
character
.
id
,
{
deleteChar
acter
.
mutate
(
character
.
id
,
{
onError
:
(
error
)
=>
alert
(
`删除
失败:
${(
error
as
Error
).
message
}
`
),
onError
:
(
error
)
=>
alert
(
`删除
角色失败:
${(
error
as
Error
).
message
}
`
),
});
});
};
};
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background relative"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"p-6 pb-24"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"max-w-7xl mx-auto"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
div
className=
"mb-6"
>
<
div
>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
<
Sparkles
className=
"h-3.5 w-3.5"
/>
<
Sparkles
className=
"w-4 h-4 text-white"
/>
Character Setup
</
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
项目设定
</
h1
>
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
管理角色、场景和道具设定。角色形象已升级为标准三视图。
</
p
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
角色设定工作台
</
h1
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
从剧本中提取角色、补充人物信息、上传三视图素材或直接使用 AI 生成形象,让后续分镜与视频生成具备统一角色基础。
</
p
>
</
div
>
</
div
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
className=
"flex flex-wrap gap-3"
>
<
div
className=
"flex items-center gap-2"
>
<
button
{
settingsTabs
.
map
((
tab
)
=>
{
onClick=
{
handleExtract
}
const
Icon
=
tab
.
icon
;
disabled=
{
extract
.
isPending
}
return
(
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
<
button
>
key=
{
tab
.
id
}
{
extract
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
RefreshCw
className=
"h-4 w-4"
/>
}
onClick=
{
()
=>
navigate
(
tab
.
path
)
}
从剧本提取角色
className=
{
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${
</
button
>
activeTab === tab.id
<
button
? "bg-accent text-accent-foreground font-medium"
onClick=
{
openCreate
}
: "text-muted-foreground hover:text-foreground hover:bg-muted"
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
}`
}
>
>
<
Plus
className=
"h-4 w-4"
/>
<
Icon
className=
"w-4 h-4"
/>
新建角色
{
tab
.
label
}
</
button
>
</
button
>
</
div
>
);
</
div
>
})
}
</
div
>
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
{
settingsTabs
.
map
((
tab
)
=>
{
const
Icon
=
tab
.
icon
;
const
active
=
activeTab
===
tab
.
id
;
<
div
className=
"flex items-center gap-2"
>
return
(
<
button
<
button
onClick=
{
openCreate
}
key=
{
tab
.
id
}
className=
"px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2 text-sm"
onClick=
{
()
=>
navigate
(
tab
.
path
)
}
className=
{
[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all"
,
active
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
].
join
(
" "
)
}
>
>
<
Plus
className=
"w-4 h
-4"
/>
<
Icon
className=
"h-4 w
-4"
/>
手动添加
{
tab
.
label
}
</
button
>
</
button
>
);
})
}
</
div
>
<
div
className=
"mt-6 grid gap-4 md:grid-cols-3"
>
{
[
{
label
:
"角色总数"
,
value
:
`${characters.length}`
},
{
label
:
"已具备正面图"
,
value
:
`${characters.filter((character) => !!getCharacterViewUrl(character, "front")).length}`
,
},
{
label
:
"三视图完整"
,
value
:
`${characters.filter((character) => CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type))).length}`
,
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
item
.
label
}
</
div
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
item
.
value
}
</
div
>
</
div
>
))
}
</
div
>
{
isLoading
?
(
<
div
className=
"flex items-center justify-center py-24"
>
<
Loader2
className=
"h-8 w-8 animate-spin text-primary"
/>
</
div
>
)
:
characters
.
length
===
0
?
(
<
div
className=
"mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"
>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
Users
className=
"h-7 w-7"
/>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有角色设定
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
你可以直接新建角色,或者先从剧本里自动提取角色基础信息,再继续完善视觉和三视图素材。
</
p
>
<
div
className=
"mt-6 flex flex-wrap justify-center gap-3"
>
<
button
<
button
onClick=
{
handleExtract
}
onClick=
{
handleExtract
}
disabled=
{
extract
.
isPending
}
disabled=
{
extract
.
isPending
}
className=
"
px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2
disabled:opacity-60"
className=
"
inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted disabled:cursor-not-allowed
disabled:opacity-60"
>
>
{
extract
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Sparkles
className=
"w-4 h-4"
/>
}
{
extract
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
RefreshCw
className=
"h-4 w-4"
/>
}
AI提取角色
从剧本提取
</
button
>
<
button
onClick=
{
openCreate
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<
Plus
className=
"h-4 w-4"
/>
新建角色
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
)
:
(
<
div
className=
"flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"
>
<
div
className=
"mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4"
>
<
div
className=
"flex items-center gap-6"
>
{
characters
.
map
((
character
)
=>
(
<
div
className=
"flex items-center gap-2"
>
<
CharacterCard
<
span
className=
"text-sm text-muted-foreground"
>
角色总数
</
span
>
key=
{
character
.
id
}
<
span
className=
"text-lg font-semibold text-foreground"
>
{
characters
.
length
}
</
span
>
character=
{
character
}
</
div
>
onEdit=
{
openEdit
}
<
div
className=
"flex items-center gap-2"
>
onGenerateImage=
{
handleGenerateImage
}
<
span
className=
"text-sm text-muted-foreground"
>
三视图完整
</
span
>
onDelete=
{
handleDelete
}
<
span
className=
"text-lg font-semibold text-green-600"
>
{
generatedCount
}
</
span
>
generating=
{
generatingImageId
===
character
.
id
}
</
div
>
/>
<
div
className=
"flex items-center gap-2"
>
))
}
<
span
className=
"text-sm text-muted-foreground"
>
生成中
</
span
>
<
span
className=
"text-lg font-semibold text-blue-600"
>
{
generatingCount
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
</
section
>
{
!
isLoading
&&
characters
.
length
===
0
&&
(
{
showModal
?
(
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-12 text-center"
>
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
Users
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"w-full max-w-5xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
暂无角色
</
h3
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
<
div
>
角色设定页现在支持三视图展示。你可以手动添加角色,或从大纲和分集里自动提取。
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Character Editor
</
div
>
</
p
>
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
<
div
className=
"flex items-center justify-center gap-3"
>
{
editingCharacter
.
id
?
"编辑角色资料"
:
"新建角色"
}
<
button
</
h3
>
onClick=
{
openCreate
}
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
className=
"px-5 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2"
补充角色名称、定位、视觉描述和三视图素材,为后续分镜与视频生成提供统一角色基础。
>
</
p
>
<
Plus
className=
"w-4 h-4"
/>
手动添加
</
button
>
<
button
onClick=
{
handleExtract
}
disabled=
{
extract
.
isPending
}
className=
"px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60"
>
{
extract
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Sparkles
className=
"w-4 h-4"
/>
}
AI提取角色
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</
div
>
</
div
>
)
}
{
isLoading
&&
(
<
div
className=
"flex items-center justify-center py-16"
>
<
Loader2
className=
"w-8 h-8 animate-spin text-muted-foreground"
/>
</
div
>
)
}
{
characters
.
length
>
0
&&
(
<
div
className=
"grid grid-cols-2 gap-6"
>
{
characters
.
map
((
character
)
=>
{
const
mainImage
=
getCharacterViewUrl
(
character
,
"front"
)
??
getCharacterViewUrl
(
character
,
"side"
)
??
getCharacterViewUrl
(
character
,
"back"
);
const
isGenerating
=
generatingImageId
===
character
.
id
||
character
.
status
===
"generating"
;
return
(
<
div
key=
{
character
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden"
>
<
div
className=
"p-5 border-b border-border"
>
<
div
className=
"flex items-start justify-between mb-4"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"w-14 h-14 rounded-full overflow-hidden bg-muted flex items-center justify-center"
>
{
mainImage
?
(
<
img
src=
{
mainImage
}
alt=
{
character
.
name
}
className=
"w-full h-full object-cover"
/>
)
:
(
<
ImageIcon
className=
"w-6 h-6 text-muted-foreground"
/>
)
}
</
div
>
<
div
>
<
h3
className=
"text-lg font-semibold text-foreground mb-1"
>
{
character
.
name
}
</
h3
>
<
div
className=
"flex items-center gap-2 flex-wrap"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
roleTypeLabel
[
character
.
roleType
]
??
character
.
roleType
}
</
span
>
{
statusBadge
(
character
.
status
)
}
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-1"
>
<
button
onClick=
{
()
=>
openEdit
(
character
)
}
className=
"p-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
<
Edit2
className=
"w-4 h-4"
/>
</
button
>
<
button
onClick=
{
()
=>
handleDelete
(
character
)
}
className=
"p-2 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors"
>
<
Trash2
className=
"w-4 h-4"
/>
</
button
>
</
div
>
</
div
>
<
div
className=
"space-y-1.5 text-sm"
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
性别
</
span
>
<
span
className=
"text-foreground"
>
{
character
.
gender
===
"male"
?
"男"
:
character
.
gender
===
"female"
?
"女"
:
character
.
gender
}
</
span
>
</
div
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
年龄
</
span
>
<
span
className=
"text-foreground"
>
{
character
.
age
||
"未填写"
}
</
span
>
</
div
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
性格
</
span
>
<
span
className=
"text-foreground"
>
{
character
.
personality
||
"未填写"
}
</
span
>
</
div
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
服装
</
span
>
<
span
className=
"text-foreground line-clamp-1"
>
{
character
.
costume
||
"未填写"
}
</
span
>
</
div
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
外貌
</
span
>
<
span
className=
"text-foreground line-clamp-1"
>
{
character
.
visualHint
||
"未填写"
}
</
span
>
</
div
>
</
div
>
</
div
>
<
div
className=
"p-5 bg-muted/20"
>
<
div
className=
"grid gap-6 xl:grid-cols-[1fr_380px]"
>
<
div
className=
"flex items-center justify-between mb-3"
>
<
div
className=
"space-y-5"
>
<
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
span
className=
"text-xs text-muted-foreground font-medium"
>
角色三视图
</
span
>
<
div
>
<
p
className=
"text-[11px] text-muted-foreground mt-0.5"
>
正面 + 侧面 + 背面,用于完整展示角色造型。
</
p
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
角色名称
</
label
>
</
div
>
<
input
<
button
value=
{
editingCharacter
.
name
??
""
}
onClick=
{
()
=>
handleGenerateImage
(
character
)
}
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
name
:
e
.
target
.
value
}))
}
disabled=
{
isGenerating
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
className=
"px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1.5 disabled:opacity-50"
placeholder=
"例如:乔沫"
>
/>
{
isGenerating
?
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
:
<
RefreshCw
className=
"w-3 h-3"
/>
}
</
div
>
{
hasCompleteThreeViews
(
character
)
?
"重新生成三视图"
:
"AI生成三视图"
}
<
div
>
</
button
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
角色定位
</
label
>
</
div
>
<
select
value=
{
editingCharacter
.
roleType
??
"supporting"
}
<
div
className=
"grid grid-cols-3 gap-3"
>
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
roleType
:
e
.
target
.
value
}))
}
{
CHARACTER_VIEWS
.
map
((
view
)
=>
{
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
const
imageUrl
=
getCharacterViewUrl
(
character
,
view
.
type
);
>
return
(
{
ROLE_TYPES
.
map
((
role
)
=>
(
<
div
key=
{
view
.
type
}
>
<
option
key=
{
role
.
value
}
value=
{
role
.
value
}
>
<
div
className=
"text-[11px] text-muted-foreground mb-1 text-center"
>
{
view
.
label
}
</
div
>
{
role
.
label
}
<
div
className=
"aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border"
>
</
option
>
{
imageUrl
?
(
))
}
<
img
src=
{
imageUrl
}
alt=
{
`${character.name}-${view.label}`
}
className=
"w-full h-full object-cover"
/>
</
select
>
)
:
(
</
div
>
<
div
className=
"w-full h-full flex flex-col items-center justify-center gap-2"
>
</
div
>
{
isGenerating
?
(
<>
<
Loader2
className=
"w-6 h-6 text-primary animate-spin"
/>
<
span
className=
"text-xs text-muted-foreground"
>
生成中
</
span
>
</>
)
:
(
<>
<
ImageIcon
className=
"w-6 h-6 text-muted-foreground"
/>
<
span
className=
"text-xs text-muted-foreground"
>
待补充
</
span
>
</>
)
}
</
div
>
)
}
</
div
>
</
div
>
);
})
}
</
div
>
{
character
.
imagePrompt
&&
(
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
p
className=
"mt-3 text-xs text-muted-foreground line-clamp-3"
>
{
character
.
imagePrompt
}
</
p
>
<
div
>
)
}
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
性别
</
label
>
</
div
>
<
select
value=
{
editingCharacter
.
gender
??
"female"
}
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
gender
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<
option
value=
"female"
>
女性
</
option
>
<
option
value=
"male"
>
男性
</
option
>
</
select
>
</
div
>
</
div
>
);
<
div
>
})
}
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
年龄
</
label
>
</
div
>
<
input
)
}
value=
{
editingCharacter
.
age
??
""
}
</
div
>
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
age
:
e
.
target
.
value
}))
}
</
div
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"例如:28 岁"
/>
</
div
>
</
div
>
<
div
className=
"fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"
>
<
div
>
<
div
className=
"max-w-7xl mx-auto flex items-center justify-end gap-3"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
性格特征
</
label
>
<
button
<
textarea
onClick=
{
()
=>
navigate
(
`/project/${projectId}/scenes`
)
}
value=
{
editingCharacter
.
personality
??
""
}
className=
"px-6 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 text-sm font-medium"
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
personality
:
e
.
target
.
value
}))
}
>
className=
"min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<
span
>
继续生成场景设定
</
span
>
placeholder=
"例如:善良坚韧,外柔内刚,遇事很有主见。"
<
ArrowRight
className=
"w-5 h-5"
/>
/>
</
button
>
</
div
>
</
div
>
</
div
>
{
showModal
&&
(
<
div
>
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
外观描述
</
label
>
<
div
className=
"bg-card rounded-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col"
>
<
textarea
<
div
className=
"flex items-center justify-between p-6 border-b border-border flex-shrink-0"
>
value=
{
editingCharacter
.
visualHint
??
""
}
<
div
>
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
visualHint
:
e
.
target
.
value
}))
}
<
h2
className=
"text-lg font-semibold text-foreground"
>
{
editingChar
.
id
?
"编辑角色"
:
"手动添加角色"
}
</
h2
>
className=
"min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<
p
className=
"text-sm text-muted-foreground mt-1"
>
支持上传正面、侧面、背面三张标准视角图。
</
p
>
placeholder=
"例如:黑长直,杏眼,气质清冷,身形修长。"
/>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
服装与造型
</
label
>
<
textarea
value=
{
editingCharacter
.
costume
??
""
}
onChange=
{
(
e
)
=>
setEditingCharacter
((
current
)
=>
({
...
current
,
costume
:
e
.
target
.
value
}))
}
className=
"min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"例如:浅色风衣搭配衬衫长裤,整体简洁利落。"
/>
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
</
button
>
</
div
>
<
div
className=
"overflow-auto p-6 space-y-5 flex-1"
>
<
div
className=
"rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"
>
<
div
>
<
div
className=
"mb-4 text-base font-medium text-foreground"
>
三视图素材
</
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-3"
>
角色三视图上传(可选)
</
label
>
<
div
className=
"space-y-4"
>
<
div
className=
"grid grid-cols-3 gap-4"
>
{
CHARACTER_VIEWS
.
map
((
view
)
=>
(
{
CHARACTER_VIEWS
.
map
((
view
)
=>
(
<
div
key=
{
view
.
type
}
className=
"rounded-xl border border-border bg-muted/20 p-3"
>
<
div
key=
{
view
.
type
}
className=
"rounded-[24px] border border-border/80 bg-white/80 p-4"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
div
className=
"mb-3 flex items-center justify-between"
>
<
span
className=
"text-sm font-medium text-foreground"
>
{
view
.
label
}
</
span
>
<
div
className=
"text-sm font-medium text-foreground"
>
{
view
.
label
}
</
div
>
<
span
className=
"text-[11px] text-muted-foreground"
>
{
view
.
shortLabel
}
视图
</
span
>
<
button
onClick=
{
()
=>
fileRefs
.
current
[
view
.
type
]?.
click
()
}
className=
"inline-flex items-center gap-2 rounded-xl border border-border/80 bg-white px-3 py-2 text-xs text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<
Upload
className=
"h-3.5 w-3.5"
/>
上传图片
</
button
>
</
div
>
</
div
>
<
div
className=
"aspect-[3/4] rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
<
div
className=
"aspect-[4/5] overflow-hidden rounded-[20px] bg-muted"
>
onClick=
{
()
=>
fileInputRefs
.
current
[
view
.
type
]?.
click
()
}
>
{
imagePreviews
[
view
.
type
]
?
(
{
imagePreviews
[
view
.
type
]
?
(
<
img
src=
{
imagePreviews
[
view
.
type
]
??
""
}
alt=
{
`${view.label}预览`
}
className=
"w-full h-full object-cover"
/>
<
img
src=
{
imagePreviews
[
view
.
type
]
??
""
}
alt=
{
view
.
label
}
className=
"h-full w-full object-cover"
/>
)
:
(
)
:
(
<
div
className=
"text-center px-4"
>
<
div
className=
"flex h-full items-center justify-center"
>
<
Upload
className=
"w-5 h-5 text-muted-foreground mx-auto mb-2"
/>
<
ImageIcon
className=
"h-8 w-8 text-muted-foreground"
/>
<
span
className=
"text-xs text-muted-foreground"
>
点击上传
{
view
.
label
}
</
span
>
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
<
div
className=
"mt-2 flex items-center justify-between text-xs"
>
<
span
className=
"text-muted-foreground"
>
建议全身标准视角
</
span
>
{
imagePreviews
[
view
.
type
]
&&
(
<
button
onClick=
{
()
=>
clearImage
(
view
.
type
)
}
className=
"text-destructive hover:underline"
>
移除
</
button
>
)
}
</
div
>
<
input
<
input
ref=
{
(
node
)
=>
{
ref=
{
(
element
)
=>
{
file
InputRefs
.
current
[
view
.
type
]
=
node
;
file
Refs
.
current
[
view
.
type
]
=
element
;
}
}
}
}
type=
"file"
type=
"file"
accept=
"image/*"
accept=
"image/*"
className=
"hidden"
className=
"hidden"
onChange=
{
(
e
)
=>
handleFileSelect
(
view
.
type
,
e
)
}
onChange=
{
(
e
vent
)
=>
handleFileSelect
(
view
.
type
,
event
)
}
/>
/>
</
div
>
</
div
>
))
}
))
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
角色名称
<
span
className=
"text-destructive ml-1"
>
*
</
span
>
</
label
>
<
input
type=
"text"
value=
{
editingChar
.
name
??
""
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
name
:
e
.
target
.
value
})
}
placeholder=
"例如:乔沫"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<
div
className=
"grid grid-cols-2 gap-3"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
角色类型
</
label
>
<
select
value=
{
editingChar
.
roleType
??
"supporting"
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
roleType
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
{
ROLE_TYPES
.
map
((
role
)
=>
(
<
option
key=
{
role
.
value
}
value=
{
role
.
value
}
>
{
role
.
label
}
</
option
>
))
}
</
select
>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
性别
</
label
>
<
select
value=
{
editingChar
.
gender
??
"female"
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
gender
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
<
option
value=
"female"
>
女
</
option
>
<
option
value=
"male"
>
男
</
option
>
</
select
>
</
div
>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
年龄
</
label
>
<
input
type=
"text"
value=
{
editingChar
.
age
??
""
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
age
:
e
.
target
.
value
})
}
placeholder=
"例如:28岁"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
性格描述
</
label
>
<
textarea
value=
{
editingChar
.
personality
??
""
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
personality
:
e
.
target
.
value
})
}
placeholder=
"例如:善良坚韧,外柔内刚,遇事很有主见。"
rows=
{
2
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
外貌特征
</
label
>
<
textarea
value=
{
editingChar
.
visualHint
??
""
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
visualHint
:
e
.
target
.
value
})
}
placeholder=
"例如:黑长直,杏眼,气质清冷,身形修长。"
rows=
{
2
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
服装描述
</
label
>
<
textarea
value=
{
editingChar
.
costume
??
""
}
onChange=
{
(
e
)
=>
setEditingChar
({
...
editingChar
,
costume
:
e
.
target
.
value
})
}
placeholder=
"例如:浅色风衣搭配衬衫长裤,整体简洁利落。"
rows=
{
2
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
</
div
>
<
div
className=
"
flex gap-3 p-6 border-t border-border flex-shrink-0
"
>
<
div
className=
"
mt-6 flex items-center justify-end gap-3
"
>
<
button
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"
flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors
"
className=
"
rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted
"
>
>
取消
取消
</
button
>
</
button
>
<
button
<
button
onClick=
{
handleSave
}
onClick=
{
handleSave
}
disabled=
{
!
editingChar
.
name
?.
trim
()
||
saving
}
disabled=
{
saving
||
!
editingCharacter
.
name
?.
trim
()
}
className=
"
flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-5
0"
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-6
0"
>
>
{
saving
?
<
Loader2
className=
"
w-4 h-4 animate-spin"
/>
:
<
Check
className=
"w-4 h
-4"
/>
}
{
saving
?
<
Loader2
className=
"
h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w
-4"
/>
}
保存
保存
角色
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/Dashboard.tsx
View file @
75992d7b
import
{
use
State
,
useRef
}
from
"react"
;
import
{
use
Ref
,
useState
}
from
"react"
;
import
{
Link
,
useNavigate
}
from
"react-router"
;
import
{
Link
,
useNavigate
}
from
"react-router"
;
import
{
import
{
Plus
,
Film
,
Users
,
Clock
,
Play
,
Search
,
Calendar
,
Calendar
,
Filter
,
Loader2
,
Pencil
,
Trash2
,
X
,
Check
,
Upload
,
Clock3
,
Film
,
Loader2
,
Pencil
,
Plus
,
Search
,
Sparkles
,
Trash2
,
TrendingUp
,
Upload
,
Users
,
Video
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
useProjects
,
useUpdateProject
,
useDeleteProject
}
from
"../../hooks/useProjects"
;
import
{
useTeamMembers
}
from
"../../hooks/useTeam"
;
import
{
projectsApi
}
from
"../../lib/api/projects"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
type
{
ProjectDTO
}
from
"../../lib/api/projects"
;
import
{
useDeleteProject
,
useProjects
,
useUpdateProject
}
from
"../../hooks/useProjects"
;
import
{
useTeamMembers
}
from
"../../hooks/useTeam"
;
import
{
projectsApi
,
type
ProjectDTO
}
from
"../../lib/api/projects"
;
import
{
import
{
aspectRatioOptions
,
aspectRatioOptions
,
getAspectRatioLabel
,
getAspectRatioLabel
,
...
@@ -17,44 +29,101 @@ import {
...
@@ -17,44 +29,101 @@ import {
projectStyleOptions
,
projectStyleOptions
,
resolutionOptions
,
resolutionOptions
,
}
from
"../../lib/projectStyles"
;
}
from
"../../lib/projectStyles"
;
import
{
brand
}
from
"../../lib/brand"
;
// ── Default cover generator ─────────────────────────────────────────────────
function
getProjectGradient
(
name
:
string
):
string
{
function
getProjectGradient
(
name
:
string
):
string
{
let
h
=
0
;
let
hash
=
0
;
for
(
let
i
=
0
;
i
<
name
.
length
;
i
++
)
{
for
(
let
i
=
0
;
i
<
name
.
length
;
i
+=
1
)
{
h
=
(
Math
.
imul
(
31
,
h
)
+
name
.
charCodeAt
(
i
))
|
0
;
hash
=
Math
.
imul
(
31
,
hash
)
+
name
.
charCodeAt
(
i
);
}
const
hue
=
Math
.
abs
(
hash
)
%
360
;
return
`linear-gradient(135deg, hsl(
${
hue
}
, 68%, 32%), hsl(
${(
hue
+
34
)
%
360
}
, 82%, 56%))`
;
}
function
getProjectProgress
(
status
?:
string
)
{
switch
(
status
)
{
case
"completed"
:
return
100
;
case
"processing"
:
return
72
;
case
"storyboard"
:
return
68
;
case
"video"
:
return
88
;
case
"draft"
:
return
20
;
default
:
return
42
;
}
}
const
hue
=
((
h
>>>
0
)
%
360
);
return
`linear-gradient(135deg, hsl(
${
hue
}
,60%,40%), hsl(
${(
hue
+
45
)
%
360
}
,65%,55%))`
;
}
}
function
ProjectCover
({
url
,
name
,
className
=
""
}:
{
url
?:
string
;
name
:
string
;
className
?:
string
})
{
const
statusMeta
:
Record
<
string
,
{
label
:
string
;
chip
:
string
}
>
=
{
const
initials
=
name
.
slice
(
0
,
2
);
draft
:
{
label
:
"待完善"
,
chip
:
"bg-slate-100 text-slate-700"
,
},
outline
:
{
label
:
"剧本拆解中"
,
chip
:
"bg-sky-100 text-sky-700"
,
},
processing
:
{
label
:
"处理中"
,
chip
:
"bg-amber-100 text-amber-700"
,
},
storyboard
:
{
label
:
"分镜推进中"
,
chip
:
"bg-indigo-100 text-indigo-700"
,
},
video
:
{
label
:
"视频生成中"
,
chip
:
"bg-cyan-100 text-cyan-700"
,
},
completed
:
{
label
:
"已完成"
,
chip
:
"bg-emerald-100 text-emerald-700"
,
},
};
function
ProjectCover
({
name
,
url
,
className
=
""
,
}:
{
name
:
string
;
url
?:
string
;
className
?:
string
;
})
{
if
(
url
)
{
if
(
url
)
{
return
<
img
src=
{
url
}
alt=
{
name
}
className=
{
`
w-full h
-full object-cover ${className}`
}
/>;
return
<
img
src=
{
url
}
alt=
{
name
}
className=
{
`
h-full w
-full object-cover ${className}`
}
/>;
}
}
return
(
return
(
<
div
<
div
className=
{
`
w-full h-full flex
items-center justify-center ${className}`
}
className=
{
`
flex h-full w-full
items-center justify-center ${className}`
}
style=
{
{
background
:
getProjectGradient
(
name
)
}
}
style=
{
{
background
:
getProjectGradient
(
name
)
}
}
>
>
<
span
className=
"text-white text-2xl font-bold tracking-wider select-none opacity-80"
>
<
div
className=
"rounded-3xl border border-white/16 bg-white/8 px-4 py-3 text-center backdrop-blur-sm"
>
{
initials
}
<
div
className=
"text-[11px] uppercase tracking-[0.32em] text-white/76"
>
Project
</
div
>
</
span
>
<
div
className=
"mt-2 text-3xl font-semibold tracking-[0.2em] text-white"
>
{
name
.
slice
(
0
,
2
).
toUpperCase
()
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
// ── Edit Modal ───────────────────────────────────────────────────────────────
function
EditProjectDialog
({
function
EditModal
({
project
,
project
,
onClose
,
onClose
,
}:
{
}:
{
project
:
ProjectDTO
;
project
:
ProjectDTO
;
onClose
:
()
=>
void
;
onClose
:
()
=>
void
;
})
{
})
{
const
q
c
=
useQueryClient
();
const
q
ueryClient
=
useQueryClient
();
const
updateProject
=
useUpdateProject
(
project
.
id
);
const
updateProject
=
useUpdateProject
(
project
.
id
);
const
fileRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
name
,
setName
]
=
useState
(
project
.
name
);
const
[
name
,
setName
]
=
useState
(
project
.
name
);
const
[
style
,
setStyle
]
=
useState
(
project
.
style
??
""
);
const
[
style
,
setStyle
]
=
useState
(
project
.
style
??
""
);
const
[
aspectRatio
,
setAspectRatio
]
=
useState
(
project
.
aspectRatio
??
""
);
const
[
aspectRatio
,
setAspectRatio
]
=
useState
(
project
.
aspectRatio
??
""
);
...
@@ -62,7 +131,6 @@ function EditModal({
...
@@ -62,7 +131,6 @@ function EditModal({
const
[
coverFile
,
setCoverFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
coverFile
,
setCoverFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
coverPreview
,
setCoverPreview
]
=
useState
<
string
>
(
project
.
coverUrl
??
""
);
const
[
coverPreview
,
setCoverPreview
]
=
useState
<
string
>
(
project
.
coverUrl
??
""
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
fileRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
handleCoverChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
handleCoverChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
e
.
target
.
files
?.[
0
];
const
file
=
e
.
target
.
files
?.[
0
];
...
@@ -74,6 +142,7 @@ function EditModal({
...
@@ -74,6 +142,7 @@ function EditModal({
const
handleSave
=
async
()
=>
{
const
handleSave
=
async
()
=>
{
if
(
!
name
.
trim
())
return
;
if
(
!
name
.
trim
())
return
;
setSaving
(
true
);
setSaving
(
true
);
try
{
try
{
await
updateProject
.
mutateAsync
({
await
updateProject
.
mutateAsync
({
name
:
name
.
trim
(),
name
:
name
.
trim
(),
...
@@ -81,11 +150,13 @@ function EditModal({
...
@@ -81,11 +150,13 @@ function EditModal({
aspectRatio
:
aspectRatio
||
undefined
,
aspectRatio
:
aspectRatio
||
undefined
,
resolution
:
resolution
||
undefined
,
resolution
:
resolution
||
undefined
,
});
});
if
(
coverFile
)
{
if
(
coverFile
)
{
await
projectsApi
.
uploadAsset
(
project
.
id
,
coverFile
,
"cover"
);
await
projectsApi
.
uploadAsset
(
project
.
id
,
coverFile
,
"cover"
);
qc
.
invalidateQueries
({
queryKey
:
[
"projects"
]
});
await
queryClient
.
invalidateQueries
({
queryKey
:
[
"projects"
]
});
qc
.
invalidateQueries
({
queryKey
:
[
"projects"
,
project
.
id
]
});
await
queryClient
.
invalidateQueries
({
queryKey
:
[
"projects"
,
project
.
id
]
});
}
}
onClose
();
onClose
();
}
finally
{
}
finally
{
setSaving
(
false
);
setSaving
(
false
);
...
@@ -93,41 +164,42 @@ function EditModal({
...
@@ -93,41 +164,42 @@ function EditModal({
};
};
return
(
return
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"bg-card border border-border rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden"
>
<
div
className=
"w-full max-w-2xl rounded-[32px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
{
/* Header */
}
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
div
className=
"flex items-center justify-between px-6 py-4 border-b border-border"
>
<
div
>
<
h3
className=
"text-base font-semibold text-foreground"
>
编辑项目
</
h3
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Project Edit
</
div
>
<
button
onClick=
{
onClose
}
className=
"p-1 rounded-lg hover:bg-muted transition"
>
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
编辑项目资料
</
h3
>
<
X
className=
"w-4 h-4 text-muted-foreground"
/>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
调整项目名称、风格方向和封面信息,让项目首页与流程页保持统一视觉。
</
p
>
</
div
>
<
button
onClick=
{
onClose
}
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
<
div
className=
"px-6 py-5 space-y-5"
>
<
div
className=
"grid gap-6 lg:grid-cols-[240px_1fr]"
>
{
/* Cover image */
}
<
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
项目封面
</
label
>
<
div
<
div
className=
"flex items-center gap-4"
>
className=
"group relative aspect-[3/4] overflow-hidden rounded-[28px] border border-border/80 bg-muted shadow-sm"
{
/* Preview */
}
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
<
div
>
className=
"w-24 h-16 rounded-lg overflow-hidden border border-border flex-shrink-0 cursor-pointer"
<
ProjectCover
name=
{
name
||
project
.
name
}
url=
{
coverPreview
||
undefined
}
/>
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
<
div
className=
"absolute inset-x-4 bottom-4 rounded-2xl bg-slate-950/55 px-4 py-3 text-center text-sm text-white opacity-0 backdrop-blur transition group-hover:opacity-100"
>
>
点击上传新封面
<
ProjectCover
url=
{
coverPreview
||
undefined
}
name=
{
name
||
project
.
name
}
className=
"group-hover:scale-105 transition"
/>
</
div
>
<
div
className=
"flex-1"
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
className=
"flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition w-full justify-center"
>
<
Upload
className=
"w-3.5 h-3.5"
/>
{
coverPreview
?
"更换封面图"
:
"上传封面图"
}
</
button
>
<
p
className=
"text-[11px] text-muted-foreground mt-1.5 text-center"
>
支持 JPG、PNG,建议 16:9
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
className=
"mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<
Upload
className=
"h-4 w-4"
/>
上传封面
</
button
>
<
input
<
input
ref=
{
fileRef
}
ref=
{
fileRef
}
type=
"file"
type=
"file"
...
@@ -137,83 +209,92 @@ function EditModal({
...
@@ -137,83 +209,92 @@ function EditModal({
/>
/>
</
div
>
</
div
>
{
/* Project name */
}
<
div
className=
"space-y-5"
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
项目名称
</
label
>
<
input
type=
"text"
value=
{
name
}
onChange=
{
(
e
)
=>
setName
(
e
.
target
.
value
)
}
className=
"w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder=
"请输入项目名称"
maxLength=
{
200
}
/>
</
div
>
<
div
className=
"grid grid-cols-3 gap-3"
>
<
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
视觉风格
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
项目名称
</
label
>
<
select
<
input
value=
{
style
}
type=
"text"
onChange=
{
(
e
)
=>
setStyle
(
e
.
target
.
value
)
}
value=
{
name
}
className=
"w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
onChange=
{
(
e
)
=>
setName
(
e
.
target
.
value
)
}
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<
option
value=
""
>
未设置
</
option
>
placeholder=
"请输入项目名称"
{
projectStyleOptions
.
map
((
option
)
=>
(
maxLength=
{
200
}
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
/>
{
option
.
label
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
画幅比例
</
label
>
<
div
className=
"grid gap-4 md:grid-cols-3"
>
<
select
<
div
>
value=
{
aspectRatio
}
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
视觉风格
</
label
>
onChange=
{
(
e
)
=>
setAspectRatio
(
e
.
target
.
value
)
}
<
select
className=
"w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
value=
{
style
}
>
onChange=
{
(
e
)
=>
setStyle
(
e
.
target
.
value
)
}
<
option
value=
""
>
未设置
</
option
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
{
aspectRatioOptions
.
map
((
option
)
=>
(
>
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
<
option
value=
""
>
未设置
</
option
>
{
option
.
label
}
{
option
.
helper
??
""
}
{
projectStyleOptions
.
map
((
option
)
=>
(
</
option
>
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
))
}
{
option
.
label
}
</
select
>
</
option
>
))
}
</
select
>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
画幅比例
</
label
>
<
select
value=
{
aspectRatio
}
onChange=
{
(
e
)
=>
setAspectRatio
(
e
.
target
.
value
)
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<
option
value=
""
>
未设置
</
option
>
{
aspectRatioOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
</
select
>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
清晰度
</
label
>
<
select
value=
{
resolution
}
onChange=
{
(
e
)
=>
setResolution
(
e
.
target
.
value
)
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<
option
value=
""
>
未设置
</
option
>
{
resolutionOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
</
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
清晰度
</
label
>
<
div
className=
"rounded-[28px] border border-primary/10 bg-primary/5 px-5 py-4"
>
<
select
<
div
className=
"text-sm font-medium text-foreground"
>
品牌展示建议
</
div
>
value=
{
resolution
}
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
onChange=
{
(
e
)
=>
setResolution
(
e
.
target
.
value
)
}
封面、风格和分辨率会直接影响项目工作台卡片、详情页头图和后续生成流程的展示一致性。
className=
"w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
</
p
>
>
<
option
value=
""
>
未设置
</
option
>
{
resolutionOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Footer */
}
<
div
className=
"mt-6 flex items-center justify-end gap-3"
>
<
div
className=
"flex items-center justify-end gap-3 px-6 py-4 border-t border-border"
>
<
button
<
button
onClick=
{
onClose
}
onClick=
{
onClose
}
className=
"
px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition
"
className=
"
rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted
"
>
>
取消
取消
</
button
>
</
button
>
<
button
<
button
onClick=
{
handleSave
}
onClick=
{
handleSave
}
disabled=
{
saving
||
!
name
.
trim
()
}
disabled=
{
saving
||
!
name
.
trim
()
}
className=
"
flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm disabled:opacity-50 hover:shadow-md transition
"
className=
"
inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60
"
>
>
{
saving
?
<
Loader2
className=
"
w-3.5 h-3.5 animate-spin"
/>
:
<
Check
className=
"w-3.5 h-3.5
"
/>
}
{
saving
?
<
Loader2
className=
"
h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4
"
/>
}
保存
保存
更新
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -221,8 +302,7 @@ function EditModal({
...
@@ -221,8 +302,7 @@ function EditModal({
);
);
}
}
// ── Delete Confirm ────────────────────────────────────────────────────────────
function
DeleteConfirmDialog
({
function
DeleteConfirm
({
project
,
project
,
onClose
,
onClose
,
}:
{
}:
{
...
@@ -230,7 +310,6 @@ function DeleteConfirm({
...
@@ -230,7 +310,6 @@ function DeleteConfirm({
onClose
:
()
=>
void
;
onClose
:
()
=>
void
;
})
{
})
{
const
deleteProject
=
useDeleteProject
();
const
deleteProject
=
useDeleteProject
();
const
navigate
=
useNavigate
();
const
handleDelete
=
async
()
=>
{
const
handleDelete
=
async
()
=>
{
await
deleteProject
.
mutateAsync
(
project
.
id
);
await
deleteProject
.
mutateAsync
(
project
.
id
);
...
@@ -238,29 +317,30 @@ function DeleteConfirm({
...
@@ -238,29 +317,30 @@ function DeleteConfirm({
};
};
return
(
return
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-
black/50
backdrop-blur-sm"
>
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-
slate-950/35 p-4
backdrop-blur-sm"
>
<
div
className=
"
bg-card border border-border rounded-2xl shadow-xl w-full max-w-sm mx-4 p-6
"
>
<
div
className=
"
w-full max-w-md rounded-[32px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl
"
>
<
div
className=
"
w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4
"
>
<
div
className=
"
mx-auto flex h-14 w-14 items-center justify-center rounded-3xl bg-red-100 text-red-600
"
>
<
Trash2
className=
"
w-6 h-6 text-red-500
"
/>
<
Trash2
className=
"
h-6 w-6
"
/>
</
div
>
</
div
>
<
h3
className=
"
text-base font-semibold text-foreground text-center mb-2
"
>
删除项目
</
h3
>
<
h3
className=
"
mt-5 text-center text-2xl font-semibold text-foreground
"
>
删除项目
</
h3
>
<
p
className=
"
text-sm text-muted-foreground text-center mb-6
"
>
<
p
className=
"
mt-3 text-center text-sm leading-6 text-muted-foreground
"
>
确定删除「
{
project
.
name
}
」?此操作无法撤销,项目数据将被软删除
。
删除后,项目“
{
project
.
name
}
”将从工作台移除,相关流程记录不再在当前列表中展示
。
</
p
>
</
p
>
<
div
className=
"flex gap-3"
>
<
div
className=
"mt-6 flex gap-3"
>
<
button
<
button
onClick=
{
onClose
}
onClick=
{
onClose
}
className=
"flex-1
px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition
"
className=
"flex-1
rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted
"
>
>
取消
取消
</
button
>
</
button
>
<
button
<
button
onClick=
{
handleDelete
}
onClick=
{
handleDelete
}
disabled=
{
deleteProject
.
isPending
}
disabled=
{
deleteProject
.
isPending
}
className=
"
flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600 transition disabled:opacity-5
0"
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-6
0"
>
>
{
deleteProject
.
isPending
?
<
Loader2
className=
"
w-3.5 h-3.5 animate-spin"
/>
:
<
Trash2
className=
"w-3.5 h-3.5
"
/>
}
{
deleteProject
.
isPending
?
<
Loader2
className=
"
h-4 w-4 animate-spin"
/>
:
<
Trash2
className=
"h-4 w-4
"
/>
}
删除
确认
删除
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -268,308 +348,454 @@ function DeleteConfirm({
...
@@ -268,308 +348,454 @@ function DeleteConfirm({
);
);
}
}
// ── Status helpers ────────────────────────────────────────────────────────────
const
statusText
:
Record
<
string
,
string
>
=
{
outline
:
"大纲阶段"
,
storyboard
:
"分镜制作"
,
video
:
"视频生成"
,
draft
:
"草稿"
,
processing
:
"处理中"
,
completed
:
"已完成"
,
};
const
statusColor
:
Record
<
string
,
string
>
=
{
outline
:
"bg-blue-100 text-blue-700"
,
storyboard
:
"bg-yellow-100 text-yellow-700"
,
video
:
"bg-green-100 text-green-700"
,
draft
:
"bg-gray-100 text-gray-600"
,
processing
:
"bg-orange-100 text-orange-700"
,
completed
:
"bg-green-100 text-green-700"
,
};
// ── Dashboard ─────────────────────────────────────────────────────────────────
export
function
Dashboard
()
{
export
function
Dashboard
()
{
const
navigate
=
useNavigate
();
const
[
searchTerm
,
setSearchTerm
]
=
useState
(
""
);
const
[
searchTerm
,
setSearchTerm
]
=
useState
(
""
);
const
[
dateRange
,
setDateRange
]
=
useState
({
start
:
""
,
end
:
""
});
const
[
dateRange
,
setDateRange
]
=
useState
({
start
:
""
,
end
:
""
});
const
[
selectedMembers
,
setSelectedMembers
]
=
useState
<
string
[]
>
([]);
const
[
showMemberFilter
,
setShowMemberFilter
]
=
useState
(
false
);
const
[
editingProject
,
setEditingProject
]
=
useState
<
ProjectDTO
|
null
>
(
null
);
const
[
editingProject
,
setEditingProject
]
=
useState
<
ProjectDTO
|
null
>
(
null
);
const
[
deletingProject
,
setDeletingProject
]
=
useState
<
ProjectDTO
|
null
>
(
null
);
const
[
deletingProject
,
setDeletingProject
]
=
useState
<
ProjectDTO
|
null
>
(
null
);
const
{
data
:
apiProjects
,
isLoading
}
=
useProjects
();
const
{
data
:
apiProjects
,
isLoading
}
=
useProjects
();
const
{
data
:
teamMembersData
=
[]
}
=
useTeamMembers
();
const
{
data
:
teamMembers
=
[]
}
=
useTeamMembers
();
const
allMembers
=
teamMembersData
.
map
((
m
)
=>
m
.
username
).
filter
(
Boolean
);
const
projects
=
(
apiProjects
??
[]).
map
((
project
)
=>
{
const
projects
=
(
apiProjects
??
[]).
map
((
p
)
=>
({
const
status
=
project
.
status
===
"draft"
?
"outline"
:
project
.
status
;
raw
:
p
,
const
progress
=
getProjectProgress
(
project
.
status
);
id
:
p
.
id
,
title
:
p
.
name
,
return
{
description
:
p
.
description
??
""
,
raw
:
project
,
status
:
p
.
status
===
"draft"
?
"outline"
:
p
.
status
,
id
:
project
.
id
,
progress
:
p
.
status
===
"completed"
?
100
:
p
.
status
===
"draft"
?
0
:
50
,
title
:
project
.
name
,
updatedAt
:
p
.
updatedAt
?.
slice
(
0
,
10
)
??
""
,
description
:
project
.
description
??
""
,
createdAt
:
p
.
createdAt
?.
slice
(
0
,
10
)
??
""
,
status
,
coverUrl
:
p
.
coverUrl
,
progress
,
tags
:
[
updatedAt
:
project
.
updatedAt
?.
slice
(
0
,
10
)
??
""
,
{
key
:
"style"
,
label
:
getProjectStyleLabel
(
p
.
style
)
},
createdAt
:
project
.
createdAt
?.
slice
(
0
,
10
)
??
""
,
{
key
:
"aspectRatio"
,
label
:
getAspectRatioLabel
(
p
.
aspectRatio
)
},
coverUrl
:
project
.
coverUrl
,
{
key
:
"resolution"
,
label
:
getResolutionLabel
(
p
.
resolution
)
},
tags
:
[
].
filter
((
tag
)
=>
tag
.
label
!==
"未设置"
),
getProjectStyleLabel
(
project
.
style
),
}));
getAspectRatioLabel
(
project
.
aspectRatio
),
getResolutionLabel
(
project
.
resolution
),
const
filtered
=
projects
.
filter
((
p
)
=>
{
].
filter
((
item
)
=>
item
!==
"未设置"
),
if
(
searchTerm
&&
!
p
.
title
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
()))
return
false
;
};
if
(
dateRange
.
start
&&
p
.
createdAt
<
dateRange
.
start
)
return
false
;
});
if
(
dateRange
.
end
&&
p
.
createdAt
>
dateRange
.
end
)
return
false
;
const
filtered
=
projects
.
filter
((
project
)
=>
{
if
(
searchTerm
&&
!
project
.
title
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
()))
{
return
false
;
}
if
(
dateRange
.
start
&&
project
.
createdAt
<
dateRange
.
start
)
{
return
false
;
}
if
(
dateRange
.
end
&&
project
.
createdAt
>
dateRange
.
end
)
{
return
false
;
}
return
true
;
return
true
;
});
});
const
stats
=
[
{
label
:
"项目总数"
,
value
:
filtered
.
length
,
icon
:
Film
,
hint
:
"覆盖所有在研与已完成项目"
,
},
{
label
:
"推进中项目"
,
value
:
filtered
.
filter
((
project
)
=>
project
.
progress
<
100
).
length
,
icon
:
TrendingUp
,
hint
:
"处于拆解、设定、分镜或视频阶段"
,
},
{
label
:
"协作成员"
,
value
:
teamMembers
.
length
,
icon
:
Users
,
hint
:
"当前团队可参与创作的人数"
,
},
{
label
:
"已完成项目"
,
value
:
filtered
.
filter
((
project
)
=>
project
.
progress
===
100
).
length
,
icon
:
Video
,
hint
:
"已进入交付或归档阶段"
,
},
];
const
recentProject
=
filtered
[
0
];
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"max-w-7xl mx-auto"
>
<
section
className=
"grid gap-5 xl:grid-cols-[1.25fr_0.75fr]"
>
<
div
className=
"relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]"
>
<
div
className=
"absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]"
/>
<
div
className=
"absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14"
/>
<
div
className=
"absolute right-20 top-20 h-28 w-28 rounded-full border border-white/10"
/>
<
div
className=
"relative"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74"
>
<
Sparkles
className=
"h-4 w-4"
/>
Creative Command
</
div
>
<
h1
className=
"mt-5 max-w-3xl text-4xl font-semibold leading-tight"
>
{
brand
.
companyName
}
AI 短剧生产工作台
</
h1
>
<
p
className=
"mt-4 max-w-2xl text-base leading-7 text-white/76"
>
在这里查看所有项目进度、团队产能和最近更新,统一推进从剧本拆解到视频生成的整条生产链路。
</
p
>
{
/* Header */
}
<
div
className=
"mt-8 flex flex-wrap gap-3"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
Link
<
div
>
to=
"/new-project"
<
h1
className=
"text-2xl font-semibold text-foreground mb-1"
>
我的项目
</
h1
>
className=
"inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-3 text-sm font-medium text-[#0d4174] shadow-[0_18px_32px_rgba(8,31,63,0.14)] transition hover:translate-y-[-1px]"
<
p
className=
"text-sm text-muted-foreground"
>
管理和创作您的AI短剧项目
</
p
>
>
<
Plus
className=
"h-4 w-4"
/>
创建短剧项目
</
Link
>
<
button
onClick=
{
()
=>
navigate
(
"/assets/character"
)
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-white/16 bg-white/10 px-5 py-3 text-sm text-white transition hover:bg-white/14"
>
<
WandSparkles
className=
"h-4 w-4"
/>
进入资产中心
</
button
>
</
div
>
<
div
className=
"mt-8 grid gap-3 sm:grid-cols-3"
>
{
[
"统一管理剧本、大纲、角色、场景、分镜和视频任务"
,
"支持企业团队协作、品牌化展示与项目状态追踪"
,
"用一套工作台承接创作入口、资产沉淀与交付流程"
,
].
map
((
item
)
=>
(
<
div
key=
{
item
}
className=
"rounded-3xl border border-white/14 bg-white/10 px-4 py-4 text-sm leading-6 text-white/82 backdrop-blur-sm"
>
{
item
}
</
div
>
))
}
</
div
>
</
div
>
</
div
>
<
Link
to=
"/new-project"
className=
"px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center gap-2 hover:shadow-md transition-all text-sm"
>
<
Plus
className=
"w-4 h-4"
/>
新建项目
</
Link
>
</
div
>
</
div
>
{
/* Search & Filters */
}
<
div
className=
"grid gap-5"
>
<
div
className=
"mb-6 p-4 rounded-xl border border-border bg-card"
>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_20px_50px_rgba(11,44,81,0.10)] backdrop-blur-xl"
>
<
div
className=
"grid grid-cols-5 gap-4"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
className=
"col-span-2"
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
搜索项目
</
label
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Today Focus
</
div
>
<
div
className=
"relative"
>
<
div
className=
"mt-2 text-2xl font-semibold text-foreground"
>
创作节奏总览
</
div
>
<
Search
className=
"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<
input
type=
"text"
value=
{
searchTerm
}
onChange=
{
(
e
)
=>
setSearchTerm
(
e
.
target
.
value
)
}
placeholder=
"输入项目名称搜索..."
className=
"w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
div
>
<
Clock3
className=
"h-5 w-5"
/>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
开始日期
</
label
>
<
div
className=
"relative"
>
<
Calendar
className=
"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<
input
type=
"date"
value=
{
dateRange
.
start
}
onChange=
{
(
e
)
=>
setDateRange
({
...
dateRange
,
start
:
e
.
target
.
value
})
}
className=
"w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
结束日期
</
label
>
<
div
className=
"relative"
>
<
Calendar
className=
"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<
input
type=
"date"
value=
{
dateRange
.
end
}
onChange=
{
(
e
)
=>
setDateRange
({
...
dateRange
,
end
:
e
.
target
.
value
})
}
className=
"w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
>
<
div
className=
"space-y-3"
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
按成员筛选
</
label
>
{
[
<
div
className=
"relative"
>
"优先跟进处于“剧本拆解中”和“分镜推进中”的项目,能最快带动整条链路推进。"
,
<
button
"将项目封面、风格和分辨率补全,有利于工作台和详情页展示一致性。"
,
onClick=
{
()
=>
setShowMemberFilter
(
!
showMemberFilter
)
}
"团队成员与资产中心已就绪时,可直接进入角色、场景与视频流程。"
,
className=
"w-full px-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground text-left flex items-center justify-between hover:bg-muted transition-colors"
].
map
((
item
,
index
)
=>
(
<
div
key=
{
item
}
className=
"rounded-2xl border border-border/70 bg-white/74 px-4 py-4 shadow-sm"
>
>
<
span
className=
"flex items-center gap-2"
>
<
div
className=
"mb-2 text-xs uppercase tracking-[0.24em] text-primary/70"
>
<
Filter
className=
"w-4 h-4 text-muted-foreground"
/>
0
{
index
+
1
}
{
selectedMembers
.
length
>
0
?
`${selectedMembers.length}个成员`
:
"选择成员"
}
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
{
showMemberFilter
?
"▲"
:
"▼"
}
</
span
>
</
button
>
{
showMemberFilter
&&
(
<
div
className=
"absolute top-full left-0 right-0 mt-2 p-3 rounded-lg border border-border bg-card shadow-lg z-10"
>
<
div
className=
"flex flex-wrap gap-2"
>
{
allMembers
.
map
((
member
)
=>
(
<
button
key=
{
member
}
onClick=
{
()
=>
setSelectedMembers
((
prev
)
=>
prev
.
includes
(
member
)
?
prev
.
filter
((
m
)
=>
m
!==
member
)
:
[...
prev
,
member
]
)
}
className=
{
`px-3 py-1.5 rounded-lg text-xs transition-colors ${
selectedMembers.includes(member) ? "bg-primary text-white" : "bg-muted text-foreground hover:bg-muted-foreground/10"
}`
}
>
{
member
}
</
button
>
))
}
</
div
>
{
selectedMembers
.
length
>
0
&&
(
<
button
onClick=
{
()
=>
setSelectedMembers
([])
}
className=
"mt-3 text-xs text-destructive hover:underline"
>
清除筛选
</
button
>
)
}
</
div
>
</
div
>
)
}
<
div
className=
"text-sm leading-6 text-foreground/88"
>
{
item
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_20px_50px_rgba(11,44,81,0.10)] backdrop-blur-xl"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Recent Project
</
div
>
<
div
className=
"mt-2 text-xl font-semibold text-foreground"
>
{
recentProject
?.
title
??
"等待创建第一个项目"
}
</
div
>
</
div
>
<
div
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
{
recentProject
?
"最近更新"
:
"空状态"
}
</
div
>
</
div
>
</
div
>
</
div
>
{
recentProject
?
(
<>
<
div
className=
"text-sm leading-6 text-muted-foreground"
>
{
recentProject
.
description
||
"这个项目已经进入工作台,可继续推进剧本拆解、角色设定或分镜制作。"
}
</
div
>
<
div
className=
"mt-5"
>
<
div
className=
"mb-2 flex items-center justify-between text-sm text-muted-foreground"
>
<
span
>
完成度
</
span
>
<
span
>
{
recentProject
.
progress
}
%
</
span
>
</
div
>
<
div
className=
"h-2.5 overflow-hidden rounded-full bg-muted"
>
<
div
className=
"h-full rounded-full bg-[linear-gradient(90deg,#0f74d8_0%,#1ea5ff_100%)]"
style=
{
{
width
:
`${recentProject.progress}%`
}
}
/>
</
div
>
</
div
>
</>
)
:
(
<
div
className=
"text-sm leading-6 text-muted-foreground"
>
还没有项目时,建议先从“创建短剧项目”开始,系统会自动串起后续工作流。
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
{
/* Stats */
}
<
section
className=
"mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4"
>
<
div
className=
"grid grid-cols-4 gap-4 mb-6"
>
{
stats
.
map
((
stat
)
=>
{
{
[
const
Icon
=
stat
.
icon
;
{
label
:
"总项目"
,
value
:
filtered
.
length
,
icon
:
Film
},
return
(
{
label
:
"进行中"
,
value
:
filtered
.
filter
((
p
)
=>
p
.
progress
<
100
).
length
,
icon
:
Play
},
<
div
{
label
:
"团队成员"
,
value
:
allMembers
.
length
,
icon
:
Users
},
key=
{
stat
.
label
}
{
label
:
"已完成"
,
value
:
filtered
.
filter
((
p
)
=>
p
.
progress
===
100
).
length
,
icon
:
Clock
},
className=
"rounded-[30px] border border-white/80 bg-white/82 p-5 shadow-[0_18px_40px_rgba(11,44,81,0.08)] backdrop-blur-xl"
].
map
((
s
)
=>
(
>
<
div
key=
{
s
.
label
}
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"w-10 h-10 rounded-lg bg-accent flex items-center justify-center"
>
<
s
.
icon
className=
"w-5 h-5 text-primary"
/>
</
div
>
<
div
>
<
div
>
<
div
className=
"text-2xl font-semibold text-foreground"
>
{
s
.
value
}
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
stat
.
label
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
{
s
.
label
}
</
div
>
<
div
className=
"mt-3 text-3xl font-semibold text-foreground"
>
{
stat
.
value
}
</
div
>
</
div
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
Icon
className=
"h-5 w-5"
/>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"mt-3 text-sm leading-6 text-muted-foreground"
>
{
stat
.
hint
}
</
div
>
</
div
>
</
div
>
))
}
);
})
}
</
section
>
<
section
className=
"mt-6 rounded-[32px] border border-white/80 bg-white/82 p-6 shadow-[0_20px_50px_rgba(11,44,81,0.10)] backdrop-blur-xl"
>
<
div
className=
"flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Project Matrix
</
div
>
<
h2
className=
"mt-2 text-3xl font-semibold text-foreground"
>
项目矩阵
</
h2
>
<
p
className=
"mt-3 max-w-2xl text-sm leading-6 text-muted-foreground"
>
统一查看项目状态、封面表现和参数配置,快速进入项目详情、编辑资料或继续后续流程。
</
p
>
</
div
>
<
Link
to=
"/new-project"
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<
Plus
className=
"h-4 w-4"
/>
新建项目
</
Link
>
</
div
>
<
div
className=
"mt-6 grid gap-4 xl:grid-cols-[1fr_auto_auto_auto]"
>
<
div
className=
"relative"
>
<
Search
className=
"pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"text"
value=
{
searchTerm
}
onChange=
{
(
e
)
=>
setSearchTerm
(
e
.
target
.
value
)
}
placeholder=
"搜索项目名称"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-11 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/>
</
div
>
<
div
className=
"relative"
>
<
Calendar
className=
"pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"date"
value=
{
dateRange
.
start
}
onChange=
{
(
e
)
=>
setDateRange
((
current
)
=>
({
...
current
,
start
:
e
.
target
.
value
}))
}
className=
"h-12 rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
/>
</
div
>
<
div
className=
"relative"
>
<
Calendar
className=
"pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"date"
value=
{
dateRange
.
end
}
onChange=
{
(
e
)
=>
setDateRange
((
current
)
=>
({
...
current
,
end
:
e
.
target
.
value
}))
}
className=
"h-12 rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
/>
</
div
>
<
button
onClick=
{
()
=>
{
setSearchTerm
(
""
);
setDateRange
({
start
:
""
,
end
:
""
});
}
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
清空筛选
</
button
>
</
div
>
<
div
className=
"mt-6 flex items-center justify-between gap-4"
>
<
div
className=
"text-sm text-muted-foreground"
>
共
{
filtered
.
length
}
个项目,已接入
{
teamMembers
.
length
}
位团队成员协作。
</
div
>
<
div
className=
"hidden items-center gap-2 md:flex"
>
{
[
"企业展示"
,
"统一视觉"
,
"流程驱动"
].
map
((
chip
)
=>
(
<
span
key=
{
chip
}
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
{
chip
}
</
span
>
))
}
</
div
>
</
div
>
</
div
>
{
/* Projects Grid */
}
{
isLoading
?
(
{
isLoading
?
(
<
div
className=
"flex items-center justify-center py-2
0
"
>
<
div
className=
"flex items-center justify-center py-2
4
"
>
<
Loader2
className=
"
w-8 h
-8 animate-spin text-primary"
/>
<
Loader2
className=
"
h-8 w
-8 animate-spin text-primary"
/>
</
div
>
</
div
>
)
:
filtered
.
length
>
0
?
(
)
:
filtered
.
length
>
0
?
(
<
div
className=
"grid grid-cols-3 gap-4"
>
<
div
className=
"mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-3"
>
{
filtered
.
map
((
project
)
=>
(
{
filtered
.
map
((
project
)
=>
{
<
div
key=
{
project
.
id
}
className=
"group relative rounded-xl border border-border bg-card overflow-hidden hover:shadow-md transition-all"
>
const
meta
=
statusMeta
[
project
.
status
]
??
statusMeta
.
outline
;
return
(
{
/* Cover */
}
<
div
<
Link
to=
{
`/project/${project.id}`
}
className=
"block aspect-video bg-muted overflow-hidden"
>
key=
{
project
.
id
}
<
ProjectCover
className=
"group overflow-hidden rounded-[30px] border border-white/80 bg-white/88 shadow-[0_20px_50px_rgba(11,44,81,0.10)] transition hover:translate-y-[-2px] hover:shadow-[0_28px_64px_rgba(11,44,81,0.14)]"
url=
{
project
.
coverUrl
}
>
name=
{
project
.
title
}
<
div
className=
"relative aspect-[16/10] overflow-hidden bg-muted"
>
className=
"group-hover:scale-105 transition-transform duration-300"
<
Link
to=
{
`/project/${project.id}`
}
className=
"block h-full w-full"
>
/>
<
ProjectCover
{
/* Status badge */
}
name=
{
project
.
title
}
<
div
className=
{
`absolute top-3 right-3 px-2.5 py-1 rounded-md text-xs font-medium ${statusColor[project.status] ?? "bg-gray-100 text-gray-600"}`
}
>
url=
{
project
.
coverUrl
}
{
statusText
[
project
.
status
]
??
project
.
status
}
className=
"transition duration-500 group-hover:scale-105"
/>
</
Link
>
<
div
className=
"absolute left-4 top-4 flex gap-2 opacity-0 transition group-hover:opacity-100"
>
<
button
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
setEditingProject
(
project
.
raw
);
}
}
className=
"flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-950/55 text-white backdrop-blur transition hover:bg-slate-950/75"
title=
"编辑项目"
>
<
Pencil
className=
"h-4 w-4"
/>
</
button
>
<
button
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
setDeletingProject
(
project
.
raw
);
}
}
className=
"flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-950/55 text-white backdrop-blur transition hover:bg-red-600/85"
title=
"删除项目"
>
<
Trash2
className=
"h-4 w-4"
/>
</
button
>
</
div
>
<
div
className=
{
`absolute right-4 top-4 rounded-full px-3 py-1 text-xs font-medium ${meta.chip}`
}
>
{
meta
.
label
}
</
div
>
</
div
>
</
div
>
</
Link
>
{
/* Action buttons — visible on hover */
}
<
div
className=
"absolute top-3 left-3 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<
button
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
setEditingProject
(
project
.
raw
);
}
}
className=
"w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-black/80 transition"
title=
"编辑项目"
>
<
Pencil
className=
"w-3.5 h-3.5 text-white"
/>
</
button
>
<
button
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
setDeletingProject
(
project
.
raw
);
}
}
className=
"w-7 h-7 rounded-lg bg-black/60 backdrop-blur-sm flex items-center justify-center hover:bg-red-600/80 transition"
title=
"删除项目"
>
<
Trash2
className=
"w-3.5 h-3.5 text-white"
/>
</
button
>
</
div
>
{
/* Card body */
}
<
div
className=
"p-5"
>
<
Link
to=
{
`/project/${project.id}`
}
className=
"block p-4"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
h3
className=
"font-medium text-foreground mb-1.5 line-clamp-1"
>
{
project
.
title
}
</
h3
>
<
div
className=
"min-w-0"
>
{
project
.
description
&&
(
<
Link
<
p
className=
"text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed"
>
to=
{
`/project/${project.id}`
}
{
project
.
description
}
className=
"block truncate text-lg font-semibold text-foreground transition hover:text-primary"
>
{
project
.
title
}
</
Link
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
最近更新
{
project
.
updatedAt
||
"暂无时间"
}
</
div
>
</
div
>
<
div
className=
"rounded-2xl bg-accent px-3 py-2 text-xs text-primary"
>
#
{
project
.
id
.
slice
(
0
,
6
)
}
</
div
>
</
div
>
<
p
className=
"mt-4 min-h-[48px] text-sm leading-6 text-foreground/80"
>
{
project
.
description
||
"项目已创建完成,可继续推进剧本拆解、角色设定、分镜与视频生成。"
}
</
p
>
</
p
>
)
}
{
project
.
tags
.
length
>
0
&&
(
<
div
className=
"mt-4 flex flex-wrap gap-2"
>
<
div
className=
"flex flex-wrap gap-1.5 mb-3"
>
{
project
.
tags
.
length
>
0
?
(
{
project
.
tags
.
slice
(
0
,
3
).
map
((
tag
)
=>
(
project
.
tags
.
slice
(
0
,
3
).
map
((
tag
)
=>
(
<
span
key=
{
tag
.
key
}
className=
"px-2 py-0.5 rounded bg-muted text-xs text-foreground"
>
<
span
{
tag
.
label
}
key=
{
tag
}
className=
"rounded-full border border-border/80 bg-white px-3 py-1 text-xs text-muted-foreground"
>
{
tag
}
</
span
>
))
)
:
(
<
span
className=
"rounded-full border border-dashed border-border/80 px-3 py-1 text-xs text-muted-foreground"
>
待补充项目参数
</
span
>
</
span
>
))
}
)
}
</
div
>
)
}
<
div
className=
"flex items-center gap-3 text-xs text-muted-foreground mb-3"
>
<
div
className=
"flex items-center gap-1"
>
<
Clock
className=
"w-3 h-3"
/>
{
project
.
updatedAt
?
new
Date
(
project
.
updatedAt
).
toLocaleDateString
(
"zh-CN"
,
{
month
:
"numeric"
,
day
:
"numeric"
})
:
"—"
}
</
div
>
</
div
>
</
div
>
<
div
className=
"space-y-1.5"
>
<
div
className=
"mt-5"
>
<
div
className=
"flex items-center justify-between text-xs text-muted-foreground"
>
<
div
className=
"mb-2 flex items-center justify-between text-sm text-muted-foreground"
>
<
span
>
完成度
</
span
>
<
span
>
生产完成度
</
span
>
<
span
>
{
project
.
progress
}
%
</
span
>
<
span
>
{
project
.
progress
}
%
</
span
>
</
div
>
</
div
>
<
div
className=
"h-1.5 rounded-full bg-muted overflow-hidden"
>
<
div
className=
"h-2.5 overflow-hidden rounded-full bg-muted"
>
<
div
<
div
className=
"h-full bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] rounded-full transition-all"
className=
"h-full rounded-full bg-[linear-gradient(90deg,#0f74d8_0%,#1ea5ff_100%)] transition-all"
style=
{
{
width
:
`${project.progress}%`
}
}
style=
{
{
width
:
`${project.progress}%`
}
}
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
Link
>
</
div
>
</
div
>
);
)
)
}
}
)
}
</
div
>
</
div
>
)
:
projects
.
length
===
0
?
(
)
:
projects
.
length
===
0
?
(
<
div
className=
"text-center py-16"
>
<
div
className=
"mt-6 rounded-[30px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"
>
<
Film
className=
"w-16 h-16 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
p
className=
"text-muted-foreground mb-4"
>
还没有项目,开始创建第一个吧
</
p
>
<
Film
className=
"h-7 w-7"
/>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有项目
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
从剧本上传或空白创建开始,平台会自动接上大纲、设定、分镜和视频生成流程。
</
p
>
<
Link
<
Link
to=
"/new-project"
to=
"/new-project"
className=
"
inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm hover:shadow-md transition-all
"
className=
"
mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Plus
className=
"
w-4 h
-4"
/>
<
Plus
className=
"
h-4 w
-4"
/>
新建
项目
创建第一个
项目
</
Link
>
</
Link
>
</
div
>
</
div
>
)
:
(
)
:
(
<
div
className=
"text-center py-16"
>
<
div
className=
"mt-6 rounded-[30px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"
>
<
Film
className=
"w-16 h-16 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
p
className=
"text-muted-foreground"
>
没有找到符合条件的项目
</
p
>
<
Search
className=
"h-7 w-7"
/>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
没有匹配的项目
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
当前筛选条件下没有找到对应项目,可以尝试放宽关键词或清除时间范围。
</
p
>
<
button
<
button
onClick=
{
()
=>
{
setSearchTerm
(
""
);
setDateRange
({
start
:
""
,
end
:
""
});
setSelectedMembers
([]);
}
}
onClick=
{
()
=>
{
className=
"mt-4 text-sm text-primary hover:underline"
setSearchTerm
(
""
);
setDateRange
({
start
:
""
,
end
:
""
});
}
}
className=
"mt-6 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
>
清
除所有筛选条件
清
空筛选
</
button
>
</
button
>
</
div
>
</
div
>
)
}
)
}
</
div
>
</
section
>
{
/* Modals */
}
{
editingProject
?
(
{
editingProject
&&
(
<
EditProjectDialog
project=
{
editingProject
}
onClose=
{
()
=>
setEditingProject
(
null
)
}
/>
<
EditModal
)
:
null
}
project=
{
editingProject
}
{
deletingProject
?
(
onClose=
{
()
=>
setEditingProject
(
null
)
}
<
DeleteConfirmDialog
project=
{
deletingProject
}
onClose=
{
()
=>
setDeletingProject
(
null
)
}
/>
/>
)
:
null
}
)
}
{
deletingProject
&&
(
<
DeleteConfirm
project=
{
deletingProject
}
onClose=
{
()
=>
setDeletingProject
(
null
)
}
/>
)
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/EpisodeGeneration.tsx
View file @
75992d7b
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
ArrowRight
,
Film
,
Loader2
,
AlertCircle
,
RefreshCw
}
from
"lucide-react"
;
import
{
AlertCircle
,
ArrowRight
,
Clapperboard
,
Film
,
Loader2
,
RefreshCw
,
Sparkles
,
}
from
"lucide-react"
;
import
{
useEpisodes
}
from
"../../hooks/useAi"
;
import
{
useEpisodes
}
from
"../../hooks/useAi"
;
export
function
EpisodeGeneration
()
{
export
function
EpisodeGeneration
()
{
...
@@ -11,10 +19,14 @@ export function EpisodeGeneration() {
...
@@ -11,10 +19,14 @@ export function EpisodeGeneration() {
if
(
isLoading
||
(
isFetching
&&
episodes
.
length
===
0
))
{
if
(
isLoading
||
(
isFetching
&&
episodes
.
length
===
0
))
{
return
(
return
(
<
div
className=
"h-full flex items-center justify-center bg-background"
>
<
div
className=
"mx-auto max-w-[1180px]"
>
<
div
className=
"text-center"
>
<
div
className=
"flex min-h-[420px] items-center justify-center rounded-[36px] border border-white/80 bg-white/82 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
Loader2
className=
"w-10 h-10 text-primary animate-spin mx-auto mb-4"
/>
<
div
className=
"text-center"
>
<
p
className=
"text-sm text-muted-foreground"
>
加载分集数据...
</
p
>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-[28px] bg-primary/10 text-primary"
>
<
Loader2
className=
"h-8 w-8 animate-spin"
/>
</
div
>
<
p
className=
"mt-5 text-sm text-muted-foreground"
>
正在加载分集数据...
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
@@ -22,10 +34,12 @@ export function EpisodeGeneration() {
...
@@ -22,10 +34,12 @@ export function EpisodeGeneration() {
if
(
error
)
{
if
(
error
)
{
return
(
return
(
<
div
className=
"h-full flex items-center justify-center p-6"
>
<
div
className=
"mx-auto max-w-[1180px]"
>
<
div
className=
"flex items-center gap-3 text-red-600"
>
<
div
className=
"flex min-h-[420px] items-center justify-center rounded-[36px] border border-white/80 bg-white/82 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
AlertCircle
className=
"w-5 h-5"
/>
<
div
className=
"flex items-center gap-3 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-red-700"
>
<
span
className=
"text-sm"
>
{
(
error
as
Error
).
message
}
</
span
>
<
AlertCircle
className=
"h-5 w-5"
/>
<
span
className=
"text-sm"
>
{
(
error
as
Error
).
message
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
@@ -33,24 +47,29 @@ export function EpisodeGeneration() {
...
@@ -33,24 +47,29 @@ export function EpisodeGeneration() {
if
(
episodes
.
length
===
0
)
{
if
(
episodes
.
length
===
0
)
{
return
(
return
(
<
div
className=
"h-full flex items-center justify-center bg-background p-6"
>
<
div
className=
"mx-auto max-w-[1180px]"
>
<
div
className=
"text-center"
>
<
div
className=
"rounded-[36px] border border-white/80 bg-white/82 px-6 py-16 text-center shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
Film
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-[28px] bg-primary/10 text-primary"
>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
暂无分集
</
h3
>
<
Film
className=
"h-8 w-8"
/>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
请先在大纲页面生成分集内容
</
p
>
</
div
>
<
div
className=
"flex gap-3 justify-center"
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
暂时还没有分集内容
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
请先在大纲页面生成分集内容,系统会自动把结果同步到这里,供你继续进入分镜工作台。
</
p
>
<
div
className=
"mt-6 flex flex-wrap justify-center gap-3"
>
<
button
<
button
onClick=
{
()
=>
refetch
()
}
onClick=
{
()
=>
refetch
()
}
className=
"
px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm flex items-center gap-2
"
className=
"
inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted
"
>
>
<
RefreshCw
className=
"
w-4 h
-4"
/>
<
RefreshCw
className=
"
h-4 w
-4"
/>
刷新
刷新
</
button
>
</
button
>
<
button
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
className=
"
px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm
"
className=
"
inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
返回大纲页面
<
Sparkles
className=
"h-4 w-4"
/>
返回大纲页
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -59,57 +78,70 @@ export function EpisodeGeneration() {
...
@@ -59,57 +78,70 @@ export function EpisodeGeneration() {
}
}
return
(
return
(
<
div
className=
"
h-full overflow-auto bg-background p-6
"
>
<
div
className=
"
mx-auto max-w-[1180px]
"
>
<
div
className=
"max-w-6xl mx-auto
"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl
"
>
<
div
className=
"mb-6 flex
items-center justify-between
"
>
<
div
className=
"mb-6 flex
flex-wrap items-start justify-between gap-4
"
>
<
div
>
<
div
>
<
h1
className=
"text-2xl font-semibold text-foreground mb-1"
>
分集列表
</
h1
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Episodes
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
AI已生成
{
episodes
.
length
}
集内容
</
p
>
<
h1
className=
"mt-2 text-3xl font-semibold text-foreground"
>
分集列表
</
h1
>
<
p
className=
"mt-3 text-sm leading-6 text-muted-foreground"
>
AI 已生成
{
episodes
.
length
}
集内容,建议按顺序进入分镜工作台继续推进镜头设计。
</
p
>
</
div
>
</
div
>
{
isFetching
&&
(
<
div
className=
"flex items-center gap-2 text-sm text-muted-foreground"
>
{
isFetching
?
(
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-accent px-4 py-2 text-sm text-primary"
>
刷新中...
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
正在刷新
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
<
div
className=
"grid g
rid-cols-1 gap-3 mb-8
"
>
<
div
className=
"grid g
ap-4
"
>
{
episodes
.
map
((
ep
)
=>
(
{
episodes
.
map
((
ep
isode
)
=>
(
<
div
<
button
key=
{
ep
.
id
}
key=
{
ep
isode
.
id
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${ep.id}`
)
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${ep
isode
.id}`
)
}
className=
"rounded-
xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer
"
className=
"rounded-
[28px] border border-white/80 bg-white/78 p-5 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20
"
>
>
<
div
className=
"flex items-start gap-4"
>
<
div
className=
"flex items-start gap-4"
>
<
div
className=
"
w-10 h-10 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center flex-shrink-0 text-white font-semibold text-sm
"
>
<
div
className=
"
flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-3xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-sm font-semibold text-white shadow-[0_14px_24px_rgba(15,116,216,0.16)]
"
>
{
ep
.
episodeNumber
}
{
ep
isode
.
episodeNumber
}
</
div
>
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"flex items-center gap-2 mb-1"
>
<
div
className=
"flex items-center gap-2"
>
<
Film
className=
"w-3.5 h-3.5 text-primary flex-shrink-0"
/>
<
Clapperboard
className=
"h-4 w-4 text-primary"
/>
<
span
className=
"font-medium text-foreground text-sm"
>
{
ep
.
title
||
`第${ep.episodeNumber}集`
}
</
span
>
<
span
className=
"text-lg font-medium text-foreground"
>
{
episode
.
title
||
`第 ${episode.episodeNumber} 集`
}
</
span
>
</
div
>
</
div
>
<
p
className=
"text-xs text-muted-foreground line-clamp-2"
>
{
ep
.
summary
}
</
p
>
<
p
className=
"mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground"
>
{
episode
.
summary
}
</
p
>
</
div
>
</
div
>
<
span
className=
{
`text-xs px-2 py-1 rounded-full flex-shrink-0 ${
<
span
ep.status === "ready" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
className=
{
[
}`
}
>
"rounded-full px-3 py-1 text-xs"
,
{
ep
.
status
===
"ready"
?
"已就绪"
:
"草稿"
}
episode
.
status
===
"ready"
?
"bg-emerald-100 text-emerald-700"
:
"bg-slate-100 text-slate-600"
,
].
join
(
" "
)
}
>
{
episode
.
status
===
"ready"
?
"已就绪"
:
"草稿"
}
</
span
>
</
span
>
</
div
>
</
div
>
</
div
>
</
button
>
))
}
))
}
</
div
>
</
div
>
<
button
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${episodes[0].id}`
)
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${episodes[0].id}`
)
}
className=
"
w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all
"
className=
"
mt-6 inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px]
"
>
>
<
span
>
进入分镜工作台
</
span
>
进入分镜工作台
<
ArrowRight
className=
"
w-5 h
-5"
/>
<
ArrowRight
className=
"
h-5 w
-5"
/>
</
button
>
</
button
>
</
div
>
</
section
>
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/LoginPage.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
Link
}
from
"react-router"
;
import
{
Link
}
from
"react-router"
;
import
{
LockKeyhole
,
Mail
,
ShieldCheck
}
from
"lucide-react"
;
import
{
Button
}
from
"../components/ui/button"
;
import
{
Button
}
from
"../components/ui/button"
;
import
{
Input
}
from
"../components/ui/input"
;
import
{
Input
}
from
"../components/ui/input"
;
import
{
Label
}
from
"../components/ui/label"
;
import
{
Label
}
from
"../components/ui/label"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
}
from
"../components/ui/card"
;
import
{
useAuth
}
from
"../../hooks/useAuth"
;
import
{
useAuth
}
from
"../../hooks/useAuth"
;
import
{
AuthShell
}
from
"../components/AuthShell"
;
export
function
LoginPage
()
{
export
function
LoginPage
()
{
const
{
login
,
loginPending
,
loginError
}
=
useAuth
();
const
{
login
,
loginPending
,
loginError
}
=
useAuth
();
...
@@ -16,56 +17,78 @@ export function LoginPage() {
...
@@ -16,56 +17,78 @@ export function LoginPage() {
};
};
return
(
return
(
<
div
className=
"min-h-screen flex items-center justify-center bg-background px-4"
>
<
AuthShell
<
Card
className=
"w-full max-w-sm"
>
title=
"登录企业工作台"
<
CardHeader
className=
"text-center"
>
description=
"使用企业账号进入中科集团 AI 短剧工业化平台,继续推进剧本、设定、分镜与视频任务。"
<
CardTitle
className=
"text-2xl"
>
YaoAI Comic Studio
</
CardTitle
>
footer=
{
<
CardDescription
>
登录您的账号
</
CardDescription
>
<
div
className=
"flex items-center justify-between gap-3"
>
</
CardHeader
>
<
div
className=
"inline-flex items-center gap-2 text-xs text-muted-foreground"
>
<
CardContent
>
<
ShieldCheck
className=
"h-4 w-4 text-primary"
/>
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-4"
>
统一身份认证与团队空间隔离
<
div
className=
"space-y-2"
>
</
div
>
<
Label
htmlFor=
"email"
>
邮箱
</
Label
>
<
div
>
<
Input
没有账号?
id=
"email"
<
Link
to=
"/register"
className=
"ml-1 font-medium text-primary hover:underline"
>
type=
"email"
立即注册
placeholder=
"you@example.com"
</
Link
>
required
</
div
>
value=
{
form
.
email
}
</
div
>
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
email
:
e
.
target
.
value
}))
}
}
/>
>
</
div
>
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-5"
>
<
div
className=
"space-y-2"
>
<
div
className=
"space-y-2"
>
<
Label
htmlFor=
"password"
>
密码
</
Label
>
<
Label
htmlFor=
"email"
className=
"text-sm text-foreground"
>
<
Input
邮箱账号
id=
"password"
</
Label
>
type=
"password"
<
div
className=
"relative"
>
placeholder=
"••••••••"
<
Mail
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
required
<
Input
value=
{
form
.
password
}
id=
"email"
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
password
:
e
.
target
.
value
}))
}
type=
"email"
/>
placeholder=
"you@example.com"
</
div
>
required
value=
{
form
.
email
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
email
:
e
.
target
.
value
}))
}
className=
"h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</
div
>
</
div
>
{
loginError
&&
(
<
div
className=
"space-y-2"
>
<
p
className=
"text-sm text-destructive"
>
<
div
className=
"flex items-center justify-between"
>
{
loginError
instanceof
Error
?
loginError
.
message
:
"登录失败"
}
<
Label
htmlFor=
"password"
className=
"text-sm text-foreground"
>
</
p
>
登录密码
)
}
</
Label
>
<
span
className=
"text-xs text-muted-foreground"
>
建议使用企业统一密码
</
span
>
</
div
>
<
div
className=
"relative"
>
<
LockKeyhole
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
Input
id=
"password"
type=
"password"
placeholder=
"••••••••"
required
value=
{
form
.
password
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
password
:
e
.
target
.
value
}))
}
className=
"h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</
div
>
</
div
>
<
Button
type=
"submit"
className=
"w-full"
disabled=
{
loginPending
}
>
{
loginError
?
(
{
loginPending
?
"登录中..."
:
"登录"
}
<
div
className=
"rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
>
</
Button
>
{
loginError
instanceof
Error
?
loginError
.
message
:
"登录失败,请检查账号和密码。"
}
</
div
>
)
:
null
}
<
p
className=
"text-center text-sm text-muted-foreground"
>
<
Button
没有账号?
{
" "
}
type=
"submit"
<
Link
to=
"/register"
className=
"text-primary underline-offset-4 hover:underline"
>
className=
"h-12 w-full rounded-xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-base font-medium shadow-[0_16px_30px_rgba(15,116,216,0.22)] transition hover:translate-y-[-1px] hover:shadow-[0_20px_38px_rgba(15,116,216,0.26)]"
立即注册
disabled=
{
loginPending
}
</
Link
>
>
</
p
>
{
loginPending
?
"正在登录..."
:
"进入工作台"
}
</
form
>
</
Button
>
</
CardContent
>
</
form
>
</
Card
>
</
AuthShell
>
</
div
>
);
);
}
}
doc/html/src/app/pages/ModelSettings.tsx
View file @
75992d7b
import
{
use
State
,
useEffect
}
from
"react"
;
import
{
use
Effect
,
useMemo
,
useState
}
from
"react"
;
import
{
import
{
Sparkles
,
Plus
,
Trash2
,
X
,
Check
,
Check
,
Search
,
MessageSquare
,
Image
as
ImageIcon
,
Image
as
ImageIcon
,
Video
,
Zap
,
Settings2
,
Loader2
,
Loader2
,
MessageSquare
,
Plus
,
Save
,
Search
,
Settings2
,
Sparkles
,
Trash2
,
Video
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
import
{
useModelProviders
,
useCreateProvider
,
useCreateProvider
,
useUpdateProvider
,
useDeleteProvider
,
useDeleteProvider
,
useModelDefaults
,
useModelDefaults
,
useModelProviders
,
useSaveModelDefaults
,
useSaveModelDefaults
,
useUpdateProvider
,
}
from
"../../hooks/useModelProviders"
;
}
from
"../../hooks/useModelProviders"
;
import
type
{
ModelProvider
,
ModelDefaults
,
CreateProviderPayload
}
from
"../../lib/api/modelProviders"
;
import
type
{
CreateProviderPayload
,
ModelDefaults
,
ModelProvider
,
}
from
"../../lib/api/modelProviders"
;
type
Capability
=
"text"
|
"image"
|
"video"
;
type
Capability
=
"text"
|
"image"
|
"video"
;
type
Protocol
=
"openai"
|
"gemini"
|
"dashscope"
|
"seedance"
|
"kling"
;
type
Protocol
=
"openai"
|
"gemini"
|
"dashscope"
|
"seedance"
|
"kling"
;
const
protocolOptions
:
{
value
:
Protocol
;
label
:
string
;
capabilities
:
Capability
[]
}[]
=
[
const
capabilityMeta
=
{
text
:
{
label
:
"语言模型"
,
icon
:
MessageSquare
,
description
:
"用于剧本解析、角色提取与文本内容生成。"
,
},
image
:
{
label
:
"图片模型"
,
icon
:
ImageIcon
,
description
:
"用于角色、场景与道具图像生成。"
,
},
video
:
{
label
:
"视频模型"
,
icon
:
Video
,
description
:
"用于镜头视频生成与视频制作环节。"
,
},
}
satisfies
Record
<
Capability
,
{
label
:
string
;
icon
:
typeof
MessageSquare
;
description
:
string
}
>
;
const
protocolOptions
:
Array
<
{
value
:
Protocol
;
label
:
string
;
capabilities
:
Capability
[]
}
>
=
[
{
value
:
"openai"
,
label
:
"OpenAI 兼容"
,
capabilities
:
[
"text"
,
"image"
]
},
{
value
:
"openai"
,
label
:
"OpenAI 兼容"
,
capabilities
:
[
"text"
,
"image"
]
},
{
value
:
"dashscope"
,
label
:
"阿里云百炼
(DashScope)
"
,
capabilities
:
[
"text"
,
"image"
]
},
{
value
:
"dashscope"
,
label
:
"阿里云百炼"
,
capabilities
:
[
"text"
,
"image"
]
},
{
value
:
"gemini"
,
label
:
"Google Gemini"
,
capabilities
:
[
"text"
,
"image"
,
"video"
]
},
{
value
:
"gemini"
,
label
:
"Google Gemini"
,
capabilities
:
[
"text"
,
"image"
,
"video"
]
},
{
value
:
"seedance"
,
label
:
"字节
跳动
Seedance"
,
capabilities
:
[
"video"
]
},
{
value
:
"seedance"
,
label
:
"字节 Seedance"
,
capabilities
:
[
"video"
]
},
{
value
:
"kling"
,
label
:
"
快手
可灵 Kling"
,
capabilities
:
[
"image"
,
"video"
]
},
{
value
:
"kling"
,
label
:
"可灵 Kling"
,
capabilities
:
[
"image"
,
"video"
]
},
];
];
const
capabilityConfig
=
{
text
:
{
label
:
"语言模型 (LLM)"
,
icon
:
MessageSquare
,
color
:
"blue"
,
description
:
"用于剧本解析、角色提取、分镜规划等文本生成任务"
},
image
:
{
label
:
"图片模型"
,
icon
:
ImageIcon
,
color
:
"green"
,
description
:
"用于角色四视图、场景图、道具图等图片生成任务"
},
video
:
{
label
:
"视频模型"
,
icon
:
Video
,
color
:
"purple"
,
description
:
"用于分镜视频生成、首尾帧插值等视频生成任务"
},
};
const
colorMap
:
Record
<
string
,
string
>
=
{
blue
:
"from-blue-500 to-blue-600"
,
green
:
"from-green-500 to-green-600"
,
purple
:
"from-purple-500 to-purple-600"
,
};
const
bgColorMap
:
Record
<
string
,
string
>
=
{
blue
:
"bg-blue-500/10 text-blue-600"
,
green
:
"bg-green-500/10 text-green-600"
,
purple
:
"bg-purple-500/10 text-purple-600"
,
};
type
EditingProvider
=
Omit
<
ModelProvider
,
"id"
>
&
{
id
?:
number
};
type
EditingProvider
=
Omit
<
ModelProvider
,
"id"
>
&
{
id
?:
number
};
export
function
ModelSettings
()
{
export
function
ModelSettings
()
{
const
[
activeCapability
,
setActiveCapability
]
=
useState
<
Capability
>
(
"text"
);
const
[
activeCapability
,
setActiveCapability
]
=
useState
<
Capability
>
(
"text"
);
const
[
show
AddModal
,
setShowAdd
Modal
]
=
useState
(
false
);
const
[
show
Modal
,
setShow
Modal
]
=
useState
(
false
);
const
[
editingProvider
,
setEditingProvider
]
=
useState
<
EditingProvider
|
null
>
(
null
);
const
[
editingProvider
,
setEditingProvider
]
=
useState
<
EditingProvider
|
null
>
(
null
);
const
[
modelSearch
,
setModelSearch
]
=
useState
(
""
);
const
[
modelSearch
,
setModelSearch
]
=
useState
(
""
);
...
@@ -69,70 +74,72 @@ export function ModelSettings() {
...
@@ -69,70 +74,72 @@ export function ModelSettings() {
const
[
localDefaults
,
setLocalDefaults
]
=
useState
<
ModelDefaults
>
({});
const
[
localDefaults
,
setLocalDefaults
]
=
useState
<
ModelDefaults
>
({});
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
loadingDefaults
)
setLocalDefaults
(
defaults
);
if
(
!
loadingDefaults
)
{
setLocalDefaults
(
defaults
);
}
},
[
loadingDefaults
,
defaults
]);
},
[
loadingDefaults
,
defaults
]);
const
getProvidersByCapability
=
(
cap
:
Capability
)
=>
const
providersByCapability
=
useMemo
(
providers
.
filter
((
p
)
=>
p
.
capability
===
cap
);
()
=>
providers
.
filter
((
provider
)
=>
provider
.
capability
===
activeCapability
),
[
providers
,
activeCapability
],
const
getCheckedModels
=
(
cap
:
Capability
)
=>
{
);
const
result
:
{
providerId
:
number
;
providerName
:
string
;
modelId
:
string
;
modelName
:
string
}[]
=
[];
providers
.
filter
((
p
)
=>
p
.
capability
===
cap
)
.
forEach
((
p
)
=>
{
(
p
.
models
??
[]).
filter
((
m
)
=>
m
.
checked
).
forEach
((
m
)
=>
{
result
.
push
({
providerId
:
p
.
id
,
providerName
:
p
.
name
,
modelId
:
m
.
id
,
modelName
:
m
.
name
});
});
});
return
result
;
};
const
handleToggleModel
=
async
(
provider
:
ModelProvider
,
modelId
:
string
)
=>
{
const
updated
=
{
...
provider
,
models
:
provider
.
models
.
map
((
m
)
=>
m
.
id
===
modelId
?
{
...
m
,
checked
:
!
m
.
checked
}
:
m
),
};
await
updateProvider
.
mutateAsync
({
id
:
provider
.
id
,
patch
:
updated
});
};
const
handleDeleteProvider
=
async
(
id
:
number
)
=>
{
const
checkedModels
=
useMemo
(()
=>
{
if
(
!
confirm
(
"确定要删除该供应商配置吗?"
))
return
;
return
providers
.
map
((
provider
)
=>
({
await
deleteProvider
.
mutateAsync
(
id
);
provider
,
};
models
:
provider
.
models
.
filter
((
model
)
=>
model
.
checked
),
}));
},
[
providers
]);
const
open
AddProvider
=
(
capability
:
Capability
)
=>
{
const
open
Create
=
(
capability
:
Capability
)
=>
{
const
proto
=
protocolOptions
.
find
((
p
)
=>
p
.
capabilities
.
includes
(
capability
));
const
proto
col
=
protocolOptions
.
find
((
item
)
=>
item
.
capabilities
.
includes
(
capability
));
setEditingProvider
({
setEditingProvider
({
name
:
""
,
name
:
""
,
protocol
:
proto
?.
value
||
"openai"
,
protocol
:
proto
col
?.
value
??
"openai"
,
capability
,
capability
,
baseUrl
:
""
,
baseUrl
:
""
,
apiKey
:
""
,
apiKey
:
""
,
secretKey
:
""
,
secretKey
:
""
,
models
:
[],
models
:
[],
});
});
setShowAddModal
(
true
);
setShowModal
(
true
);
};
const
handleToggleModel
=
async
(
provider
:
ModelProvider
,
modelId
:
string
)
=>
{
const
patch
=
{
...
provider
,
models
:
provider
.
models
.
map
((
model
)
=>
model
.
id
===
modelId
?
{
...
model
,
checked
:
!
model
.
checked
}
:
model
,
),
};
await
updateProvider
.
mutateAsync
({
id
:
provider
.
id
,
patch
});
};
const
handleDeleteProvider
=
async
(
id
:
number
)
=>
{
if
(
!
confirm
(
"确定要删除该模型提供商配置吗?"
))
return
;
await
deleteProvider
.
mutateAsync
(
id
);
};
};
const
handleSaveProvider
=
async
()
=>
{
const
handleSaveProvider
=
async
()
=>
{
if
(
!
editingProvider
||
!
editingProvider
.
name
.
trim
())
return
;
if
(
!
editingProvider
||
!
editingProvider
.
name
.
trim
())
return
;
const
payload
:
CreateProviderPayload
=
{
const
payload
:
CreateProviderPayload
=
{
name
:
editingProvider
.
name
,
name
:
editingProvider
.
name
,
protocol
:
editingProvider
.
protocol
,
protocol
:
editingProvider
.
protocol
,
capability
:
editingProvider
.
capability
as
Capability
,
capability
:
editingProvider
.
capability
,
baseUrl
:
editingProvider
.
baseUrl
,
baseUrl
:
editingProvider
.
baseUrl
,
apiKey
:
editingProvider
.
apiKey
,
apiKey
:
editingProvider
.
apiKey
,
secretKey
:
editingProvider
.
secretKey
||
""
,
secretKey
:
editingProvider
.
secretKey
,
models
:
editingProvider
.
models
,
models
:
editingProvider
.
models
,
};
};
if
(
editingProvider
.
id
)
{
if
(
editingProvider
.
id
)
{
await
updateProvider
.
mutateAsync
({
id
:
editingProvider
.
id
,
patch
:
payload
});
await
updateProvider
.
mutateAsync
({
id
:
editingProvider
.
id
,
patch
:
payload
});
}
else
{
}
else
{
await
createProvider
.
mutateAsync
(
payload
);
await
createProvider
.
mutateAsync
(
payload
);
}
}
setShowAddModal
(
false
);
setShowModal
(
false
);
setEditingProvider
(
null
);
setEditingProvider
(
null
);
};
};
...
@@ -142,394 +149,340 @@ export function ModelSettings() {
...
@@ -142,394 +149,340 @@ export function ModelSettings() {
if
(
loadingProviders
)
{
if
(
loadingProviders
)
{
return
(
return
(
<
div
className=
"
h-full flex
items-center justify-center"
>
<
div
className=
"
flex h-full
items-center justify-center"
>
<
Loader2
className=
"
w-6 h-6
animate-spin text-primary"
/>
<
Loader2
className=
"
h-8 w-8
animate-spin text-primary"
/>
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"p-6"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"max-w-5xl mx-auto"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
{
/* Header */
}
<
div
>
<
div
className=
"mb-8"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
Settings2
className=
"h-3.5 w-3.5"
/>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
Model Hub
<
Settings2
className=
"w-4 h-4 text-white"
/>
</
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
模型配置
</
h1
>
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
模型配置中心
</
h1
>
配置 AI 模型供应商和默认模型,支持语言模型、图片模型和视频模型的独立配置
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
管理语言、图像与视频模型提供商,并为每类能力设置默认模型,保证生产流程的调用一致性。
</
p
>
</
p
>
</
div
>
</
div
>
{
/* Default Model Selection */
}
<
button
<
div
className=
"rounded-xl border border-border bg-card p-5 mb-6"
>
onClick=
{
()
=>
openCreate
(
activeCapability
)
}
<
h2
className=
"text-base font-semibold text-foreground mb-4 flex items-center gap-2"
>
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
<
Zap
className=
"w-4 h-4 text-yellow-500"
/>
>
默认模型
<
Plus
className=
"h-4 w-4"
/>
</
h2
>
新建模型提供商
<
p
className=
"text-xs text-muted-foreground mb-4"
>
</
button
>
选择每种任务类型的默认模型,生成时可临时切换。仅显示已勾选的模型。
</
div
>
</
p
>
<
div
className=
"grid grid-cols-3 gap-4"
>
<
div
className=
"mt-6 grid gap-6 xl:grid-cols-[1fr_0.96fr]"
>
{
([
"text"
,
"image"
,
"video"
]
as
Capability
[]).
map
((
cap
)
=>
{
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
const
config
=
capabilityConfig
[
cap
];
<
div
className=
"mb-4 flex items-center justify-between"
>
const
Icon
=
config
.
icon
;
<
div
>
const
checked
=
getCheckedModels
(
cap
);
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Default Models
</
div
>
const
current
=
localDefaults
[
cap
];
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
默认模型
</
h2
>
</
div
>
<
button
onClick=
{
handleSaveDefaults
}
disabled=
{
saveDefaults
.
isPending
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{
saveDefaults
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Save
className=
"h-4 w-4"
/>
}
保存默认模型
</
button
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-3"
>
{
([
"text"
,
"image"
,
"video"
]
as
Capability
[]).
map
((
capability
)
=>
{
const
meta
=
capabilityMeta
[
capability
];
const
Icon
=
meta
.
icon
;
const
available
=
checkedModels
.
flatMap
(({
provider
,
models
})
=>
models
.
filter
(()
=>
provider
.
capability
===
capability
)
.
map
((
model
)
=>
({
providerId
:
provider
.
id
,
providerName
:
provider
.
name
,
modelId
:
model
.
id
,
modelName
:
model
.
name
,
})),
);
return
(
return
(
<
div
key=
{
cap
}
className=
"rounded-lg border border-border p-4"
>
<
div
<
div
className=
"flex items-center gap-2 mb-3"
>
key=
{
capability
}
<
div
className=
{
`w-7 h-7 rounded-md flex items-center justify-center ${bgColorMap[config.color]}`
}
>
className=
"rounded-[28px] border border-border/80 bg-white px-4 py-5 shadow-sm"
<
Icon
className=
"w-3.5 h-3.5"
/
>
>
</
div
>
<
div
className=
"mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
<
span
className=
"text-sm font-medium text-foreground"
>
{
config
.
label
}
</
span
>
<
Icon
className=
"h-5 w-5"
/
>
</
div
>
</
div
>
<
div
className=
"text-base font-medium text-foreground"
>
{
meta
.
label
}
</
div
>
<
div
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
{
meta
.
description
}
</
div
>
<
select
<
select
value=
{
current
?
`${current.providerId}:${current.modelId}`
:
""
}
value=
{
localDefaults
[
capability
]
?
`${localDefaults[capability]?.providerId}:${localDefaults[capability]?.modelId}`
:
""
}
onChange=
{
(
e
)
=>
{
onChange=
{
(
e
)
=>
{
if
(
!
e
.
target
.
value
)
{
if
(
!
e
.
target
.
value
)
{
setLocalDefaults
(
{
...
localDefaults
,
[
cap
]:
null
}
);
setLocalDefaults
(
(
current
)
=>
({
...
current
,
[
capability
]:
null
})
);
}
else
{
}
else
{
const
[
pid
,
mid
]
=
e
.
target
.
value
.
split
(
":"
);
const
[
providerId
,
modelId
]
=
e
.
target
.
value
.
split
(
":"
);
setLocalDefaults
({
...
localDefaults
,
[
cap
]:
{
providerId
:
Number
(
pid
),
modelId
:
mid
}
});
setLocalDefaults
((
current
)
=>
({
...
current
,
[
capability
]:
{
providerId
:
Number
(
providerId
),
modelId
},
}));
}
}
}
}
}
}
className=
"
w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer
"
className=
"
mt-4 h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30
"
>
>
<
option
value=
""
>
未选择
</
option
>
<
option
value=
""
>
未选择
</
option
>
{
checked
.
map
((
m
)
=>
(
{
available
.
map
((
model
)
=>
(
<
option
key=
{
`${m.providerId}:${m.modelId}`
}
value=
{
`${m.providerId}:${m.modelId}`
}
>
<
option
{
m
.
providerName
}
/
{
m
.
modelName
}
key=
{
`${model.providerId}:${model.modelId}`
}
value=
{
`${model.providerId}:${model.modelId}`
}
>
{
model
.
providerName
}
/
{
model
.
modelName
}
</
option
>
</
option
>
))
}
))
}
</
select
>
</
select
>
{
checked
.
length
===
0
&&
(
<
p
className=
"text-xs text-muted-foreground mt-2"
>
请先在下方勾选可用模型
</
p
>
)
}
</
div
>
</
div
>
);
);
})
}
})
}
</
div
>
</
div
>
<
div
className=
"mt-4 flex justify-end"
>
<
button
onClick=
{
handleSaveDefaults
}
disabled=
{
saveDefaults
.
isPending
}
className=
"px-4 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90 transition-opacity flex items-center gap-2 disabled:opacity-60"
>
{
saveDefaults
.
isPending
?
<
Loader2
className=
"w-3.5 h-3.5 animate-spin"
/>
:
<
Check
className=
"w-3.5 h-3.5"
/>
}
保存默认模型
</
button
>
</
div
>
</
div
>
</
div
>
{
/* Capability Tabs */
}
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
className=
"flex items-center gap-2 mb-6"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
{
([
"text"
,
"image"
,
"video"
]
as
Capability
[]).
map
((
cap
)
=>
{
<
div
>
const
config
=
capabilityConfig
[
cap
];
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Provider Overview
</
div
>
const
Icon
=
config
.
icon
;
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
能力分类
</
h2
>
const
count
=
getProvidersByCapability
(
cap
).
length
;
</
div
>
return
(
</
div
>
<
button
key=
{
cap
}
<
div
className=
"grid gap-4 md:grid-cols-3"
>
onClick=
{
()
=>
setActiveCapability
(
cap
)
}
{
([
"text"
,
"image"
,
"video"
]
as
Capability
[]).
map
((
capability
)
=>
{
className=
{
`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm transition-all ${
const
meta
=
capabilityMeta
[
capability
];
activeCapability === cap
const
Icon
=
meta
.
icon
;
? `
bg
-
gradient
-
to
-
r
$
{
colorMap
[
config
.
color
]}
text
-
white
shadow
-
sm
`
const
active
=
activeCapability
===
capability
;
: "bg-card border border-border text-foreground hover:bg-muted"
return
(
}`
}
<
button
>
key=
{
capability
}
<
Icon
className=
"w-4 h-4"
/>
onClick=
{
()
=>
setActiveCapability
(
capability
)
}
{
config
.
label
}
className=
{
[
<
span
className=
{
`px-1.5 py-0.5 rounded text-xs ${
"rounded-[28px] border px-5 py-5 text-left transition-all"
,
activeCapability === cap ? "bg-white/20" : "bg-muted text-muted-foreground"
active
}`
}
>
?
"border-primary/20 bg-primary/5 shadow-[0_16px_28px_rgba(15,116,216,0.10)]"
{
count
}
:
"border-border/80 bg-white shadow-sm hover:border-primary/20"
,
</
span
>
].
join
(
" "
)
}
</
button
>
>
);
<
div
className=
"mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
})
}
<
Icon
className=
"h-5 w-5"
/>
</
div
>
<
div
className=
"text-base font-medium text-foreground"
>
{
meta
.
label
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
providers
.
filter
((
provider
)
=>
provider
.
capability
===
capability
).
length
}
个提供商
</
div
>
</
button
>
);
})
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-lg border border-border bg-muted/30 p-3 mb-4 flex items-start gap-2"
>
<
div
className=
"mt-6 rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
Sparkles
className=
"w-4 h-4 text-primary mt-0.5 flex-shrink-0"
/>
<
div
className=
"mb-4 flex flex-wrap items-center justify-between gap-4"
>
<
span
className=
"text-xs text-muted-foreground"
>
<
div
>
{
capabilityConfig
[
activeCapability
].
description
}
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Provider List
</
div
>
</
span
>
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
{
capabilityMeta
[
activeCapability
].
label
}
提供商
</
h2
>
</
div
>
<
div
className=
"relative w-full max-w-sm"
>
<
Search
className=
"pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
input
value=
{
modelSearch
}
onChange=
{
(
e
)
=>
setModelSearch
(
e
.
target
.
value
)
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"搜索模型"
/>
</
div
>
</
div
>
</
div
>
{
/* Provider List */
}
<
div
className=
"space-y-4"
>
<
div
className=
"space-y-4"
>
{
getProvidersByCapability
(
activeCapability
).
map
((
provider
)
=>
(
{
providersByCapability
.
length
===
0
?
(
<
div
key=
{
provider
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden"
>
<
div
className=
"rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"
>
<
div
className=
"p-4 border-b border-border"
>
当前能力下还没有配置提供商,请先新增一个模型提供商。
<
div
className=
"flex items-center justify-between"
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
)
:
(
<
div
className=
{
`w-8 h-8 rounded-lg flex items-center justify-center ${bgColorMap[capabilityConfig[activeCapability].color]}`
}
>
providersByCapability
.
map
((
provider
)
=>
(
<
span
className=
"text-xs font-bold"
>
{
provider
.
name
[
0
]
}
</
span
>
<
div
key=
{
provider
.
id
}
className=
"rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
div
>
<
div
className=
"text-lg font-medium text-foreground"
>
{
provider
.
name
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
协议:
{
protocolOptions
.
find
((
option
)
=>
option
.
value
===
provider
.
protocol
)?.
label
??
provider
.
protocol
}
</
div
>
</
div
>
<
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
<
h3
className=
"text-sm font-semibold text-foreground"
>
{
provider
.
name
}
</
h3
>
接口地址:
{
provider
.
baseUrl
||
"未设置"
}
<
div
className=
"flex items-center gap-2 mt-0.5"
>
<
span
className=
"px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground"
>
{
protocolOptions
.
find
((
p
)
=>
p
.
value
===
provider
.
protocol
)?.
label
}
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
{
(
provider
.
models
??
[]).
filter
((
m
)
=>
m
.
checked
).
length
}
/
{
(
provider
.
models
??
[]).
length
}
模型已启用
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
button
<
button
onClick=
{
()
=>
handleDeleteProvider
(
provider
.
id
)
}
onClick=
{
()
=>
{
setEditingProvider
({
...
provider
});
setShowAddModal
(
true
);
}
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
className=
"px-3 py-1.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1"
>
>
<
Trash2
className=
"h-4 w-4"
/>
<
Settings2
className=
"w-3 h-3"
/>
删除
配置
</
button
>
</
button
>
<
button
onClick=
{
()
=>
handleDeleteProvider
(
provider
.
id
)
}
disabled=
{
deleteProvider
.
isPending
}
className=
"p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
<
Trash2
className=
"w-3 h-3"
/>
</
button
>
</
div
>
</
div
>
<
div
className=
"mt-3 flex items-center gap-4"
>
<
div
className=
"flex items-center gap-2 flex-1"
>
<
span
className=
"text-xs text-muted-foreground w-16"
>
Base URL:
</
span
>
<
span
className=
"text-xs text-foreground font-mono truncate"
>
{
provider
.
baseUrl
||
"未配置"
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-xs text-muted-foreground"
>
API Key:
</
span
>
{
provider
.
apiKey
?
(
<
span
className=
"text-xs text-foreground font-mono"
>
{
provider
.
apiKey
}
</
span
>
)
:
(
<
span
className=
"text-xs text-yellow-600"
>
未配置
</
span
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Model List */
}
<
div
className=
"mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3"
>
<
div
className=
"p-4"
>
{
provider
.
models
<
div
className=
"flex items-center justify-between mb-3"
>
.
filter
((
model
)
=>
!
modelSearch
||
model
.
name
.
toLowerCase
().
includes
(
modelSearch
.
toLowerCase
()))
<
span
className=
"text-xs text-muted-foreground font-medium"
>
可用模型
</
span
>
.
map
((
model
)
=>
(
<
div
className=
"relative"
>
<
button
<
Search
className=
"absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground"
/>
key=
{
model
.
id
}
<
input
onClick=
{
()
=>
handleToggleModel
(
provider
,
model
.
id
)
}
type=
"text"
className=
{
[
placeholder=
"搜索模型..."
"rounded-[20px] border px-4 py-4 text-left transition-all"
,
value=
{
modelSearch
}
model
.
checked
onChange=
{
(
e
)
=>
setModelSearch
(
e
.
target
.
value
)
}
?
"border-primary/20 bg-primary/5"
className=
"pl-7 pr-3 py-1 rounded-md border border-border bg-background text-xs text-foreground placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/20 w-48"
:
"border-border/80 bg-white hover:border-primary/20"
,
/>
].
join
(
" "
)
}
</
div
>
>
</
div
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"space-y-1"
>
<
div
>
{
(
provider
.
models
??
[])
<
div
className=
"text-sm font-medium text-foreground"
>
{
model
.
name
}
</
div
>
.
filter
((
m
)
=>
!
modelSearch
||
m
.
name
.
toLowerCase
().
includes
(
modelSearch
.
toLowerCase
())
||
m
.
id
.
toLowerCase
().
includes
(
modelSearch
.
toLowerCase
()))
<
div
className=
"mt-1 text-xs text-muted-foreground"
>
{
model
.
id
}
</
div
>
.
map
((
model
)
=>
{
const
isDefault
=
localDefaults
[
provider
.
capability
as
Capability
]?.
providerId
===
provider
.
id
&&
localDefaults
[
provider
.
capability
as
Capability
]?.
modelId
===
model
.
id
;
return
(
<
button
key=
{
model
.
id
}
onClick=
{
()
=>
handleToggleModel
(
provider
,
model
.
id
)
}
className=
{
`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all text-left ${
model.checked ? "bg-accent border border-primary/30" : "border border-transparent hover:bg-muted"
}`
}
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
{
`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${
model.checked ? "border-primary bg-primary" : "border-muted-foreground"
}`
}
>
{
model
.
checked
&&
<
Check
className=
"w-3 h-3 text-white"
/>
}
</
div
>
<
div
>
<
span
className=
"text-sm text-foreground"
>
{
model
.
name
}
</
span
>
<
span
className=
"text-xs text-muted-foreground ml-2 font-mono"
>
{
model
.
id
}
</
span
>
</
div
>
</
div
>
</
div
>
{
isDefault
&&
(
{
model
.
checked
?
(
<
span
className=
"px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary font-medium"
>
<
div
className=
"flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white"
>
默认
<
Check
className=
"h-3.5 w-3.5"
/>
</
span
>
</
div
>
)
}
)
:
null
}
</
button
>
</
div
>
);
</
button
>
})
}
))
}
{
(
provider
.
models
??
[]).
length
===
0
&&
(
<
p
className=
"text-xs text-muted-foreground py-2 text-center"
>
暂无模型,请编辑供应商添加
</
p
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
))
))
}
)
}
<
button
onClick=
{
()
=>
openAddProvider
(
activeCapability
)
}
className=
"w-full py-4 rounded-xl border-2 border-dashed border-border bg-card hover:bg-muted transition-colors flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<
Plus
className=
"w-4 h-4"
/>
添加
{
capabilityConfig
[
activeCapability
].
label
}
供应商
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
{
/* Add/Edit Provider Modal */
}
{
showModal
&&
editingProvider
?
(
{
showAddModal
&&
editingProvider
&&
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8
"
>
<
div
className=
"w-full max-w-2xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl
"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-lg w-full max-h-[85vh] overflow-auto
"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4
"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Provider Editor
</
div
>
{
editingProvider
.
id
?
"编辑供应商"
:
"添加供应商"
}
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
新建模型提供商
</
h3
>
</
h2
>
</
div
>
<
button
<
button
onClick=
{
()
=>
{
setShowAddModal
(
false
);
setEditingProvider
(
null
);
}
}
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"
p-2 hover:bg-muted rounded-lg transition-colors
"
className=
"
flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary
"
>
>
<
X
className=
"
w-5 h-5 text-muted-foreground
"
/>
<
X
className=
"
h-4 w-4
"
/>
</
button
>
</
button
>
</
div
>
</
div
>
<
div
className=
"space-y-
4
"
>
<
div
className=
"space-y-
5
"
>
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2"
>
供应
商名称
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground"
>
提供
商名称
</
label
>
<
input
<
input
type=
"text"
value=
{
editingProvider
.
name
}
value=
{
editingProvider
.
name
}
onChange=
{
(
e
)
=>
setEditingProvider
(
{
...
editingProvider
,
name
:
e
.
target
.
value
}
)
}
onChange=
{
(
e
)
=>
setEditingProvider
(
(
current
)
=>
current
?
{
...
current
,
name
:
e
.
target
.
value
}
:
current
)
}
placeholder=
"例如:通义千问、OpenAI、Gemini
"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]
"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20
"
placeholder=
"例如:OpenAI、Gemini、可灵
"
/>
/>
</
div
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2
"
>
协议
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground
"
>
协议
</
label
>
<
select
<
select
value=
{
editingProvider
.
protocol
}
value=
{
editingProvider
.
protocol
}
onChange=
{
(
e
)
=>
setEditingProvider
({
...
editingProvider
,
protocol
:
e
.
target
.
value
}
)
}
onChange=
{
(
e
)
=>
setEditingProvider
((
current
)
=>
current
?
{
...
current
,
protocol
:
e
.
target
.
value
as
Protocol
}
:
current
)
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer
"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30
"
>
>
{
protocolOptions
{
protocolOptions
.
map
((
option
)
=>
(
.
filter
((
p
)
=>
p
.
capabilities
.
includes
(
editingProvider
.
capability
as
Capability
))
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
.
map
((
p
)
=>
(
{
option
.
label
}
<
option
key=
{
p
.
value
}
value=
{
p
.
value
}
>
{
p
.
label
}
<
/
option
>
</
option
>
))
}
))
}
</
select
>
</
select
>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
能力类型
</
label
>
<
select
value=
{
editingProvider
.
capability
}
onChange=
{
(
e
)
=>
setEditingProvider
((
current
)
=>
current
?
{
...
current
,
capability
:
e
.
target
.
value
as
Capability
}
:
current
)
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{
([
"text"
,
"image"
,
"video"
]
as
Capability
[]).
map
((
capability
)
=>
(
<
option
key=
{
capability
}
value=
{
capability
}
>
{
capabilityMeta
[
capability
].
label
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2"
>
Base URL
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground"
>
接口地址
</
label
>
<
input
<
input
type=
"text"
value=
{
editingProvider
.
baseUrl
}
value=
{
editingProvider
.
baseUrl
}
onChange=
{
(
e
)
=>
setEditingProvider
({
...
editingProvider
,
baseUrl
:
e
.
target
.
value
})
}
onChange=
{
(
e
)
=>
setEditingProvider
((
current
)
=>
current
?
{
...
current
,
baseUrl
:
e
.
target
.
value
}
:
current
)
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"https://api.example.com/v1"
placeholder=
"https://api.example.com/v1"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/>
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2
"
>
API Key
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground
"
>
API Key
</
label
>
<
input
<
input
type=
"password"
value=
{
editingProvider
.
apiKey
}
value=
{
editingProvider
.
apiKey
}
onChange=
{
(
e
)
=>
setEditingProvider
(
{
...
editingProvider
,
apiKey
:
e
.
target
.
value
}
)
}
onChange=
{
(
e
)
=>
setEditingProvider
(
(
current
)
=>
current
?
{
...
current
,
apiKey
:
e
.
target
.
value
}
:
current
)
}
placeholder=
"sk-xxxxxxxxxxxxxxxx(留空保持不变)
"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]
"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm
"
placeholder=
"sk-xxxxxxxx
"
/>
/>
</
div
>
</
div
>
{
editingProvider
.
protocol
===
"kling"
&&
(
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
Secret Key
</
label
>
<
input
type=
"password"
value=
{
editingProvider
.
secretKey
||
""
}
onChange=
{
(
e
)
=>
setEditingProvider
({
...
editingProvider
,
secretKey
:
e
.
target
.
value
})
}
placeholder=
"可灵 Secret Key"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/>
</
div
>
)
}
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2"
>
模型 ID(回车添加)
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground"
>
Secret Key
</
label
>
<
input
<
input
type=
"text"
value=
{
editingProvider
.
secretKey
??
""
}
placeholder=
"输入模型 ID,例如 gpt-4o"
onChange=
{
(
e
)
=>
setEditingProvider
((
current
)
=>
current
?
{
...
current
,
secretKey
:
e
.
target
.
value
}
:
current
)
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
onKeyDown=
{
(
e
)
=>
{
placeholder=
"如需额外 Secret Key,可在这里填写"
if
(
e
.
key
===
"Enter"
)
{
const
v
=
e
.
currentTarget
.
value
.
trim
();
if
(
v
)
{
setEditingProvider
({
...
editingProvider
,
models
:
[...
editingProvider
.
models
,
{
id
:
v
,
name
:
v
,
checked
:
true
}],
});
e
.
currentTarget
.
value
=
""
;
}
}
}
}
/>
/>
</
div
>
</
div
>
{
editingProvider
.
models
.
length
>
0
&&
(
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
已添加的模型
</
label
>
<
div
className=
"space-y-1 max-h-40 overflow-auto"
>
{
editingProvider
.
models
.
map
((
m
)
=>
(
<
div
key=
{
m
.
id
}
className=
"flex items-center justify-between px-3 py-1.5 rounded-lg bg-muted"
>
<
span
className=
"text-xs font-mono text-foreground"
>
{
m
.
name
}
</
span
>
<
button
onClick=
{
()
=>
setEditingProvider
({
...
editingProvider
,
models
:
editingProvider
.
models
.
filter
((
em
)
=>
em
.
id
!==
m
.
id
),
})
}
className=
"p-1 rounded hover:bg-background transition-colors"
>
<
X
className=
"w-3 h-3 text-muted-foreground"
/>
</
button
>
</
div
>
))
}
</
div
>
</
div
>
)
}
</
div
>
</
div
>
<
div
className=
"
flex gap-3 mt-6 pt-6 border-t border-border
"
>
<
div
className=
"
mt-6 flex items-center justify-end gap-3
"
>
<
button
<
button
onClick=
{
()
=>
{
setShowAddModal
(
false
);
setEditingProvider
(
null
);
}
}
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"
flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors
"
className=
"
rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted
"
>
>
取消
取消
</
button
>
</
button
>
<
button
<
button
onClick=
{
handleSaveProvider
}
onClick=
{
handleSaveProvider
}
disabled=
{
!
editingProvider
.
name
.
trim
()
||
createProvider
.
isPending
||
updateProvider
.
isPending
}
disabled=
{
createProvider
.
isPending
||
updateProvider
.
isPending
}
className=
"
flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-5
0"
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-6
0"
>
>
{
(
createProvider
.
isPending
||
updateProvider
.
isPending
)
?
(
{
createProvider
.
isPending
||
updateProvider
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4"
/>
}
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
保存配置
)
:
(
<
Check
className=
"w-4 h-4"
/>
)
}
保存
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/NewProject.tsx
View file @
75992d7b
...
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router";
...
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router";
import
{
import
{
AlertCircle
,
AlertCircle
,
ArrowRight
,
ArrowRight
,
CheckCircle2
,
Clapperboard
,
FileText
,
FileText
,
Image
as
ImageIcon
,
Image
as
ImageIcon
,
Loader2
,
Loader2
,
...
@@ -14,6 +16,7 @@ import { Progress } from "../components/ui/progress";
...
@@ -14,6 +16,7 @@ import { Progress } from "../components/ui/progress";
import
{
useCreateProject
}
from
"../../hooks/useProjects"
;
import
{
useCreateProject
}
from
"../../hooks/useProjects"
;
import
{
projectsApi
}
from
"../../lib/api/projects"
;
import
{
projectsApi
}
from
"../../lib/api/projects"
;
import
{
aspectRatioOptions
,
projectStyleOptions
,
resolutionOptions
}
from
"../../lib/projectStyles"
;
import
{
aspectRatioOptions
,
projectStyleOptions
,
resolutionOptions
}
from
"../../lib/projectStyles"
;
import
{
brand
}
from
"../../lib/brand"
;
type
SubmitStage
=
"idle"
|
"creating"
|
"uploading"
|
"redirecting"
;
type
SubmitStage
=
"idle"
|
"creating"
|
"uploading"
|
"redirecting"
;
...
@@ -22,24 +25,26 @@ const submitStageMeta: Record<
...
@@ -22,24 +25,26 @@ const submitStageMeta: Record<
{
title
:
string
;
description
:
string
;
progress
:
number
}
{
title
:
string
;
description
:
string
;
progress
:
number
}
>
=
{
>
=
{
creating
:
{
creating
:
{
title
:
"正在创建项目"
,
title
:
"正在创建项目
空间
"
,
description
:
"
项目基础信息正在写入,请稍候
。"
,
description
:
"
系统正在初始化项目基础信息,并为后续剧本拆解和资产生产准备工作区
。"
,
progress
:
28
,
progress
:
28
,
},
},
uploading
:
{
uploading
:
{
title
:
"正在上传素材"
,
title
:
"正在上传素材
文件
"
,
description
:
"如果
你上传了剧本或封面,系统会先完成上传再进入大纲页
。"
,
description
:
"如果
已上传剧本或封面,平台会先完成素材入库,再进入下一步流程
。"
,
progress
:
72
,
progress
:
72
,
},
},
redirecting
:
{
redirecting
:
{
title
:
"
准备进入剧本大纲
"
,
title
:
"
正在进入剧本拆解流程
"
,
description
:
"
马上跳转到“生成剧本大纲”,可以继续解析已上传剧本,或手动输入内容
。"
,
description
:
"
项目创建完成,马上跳转到大纲生成页,继续推进剧本拆解与分集流程
。"
,
progress
:
96
,
progress
:
96
,
},
},
};
};
export
function
NewProject
()
{
export
function
NewProject
()
{
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
createProject
=
useCreateProject
();
const
[
projectName
,
setProjectName
]
=
useState
(
""
);
const
[
projectName
,
setProjectName
]
=
useState
(
""
);
const
[
scriptFile
,
setScriptFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
scriptFile
,
setScriptFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
coverFile
,
setCoverFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
coverFile
,
setCoverFile
]
=
useState
<
File
|
null
>
(
null
);
...
@@ -49,14 +54,13 @@ export function NewProject() {
...
@@ -49,14 +54,13 @@ export function NewProject() {
const
[
style
,
setStyle
]
=
useState
(
projectStyleOptions
[
0
].
value
);
const
[
style
,
setStyle
]
=
useState
(
projectStyleOptions
[
0
].
value
);
const
[
submitStage
,
setSubmitStage
]
=
useState
<
SubmitStage
>
(
"idle"
);
const
[
submitStage
,
setSubmitStage
]
=
useState
<
SubmitStage
>
(
"idle"
);
const
[
submitError
,
setSubmitError
]
=
useState
<
string
|
null
>
(
null
);
const
[
submitError
,
setSubmitError
]
=
useState
<
string
|
null
>
(
null
);
const
createProject
=
useCreateProject
();
const
isSubmitting
=
submitStage
!==
"idle"
;
const
isSubmitting
=
submitStage
!==
"idle"
;
const
hasUpload
=
!!
scriptFile
||
!!
coverFile
;
const
hasUpload
=
Boolean
(
scriptFile
||
coverFile
);
const
activeSubmitMeta
=
submitStage
===
"idle"
?
null
:
submitStageMeta
[
submitStage
];
const
handleScriptChange
=
(
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
handleScriptChange
=
(
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
!
e
.
target
.
files
?.[
0
]
||
isSubmitting
)
return
;
if
(
!
e
.
target
.
files
?.[
0
]
||
isSubmitting
)
return
;
const
file
=
e
.
target
.
files
[
0
];
const
file
=
e
.
target
.
files
[
0
];
setScriptFile
(
file
);
setScriptFile
(
file
);
...
@@ -67,11 +71,10 @@ export function NewProject() {
...
@@ -67,11 +71,10 @@ export function NewProject() {
const
handleCoverChange
=
(
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
handleCoverChange
=
(
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
!
e
.
target
.
files
?.[
0
]
||
isSubmitting
)
return
;
if
(
!
e
.
target
.
files
?.[
0
]
||
isSubmitting
)
return
;
const
file
=
e
.
target
.
files
[
0
];
const
file
=
e
.
target
.
files
[
0
];
setCoverFile
(
file
);
setCoverFile
(
file
);
setCoverPreview
((
prev
)
=>
{
setCoverPreview
((
prev
ious
)
=>
{
if
(
prev
)
URL
.
revokeObjectURL
(
prev
);
if
(
prev
ious
)
URL
.
revokeObjectURL
(
previous
);
return
URL
.
createObjectURL
(
file
);
return
URL
.
createObjectURL
(
file
);
});
});
};
};
...
@@ -125,241 +128,350 @@ export function NewProject() {
...
@@ -125,241 +128,350 @@ export function NewProject() {
});
});
}
catch
(
error
)
{
}
catch
(
error
)
{
setSubmitStage
(
"idle"
);
setSubmitStage
(
"idle"
);
setSubmitError
((
error
as
Error
)?.
message
??
"创建项目失败,请
重试
"
);
setSubmitError
((
error
as
Error
)?.
message
??
"创建项目失败,请
稍后重试。
"
);
}
}
};
};
const
canSubmit
=
!
isSubmitting
;
const
activeSubmitMeta
=
submitStage
===
"idle"
?
null
:
submitStageMeta
[
submitStage
];
return
(
return
(
<
div
className=
"
relative h-full overflow-auto bg-background
"
>
<
div
className=
"
mx-auto max-w-[1380px]
"
>
{
activeSubmitMeta
&&
(
{
activeSubmitMeta
?
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-
sm
"
>
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-
md
"
>
<
div
className=
"w-full max-w-
md rounded-2xl border border-border bg-card p-6 shadow-2
xl"
>
<
div
className=
"w-full max-w-
2xl rounded-[34px] border border-white/80 bg-white/92 p-7 shadow-[0_36px_90px_rgba(12,43,78,0.22)] backdrop-blur-
xl"
>
<
div
className=
"mb-5 flex h-1
4 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9
]"
>
<
div
className=
"mb-5 flex h-1
6 w-16 items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] shadow-[0_18px_36px_rgba(15,116,216,0.22)
]"
>
<
Loader2
className=
"h-
7 w-7
animate-spin text-white"
/>
<
Loader2
className=
"h-
8 w-8
animate-spin text-white"
/>
</
div
>
</
div
>
<
h2
className=
"
mb-2 text-
xl font-semibold text-foreground"
>
{
activeSubmitMeta
.
title
}
</
h2
>
<
h2
className=
"
text-2
xl font-semibold text-foreground"
>
{
activeSubmitMeta
.
title
}
</
h2
>
<
p
className=
"m
b-5 text-sm leading-relaxed
text-muted-foreground"
>
<
p
className=
"m
t-3 text-sm leading-7
text-muted-foreground"
>
{
activeSubmitMeta
.
description
}
{
activeSubmitMeta
.
description
}
</
p
>
</
p
>
<
Progress
value=
{
activeSubmitMeta
.
progress
}
className=
"mb-4 h-2.5"
/>
<
div
className=
"space-y-2 text-sm"
>
<
div
className=
"mt-6 rounded-[28px] border border-primary/10 bg-primary/5 p-5"
>
<
div
className=
"flex items-center justify-between text-foreground"
>
<
div
className=
"mb-3 flex items-center justify-between text-sm"
>
<
span
>
创建项目
</
span
>
<
span
className=
"font-medium text-foreground"
>
流程进度
</
span
>
<
span
>
{
submitStage
===
"creating"
?
"进行中"
:
"已准备"
}
</
span
>
<
span
className=
"text-primary"
>
{
activeSubmitMeta
.
progress
}
%
</
span
>
</
div
>
<
div
className=
"flex items-center justify-between text-foreground"
>
<
span
>
上传素材
</
span
>
<
span
>
{
!
hasUpload
?
"已跳过"
:
submitStage
===
"uploading"
?
"进行中"
:
submitStage
===
"redirecting"
?
"已完成"
:
"等待中"
}
</
span
>
</
div
>
</
div
>
<
div
className=
"flex items-center justify-between text-foreground"
>
<
Progress
value=
{
activeSubmitMeta
.
progress
}
className=
"h-2.5"
/>
<
span
>
进入大纲页
</
span
>
<
div
className=
"mt-5 space-y-3 text-sm"
>
<
span
>
{
submitStage
===
"redirecting"
?
"进行中"
:
"等待中"
}
</
span
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3"
>
<
span
className=
"text-foreground"
>
初始化项目空间
</
span
>
<
span
className=
"text-muted-foreground"
>
{
submitStage
===
"creating"
?
"进行中"
:
"已完成"
}
</
span
>
</
div
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3"
>
<
span
className=
"text-foreground"
>
上传剧本与封面
</
span
>
<
span
className=
"text-muted-foreground"
>
{
!
hasUpload
?
"已跳过"
:
submitStage
===
"uploading"
?
"进行中"
:
submitStage
===
"redirecting"
?
"已完成"
:
"等待中"
}
</
span
>
</
div
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-border/70 bg-white/78 px-4 py-3"
>
<
span
className=
"text-foreground"
>
进入剧本拆解
</
span
>
<
span
className=
"text-muted-foreground"
>
{
submitStage
===
"redirecting"
?
"进行中"
:
"等待中"
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
p
className=
"mt-4 text-xs text-muted-foreground"
>
处理中已锁定按钮,避免重复点击创建多个项目。
<
p
className=
"mt-5 text-xs leading-6 text-muted-foreground"
>
当前为前端阶段性反馈,后续可继续接入后端真实进度接口,显示更精确的任务状态。
</
p
>
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
<
div
className=
"mx-auto max-w-4xl p-8"
>
<
section
className=
"grid gap-6 xl:grid-cols-[1.02fr_0.98fr]"
>
<
div
className=
"mb-8 text-center"
>
<
div
className=
"relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]"
>
<
h1
className=
"mb-2 text-3xl font-semibold text-foreground"
>
创建短剧项目
</
h1
>
<
div
className=
"absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]"
/>
<
p
className=
"text-sm text-muted-foreground"
>
<
div
className=
"absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14"
/>
可以先上传剧本直接进入解析流程,也可以跳过上传,在下一页手动输入内容生成大纲。
<
div
className=
"absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10"
/>
</
p
>
</
div
>
<
div
className=
"relative"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74"
>
<
Sparkles
className=
"h-4 w-4"
/>
Project Launch
</
div
>
<
h1
className=
"mt-5 max-w-2xl text-4xl font-semibold leading-tight"
>
创建新项目,直接接入
{
brand
.
shortProductName
}
的完整生产链路
</
h1
>
<
p
className=
"mt-4 max-w-2xl text-base leading-7 text-white/76"
>
上传剧本后,平台会继续推进大纲、分集、角色、场景、分镜与视频生成;如果暂时没有素材,也可以先创建项目再逐步补充。
</
p
>
<
div
className=
"mt-8 grid gap-4 sm:grid-cols-3"
>
{
[
{
title
:
"创建项目空间"
,
desc
:
"初始化项目基础参数与封面展示信息"
,
},
{
title
:
"导入剧本素材"
,
desc
:
"支持 doc、docx、txt、pdf、md 等常见格式"
,
},
{
title
:
"进入拆解流程"
,
desc
:
"自动接入大纲生成与后续工作流页面"
,
},
].
map
((
item
,
index
)
=>
(
<
div
key=
{
item
.
title
}
className=
"rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<
div
className=
"mb-3 text-xs uppercase tracking-[0.24em] text-white/64"
>
0
{
index
+
1
}
</
div
>
<
div
className=
"text-lg font-medium"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-2 text-sm leading-6 text-white/74"
>
{
item
.
desc
}
</
div
>
</
div
>
))
}
</
div
>
{
submitError
&&
(
<
div
className=
"mt-8 rounded-[28px] border border-white/14 bg-white/10 p-5 backdrop-blur-sm"
>
<
div
className=
"mb-5 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4"
>
<
div
className=
"mb-3 text-xs uppercase tracking-[0.24em] text-white/64"
>
<
AlertCircle
className=
"mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
Recommended Flow
<
p
className=
"text-sm text-red-700"
>
{
submitError
}
</
p
>
</
div
>
<
div
className=
"grid gap-3 md:grid-cols-3"
>
{
[
"先上传剧本,项目名可自动带入,减少手工录入。"
,
"尽量同时补充封面、风格和画幅,方便后续统一展示。"
,
"创建后直接进入剧本拆解页,最快形成完整项目骨架。"
,
].
map
((
item
)
=>
(
<
div
key=
{
item
}
className=
"rounded-2xl border border-white/12 bg-white/8 px-4 py-4 text-sm leading-6 text-white/82"
>
{
item
}
</
div
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
)
}
<
div
className=
"mb-5"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
项目名称
</
label
>
<
input
type=
"text"
value=
{
projectName
}
onChange=
{
(
e
)
=>
setProjectName
(
e
.
target
.
value
)
}
disabled=
{
isSubmitting
}
placeholder=
"输入项目名称,或上传剧本后自动带入文件名"
className=
"w-full rounded-xl border border-border bg-card px-4 py-3 text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
</
div
>
</
div
>
<
div
className=
"mb-6 flex gap-4"
>
<
div
className=
"rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"flex-shrink-0"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
<
div
>
封面图片
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Project Setup
</
div
>
<
span
className=
"ml-1 font-normal text-muted-foreground"
>
(可选)
</
span
>
<
h2
className=
"mt-2 text-3xl font-semibold text-foreground"
>
项目基础配置
</
h2
>
</
label
>
<
p
className=
"mt-3 text-sm leading-6 text-muted-foreground"
>
<
div
className=
"relative flex h-40 w-28 items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-card"
>
定义项目名称、剧本素材、封面与视觉参数,这些配置会贯穿整个创作流程。
<
input
</
p
>
type=
"file"
</
div
>
accept=
"image/*"
<
div
className=
"flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary"
>
onChange=
{
handleCoverChange
}
<
Clapperboard
className=
"h-6 w-6"
/>
disabled=
{
isSubmitting
}
className=
"absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{
coverPreview
?
(
<>
<
img
src=
{
coverPreview
}
alt=
"封面预览"
className=
"h-full w-full object-cover"
/>
<
button
onClick=
{
removeCover
}
disabled=
{
isSubmitting
}
className=
"absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 transition-colors hover:bg-black/80 disabled:opacity-50"
>
<
X
className=
"h-3 w-3 text-white"
/>
</
button
>
</>
)
:
(
<
div
className=
"p-2 text-center"
>
<
ImageIcon
className=
"mx-auto mb-1 h-6 w-6 text-muted-foreground"
/>
<
span
className=
"text-xs text-muted-foreground"
>
点击上传
</
span
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex-1"
>
{
submitError
?
(
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
<
div
className=
"mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4"
>
剧本文件
<
AlertCircle
className=
"mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
<
span
className=
"ml-1 font-normal text-muted-foreground"
>
(可选)
</
span
>
<
p
className=
"text-sm leading-6 text-red-700"
>
{
submitError
}
</
p
>
</
label
>
</
div
>
<
div
className=
"relative flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-border bg-card"
>
)
:
null
}
<
div
className=
"space-y-6"
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
项目名称
</
label
>
<
input
<
input
type=
"
file
"
type=
"
text
"
accept=
".txt,.doc,.docx,.pdf,.md"
value=
{
projectName
}
onChange=
{
handleScriptChange
}
onChange=
{
(
e
)
=>
setProjectName
(
e
.
target
.
value
)
}
disabled=
{
isSubmitting
}
disabled=
{
isSubmitting
}
className=
"absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
placeholder=
"输入项目名称,或上传剧本后自动带入文件名"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)] disabled:cursor-not-allowed disabled:opacity-60"
/>
/>
{
scriptFile
?
(
</
div
>
<
div
className=
"flex flex-col items-center gap-3"
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9]"
>
<
div
className=
"grid gap-5 md:grid-cols-[220px_1fr]"
>
<
FileText
className=
"h-6 w-6 text-white"
/>
<
div
>
</
div
>
<
div
className=
"mb-2 flex items-center justify-between"
>
<
div
className=
"text-center"
>
<
label
className=
"block text-sm font-medium text-foreground"
>
项目封面
</
label
>
<
div
className=
"mb-0.5 text-sm font-medium text-foreground"
>
{
scriptFile
.
name
}
</
div
>
<
span
className=
"text-xs text-muted-foreground"
>
可选
</
span
>
<
div
className=
"text-xs text-muted-foreground"
>
</
div
>
{
(
scriptFile
.
size
/
1024
).
toFixed
(
1
)
}
KB
</
div
>
<
div
className=
"relative flex aspect-[3/4] items-center justify-center overflow-hidden rounded-[28px] border-2 border-dashed border-border bg-muted"
>
</
div
>
<
input
<
button
type=
"file"
onClick=
{
(
e
)
=>
{
accept=
"image/*"
e
.
preventDefault
();
onChange=
{
handleCoverChange
}
if
(
!
isSubmitting
)
setScriptFile
(
null
);
}
}
disabled=
{
isSubmitting
}
disabled=
{
isSubmitting
}
className=
"text-xs text-primary hover:underline disabled:no-underline disabled:opacity-50"
className=
"absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
>
/>
重新上传
{
coverPreview
?
(
</
button
>
<>
<
img
src=
{
coverPreview
}
alt=
"封面预览"
className=
"h-full w-full object-cover"
/>
<
button
onClick=
{
removeCover
}
disabled=
{
isSubmitting
}
className=
"absolute right-3 top-3 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-slate-950/60 text-white transition hover:bg-slate-950/80 disabled:opacity-50"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</>
)
:
(
<
div
className=
"px-4 text-center"
>
<
ImageIcon
className=
"mx-auto mb-3 h-7 w-7 text-muted-foreground"
/>
<
div
className=
"text-sm text-foreground"
>
点击上传封面
</
div
>
<
div
className=
"mt-2 text-xs leading-5 text-muted-foreground"
>
建议使用竖版海报或封面图,用于项目卡片与详情页头图展示。
</
div
>
</
div
>
)
}
</
div
>
</
div
>
)
:
(
</
div
>
<
div
className=
"flex flex-col items-center gap-3"
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-xl bg-muted"
>
<
div
>
<
Upload
className=
"h-6 w-6 text-muted-foreground"
/>
<
div
className=
"mb-2 flex items-center justify-between"
>
</
div
>
<
label
className=
"block text-sm font-medium text-foreground"
>
剧本文件
</
label
>
<
div
className=
"text-center"
>
<
span
className=
"text-xs text-muted-foreground"
>
可选
</
span
>
<
div
className=
"mb-0.5 text-sm text-foreground"
>
点击或拖拽剧本文件到这里
</
div
>
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
支持 doc、docx、txt、pdf、md,大小不超过 20M;也可以先跳过,下一页手动输入剧本。
<
div
className=
"relative flex min-h-[300px] items-center justify-center rounded-[28px] border-2 border-dashed border-border bg-white"
>
<
input
type=
"file"
accept=
".txt,.doc,.docx,.pdf,.md"
onChange=
{
handleScriptChange
}
disabled=
{
isSubmitting
}
className=
"absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{
scriptFile
?
(
<
div
className=
"flex flex-col items-center gap-4 px-8 py-8 text-center"
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_30px_rgba(15,116,216,0.18)]"
>
<
FileText
className=
"h-8 w-8"
/>
</
div
>
<
div
>
<
div
className=
"text-base font-medium text-foreground"
>
{
scriptFile
.
name
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
(
scriptFile
.
size
/
1024
).
toFixed
(
1
)
}
KB
</
div
>
</
div
>
<
button
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
if
(
!
isSubmitting
)
setScriptFile
(
null
);
}
}
disabled=
{
isSubmitting
}
className=
"text-sm font-medium text-primary transition hover:underline disabled:no-underline disabled:opacity-50"
>
重新上传
</
button
>
</
div
>
</
div
>
</
div
>
)
:
(
<
div
className=
"flex flex-col items-center gap-4 px-8 py-8 text-center"
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-[28px] bg-muted text-muted-foreground"
>
<
Upload
className=
"h-8 w-8"
/>
</
div
>
<
div
>
<
div
className=
"text-base font-medium text-foreground"
>
点击或拖拽剧本文件到这里
</
div
>
<
div
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
支持 doc、docx、txt、pdf、md,建议单个文件不超过 20 MB。
如果先不上传,也可以在下一页手动输入剧本文本。
</
div
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"mb-6 grid grid-cols-3 gap-4"
>
<
div
className=
"grid gap-4 md:grid-cols-3"
>
<
div
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
className=
"rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm"
>
<
div
className=
"mb-2 text-xs text-muted-foreground"
>
画幅比例
</
div
>
<
div
className=
"mb-2 text-xs uppercase tracking-[0.24em] text-primary/70"
>
Aspect Ratio
</
div
>
<
select
<
div
className=
"mb-3 text-lg font-medium text-foreground"
>
画幅比例
</
div
>
value=
{
aspectRatio
}
<
select
onChange=
{
(
e
)
=>
setAspectRatio
(
e
.
target
.
value
)
}
value=
{
aspectRatio
}
disabled=
{
isSubmitting
}
onChange=
{
(
e
)
=>
setAspectRatio
(
e
.
target
.
value
)
}
className=
"w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
disabled=
{
isSubmitting
}
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
{
aspectRatioOptions
.
map
((
option
)
=>
(
>
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
aspectRatioOptions
.
map
((
option
)
=>
(
{
option
.
label
}
·
{
option
.
helper
}
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
</
option
>
{
option
.
label
}
·
{
option
.
helper
}
))
}
</
option
>
</
select
>
))
}
</
div
>
</
select
>
</
div
>
<
div
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
className=
"mb-2 text-xs text-muted-foreground"
>
清晰度
</
div
>
<
select
value=
{
resolution
}
onChange=
{
(
e
)
=>
setResolution
(
e
.
target
.
value
)
}
disabled=
{
isSubmitting
}
className=
"w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{
resolutionOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
·
{
option
.
helper
}
</
option
>
))
}
</
select
>
</
div
>
<
div
className=
"rounded-xl border border-border bg-card p-4
"
>
<
div
className=
"rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm
"
>
<
div
className=
"mb-2 text-xs text-muted-foreground"
>
视觉风格
</
div
>
<
div
className=
"mb-2 text-xs uppercase tracking-[0.24em] text-primary/70"
>
Resolution
</
div
>
<
select
<
div
className=
"mb-3 text-lg font-medium text-foreground"
>
清晰度
</
div
>
value=
{
style
}
<
select
onChange=
{
(
e
)
=>
setStyle
(
e
.
target
.
value
)
}
value=
{
resolution
}
disabled=
{
isSubmitting
}
onChange=
{
(
e
)
=>
setResolution
(
e
.
target
.
value
)
}
className=
"w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
disabled=
{
isSubmitting
}
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
{
projectStyleOptions
.
map
((
option
)
=>
(
>
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
resolutionOptions
.
map
((
option
)
=>
(
{
option
.
label
}
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
</
option
>
{
option
.
label
}
·
{
option
.
helper
}
))
}
</
option
>
</
select
>
))
}
</
div
>
</
select
>
</
div
>
</
div
>
<
div
className=
"mb-6 rounded-xl border border-primary/20 bg-accent/50 p-4"
>
<
div
className=
"rounded-[28px] border border-border/80 bg-white/70 p-4 shadow-sm"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"mb-2 text-xs uppercase tracking-[0.24em] text-primary/70"
>
Style
</
div
>
<
div
className=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10"
>
<
div
className=
"mb-3 text-lg font-medium text-foreground"
>
视觉风格
</
div
>
<
Sparkles
className=
"h-5 w-5 text-primary"
/>
<
select
value=
{
style
}
onChange=
{
(
e
)
=>
setStyle
(
e
.
target
.
value
)
}
disabled=
{
isSubmitting
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground outline-none transition focus:border-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{
projectStyleOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
</
div
>
<
div
>
<
h3
className=
"mb-1 text-sm font-medium text-foreground"
>
AI 智能处理
</
h3
>
<
div
className=
"rounded-[28px] border border-primary/12 bg-primary/5 p-5"
>
<
p
className=
"text-sm leading-relaxed text-muted-foreground"
>
<
div
className=
"mb-3 flex items-center gap-3"
>
如果上传了剧本,下一页会继续自动解析;如果没有上传,也可以直接手动输入剧本内容生成大纲。这里选择的视觉风格会继续影响后续人物与场景的 AI 生成效果。
<
div
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
</
p
>
<
CheckCircle2
className=
"h-5 w-5"
/>
</
div
>
<
div
>
<
div
className=
"text-base font-medium text-foreground"
>
创建后会发生什么?
</
div
>
<
div
className=
"text-sm text-muted-foreground"
>
项目会立刻接入后续 AI 剧本拆解流程。
</
div
>
</
div
>
</
div
>
<
div
className=
"grid gap-3 md:grid-cols-3"
>
{
[
"如已上传剧本,系统会优先尝试识别剧本结构。"
,
"如果暂时没有剧本,也可在下一页粘贴文本生成大纲。"
,
"这里选择的风格与画幅会影响后续角色和场景生成。"
,
].
map
((
item
)
=>
(
<
div
key=
{
item
}
className=
"rounded-2xl border border-border/70 bg-white/78 px-4 py-4 text-sm leading-6 text-foreground/82"
>
{
item
}
</
div
>
))
}
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
handleSubmit
}
disabled=
{
isSubmitting
}
className=
"inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
<
span
>
{
isSubmitting
?
activeSubmitMeta
?.
title
??
"处理中..."
:
"创建新项目"
}
</
span
>
{
isSubmitting
?
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
:
<
ArrowRight
className=
"h-5 w-5"
/>
}
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
<
button
onClick=
{
handleSubmit
}
disabled=
{
!
canSubmit
}
className=
"flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
>
<
span
>
{
isSubmitting
?
activeSubmitMeta
?.
title
??
"处理中..."
:
"创建新短剧项目"
}
</
span
>
{
isSubmitting
?
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
:
<
ArrowRight
className=
"h-5 w-5"
/>
}
</
button
>
</
div
>
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/OutlineGeneration.tsx
View file @
75992d7b
...
@@ -11,11 +11,13 @@ import {
...
@@ -11,11 +11,13 @@ import {
RefreshCw
,
RefreshCw
,
Sparkles
,
Sparkles
,
Users
,
Users
,
WandSparkles
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
Progress
}
from
"../components/ui/progress"
;
import
{
Progress
}
from
"../components/ui/progress"
;
import
{
useGenerateEpisodes
,
useGenerateOutline
,
useOutline
}
from
"../../hooks/useAi"
;
import
{
useGenerateEpisodes
,
useGenerateOutline
,
useOutline
}
from
"../../hooks/useAi"
;
import
{
projectsApi
,
type
ScriptInfoDTO
}
from
"../../lib/api/projects"
;
import
{
projectsApi
,
type
ScriptInfoDTO
}
from
"../../lib/api/projects"
;
import
{
brand
}
from
"../../lib/brand"
;
type
ProcessingStep
=
{
type
ProcessingStep
=
{
label
:
string
;
label
:
string
;
...
@@ -36,50 +38,50 @@ type OutlineLocationState = {
...
@@ -36,50 +38,50 @@ type OutlineLocationState = {
const
checkingOutlineState
:
ProcessingState
=
{
const
checkingOutlineState
:
ProcessingState
=
{
title
:
"正在检查项目状态"
,
title
:
"正在检查项目状态"
,
description
:
"
先确认这个项目是否已经有可用大纲
。"
,
description
:
"
系统先确认当前项目是否已经存在可复用的大纲结果,以避免重复解析剧本
。"
,
steps
:
[
steps
:
[
{
{
label
:
"检查已有大纲"
,
label
:
"检查已有大纲"
,
hint
:
"
正在确认是否需要重新解析上传的剧本
。"
,
hint
:
"
确认当前项目是否已完成剧本拆解与结构生成
。"
,
progress
:
12
,
progress
:
12
,
},
},
],
],
};
};
const
parsingScriptState
:
ProcessingState
=
{
const
parsingScriptState
:
ProcessingState
=
{
title
:
"正在解析上传
的
剧本"
,
title
:
"正在解析上传剧本"
,
description
:
"
系统正在读取文档内容,并抽取剧情结构、人物与场景信息
。"
,
description
:
"
平台正在读取文档内容,并抽取剧情结构、角色信息与关键场景,准备生成结构化预览
。"
,
steps
:
[
steps
:
[
{
{
label
:
"读取剧本文件"
,
label
:
"读取剧本文件"
,
hint
:
"正在加载
上传的 Word、PDF 或文
本内容。"
,
hint
:
"正在加载
Word、PDF 或文本剧
本内容。"
,
progress
:
24
,
progress
:
24
,
},
},
{
{
label
:
"分析剧情结构"
,
label
:
"分析剧情结构"
,
hint
:
"
正在识别主线冲突、题材和整体
节奏。"
,
hint
:
"
识别主线冲突、题材方向与故事
节奏。"
,
progress
:
52
,
progress
:
52
,
},
},
{
{
label
:
"整理角色与场景"
,
label
:
"整理角色与场景"
,
hint
:
"
正在生成可预览的结构化摘要
。"
,
hint
:
"
输出便于确认的结构化摘要和生产入口
。"
,
progress
:
82
,
progress
:
82
,
},
},
],
],
};
};
const
readingScriptState
:
ProcessingState
=
{
const
readingScriptState
:
ProcessingState
=
{
title
:
"正在
准备
剧本文本"
,
title
:
"正在
读取
剧本文本"
,
description
:
"未
拿到结构化摘要,正在回退为读取原始剧本文本
。"
,
description
:
"未
获取到结构化摘要,平台正在回退为读取原始文本内容,方便你直接手动生成大纲
。"
,
steps
:
[
steps
:
[
{
{
label
:
"
读
取原始文本"
,
label
:
"
提
取原始文本"
,
hint
:
"正在
提取
上传剧本中的正文内容。"
,
hint
:
"正在
读取已
上传剧本中的正文内容。"
,
progress
:
36
,
progress
:
36
,
},
},
{
{
label
:
"填充编辑器"
,
label
:
"填充编辑器"
,
hint
:
"
马上就可以在这里直接
生成大纲。"
,
hint
:
"
完成后即可在当前页面继续
生成大纲。"
,
progress
:
74
,
progress
:
74
,
},
},
],
],
...
@@ -87,21 +89,21 @@ const readingScriptState: ProcessingState = {
...
@@ -87,21 +89,21 @@ const readingScriptState: ProcessingState = {
const
importingScriptState
:
ProcessingState
=
{
const
importingScriptState
:
ProcessingState
=
{
title
:
"正在导入剧本数据"
,
title
:
"正在导入剧本数据"
,
description
:
"系统会把剧本拆解为大纲、分集、角色、场景和分镜,请稍候。"
,
description
:
"系统会把剧本拆解为大纲、分集、角色、场景和分镜
所需的初始骨架
,请稍候。"
,
steps
:
[
steps
:
[
{
{
label
:
"
解
析整体结构"
,
label
:
"
分
析整体结构"
,
hint
:
"
正在理解故事主线与
题材风格。"
,
hint
:
"
理解故事主线、角色关系和
题材风格。"
,
progress
:
24
,
progress
:
24
,
},
},
{
{
label
:
"生成大纲与分集"
,
label
:
"生成大纲与分集"
,
hint
:
"
正在生成故事梗概和每集节奏
。"
,
hint
:
"
建立项目的基础剧情骨架和节奏划分
。"
,
progress
:
56
,
progress
:
56
,
},
},
{
{
label
:
"写入
角色与场景
"
,
label
:
"写入
项目空间
"
,
hint
:
"
正在把结果保存到项目工作区
。"
,
hint
:
"
将结果同步到后续工作流所需的数据结构中
。"
,
progress
:
88
,
progress
:
88
,
},
},
],
],
...
@@ -109,33 +111,37 @@ const importingScriptState: ProcessingState = {
...
@@ -109,33 +111,37 @@ const importingScriptState: ProcessingState = {
const
generatingOutlineState
:
ProcessingState
=
{
const
generatingOutlineState
:
ProcessingState
=
{
title
:
"AI 正在生成剧本大纲"
,
title
:
"AI 正在生成剧本大纲"
,
description
:
"
正在根据剧本文本组织故事主线、题材和概要描述
。"
,
description
:
"
平台正在根据剧本文本组织题材、故事线与内容摘要,并准备生成分集入口
。"
,
steps
:
[
steps
:
[
{
{
label
:
"理解
剧本
主线"
,
label
:
"理解
故事
主线"
,
hint
:
"
正在提取核心矛盾和人物
关系。"
,
hint
:
"
提取核心矛盾、题材调性和角色
关系。"
,
progress
:
30
,
progress
:
30
,
},
},
{
{
label
:
"归纳
故事结构
"
,
label
:
"归纳
结构节奏
"
,
hint
:
"
正在组织题材、世界观和章节节奏
。"
,
hint
:
"
整理大纲、世界观和情节推进逻辑
。"
,
progress
:
62
,
progress
:
62
,
},
},
{
{
label
:
"输出大纲结果"
,
label
:
"输出大纲结果"
,
hint
:
"
正在整理
可直接预览的大纲内容。"
,
hint
:
"
生成
可直接预览的大纲内容。"
,
progress
:
90
,
progress
:
90
,
},
},
],
],
};
};
function
formatElapsed
(
seconds
:
number
)
{
function
formatElapsed
(
seconds
:
number
)
{
const
mins
=
Math
.
floor
(
seconds
/
60
);
const
min
ute
s
=
Math
.
floor
(
seconds
/
60
);
const
sec
s
=
seconds
%
60
;
const
remainSecond
s
=
seconds
%
60
;
return
min
s
>
0
?
`
${
mins
}
分
${
secs
}
秒`
:
`
${
secs
}
秒`
;
return
min
utes
>
0
?
`
${
minutes
}
分
${
remainSeconds
}
秒`
:
`
${
remainSeconds
}
秒`
;
}
}
function
ProcessingScreen
({
state
,
elapsedSeconds
,
projectName
}:
{
function
ProcessingScreen
({
state
,
elapsedSeconds
,
projectName
,
}:
{
state
:
ProcessingState
;
state
:
ProcessingState
;
elapsedSeconds
:
number
;
elapsedSeconds
:
number
;
projectName
?:
string
;
projectName
?:
string
;
...
@@ -153,64 +159,93 @@ function ProcessingScreen({ state, elapsedSeconds, projectName }: {
...
@@ -153,64 +159,93 @@ function ProcessingScreen({ state, elapsedSeconds, projectName }: {
return
()
=>
window
.
clearInterval
(
timer
);
return
()
=>
window
.
clearInterval
(
timer
);
},
[
state
]);
},
[
state
]);
const
progressValue
=
state
.
steps
[
Math
.
min
(
activeStep
,
state
.
steps
.
length
-
1
)]?.
progress
??
0
;
const
currentStep
=
state
.
steps
[
Math
.
min
(
activeStep
,
state
.
steps
.
length
-
1
)]
;
return
(
return
(
<
div
className=
"h-full bg-background flex items-center justify-center p-6"
>
<
div
className=
"mx-auto max-w-[1380px]"
>
<
div
className=
"w-full max-w-2xl rounded-3xl border border-border bg-card shadow-xl p-8"
>
<
div
className=
"grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"
>
<
div
className=
"flex items-start gap-4 mb-6"
>
<
div
className=
"relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]"
>
<
div
className=
"w-16 h-16 rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center flex-shrink-0"
>
<
div
className=
"absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]"
/>
<
Loader2
className=
"w-8 h-8 text-white animate-spin"
/>
<
div
className=
"absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14"
/>
</
div
>
<
div
className=
"absolute right-20 top-20 h-32 w-32 rounded-full border border-white/10"
/>
<
div
className=
"flex-1"
>
<
div
className=
"flex items-center justify-between gap-4 mb-2"
>
<
div
className=
"relative"
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
{
state
.
title
}
</
h1
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74"
>
<
span
className=
"text-xs text-muted-foreground whitespace-nowrap"
>
已用时
{
formatElapsed
(
elapsedSeconds
)
}
</
span
>
<
WandSparkles
className=
"h-4 w-4"
/>
Outline Processing
</
div
>
<
h1
className=
"mt-5 max-w-2xl text-4xl font-semibold leading-tight"
>
{
state
.
title
}
</
h1
>
<
p
className=
"mt-4 max-w-2xl text-base leading-7 text-white/76"
>
{
state
.
description
}
</
p
>
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
<
div
className=
"rounded-full border border-white/16 bg-white/10 px-4 py-2 text-sm text-white/84"
>
企业平台:
{
brand
.
companyName
}
</
div
>
<
div
className=
"rounded-full border border-white/16 bg-white/10 px-4 py-2 text-sm text-white/84"
>
已用时:
{
formatElapsed
(
elapsedSeconds
)
}
</
div
>
{
projectName
?
(
<
div
className=
"rounded-full border border-white/16 bg-white/10 px-4 py-2 text-sm text-white/84"
>
当前项目:
{
projectName
}
</
div
>
)
:
null
}
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground leading-relaxed"
>
{
state
.
description
}
</
p
>
{
projectName
&&
(
<
p
className=
"text-xs text-primary mt-2"
>
当前项目:
{
projectName
}
</
p
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-primary/15 bg-primary/5 p-5 mb-6"
>
<
div
className=
"rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"flex items-center justify-between text-sm mb-3"
>
<
div
className=
"mb-5 flex items-center justify-between"
>
<
span
className=
"text-foreground font-medium"
>
{
state
.
steps
[
Math
.
min
(
activeStep
,
state
.
steps
.
length
-
1
)]?.
label
}
</
span
>
<
div
>
<
span
className=
"text-primary"
>
{
progressValue
}
%
</
span
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Live Progress
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-foreground"
>
{
currentStep
?.
label
}
</
div
>
</
div
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] shadow-[0_18px_36px_rgba(15,116,216,0.22)]"
>
<
Loader2
className=
"h-8 w-8 animate-spin text-white"
/>
</
div
>
</
div
>
</
div
>
<
Progress
value=
{
progressValue
}
className=
"h-2.5 mb-3"
/>
<
p
className=
"text-sm text-muted-foreground"
>
{
state
.
steps
[
Math
.
min
(
activeStep
,
state
.
steps
.
length
-
1
)]?.
hint
}
</
p
>
</
div
>
<
div
className=
"space-y-3"
>
<
div
className=
"rounded-[28px] border border-primary/10 bg-primary/5 p-5"
>
{
state
.
steps
.
map
((
step
,
index
)
=>
{
<
div
className=
"mb-3 flex items-center justify-between text-sm"
>
const
isDone
=
index
<
activeStep
;
<
span
className=
"font-medium text-foreground"
>
{
currentStep
?.
label
}
</
span
>
const
isCurrent
=
index
===
activeStep
;
<
span
className=
"text-primary"
>
{
currentStep
?.
progress
??
0
}
%
</
span
>
</
div
>
return
(
<
Progress
value=
{
currentStep
?.
progress
??
0
}
className=
"h-2.5"
/>
<
div
key=
{
step
.
label
}
className=
"flex items-start gap-3 rounded-xl border border-border/70 bg-background px-4 py-3"
>
<
p
className=
"mt-4 text-sm leading-6 text-muted-foreground"
>
{
currentStep
?.
hint
}
</
p
>
<
div
className=
"mt-0.5"
>
</
div
>
{
isDone
?
(
<
CheckCircle2
className=
"w-5 h-5 text-green-500"
/>
<
div
className=
"mt-5 space-y-3"
>
)
:
isCurrent
?
(
{
state
.
steps
.
map
((
step
,
index
)
=>
{
<
Loader2
className=
"w-5 h-5 text-primary animate-spin"
/>
const
done
=
index
<
activeStep
;
)
:
(
const
current
=
index
===
activeStep
;
<
div
className=
"w-5 h-5 rounded-full border-2 border-border"
/>
)
}
return
(
</
div
>
<
div
<
div
>
key=
{
step
.
label
}
<
div
className=
"text-sm font-medium text-foreground"
>
{
step
.
label
}
</
div
>
className=
"flex items-start gap-3 rounded-[24px] border border-border/70 bg-white/78 px-4 py-4 shadow-sm"
<
div
className=
"text-xs text-muted-foreground mt-0.5"
>
{
step
.
hint
}
</
div
>
>
<
div
className=
"mt-0.5"
>
{
done
?
(
<
CheckCircle2
className=
"h-5 w-5 text-emerald-500"
/>
)
:
current
?
(
<
Loader2
className=
"h-5 w-5 animate-spin text-primary"
/>
)
:
(
<
div
className=
"h-5 w-5 rounded-full border-2 border-border"
/>
)
}
</
div
>
<
div
>
<
div
className=
"text-sm font-medium text-foreground"
>
{
step
.
label
}
</
div
>
<
div
className=
"mt-1 text-xs leading-6 text-muted-foreground"
>
{
step
.
hint
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
})
}
})
}
</
div
>
</
div
>
<
p
className=
"text-xs text-muted-foreground mt-5"
>
<
p
className=
"mt-5 text-xs leading-6 text-muted-foreground"
>
当前为前端阶段性实时反馈。如果后端后续提供解析进度接口,这里可以进一步切成真实百分比。
当前为阶段性前端反馈,后续如果接入后端实时进度接口,这里可以展示更准确的百分比与步骤状态。
</
p
>
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
@@ -221,7 +256,7 @@ export function OutlineGeneration() {
...
@@ -221,7 +256,7 @@ export function OutlineGeneration() {
const
location
=
useLocation
();
const
location
=
useLocation
();
const
{
projectId
}
=
useParams
<
{
projectId
:
string
}
>
();
const
{
projectId
}
=
useParams
<
{
projectId
:
string
}
>
();
const
pid
=
projectId
??
""
;
const
pid
=
projectId
??
""
;
const
q
c
=
useQueryClient
();
const
q
ueryClient
=
useQueryClient
();
const
outlineState
=
location
.
state
as
OutlineLocationState
|
null
;
const
outlineState
=
location
.
state
as
OutlineLocationState
|
null
;
const
projectNameFromState
=
outlineState
?.
projectName
;
const
projectNameFromState
=
outlineState
?.
projectName
;
...
@@ -246,7 +281,8 @@ export function OutlineGeneration() {
...
@@ -246,7 +281,8 @@ export function OutlineGeneration() {
setPreparingState
(
"parsing"
);
setPreparingState
(
"parsing"
);
setImportError
(
null
);
setImportError
(
null
);
projectsApi
.
parseScript
(
pid
)
projectsApi
.
parseScript
(
pid
)
.
then
((
info
)
=>
{
.
then
((
info
)
=>
{
if
(
cancelled
)
return
;
if
(
cancelled
)
return
;
setScriptInfo
(
info
);
setScriptInfo
(
info
);
...
@@ -265,7 +301,7 @@ export function OutlineGeneration() {
...
@@ -265,7 +301,7 @@ export function OutlineGeneration() {
setScript
(
text
.
slice
(
0
,
8000
));
setScript
(
text
.
slice
(
0
,
8000
));
}
}
}
catch
{
}
catch
{
// Keep manual editor empty so
the user can paste script content manual
ly.
// Keep manual editor empty so
users can paste script content direct
ly.
}
finally
{
}
finally
{
if
(
!
cancelled
)
{
if
(
!
cancelled
)
{
setLoadingScript
(
false
);
setLoadingScript
(
false
);
...
@@ -291,13 +327,13 @@ export function OutlineGeneration() {
...
@@ -291,13 +327,13 @@ export function OutlineGeneration() {
try
{
try
{
await
projectsApi
.
importScript
(
pid
);
await
projectsApi
.
importScript
(
pid
);
await
q
c
.
invalidateQueries
({
queryKey
:
[
"outline"
,
pid
]
});
await
q
ueryClient
.
invalidateQueries
({
queryKey
:
[
"outline"
,
pid
]
});
await
q
c
.
invalidateQueries
({
queryKey
:
[
"episodes"
,
pid
]
});
await
q
ueryClient
.
invalidateQueries
({
queryKey
:
[
"episodes"
,
pid
]
});
await
q
c
.
invalidateQueries
({
queryKey
:
[
"characters"
,
pid
]
});
await
q
ueryClient
.
invalidateQueries
({
queryKey
:
[
"characters"
,
pid
]
});
await
q
c
.
invalidateQueries
({
queryKey
:
[
"scenes"
,
pid
]
});
await
q
ueryClient
.
invalidateQueries
({
queryKey
:
[
"scenes"
,
pid
]
});
navigate
(
`/project/
${
projectId
}
/episodes`
);
navigate
(
`/project/
${
projectId
}
/episodes`
);
}
catch
(
error
)
{
}
catch
(
error
)
{
setImportError
((
error
as
Error
)?.
message
??
"导入失败,请
重试
"
);
setImportError
((
error
as
Error
)?.
message
??
"导入失败,请
稍后重试。
"
);
}
finally
{
}
finally
{
setImporting
(
false
);
setImporting
(
false
);
}
}
...
@@ -305,7 +341,6 @@ export function OutlineGeneration() {
...
@@ -305,7 +341,6 @@ export function OutlineGeneration() {
const
handleGenerate
=
async
()
=>
{
const
handleGenerate
=
async
()
=>
{
if
(
!
script
.
trim
())
return
;
if
(
!
script
.
trim
())
return
;
setImportError
(
null
);
setImportError
(
null
);
await
generateOutline
.
mutateAsync
(
script
);
await
generateOutline
.
mutateAsync
(
script
);
setShowManual
(
false
);
setShowManual
(
false
);
...
@@ -313,7 +348,6 @@ export function OutlineGeneration() {
...
@@ -313,7 +348,6 @@ export function OutlineGeneration() {
const
handleContinue
=
async
()
=>
{
const
handleContinue
=
async
()
=>
{
if
(
!
outline
)
return
;
if
(
!
outline
)
return
;
await
generateEpisodes
.
mutateAsync
();
await
generateEpisodes
.
mutateAsync
();
navigate
(
`/project/
${
projectId
}
/episodes`
);
navigate
(
`/project/
${
projectId
}
/episodes`
);
};
};
...
@@ -353,239 +387,371 @@ export function OutlineGeneration() {
...
@@ -353,239 +387,371 @@ export function OutlineGeneration() {
if
(
!
outline
)
{
if
(
!
outline
)
{
return
(
return
(
<
div
className=
"
h-full overflow-auto bg-background p-6
"
>
<
div
className=
"
mx-auto max-w-[1380px]
"
>
<
div
className=
"
max-w-3xl mx-auto
"
>
<
div
className=
"
grid gap-6 xl:grid-cols-[1.02fr_0.98fr]
"
>
<
div
className=
"mb-6 flex items-center gap-3
"
>
<
section
className=
"relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]
"
>
<
div
className=
"
w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
<
div
className=
"
absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]"
/
>
<
Sparkles
className=
"w-4 h-4 text-white
"
/>
<
div
className=
"absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14
"
/>
<
/
div
>
<
div
className=
"absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10"
/
>
<
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
生成剧本大纲
</
h1
>
<
div
className=
"relative"
>
<
p
className=
"text-sm text-muted-foreground"
>
先预览剧本解析结果,再决定直接导入或手动生成大纲。
</
p
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74"
>
</
div
>
<
Sparkles
className=
"h-4 w-4"
/
>
</
div
>
Outline Stage
</
div
>
{
(
importError
||
generateOutline
.
isError
)
&&
(
<
h1
className=
"mt-5 max-w-2xl text-4xl font-semibold leading-tight"
>
<
div
className=
"mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3"
>
从剧本文本进入结构化拆解,生成项目大纲与后续流程骨架
<
AlertCircle
className=
"w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
/
>
<
/
h1
>
<
p
className=
"
text-sm text-red-700
"
>
<
p
className=
"
mt-4 max-w-2xl text-base leading-7 text-white/76
"
>
{
importError
??
(
generateOutline
.
error
as
Error
)?.
message
}
你可以直接导入平台识别到的剧本摘要,也可以手动粘贴剧本文本,让 AI 生成适合企业工作流的大纲结果。
</
p
>
</
p
>
<
div
className=
"mt-8 grid gap-4 sm:grid-cols-3"
>
{
[
{
title
:
"自动识别"
,
desc
:
"优先使用上传剧本的结构化摘要,减少重复输入。"
,
},
{
title
:
"人工确认"
,
desc
:
"支持改为手动编辑剧本文本,再决定生成方式。"
,
},
{
title
:
"继续生产"
,
desc
:
"大纲完成后,可直接接入分集、角色和分镜流程。"
,
},
].
map
((
item
,
index
)
=>
(
<
div
key=
{
item
.
title
}
className=
"rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<
div
className=
"mb-3 text-xs uppercase tracking-[0.24em] text-white/64"
>
0
{
index
+
1
}
</
div
>
<
div
className=
"text-lg font-medium"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-2 text-sm leading-6 text-white/74"
>
{
item
.
desc
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
</
div
>
)
}
</
section
>
{
scriptInfo
&&
!
showManual
?
(
<
section
className=
"rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"
rounded-xl border border-primary/40 bg-primary/5 p-6 mb
-4"
>
<
div
className=
"
mb-6 flex items-start justify-between gap
-4"
>
<
div
className=
"flex items-start justify-between mb-4"
>
<
div
>
<
div
className=
"
flex items-center gap-2"
>
<
div
className=
"
text-xs uppercase tracking-[0.28em] text-primary/70"
>
Outline Builder
</
div
>
<
FileText
className=
"w-5 h-5 text-primary"
/
>
<
h2
className=
"mt-2 text-3xl font-semibold text-foreground"
>
剧本拆解入口
</
h2
>
<
span
className=
"text-sm font-medium text-primary"
>
已识别上传剧本
</
span
>
<
p
className=
"mt-3 text-sm leading-6 text-muted-foreground"
>
</
div
>
先预览自动识别结果,再决定一键导入还是手动编辑生成大纲。
<
CheckCircle2
className=
"w-5 h-5 text-green-500"
/
>
<
/
p
>
</
div
>
</
div
>
<
div
className=
"flex h-14 w-14 items-center justify-center rounded-[28px] bg-primary/10 text-primary"
>
<
WandSparkles
className=
"h-6 w-6"
/>
</
div
>
</
div
>
<
h2
className=
"text-xl font-bold text-foreground mb-1"
>
{
scriptInfo
.
title
||
"未识别标题"
}
</
h2
>
{
importError
||
generateOutline
.
isError
?
(
<
p
className=
"text-sm text-muted-foreground mb-4"
>
{
scriptInfo
.
genre
||
"待补充题材"
}
</
p
>
<
div
className=
"mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4"
>
<
AlertCircle
className=
"mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
{
scriptInfo
.
synopsis
&&
(
<
p
className=
"text-sm leading-6 text-red-700"
>
<
p
className=
"text-sm text-foreground/80 bg-background/60 rounded-lg p-3 mb-4 leading-relaxed"
>
{
importError
??
(
generateOutline
.
error
as
Error
)?.
message
}
{
scriptInfo
.
synopsis
.
length
>
120
?
`${scriptInfo.synopsis.slice(0, 120)}
...
`
:
scriptInfo
.
synopsis
}
</
p
>
</
p
>
)
}
</
div
>
)
:
null
}
{
scriptInfo
&&
!
showManual
?
(
<
div
className=
"space-y-5"
>
<
div
className=
"rounded-[30px] border border-primary/12 bg-primary/5 p-6"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary shadow-sm"
>
<
FileText
className=
"h-3.5 w-3.5"
/>
Script Summary Detected
</
div
>
<
CheckCircle2
className=
"h-5 w-5 text-emerald-500"
/>
</
div
>
<
div
className=
"grid grid-cols-4 gap-3 mb-5"
>
<
h3
className=
"text-2xl font-semibold text-foreground"
>
{
[
{
scriptInfo
.
title
||
"已识别上传剧本"
}
{
icon
:
<
Film
className=
"w-4 h-4"
/>,
label
:
"集数"
,
value
:
`${scriptInfo.episodeCount} 集`
},
</
h3
>
{
icon
:
<
Users
className=
"w-4 h-4"
/>,
label
:
"角色"
,
value
:
`${scriptInfo.characterCount} 个`
},
<
p
className=
"mt-3 text-sm text-muted-foreground"
>
{
icon
:
<
MapPin
className=
"w-4 h-4"
/>,
label
:
"场景"
,
value
:
`${scriptInfo.sceneCount} 个`
},
{
scriptInfo
.
genre
||
"题材待补充"
}
{
icon
:
<
Sparkles
className=
"w-4 h-4"
/>,
label
:
"分镜脚本"
,
value
:
`${scriptInfo.storyboardCount} 条`
},
</
p
>
].
map
(({
icon
,
label
,
value
})
=>
(
<
div
key=
{
label
}
className=
"rounded-lg bg-background border border-border p-3 text-center"
>
{
scriptInfo
.
synopsis
?
(
<
div
className=
"flex justify-center mb-1 text-primary"
>
{
icon
}
</
div
>
<
p
className=
"mt-4 rounded-[24px] border border-border/70 bg-white/78 p-4 text-sm leading-7 text-foreground/84"
>
<
div
className=
"text-lg font-bold text-foreground"
>
{
value
}
</
div
>
{
scriptInfo
.
synopsis
.
length
>
160
<
div
className=
"text-xs text-muted-foreground"
>
{
label
}
</
div
>
?
`${scriptInfo.synopsis.slice(0, 160)}
...
`
:
scriptInfo
.
synopsis
}
</
p
>
)
:
null
}
<
div
className=
"mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
>
{
[
{
icon
:
<
Film
className=
"h-4 w-4"
/>,
label
:
"分集规模"
,
value
:
`${scriptInfo.episodeCount} 集`
,
},
{
icon
:
<
Users
className=
"h-4 w-4"
/>,
label
:
"角色数量"
,
value
:
`${scriptInfo.characterCount} 个`
,
},
{
icon
:
<
MapPin
className=
"h-4 w-4"
/>,
label
:
"场景数量"
,
value
:
`${scriptInfo.sceneCount} 个`
,
},
{
icon
:
<
Sparkles
className=
"h-4 w-4"
/>,
label
:
"分镜线索"
,
value
:
`${scriptInfo.storyboardCount} 条`
,
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"rounded-[24px] border border-border/70 bg-white/78 px-4 py-4 shadow-sm"
>
<
div
className=
"mb-3 flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
{
item
.
icon
}
</
div
>
<
div
className=
"text-lg font-semibold text-foreground"
>
{
item
.
value
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
item
.
label
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
<
button
<
button
onClick=
{
handleImport
}
onClick=
{
handleImport
}
disabled=
{
importing
}
disabled=
{
importing
}
className=
"w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className=
"inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
>
{
importing
?
(
{
importing
?
(
<>
<>
<
Loader2
className=
"w-5 h-5 animate-spin"
/>
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
<
span
>
导入中...
</
span
>
正在导入剧本骨架...
</>
</>
)
:
(
)
:
(
<>
<>
<
Sparkles
className=
"w-5 h-5"
/>
<
Sparkles
className=
"h-5 w-5"
/>
<
span
>
一键导入大纲 · 分集 · 角色 · 分镜
</
span
>
一键导入大纲、分集、角色与分镜骨架
<
ArrowRight
className=
"w-4 h-4"
/>
<
ArrowRight
className=
"h-5 w-5"
/>
</>
</>
)
}
</
button
>
<
button
onClick=
{
()
=>
setShowManual
(
true
)
}
className=
"w-full mt-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
手动编辑剧本内容,用 AI 生成大纲
</
button
>
</
div
>
)
:
(
<
div
className=
"rounded-xl border border-border bg-card p-6"
>
<
div
className=
"flex items-center justify-between mb-3"
>
<
label
className=
"block text-sm font-medium text-foreground"
>
剧本内容
</
label
>
<
div
className=
"flex items-center gap-2"
>
{
loadingScript
&&
(
<
span
className=
"text-xs text-muted-foreground flex items-center gap-1"
>
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
正在加载上传剧本...
</
span
>
)
}
{
!
loadingScript
&&
script
&&
(
<
span
className=
"text-xs text-green-600"
>
已自动带入上传剧本文本
</
span
>
)
}
)
}
{
scriptInfo
&&
(
</
button
>
<
button
onClick=
{
()
=>
setShowManual
(
true
)
}
className=
"w-full rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
改为手动编辑剧本文本,再由 AI 生成大纲
</
button
>
</
div
>
)
:
(
<
div
className=
"space-y-5"
>
<
div
className=
"rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"
>
<
div
className=
"mb-3 flex items-center justify-between gap-4"
>
<
label
className=
"text-sm font-medium text-foreground"
>
剧本文本
</
label
>
<
div
className=
"flex items-center gap-2 text-xs text-muted-foreground"
>
{
loadingScript
?
(
<>
<
Loader2
className=
"h-3.5 w-3.5 animate-spin"
/>
正在加载已上传剧本...
</>
)
:
script
?
(
<
span
className=
"text-emerald-600"
>
已自动带入剧本文本
</
span
>
)
:
null
}
</
div
>
</
div
>
<
textarea
className=
"h-72 w-full rounded-[24px] border border-border/80 bg-white p-4 text-sm leading-7 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"将剧本文本粘贴到这里,AI 会为你生成结构化大纲..."
value=
{
script
}
onChange=
{
(
e
)
=>
setScript
(
e
.
target
.
value
)
}
/>
<
p
className=
"mt-3 text-xs leading-6 text-muted-foreground"
>
支持中英文剧本,建议正文长度在 500 到 5000 字之间,便于生成更稳定的大纲结果。
</
p
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
<
button
onClick=
{
handleGenerate
}
disabled=
{
!
script
.
trim
()
||
generateOutline
.
isPending
}
className=
"inline-flex h-14 items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
generateOutline
.
isPending
?
(
<>
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
正在生成大纲...
</>
)
:
(
<>
<
Sparkles
className=
"h-5 w-5"
/>
AI 生成大纲
</>
)
}
</
button
>
{
scriptInfo
?
(
<
button
<
button
onClick=
{
()
=>
setShowManual
(
false
)
}
onClick=
{
()
=>
setShowManual
(
false
)
}
className=
"
text-xs text-primary underline
"
className=
"
rounded-[22px] border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary
"
>
>
返回
文档预览
返回
自动识别摘要
</
button
>
</
button
>
)
}
)
:
null
}
</
div
>
</
div
>
</
div
>
</
div
>
)
}
<
textarea
</
section
>
className=
"w-full h-64 p-4 rounded-lg border border-border bg-background text-sm text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
placeholder=
"将您的剧本文本粘贴到此处,AI 将自动生成大纲..."
value=
{
script
}
onChange=
{
(
e
)
=>
setScript
(
e
.
target
.
value
)
}
/>
<
p
className=
"text-xs text-muted-foreground mt-2"
>
支持中英文剧本,建议正文长度 500 到 5000 字。
</
p
>
<
button
onClick=
{
handleGenerate
}
disabled=
{
!
script
.
trim
()
||
generateOutline
.
isPending
}
className=
"w-full mt-4 py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{
generateOutline
.
isPending
?
(
<>
<
Loader2
className=
"w-5 h-5 animate-spin"
/>
<
span
>
生成中...
</
span
>
</>
)
:
(
<>
<
Sparkles
className=
"w-5 h-5"
/>
<
span
>
AI 生成大纲
</
span
>
</>
)
}
</
button
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"mx-auto max-w-[1380px]"
>
<
div
className=
"max-w-5xl mx-auto"
>
<
section
className=
"grid gap-6 xl:grid-cols-[1.02fr_0.98fr]"
>
<
div
className=
"mb-6 flex items-start justify-between"
>
<
div
className=
"relative overflow-hidden rounded-[36px] border border-white/80 bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)] p-7 text-white shadow-[0_28px_70px_rgba(12,43,78,0.20)]"
>
<
div
>
<
div
className=
"absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(255,255,255,0.18),transparent_24%),radial-gradient(circle_at_bottom_left,_rgba(255,255,255,0.10),transparent_28%)]"
/>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
div
className=
"absolute -right-10 top-6 h-52 w-52 rounded-full border border-white/14"
/>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
<
div
className=
"absolute right-24 top-20 h-32 w-32 rounded-full border border-white/10"
/>
<
Sparkles
className=
"w-4 h-4 text-white"
/>
</
div
>
<
div
className=
"relative"
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
剧本大纲
</
h1
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.28em] text-white/74"
>
<
CheckCircle2
className=
"h-4 w-4"
/>
Outline Ready
</
div
>
<
h1
className=
"mt-5 max-w-2xl text-4xl font-semibold leading-tight"
>
项目大纲已生成,可以继续进入分集与设定流程
</
h1
>
<
p
className=
"mt-4 max-w-2xl text-base leading-7 text-white/76"
>
当前大纲已经作为项目的结构化骨架写入工作台,接下来建议立即生成分集,保持整个工作流连贯推进。
</
p
>
<
div
className=
"mt-8 grid gap-4 sm:grid-cols-3"
>
{
[
{
title
:
"题材已确定"
,
desc
:
outline
.
genre
,
},
{
title
:
"集数规模"
,
desc
:
`${outline.episodeCount} 集`
,
},
{
title
:
"下一步建议"
,
desc
:
"继续生成分集内容"
,
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
title
}
className=
"rounded-[28px] border border-white/14 bg-white/10 px-5 py-5 backdrop-blur-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-white/64"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-3 text-lg font-medium"
>
{
item
.
desc
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
AI 已为您生成当前项目的大纲结果。
</
p
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowManual
(
true
)
}
className=
"px-4 py-2 rounded-lg border border-border bg-card text-foreground flex items-center gap-2 hover:bg-muted transition-colors text-sm"
>
<
RefreshCw
className=
"w-4 h-4"
/>
重新生成
</
button
>
</
div
>
</
div
>
{
showManual
&&
(
<
section
className=
"rounded-[36px] border border-white/80 bg-white/88 p-7 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"mb-6 rounded-xl border border-border bg-card p-6"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
label
className=
"block text-sm font-medium text-foreground mb-3"
>
输入新的剧本内容
</
label
>
<
div
>
<
textarea
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Outline Result
</
div
>
className=
"w-full h-40 p-3 rounded-lg border border-border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
<
h2
className=
"mt-2 text-3xl font-semibold text-foreground"
>
大纲结果确认
</
h2
>
value=
{
script
}
<
p
className=
"mt-3 text-sm leading-6 text-muted-foreground"
>
onChange=
{
(
e
)
=>
setScript
(
e
.
target
.
value
)
}
你可以在这里快速检查大纲内容,确认无误后继续生成分集;如需修改,也可以重新提交剧本文本。
/>
</
p
>
<
div
className=
"flex gap-2 mt-3"
>
<
button
onClick=
{
handleGenerate
}
disabled=
{
!
script
.
trim
()
||
generateOutline
.
isPending
}
className=
"px-4 py-2 rounded-lg bg-primary text-white text-sm disabled:opacity-50 flex items-center gap-2"
>
{
generateOutline
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
null
}
{
generateOutline
.
isPending
?
"生成中..."
:
"确认生成"
}
</
button
>
<
button
onClick=
{
()
=>
setShowManual
(
false
)
}
className=
"px-4 py-2 rounded-lg border border-border text-sm"
>
取消
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowManual
(
true
)
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<
RefreshCw
className=
"h-4 w-4"
/>
重新生成
</
button
>
</
div
>
</
div
>
)
}
{
generateEpisodes
.
isError
&&
(
{
showManual
?
(
<
div
className=
"mb-4 p-4 rounded-xl border border-red-200 bg-red-50 flex items-start gap-3"
>
<
div
className=
"mb-5 rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"
>
<
AlertCircle
className=
"w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
/>
<
label
className=
"mb-3 block text-sm font-medium text-foreground"
>
输入新的剧本文本
</
label
>
<
p
className=
"text-sm text-red-700"
>
{
(
generateEpisodes
.
error
as
Error
)?.
message
}
</
p
>
<
textarea
</
div
>
className=
"h-48 w-full rounded-[24px] border border-border/80 bg-white p-4 text-sm leading-7 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
)
}
value=
{
script
}
onChange=
{
(
e
)
=>
setScript
(
e
.
target
.
value
)
}
/>
<
div
className=
"mt-4 flex gap-3"
>
<
button
onClick=
{
handleGenerate
}
disabled=
{
!
script
.
trim
()
||
generateOutline
.
isPending
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
generateOutline
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4"
/>
}
{
generateOutline
.
isPending
?
"生成中..."
:
"确认生成"
}
</
button
>
<
button
onClick=
{
()
=>
setShowManual
(
false
)
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
</
button
>
</
div
>
</
div
>
)
:
null
}
<
div
className=
"rounded-xl border border-border bg-card p-6 mb-4"
>
{
generateEpisodes
.
isError
?
(
<
div
className=
"grid grid-cols-2 gap-6 mb-6"
>
<
div
className=
"mb-5 flex items-start gap-3 rounded-[24px] border border-red-200 bg-red-50 px-4 py-4"
>
<
div
>
<
AlertCircle
className=
"mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
剧名
</
label
>
<
p
className=
"text-sm leading-6 text-red-700"
>
{
(
generateEpisodes
.
error
as
Error
)?.
message
}
</
p
>
<
div
className=
"text-xl font-semibold text-foreground"
>
{
outline
.
title
}
</
div
>
</
div
>
</
div
>
<
div
>
)
:
null
}
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
类型
</
label
>
<
div
className=
"text-xl font-semibold text-foreground"
>
{
outline
.
genre
}
</
div
>
<
div
className=
"rounded-[30px] border border-border/80 bg-white/70 p-6 shadow-sm"
>
<
div
className=
"grid gap-5 md:grid-cols-2"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Title
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-foreground"
>
{
outline
.
title
}
</
div
>
</
div
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Genre
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-foreground"
>
{
outline
.
genre
}
</
div
>
</
div
>
</
div
>
<
div
className=
"mt-6"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Synopsis
</
div
>
<
p
className=
"mt-3 text-sm leading-7 text-foreground/84"
>
{
outline
.
synopsis
}
</
p
>
</
div
>
<
div
className=
"mt-6 inline-flex rounded-full bg-accent px-4 py-2 text-sm text-primary"
>
共
{
outline
.
episodeCount
}
集
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"mb-4"
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
故事梗概
</
label
>
<
p
className=
"text-sm text-foreground leading-relaxed"
>
{
outline
.
synopsis
}
</
p
>
</
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
集数
</
label
>
<
span
className=
"px-3 py-1 rounded-lg bg-accent text-primary text-sm"
>
{
outline
.
episodeCount
}
集
</
span
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
handleContinue
}
onClick=
{
handleContinue
}
disabled=
{
generateEpisodes
.
isPending
}
disabled=
{
generateEpisodes
.
isPending
}
className=
"w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50"
className=
"mt-6 inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
>
{
generateEpisodes
.
isPending
?
(
{
generateEpisodes
.
isPending
?
(
<>
<>
<
Loader2
className=
"w-5 h-5 animate-spin"
/>
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
<
span
>
生成分集中...
</
span
>
正在生成分集内容...
</>
</>
)
:
(
)
:
(
<>
<>
<
span
>
生成分集内容
</
span
>
继续生成分集
<
ArrowRight
className=
"w-5 h-5"
/>
<
ArrowRight
className=
"h-5 w-5"
/>
</>
</>
)
}
)
}
</
button
>
</
button
>
</
div
>
</
section
>
</
section
>
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/ProjectDetail.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
use
Params
,
useNavigate
}
from
"react-router"
;
import
{
use
Navigate
,
useParams
}
from
"react-router"
;
import
{
import
{
Film
,
ArrowRight
,
Play
,
BookOpenText
,
ChevronRight
,
ChevronRight
,
Clapperboard
,
Film
,
Image
as
ImageIcon
,
Image
as
ImageIcon
,
Loader2
,
MapPin
,
MapPin
,
PlayCircle
,
Sparkles
,
Sparkles
,
Edit2
,
Loader2
,
BookOpen
,
Users
,
Users
,
Clapperboard
,
Video
,
Video
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
useProject
}
from
"../../hooks/useProjects"
;
import
{
useProject
}
from
"../../hooks/useProjects"
;
import
{
use
Outline
,
useEpisodes
,
useCharacters
,
useScenes
,
useVideoTasks
}
from
"../../hooks/useAi"
;
import
{
use
Characters
,
useEpisodes
,
useOutline
,
useScenes
,
useVideoTasks
}
from
"../../hooks/useAi"
;
type
Tab
=
"outline"
|
"characters"
|
"storyboard"
|
"video"
;
type
Tab
=
"outline"
|
"characters"
|
"storyboard"
|
"video"
;
function
ProjectCover
({
coverUrl
,
name
}:
{
coverUrl
?:
string
;
name
:
string
})
{
if
(
coverUrl
)
{
return
<
img
src=
{
coverUrl
}
alt=
{
name
}
className=
"h-full w-full object-cover"
/>;
}
return
(
<
div
className=
"flex h-full w-full items-center justify-center bg-[linear-gradient(145deg,#0c2746_0%,#11477f_42%,#1d9de9_100%)]"
>
<
div
className=
"rounded-[28px] border border-white/14 bg-white/10 px-5 py-4 text-center text-white backdrop-blur-sm"
>
<
div
className=
"text-[11px] uppercase tracking-[0.32em] text-white/72"
>
Project
</
div
>
<
div
className=
"mt-2 text-3xl font-semibold tracking-[0.18em]"
>
{
name
.
slice
(
0
,
2
).
toUpperCase
()
}
</
div
>
</
div
>
</
div
>
);
}
function
SectionTab
({
active
,
label
,
icon
:
Icon
,
onClick
,
}:
{
active
:
boolean
;
label
:
string
;
icon
:
typeof
BookOpenText
;
onClick
:
()
=>
void
;
})
{
return
(
<
button
type=
"button"
onClick=
{
onClick
}
className=
{
[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all"
,
active
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
].
join
(
" "
)
}
>
<
Icon
className=
"h-4 w-4"
/>
{
label
}
</
button
>
);
}
export
function
ProjectDetail
()
{
export
function
ProjectDetail
()
{
const
{
projectId
}
=
useParams
();
const
{
projectId
}
=
useParams
();
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
...
@@ -34,156 +79,220 @@ export function ProjectDetail() {
...
@@ -34,156 +79,220 @@ export function ProjectDetail() {
const
[
activeTab
,
setActiveTab
]
=
useState
<
Tab
>
(
"outline"
);
const
[
activeTab
,
setActiveTab
]
=
useState
<
Tab
>
(
"outline"
);
const
tabs
=
[
const
tabs
=
[
{
id
:
"outline"
as
Tab
,
label
:
"
大纲"
,
icon
:
BookOpen
},
{
id
:
"outline"
as
Tab
,
label
:
"
剧本拆解"
,
icon
:
BookOpenText
},
{
id
:
"characters"
as
Tab
,
label
:
"角色
/
场景"
,
icon
:
Users
},
{
id
:
"characters"
as
Tab
,
label
:
"角色
与
场景"
,
icon
:
Users
},
{
id
:
"storyboard"
as
Tab
,
label
:
"分镜"
,
icon
:
Clapperboard
},
{
id
:
"storyboard"
as
Tab
,
label
:
"分镜
工作台
"
,
icon
:
Clapperboard
},
{
id
:
"video"
as
Tab
,
label
:
"视频"
,
icon
:
Video
},
{
id
:
"video"
as
Tab
,
label
:
"视频
生成
"
,
icon
:
Video
},
];
];
if
(
projectLoading
)
{
if
(
projectLoading
)
{
return
(
return
(
<
div
className=
"
h-full flex
items-center justify-center"
>
<
div
className=
"
flex h-full
items-center justify-center"
>
<
Loader2
className=
"
w-8 h-8 animate-spin text-muted-foreground
"
/>
<
Loader2
className=
"
h-8 w-8 animate-spin text-primary
"
/>
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background relative"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
{
/* Header */
}
<
section
className=
"overflow-hidden rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"border-b border-border bg-card px-6 py-3"
>
<
div
className=
"grid gap-6 xl:grid-cols-[320px_1fr]"
>
<
div
className=
"flex gap-4 items-center"
>
<
div
className=
"overflow-hidden rounded-[30px] border border-white/70 bg-muted shadow-sm"
>
<
div
className=
"w-20 aspect-[2/3] rounded-lg overflow-hidden bg-muted flex-shrink-0 flex items-center justify-center"
>
<
div
className=
"aspect-[3/4]"
>
{
project
?.
coverUrl
?
(
<
ProjectCover
coverUrl=
{
project
?.
coverUrl
}
name=
{
project
?.
name
??
"项目"
}
/>
<
img
src=
{
project
.
coverUrl
}
alt=
"封面"
className=
"w-full h-full object-cover"
/>
</
div
>
)
:
(
<
Film
className=
"w-8 h-8 text-muted-foreground"
/>
)
}
</
div
>
</
div
>
<
div
className=
"flex-1"
>
<
div
className=
"flex flex-col"
>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
h1
className=
"text-lg font-semibold text-foreground"
>
<
div
>
{
project
?.
name
??
"加载中..."
}
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
</
h1
>
<
Film
className=
"h-3.5 w-3.5"
/>
{
outline
&&
(
Project Overview
<
span
className=
"px-2 py-0.5 rounded bg-accent text-foreground text-xs border border-border"
>
</
div
>
{
outline
.
genre
}
<
h1
className=
"mt-4 text-4xl font-semibold text-foreground"
>
</
span
>
{
project
?.
name
??
"当前项目"
}
)
}
</
h1
>
<
p
className=
"mt-4 max-w-3xl text-sm leading-7 text-muted-foreground"
>
这里是项目的流程总控台,可以统一查看剧本拆解、角色场景、分镜与视频任务,并从任意阶段继续推进。
</
p
>
</
div
>
<
div
className=
"flex flex-wrap gap-3"
>
{
episodes
.
length
===
0
?
(
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<
Sparkles
className=
"h-4 w-4"
/>
开始剧本拆解
</
button
>
)
:
(
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard`
)
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
进入工作流
<
ChevronRight
className=
"h-4 w-4"
/>
</
button
>
)
}
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-4 text-xs text-muted-foreground"
>
{
outline
&&
<
span
>
{
outline
.
episodeCount
}
集
</
span
>
}
<
div
className=
"mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4"
>
{
episodes
.
length
>
0
&&
<
span
>
已生成
{
episodes
.
length
}
集分集
</
span
>
}
{
[
{
characters
.
length
>
0
&&
<
span
>
{
characters
.
length
}
个角色
</
span
>
}
{
{
scenes
.
length
>
0
&&
<
span
>
{
scenes
.
length
}
个场景
</
span
>
}
label
:
"题材类型"
,
{
project
?.
updatedAt
&&
(
value
:
outline
?.
genre
??
"待生成"
,
<
span
>
更新于
{
new
Date
(
project
.
updatedAt
).
toLocaleDateString
(
"zh-CN"
)
}
</
span
>
},
)
}
{
label
:
"分集数量"
,
value
:
outline
?
`${outline.episodeCount} 集`
:
"待生成"
,
},
{
label
:
"角色数量"
,
value
:
`${characters.length} 个`
,
},
{
label
:
"场景数量"
,
value
:
`${scenes.length} 个`
,
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
item
.
label
}
</
div
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
item
.
value
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2 flex-shrink-0"
>
<
div
className=
"mt-6 rounded-[30px] border border-primary/12 bg-primary/5 p-5"
>
{
episodes
.
length
===
0
?
(
<
div
className=
"mb-4 flex items-center gap-3"
>
<
button
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
onClick=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
<
PlayCircle
className=
"h-5 w-5"
/>
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
</
div
>
>
<
div
>
<
Sparkles
className=
"w-4 h-4"
/>
<
div
className=
"text-base font-medium text-foreground"
>
项目推进节奏
</
div
>
生成大纲
<
div
className=
"text-sm text-muted-foreground"
>
</
button
>
按“剧本拆解 → 设定 → 分镜 → 视频”顺序推进,当前工作台会持续沉淀项目资产与状态。
)
:
(
</
div
>
<
button
</
div
>
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard`
)
}
</
div
>
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
>
<
div
className=
"grid gap-3 md:grid-cols-4"
>
进入工作台
{
[
<
ChevronRight
className=
"w-4 h-4"
/>
{
</
button
>
title
:
"剧本拆解"
,
)
}
desc
:
outline
?
"已生成大纲,可继续补充分集。"
:
"建议先完成大纲和分集结构。"
,
},
{
title
:
"角色与场景"
,
desc
:
characters
.
length
+
scenes
.
length
>
0
?
"已沉淀设定资产,可继续补充细节。"
:
"角色和场景仍待生成。"
,
},
{
title
:
"分镜工作台"
,
desc
:
episodes
.
length
>
0
?
"可进入分镜页继续推进。"
:
"需先完成分集生成。"
,
},
{
title
:
"视频生成"
,
desc
:
videoTasks
.
length
>
0
?
"已有视频任务记录。"
:
"分镜完成后可提交视频任务。"
,
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
title
}
className=
"rounded-2xl border border-border/70 bg-white/78 px-4 py-4 text-sm"
>
<
div
className=
"font-medium text-foreground"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-2 leading-6 text-muted-foreground"
>
{
item
.
desc
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
{
/* Tabs */
}
<
section
className=
"mt-6 rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"flex items-center gap-1 mt-3"
>
<
div
className=
"flex flex-wrap gap-3"
>
{
tabs
.
map
((
tab
)
=>
{
{
tabs
.
map
((
tab
)
=>
(
const
Icon
=
tab
.
icon
;
<
SectionTab
return
(
key=
{
tab
.
id
}
<
button
active=
{
activeTab
===
tab
.
id
}
key=
{
tab
.
id
}
label=
{
tab
.
label
}
onClick=
{
()
=>
setActiveTab
(
tab
.
id
)
}
icon=
{
tab
.
icon
}
className=
{
`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-all ${
onClick=
{
()
=>
setActiveTab
(
tab
.
id
)
}
activeTab === tab.id
/>
? "bg-accent text-accent-foreground font-medium"
))
}
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`
}
>
<
Icon
className=
"w-4 h-4"
/>
{
tab
.
label
}
</
button
>
);
})
}
</
div
>
</
div
>
</
div
>
{
/* Content */
}
<
div
className=
"mt-6"
>
<
div
className=
"p-6"
>
{
activeTab
===
"outline"
?
(
<
div
className=
"max-w-6xl mx-auto"
>
{
activeTab
===
"outline"
&&
(
<
OutlineTab
<
OutlineTab
outline=
{
outline
}
outline=
{
outline
}
episodes=
{
episodes
}
episodes=
{
episodes
}
projectId=
{
projectId
}
onNavigateOutline=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
onNavigateOutline=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
onNavigateEpisode=
{
(
ep
Id
)
=>
navigate
(
`/project/${projectId}/storyboard/${ep
Id}`
)
}
onNavigateEpisode=
{
(
ep
isodeId
)
=>
navigate
(
`/project/${projectId}/storyboard/${episode
Id}`
)
}
/>
/>
)
}
)
:
null
}
{
activeTab
===
"characters"
&&
(
{
activeTab
===
"characters"
?
(
<
CharactersTab
<
CharactersTab
characters=
{
characters
}
characters=
{
characters
}
scenes=
{
scenes
}
scenes=
{
scenes
}
projectId=
{
projectId
}
onNavigateChars=
{
()
=>
navigate
(
`/project/${projectId}/characters`
)
}
onNavigateChars=
{
()
=>
navigate
(
`/project/${projectId}/characters`
)
}
onNavigateScenes=
{
()
=>
navigate
(
`/project/${projectId}/scenes`
)
}
onNavigateScenes=
{
()
=>
navigate
(
`/project/${projectId}/scenes`
)
}
/>
/>
)
}
)
:
null
}
{
activeTab
===
"storyboard"
&&
(
<
StoryboardTab
episodes=
{
episodes
}
projectId=
{
projectId
}
/>
{
activeTab
===
"storyboard"
?
(
)
}
<
StoryboardTab
{
activeTab
===
"video"
&&
(
episodes=
{
episodes
}
projectId=
{
projectId
}
/>
)
:
null
}
{
activeTab
===
"video"
?
(
<
VideoTab
videoTasks=
{
videoTasks
}
projectId=
{
projectId
}
/>
<
VideoTab
videoTasks=
{
videoTasks
}
projectId=
{
projectId
}
/>
)
}
)
:
null
}
</
div
>
</
div
>
</
div
>
</
section
>
</
div
>
</
div
>
);
);
}
}
// ---- Outline Tab ----
function
OutlineTab
({
function
OutlineTab
({
outline
,
outline
,
episodes
,
episodes
,
projectId
,
onNavigateOutline
,
onNavigateOutline
,
onNavigateEpisode
,
onNavigateEpisode
,
}:
{
}:
{
outline
:
import
(
"../../lib/api/ai"
).
Outline
|
undefined
;
outline
:
import
(
"../../lib/api/ai"
).
Outline
|
undefined
;
episodes
:
import
(
"../../lib/api/ai"
).
Episode
[];
episodes
:
import
(
"../../lib/api/ai"
).
Episode
[];
projectId
?:
string
;
onNavigateOutline
:
()
=>
void
;
onNavigateOutline
:
()
=>
void
;
onNavigateEpisode
:
(
epId
:
string
)
=>
void
;
onNavigateEpisode
:
(
ep
isode
Id
:
string
)
=>
void
;
})
{
})
{
if
(
!
outline
)
{
if
(
!
outline
)
{
return
(
return
(
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-12 text-center"
>
<
div
className=
"rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center"
>
<
BookOpen
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
尚未生成大纲
</
h3
>
<
BookOpenText
className=
"h-7 w-7"
/>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
上传剧本后,AI 可自动生成大纲和分集
</
p
>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
尚未生成剧本大纲
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
上传剧本后,AI 会自动帮助你完成大纲整理,并进一步推动分集、角色与分镜流程。
</
p
>
<
button
<
button
onClick=
{
onNavigateOutline
}
onClick=
{
onNavigateOutline
}
className=
"
px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 mx-auto
"
className=
"
mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Sparkles
className=
"
w-4 h
-4"
/>
<
Sparkles
className=
"
h-4 w
-4"
/>
前往生成大纲
前往生成大纲
</
button
>
</
button
>
</
div
>
</
div
>
...
@@ -191,65 +300,73 @@ function OutlineTab({
...
@@ -191,65 +300,73 @@ function OutlineTab({
}
}
return
(
return
(
<
div
>
<
div
className=
"space-y-6"
>
{
/* Outline Card */
}
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
className=
"mb-8"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
div
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
剧本大纲
</
h2
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Outline
</
div
>
<
h3
className=
"mt-2 text-3xl font-semibold text-foreground"
>
{
outline
.
title
}
</
h3
>
<
div
className=
"mt-3 flex flex-wrap gap-2"
>
<
span
className=
"rounded-full bg-accent px-3 py-1 text-sm text-primary"
>
{
outline
.
genre
}
</
span
>
<
span
className=
"rounded-full border border-border/80 bg-white px-3 py-1 text-sm text-muted-foreground"
>
{
outline
.
episodeCount
}
集
</
span
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
onNavigateOutline
}
onClick=
{
onNavigateOutline
}
className=
"
text-sm text-primary hover:underline flex items-center gap-1
"
className=
"
rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary
"
>
>
<
Edit2
className=
"w-3 h-3"
/>
编辑大纲
编辑
</
button
>
</
button
>
</
div
>
</
div
>
<
div
className=
"rounded-xl border border-border bg-card p-6"
>
<
p
className=
"mt-5 text-sm leading-7 text-foreground/84"
>
{
outline
.
synopsis
}
</
p
>
<
h3
className=
"text-base font-semibold text-foreground mb-2"
>
{
outline
.
title
}
</
h3
>
<
div
className=
"flex items-center gap-3 mb-3 text-sm text-muted-foreground"
>
<
span
>
类型:
{
outline
.
genre
}
</
span
>
<
span
>
·
</
span
>
<
span
>
{
outline
.
episodeCount
}
集
</
span
>
</
div
>
<
p
className=
"text-sm text-foreground leading-relaxed"
>
{
outline
.
synopsis
}
</
p
>
</
div
>
</
div
>
</
div
>
{
/* Episode List */
}
{
episodes
.
length
>
0
?
(
{
episodes
.
length
>
0
&&
(
<
div
>
<
div
>
<
h2
className=
"text-lg font-semibold text-foreground mb-4"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
分集列表 (
{
episodes
.
length
}
集)
<
h3
className=
"text-2xl font-semibold text-foreground"
>
分集总览
</
h3
>
</
h2
>
<
span
className=
"rounded-full bg-accent px-3 py-1 text-sm text-primary"
>
<
div
className=
"grid grid-cols-3 gap-4"
>
{
episodes
.
length
}
集
{
episodes
.
map
((
ep
)
=>
(
</
span
>
<
div
</
div
>
key=
{
ep
.
id
}
<
div
className=
"grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
onClick=
{
()
=>
onNavigateEpisode
(
ep
.
id
)
}
{
episodes
.
map
((
episode
)
=>
(
className=
"rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer"
<
button
key=
{
episode
.
id
}
onClick=
{
()
=>
onNavigateEpisode
(
episode
.
id
)
}
className=
"rounded-[28px] border border-white/80 bg-white/78 p-5 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20"
>
>
<
div
className=
"flex items-center gap-2 mb-2"
>
<
div
className=
"inline-flex rounded-full bg-primary/8 px-3 py-1 text-xs text-primary"
>
<
span
className=
"text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded"
>
第
{
episode
.
episodeNumber
}
集
第
{
ep
.
episodeNumber
}
集
</
span
>
</
div
>
</
div
>
<
h3
className=
"font-medium text-foreground text-sm mb-1"
>
{
ep
.
title
}
</
h3
>
<
div
className=
"mt-4 text-lg font-medium text-foreground"
>
{
episode
.
title
}
</
div
>
<
p
className=
"text-xs text-muted-foreground line-clamp-3"
>
{
ep
.
summary
}
</
p
>
<
p
className=
"mt-3 line-clamp-4 text-sm leading-6 text-muted-foreground"
>
</
div
>
{
episode
.
summary
}
</
p
>
<
div
className=
"mt-5 inline-flex items-center gap-2 text-sm font-medium text-primary"
>
进入分镜工作台
<
ArrowRight
className=
"h-4 w-4"
/>
</
div
>
</
button
>
))
}
))
}
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
(
<
div
className=
"rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-14 text-center"
>
{
episodes
.
length
===
0
&&
outline
&&
(
<
h3
className=
"text-2xl font-semibold text-foreground"
>
大纲已准备好,下一步生成分集
</
h3
>
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-8 text-center"
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
<
p
className=
"text-sm text-muted-foreground mb-4"
>
大纲已生成,点击前往生成分集内容
</
p
>
继续生成分集后,就可以进入角色设定与分镜制作阶段。
</
p
>
<
button
<
button
onClick=
{
onNavigateOutline
}
onClick=
{
onNavigateOutline
}
className=
"
px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto
"
className=
"
mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Sparkles
className=
"
w-4 h
-4"
/>
<
Sparkles
className=
"
h-4 w
-4"
/>
生成分集
前往
生成分集
</
button
>
</
button
>
</
div
>
</
div
>
)
}
)
}
...
@@ -257,118 +374,133 @@ function OutlineTab({
...
@@ -257,118 +374,133 @@ function OutlineTab({
);
);
}
}
// ---- Characters Tab ----
function
CharactersTab
({
function
CharactersTab
({
characters
,
characters
,
scenes
,
scenes
,
projectId
,
onNavigateChars
,
onNavigateChars
,
onNavigateScenes
,
onNavigateScenes
,
}:
{
}:
{
characters
:
import
(
"../../lib/api/ai"
).
Character
[];
characters
:
import
(
"../../lib/api/ai"
).
Character
[];
scenes
:
import
(
"../../lib/api/ai"
).
Scene
[];
scenes
:
import
(
"../../lib/api/ai"
).
Scene
[];
projectId
?:
string
;
onNavigateChars
:
()
=>
void
;
onNavigateChars
:
()
=>
void
;
onNavigateScenes
:
()
=>
void
;
onNavigateScenes
:
()
=>
void
;
})
{
})
{
const
get
MainCharacterImage
=
(
cha
r
:
import
(
"../../lib/api/ai"
).
Character
)
=>
const
get
CharacterPreview
=
(
characte
r
:
import
(
"../../lib/api/ai"
).
Character
)
=>
char
.
frontImageUrl
??
char
.
imageUrl
??
char
.
sideImageUrl
??
cha
r
.
backImageUrl
??
null
;
char
acter
.
frontImageUrl
??
character
.
imageUrl
??
character
.
sideImageUrl
??
characte
r
.
backImageUrl
??
null
;
return
(
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
{
/* Characters */
}
<
div
>
<
div
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center gap-3"
>
<
Users
className=
"w-5 h-5 text-primary"
/>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
角色设定
</
h2
>
<
Users
className=
"h-5 w-5"
/>
<
span
className=
"text-sm text-muted-foreground"
>
(
{
characters
.
length
}
)
</
span
>
</
div
>
<
div
>
<
h3
className=
"text-2xl font-semibold text-foreground"
>
角色设定
</
h3
>
<
div
className=
"text-sm text-muted-foreground"
>
累计
{
characters
.
length
}
个角色资产
</
div
>
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
onNavigateChars
}
className=
"text-sm text-primary hover:underline"
>
<
button
onClick=
{
onNavigateChars
}
className=
"rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
管理角色
管理角色
</
button
>
</
button
>
</
div
>
</
div
>
{
characters
.
length
===
0
?
(
{
characters
.
length
===
0
?
(
<
div
className=
"rounded-
xl border border-dashed border-border bg-card p-8
text-center"
>
<
div
className=
"rounded-
[32px] border border-dashed border-border bg-white/70 px-6 py-14
text-center"
>
<
p
className=
"text-sm
text-muted-foreground mb-4"
>
暂无角色,去角色页面提取
</
p
>
<
p
className=
"text-sm
leading-6 text-muted-foreground"
>
还没有角色设定,建议先从剧本中提取主要人物。
</
p
>
<
button
<
button
onClick=
{
onNavigateChars
}
onClick=
{
onNavigateChars
}
className=
"
px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto
"
className=
"
mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Sparkles
className=
"
w-4 h
-4"
/>
<
Sparkles
className=
"
h-4 w
-4"
/>
AI 提取角色
AI 提取角色
</
button
>
</
button
>
</
div
>
</
div
>
)
:
(
)
:
(
<
div
className=
"grid g
rid-cols-4 gap
-4"
>
<
div
className=
"grid g
ap-4 md:grid-cols-2 xl:grid-cols
-4"
>
{
characters
.
slice
(
0
,
8
).
map
((
char
)
=>
(
{
characters
.
slice
(
0
,
8
).
map
((
char
acter
)
=>
(
<
div
<
button
key=
{
char
.
id
}
key=
{
char
acter
.
id
}
onClick=
{
onNavigateChars
}
onClick=
{
onNavigateChars
}
className=
"rounded-
xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors
"
className=
"rounded-
[28px] border border-white/80 bg-white/78 p-5 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20
"
>
>
<
div
className=
"w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center"
>
<
div
className=
"flex h-24 items-center justify-center overflow-hidden rounded-[24px] bg-muted"
>
{
getMainCharacterImage
(
char
)
?
(
{
getCharacterPreview
(
character
)
?
(
<
img
src=
{
getMainCharacterImage
(
char
)
??
""
}
alt=
{
char
.
name
}
className=
"w-full h-full object-cover"
/>
<
img
src=
{
getCharacterPreview
(
character
)
??
""
}
alt=
{
character
.
name
}
className=
"h-full w-full object-cover"
/>
)
:
(
)
:
(
<
ImageIcon
className=
"
w-8 h
-8 text-muted-foreground"
/>
<
ImageIcon
className=
"
h-8 w
-8 text-muted-foreground"
/>
)
}
)
}
</
div
>
</
div
>
<
div
className=
"
font-medium text-foreground text-sm mb-1"
>
{
cha
r
.
name
}
</
div
>
<
div
className=
"
mt-4 text-lg font-medium text-foreground"
>
{
characte
r
.
name
}
</
div
>
<
div
className=
"
text-xs text-muted-foreground"
>
{
cha
r
.
roleType
}
</
div
>
<
div
className=
"
mt-2 text-sm text-muted-foreground"
>
{
characte
r
.
roleType
}
</
div
>
</
div
>
</
button
>
))
}
))
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
{
/* Scenes */
}
<
div
>
<
div
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center gap-3"
>
<
MapPin
className=
"w-5 h-5 text-primary"
/>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
场景设定
</
h2
>
<
MapPin
className=
"h-5 w-5"
/>
<
span
className=
"text-sm text-muted-foreground"
>
(
{
scenes
.
length
}
)
</
span
>
</
div
>
<
div
>
<
h3
className=
"text-2xl font-semibold text-foreground"
>
场景设定
</
h3
>
<
div
className=
"text-sm text-muted-foreground"
>
累计
{
scenes
.
length
}
个场景资产
</
div
>
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
onNavigateScenes
}
className=
"text-sm text-primary hover:underline"
>
<
button
onClick=
{
onNavigateScenes
}
className=
"rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
管理场景
管理场景
</
button
>
</
button
>
</
div
>
</
div
>
{
scenes
.
length
===
0
?
(
{
scenes
.
length
===
0
?
(
<
div
className=
"rounded-
xl border border-dashed border-border bg-card p-8
text-center"
>
<
div
className=
"rounded-
[32px] border border-dashed border-border bg-white/70 px-6 py-14
text-center"
>
<
p
className=
"text-sm
text-muted-foreground mb-4"
>
暂无场景,去场景页面提取
</
p
>
<
p
className=
"text-sm
leading-6 text-muted-foreground"
>
还没有场景设定,建议在角色之后继续提取关键场景。
</
p
>
<
button
<
button
onClick=
{
onNavigateScenes
}
onClick=
{
onNavigateScenes
}
className=
"
px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto
"
className=
"
mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Sparkles
className=
"
w-4 h
-4"
/>
<
Sparkles
className=
"
h-4 w
-4"
/>
AI 提取场景
AI 提取场景
</
button
>
</
button
>
</
div
>
</
div
>
)
:
(
)
:
(
<
div
className=
"grid g
rid-cols-3 gap-4
"
>
<
div
className=
"grid g
ap-4 md:grid-cols-2 xl:grid-cols-3
"
>
{
scenes
.
slice
(
0
,
6
).
map
((
scene
)
=>
(
{
scenes
.
slice
(
0
,
6
).
map
((
scene
)
=>
(
<
div
<
button
key=
{
scene
.
id
}
key=
{
scene
.
id
}
onClick=
{
onNavigateScenes
}
onClick=
{
onNavigateScenes
}
className=
"
rounded-xl border border-border bg-card overflow-hidden cursor-pointer hover:border-primary transition-colors
"
className=
"
overflow-hidden rounded-[28px] border border-white/80 bg-white/78 text-left shadow-sm transition hover:translate-y-[-1px] hover:border-primary/20
"
>
>
<
div
className=
"aspect-video bg-muted
flex items-center justify-center
"
>
<
div
className=
"aspect-video bg-muted"
>
{
scene
.
imageUrl
?
(
{
scene
.
imageUrl
?
(
<
img
src=
{
scene
.
imageUrl
}
alt=
{
scene
.
name
}
className=
"
w-full h
-full object-cover"
/>
<
img
src=
{
scene
.
imageUrl
}
alt=
{
scene
.
name
}
className=
"
h-full w
-full object-cover"
/>
)
:
(
)
:
(
<
ImageIcon
className=
"w-8 h-8 text-muted-foreground"
/>
<
div
className=
"flex h-full items-center justify-center"
>
<
ImageIcon
className=
"h-8 w-8 text-muted-foreground"
/>
</
div
>
)
}
)
}
</
div
>
</
div
>
<
div
className=
"p-
3 flex items-center justify-between
"
>
<
div
className=
"p-
4
"
>
<
div
className=
"
font-medium text-foreground text-sm
"
>
{
scene
.
name
}
</
div
>
<
div
className=
"
text-lg font-medium text-foreground
"
>
{
scene
.
name
}
</
div
>
<
span
className=
"text-xs text-muted-foreground px-2 py-0.5 rounded bg-mute
d"
>
<
div
className=
"mt-2 text-sm text-muted-foregroun
d"
>
{
scene
.
sceneType
===
"indoor"
?
"室内
"
:
"室外
"
}
{
scene
.
sceneType
===
"indoor"
?
"室内
场景"
:
"室外场景
"
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
</
button
>
))
}
))
}
</
div
>
</
div
>
)
}
)
}
...
@@ -377,7 +509,6 @@ function CharactersTab({
...
@@ -377,7 +509,6 @@ function CharactersTab({
);
);
}
}
// ---- Storyboard Tab ----
function
StoryboardTab
({
function
StoryboardTab
({
episodes
,
episodes
,
projectId
,
projectId
,
...
@@ -386,65 +517,67 @@ function StoryboardTab({
...
@@ -386,65 +517,67 @@ function StoryboardTab({
projectId
?:
string
;
projectId
?:
string
;
})
{
})
{
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
[
selectedEp
Id
,
setSelectedEp
Id
]
=
useState
<
string
|
null
>
(
const
[
selectedEp
isodeId
,
setSelectedEpisode
Id
]
=
useState
<
string
|
null
>
(
episodes
.
length
>
0
?
episodes
[
0
].
id
:
null
episodes
.
length
>
0
?
episodes
[
0
].
id
:
null
,
);
);
if
(
episodes
.
length
===
0
)
{
if
(
episodes
.
length
===
0
)
{
return
(
return
(
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-12 text-center"
>
<
div
className=
"rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center"
>
<
Clapperboard
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
尚未生成分集
</
h3
>
<
Clapperboard
className=
"h-7 w-7"
/>
<
p
className=
"text-sm text-muted-foreground"
>
先生成大纲和分集,再进入分镜工作台
</
p
>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
尚未进入分镜阶段
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
请先完成大纲与分集生成,再进入分镜工作台推进镜头设计与视频指令。
</
p
>
</
div
>
</
div
>
);
);
}
}
return
(
const
selectedEpisode
=
episodes
.
find
((
episode
)
=>
episode
.
id
===
selectedEpisodeId
)
??
episodes
[
0
];
<
div
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
分镜工作台
</
h2
>
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${selectedEpId ?? episodes[0].id}`
)
}
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
>
进入分镜工作台
<
ChevronRight
className=
"w-4 h-4"
/>
</
button
>
</
div
>
<
div
className=
"flex items-center gap-2 mb-6 flex-wrap"
>
return
(
{
episodes
.
map
((
ep
)
=>
(
<
div
className=
"space-y-6"
>
<
div
className=
"flex flex-wrap gap-3"
>
{
episodes
.
map
((
episode
)
=>
(
<
button
<
button
key=
{
ep
.
id
}
key=
{
episode
.
id
}
onClick=
{
()
=>
setSelectedEpId
(
ep
.
id
)
}
onClick=
{
()
=>
setSelectedEpisodeId
(
episode
.
id
)
}
className=
{
`px-3 py-1.5 rounded-lg text-sm transition-all ${
className=
{
[
selectedEpId === ep.id
"rounded-2xl border px-4 py-3 text-sm transition-all"
,
? "bg-primary text-white"
selectedEpisodeId
===
episode
.
id
: "bg-card border border-border text-foreground hover:border-primary"
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
}`
}
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
].
join
(
" "
)
}
>
>
第
{
ep
.
episodeNumber
}
集
第
{
ep
isode
.
episodeNumber
}
集
</
button
>
</
button
>
))
}
))
}
</
div
>
</
div
>
{
selectedEpId
&&
(
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${selectedEpId}`
)
}
<
div
>
className=
"rounded-xl border border-border bg-card p-6 text-center hover:border-primary transition-colors cursor-pointer"
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Storyboard
</
div
>
>
<
h3
className=
"mt-2 text-3xl font-semibold text-foreground"
>
{
selectedEpisode
.
title
}
</
h3
>
<
Clapperboard
className=
"w-12 h-12 text-muted-foreground mx-auto mb-3"
/>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
<
p
className=
"text-sm text-muted-foreground"
>
{
selectedEpisode
.
summary
}
点击进入第
{
episodes
.
find
(
e
=>
e
.
id
===
selectedEpId
)?.
episodeNumber
}
集分镜工作台
</
p
>
</
p
>
</
div
>
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${selectedEpisode.id}`
)
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
进入分镜工作台
<
ChevronRight
className=
"h-4 w-4"
/>
</
button
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
);
);
}
}
// ---- Video Tab ----
function
VideoTab
({
function
VideoTab
({
videoTasks
,
videoTasks
,
projectId
,
projectId
,
...
@@ -454,70 +587,77 @@ function VideoTab({
...
@@ -454,70 +587,77 @@ function VideoTab({
})
{
})
{
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
statusMap
:
Record
<
string
,
{
label
:
string
;
dot
:
string
}
>
=
{
pending
:
{
label
:
"排队中"
,
dot
:
"bg-amber-400"
},
running
:
{
label
:
"生成中"
,
dot
:
"bg-sky-500"
},
succeeded
:
{
label
:
"已完成"
,
dot
:
"bg-emerald-500"
},
failed
:
{
label
:
"失败"
,
dot
:
"bg-red-500"
},
};
if
(
videoTasks
.
length
===
0
)
{
if
(
videoTasks
.
length
===
0
)
{
return
(
return
(
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-12 text-center"
>
<
div
className=
"rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-16 text-center"
>
<
Video
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
尚无视频任务
</
h3
>
<
Video
className=
"h-7 w-7"
/>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
在视频生成页面提交生成任务
</
p
>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
暂时还没有视频任务
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
分镜完成后即可在视频页提交生成任务,平台会在这里展示视频结果和执行状态。
</
p
>
<
button
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/video`
)
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/video`
)
}
className=
"
px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 mx-auto
"
className=
"
mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Sparkles
className=
"w-4 h-4"
/>
前往视频生成
前往视频生成
<
ArrowRight
className=
"h-4 w-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
);
);
}
}
const
statusConfig
:
Record
<
string
,
{
label
:
string
;
color
:
string
}
>
=
{
pending
:
{
label
:
"排队中"
,
color
:
"bg-yellow-500"
},
running
:
{
label
:
"生成中"
,
color
:
"bg-blue-500"
},
succeeded
:
{
label
:
"已完成"
,
color
:
"bg-green-500"
},
failed
:
{
label
:
"失败"
,
color
:
"bg-red-500"
},
};
return
(
return
(
<
div
>
<
div
className=
"space-y-6"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
className=
"flex items-center justify-between"
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
<
h3
className=
"text-2xl font-semibold text-foreground"
>
视频任务总览
</
h3
>
视频任务 (
{
videoTasks
.
length
}
)
</
h2
>
<
button
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/video`
)
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/video`
)
}
className=
"
px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2
"
className=
"
rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary
"
>
>
视频生成页面
打开视频页
<
ChevronRight
className=
"w-4 h-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
<
div
className=
"grid g
rid-cols-3 gap-4
"
>
<
div
className=
"grid g
ap-4 md:grid-cols-2 xl:grid-cols-3
"
>
{
videoTasks
.
map
((
task
)
=>
{
{
videoTasks
.
map
((
task
)
=>
{
const
cfg
=
statusConfig
[
task
.
status
]
??
{
label
:
task
.
status
,
color
:
"bg-gray
-400"
};
const
status
=
statusMap
[
task
.
status
]
??
{
label
:
task
.
status
,
dot
:
"bg-slate
-400"
};
return
(
return
(
<
div
key=
{
task
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden"
>
<
div
<
div
className=
"aspect-video bg-muted flex items-center justify-center relative"
>
key=
{
task
.
id
}
className=
"overflow-hidden rounded-[28px] border border-white/80 bg-white/78 shadow-sm"
>
<
div
className=
"aspect-video bg-muted"
>
{
task
.
resultVideoUrl
?
(
{
task
.
resultVideoUrl
?
(
<
video
src=
{
task
.
resultVideoUrl
}
className=
"
w-full h
-full object-cover"
controls
/>
<
video
src=
{
task
.
resultVideoUrl
}
className=
"
h-full w
-full object-cover"
controls
/>
)
:
(
)
:
(
<
div
className=
"
text
-center"
>
<
div
className=
"
flex h-full items-center justify
-center"
>
{
task
.
status
===
"running"
?
(
{
task
.
status
===
"running"
?
(
<
Loader2
className=
"
w-10 h-10 text-primary animate-spin mx-auto mb-2
"
/>
<
Loader2
className=
"
h-10 w-10 animate-spin text-primary
"
/>
)
:
(
)
:
(
<
Film
className=
"
w-10 h-10 text-muted-foreground mx-auto mb-2
"
/>
<
Film
className=
"
h-10 w-10 text-muted-foreground
"
/>
)
}
)
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
<
div
className=
"p-3 flex items-center justify-between"
>
<
span
className=
"text-xs text-muted-foreground"
>
<
div
className=
"p-4"
>
{
new
Date
(
task
.
createdAt
).
toLocaleDateString
(
"zh-CN"
)
}
<
div
className=
"flex items-center justify-between gap-4"
>
</
span
>
<
div
className=
"text-sm text-muted-foreground"
>
<
div
className=
"flex items-center gap-1"
>
{
new
Date
(
task
.
createdAt
).
toLocaleDateString
(
"zh-CN"
)
}
<
div
className=
{
`w-2 h-2 rounded-full ${cfg.color}`
}
/>
</
div
>
<
span
className=
"text-xs text-muted-foreground"
>
{
cfg
.
label
}
</
span
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
<
span
className=
{
`h-2 w-2 rounded-full ${status.dot}`
}
/>
{
status
.
label
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
doc/html/src/app/pages/ProjectSettings.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
useParams
}
from
"react-router"
;
import
{
useParams
}
from
"react-router"
;
import
{
import
{
Users
,
UserPlus
,
Shield
,
Trash2
,
Loader2
,
X
,
Check
,
Settings
,
Check
,
Loader2
,
Settings
,
Shield
,
Trash2
,
UserPlus
,
Users
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
use
TeamMembers
,
useUpdateMember
,
useRemoveMember
,
useAdd
Member
}
from
"../../hooks/useTeam"
;
import
{
use
AddMember
,
useRemoveMember
,
useTeamMembers
,
useUpdate
Member
}
from
"../../hooks/useTeam"
;
import
{
useProject
,
useUpdateProject
}
from
"../../hooks/useProjects"
;
import
{
useProject
,
useUpdateProject
}
from
"../../hooks/useProjects"
;
import
{
import
{
aspectRatioOptions
,
aspectRatioOptions
,
getAspectRatioLabel
,
getProjectStyleLabel
,
getResolutionLabel
,
projectStyleOptions
,
projectStyleOptions
,
resolutionOptions
,
resolutionOptions
,
}
from
"../../lib/projectStyles"
;
}
from
"../../lib/projectStyles"
;
const
ROLES
=
[
"owner"
,
"admin"
,
"member"
]
as
const
;
const
roles
=
[
"owner"
,
"admin"
,
"member"
]
as
const
;
type
Role
=
typeof
ROLES
[
number
];
type
Role
=
(
typeof
roles
)
[
number
];
const
roleLabels
:
Record
<
string
,
string
>
=
{
const
roleLabels
:
Record
<
Role
,
string
>
=
{
owner
:
"所有者"
,
owner
:
"所有者"
,
admin
:
"管理员"
,
admin
:
"管理员"
,
member
:
"成员"
,
member
:
"成员"
,
};
};
const
role
Permissions
:
Record
<
string
,
string
[]
>
=
{
const
role
Descriptions
:
Record
<
Role
,
string
>
=
{
owner
:
[
"完全控制"
,
"删除项目"
,
"管理成员"
,
"编辑内容"
,
"查看内容"
]
,
owner
:
"可管理项目设置、成员与全部流程。"
,
admin
:
[
"管理成员"
,
"编辑内容"
,
"审核分镜"
,
"生成视频"
,
"查看内容"
]
,
admin
:
"可协作推进项目内容并管理部分成员。"
,
member
:
[
"编辑内容"
,
"创建分镜"
,
"修改分镜"
,
"查看内容"
]
,
member
:
"参与内容生产与流程执行。"
,
};
};
export
function
ProjectSettings
()
{
export
function
ProjectSettings
()
{
const
{
projectId
}
=
useParams
();
const
{
projectId
}
=
useParams
();
const
numI
d
=
projectId
??
""
;
const
pi
d
=
projectId
??
""
;
const
{
data
:
project
,
isLoading
:
loadingProject
}
=
useProject
(
numI
d
);
const
{
data
:
project
,
isLoading
:
loadingProject
}
=
useProject
(
pi
d
);
const
{
data
:
members
=
[],
isLoading
:
loadingMembers
}
=
useTeamMembers
();
const
{
data
:
members
=
[],
isLoading
:
loadingMembers
}
=
useTeamMembers
();
const
updateMember
=
useUpdateMember
();
const
updateMember
=
useUpdateMember
();
const
removeMember
=
useRemoveMember
();
const
removeMember
=
useRemoveMember
();
const
addMember
=
useAddMember
();
const
addMember
=
useAddMember
();
const
updateProject
=
useUpdateProject
(
numId
);
const
updateProject
=
useUpdateProject
(
pid
);
const
[
showInviteModal
,
setShowInviteModal
]
=
useState
(
false
);
const
[
inviteForm
,
setInviteForm
]
=
useState
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"member"
});
const
[
inviteError
,
setInviteError
]
=
useState
(
""
);
const
[
editingProject
,
setEditingProject
]
=
useState
(
false
);
const
[
editingProject
,
setEditingProject
]
=
useState
(
false
);
const
[
projectForm
,
setProjectForm
]
=
useState
({
const
[
projectForm
,
setProjectForm
]
=
useState
({
...
@@ -53,6 +53,15 @@ export function ProjectSettings() {
...
@@ -53,6 +53,15 @@ export function ProjectSettings() {
resolution
:
""
,
resolution
:
""
,
});
});
const
[
showInviteModal
,
setShowInviteModal
]
=
useState
(
false
);
const
[
inviteForm
,
setInviteForm
]
=
useState
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"member"
,
});
const
[
inviteError
,
setInviteError
]
=
useState
(
""
);
const
openEditProject
=
()
=>
{
const
openEditProject
=
()
=>
{
setProjectForm
({
setProjectForm
({
name
:
project
?.
name
??
""
,
name
:
project
?.
name
??
""
,
...
@@ -69,303 +78,364 @@ export function ProjectSettings() {
...
@@ -69,303 +78,364 @@ export function ProjectSettings() {
setEditingProject
(
false
);
setEditingProject
(
false
);
};
};
const
handleRoleChange
=
async
(
userId
:
number
,
role
:
string
)
=>
{
await
updateMember
.
mutateAsync
({
userId
,
patch
:
{
role
}
});
};
const
handleRemove
=
async
(
userId
:
number
)
=>
{
if
(
!
confirm
(
"确定要移除该成员吗?"
))
return
;
await
removeMember
.
mutateAsync
(
userId
);
};
const
handleInvite
=
async
()
=>
{
const
handleInvite
=
async
()
=>
{
setInviteError
(
""
);
setInviteError
(
""
);
if
(
!
inviteForm
.
username
.
trim
()
||
!
inviteForm
.
email
.
trim
()
||
!
inviteForm
.
password
.
trim
())
{
if
(
!
inviteForm
.
username
.
trim
()
||
!
inviteForm
.
email
.
trim
()
||
!
inviteForm
.
password
.
trim
())
{
setInviteError
(
"用户名、邮箱和密码不能为空"
);
setInviteError
(
"用户名、邮箱和密码不能为空
。
"
);
return
;
return
;
}
}
try
{
try
{
await
addMember
.
mutateAsync
(
inviteForm
);
await
addMember
.
mutateAsync
(
inviteForm
);
setShowInviteModal
(
false
);
setShowInviteModal
(
false
);
setInviteForm
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"member"
});
setInviteForm
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"member"
});
}
catch
{
}
catch
{
setInviteError
(
"添加
失败,该邮箱可能已存在
"
);
setInviteError
(
"添加
成员失败,该邮箱可能已经存在。
"
);
}
}
};
};
const
roleCounts
=
(
role
:
string
)
=>
members
.
filter
((
m
)
=>
m
.
role
===
role
).
length
;
const
handleRoleChange
=
async
(
userId
:
number
,
role
:
string
)
=>
{
await
updateMember
.
mutateAsync
({
userId
,
patch
:
{
role
}
});
};
const
handleRemove
=
async
(
userId
:
number
)
=>
{
if
(
!
confirm
(
"确定要移除该成员吗?"
))
return
;
await
removeMember
.
mutateAsync
(
userId
);
};
if
(
loadingProject
||
loadingMembers
)
{
if
(
loadingProject
||
loadingMembers
)
{
return
(
return
(
<
div
className=
"
h-full flex
items-center justify-center"
>
<
div
className=
"
flex h-full
items-center justify-center"
>
<
Loader2
className=
"
w-6 h-6
animate-spin text-primary"
/>
<
Loader2
className=
"
h-8 w-8
animate-spin text-primary"
/>
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"max-w-6xl mx-auto"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"mb-6"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
h1
className=
"text-2xl font-semibold text-foreground mb-1"
>
项目设置
</
h1
>
<
div
>
<
p
className=
"text-sm text-muted-foreground"
>
管理项目信息和团队成员权限
</
p
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
Settings
className=
"h-3.5 w-3.5"
/>
Project Settings
</
div
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
项目设置与成员权限
</
h1
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
统一维护项目基础参数、创作风格和协作成员权限,让项目配置与生产流程保持一致。
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setShowInviteModal
(
true
)
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<
UserPlus
className=
"h-4 w-4"
/>
添加成员
</
button
>
</
div
>
</
div
>
{
/* Project Info */
}
<
div
className=
"mt-6 grid gap-4 md:grid-cols-4"
>
<
div
className=
"rounded-xl border border-border bg-card p-5 mb-6"
>
<
div
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<
div
className=
"flex items-center justify-between mb-4"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
成员总数
</
div
>
<
h2
className=
"font-semibold text-foreground flex items-center gap-2"
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
members
.
length
}
</
div
>
<
Settings
className=
"w-4 h-4"
/>
项目信息
</
h2
>
{
!
editingProject
&&
(
<
button
onClick=
{
openEditProject
}
className=
"px-3 py-1.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors"
>
编辑
</
button
>
)
}
</
div
>
</
div
>
{
editingProject
?
(
{
roles
.
map
((
role
)
=>
(
<
div
className=
"space-y-3"
>
<
div
<
div
>
key=
{
role
}
<
label
className=
"block text-xs text-muted-foreground mb-1"
>
项目名称
</
label
>
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
<
input
>
type=
"text"
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
value=
{
projectForm
.
name
}
{
roleLabels
[
role
]
}
onChange=
{
(
e
)
=>
setProjectForm
({
...
projectForm
,
name
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
members
.
filter
((
member
)
=>
member
.
role
===
role
).
length
}
</
div
>
</
div
>
))
}
</
div
>
<
div
className=
"mt-6 grid gap-6 xl:grid-cols-[1fr_0.92fr]"
>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-1"
>
项目描述
</
label
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Project Profile
</
div
>
<
textarea
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
项目基础信息
</
h2
>
value=
{
projectForm
.
description
}
onChange=
{
(
e
)
=>
setProjectForm
({
...
projectForm
,
description
:
e
.
target
.
value
})
}
rows=
{
2
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
<
div
className=
"flex gap-2"
>
{
!
editingProject
?
(
<
button
<
button
onClick=
{
()
=>
setEditingProject
(
false
)
}
onClick=
{
openEditProject
}
className=
"
px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors
"
className=
"
rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary
"
>
>
取消
编辑项目
</
button
>
</
button
>
<
button
)
:
null
}
onClick=
{
handleSaveProject
}
disabled=
{
updateProject
.
isPending
}
className=
"px-4 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90 flex items-center gap-2 disabled:opacity-60"
>
{
updateProject
.
isPending
?
<
Loader2
className=
"w-3.5 h-3.5 animate-spin"
/>
:
<
Check
className=
"w-3.5 h-3.5"
/>
}
保存
</
button
>
</
div
>
</
div
>
)
:
(
<
div
className=
"space-y-2 text-sm"
>
<
div
>
<
span
className=
"text-muted-foreground"
>
名称:
</
span
>
<
span
className=
"text-foreground font-medium"
>
{
project
?.
name
}
</
span
>
</
div
>
<
div
>
<
span
className=
"text-muted-foreground"
>
描述:
</
span
>
<
span
className=
"text-foreground"
>
{
project
?.
description
||
"—"
}
</
span
>
</
div
>
<
div
>
<
span
className=
"text-muted-foreground"
>
状态:
</
span
>
<
span
className=
"text-foreground"
>
{
project
?.
status
}
</
span
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
{
/* Team Overview */
}
{
editingProject
?
(
<
div
className=
"grid grid-cols-4 gap-4 mb-6"
>
<
div
className=
"space-y-5"
>
<
div
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
>
<
div
className=
"w-10 h-10 rounded-lg bg-accent flex items-center justify-center mb-3"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
项目名称
</
label
>
<
Users
className=
"w-5 h-5 text-primary"
/>
<
input
</
div
>
value=
{
projectForm
.
name
}
<
div
className=
"text-2xl font-semibold text-foreground mb-0.5"
>
{
members
.
length
}
</
div
>
onChange=
{
(
e
)
=>
setProjectForm
((
current
)
=>
({
...
current
,
name
:
e
.
target
.
value
}))
}
<
div
className=
"text-xs text-muted-foreground"
>
团队成员
</
div
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
</
div
>
/>
{
[
"owner"
,
"admin"
,
"member"
].
map
((
role
)
=>
(
</
div
>
<
div
key=
{
role
}
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
className=
"text-2xl font-semibold text-foreground mb-0.5"
>
{
roleCounts
(
role
)
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
{
roleLabels
[
role
]
}
</
div
>
</
div
>
))
}
</
div
>
{
/* Invite Button */
}
<
div
>
<
div
className=
"mb-6"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
项目描述
</
label
>
<
button
<
textarea
onClick=
{
()
=>
setShowInviteModal
(
true
)
}
value=
{
projectForm
.
description
}
className=
"px-5 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center gap-2 hover:shadow-md transition-all text-sm"
onChange=
{
(
e
)
=>
setProjectForm
((
current
)
=>
({
...
current
,
description
:
e
.
target
.
value
}))
}
>
rows=
{
4
}
<
UserPlus
className=
"w-4 h-4"
/>
className=
"w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
添加成员
/>
</
button
>
</
div
>
</
div
>
{
/* Members List */
}
<
div
className=
"grid gap-4 md:grid-cols-3"
>
<
div
className=
"mb-8"
>
<
div
>
<
h2
className=
"text-lg font-semibold text-foreground mb-4"
>
团队成员
</
h2
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
视觉风格
</
label
>
{
members
.
length
===
0
?
(
<
select
<
div
className=
"rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground"
>
value=
{
projectForm
.
style
}
暂无成员
onChange=
{
(
e
)
=>
setProjectForm
((
current
)
=>
({
...
current
,
style
:
e
.
target
.
value
}))
}
</
div
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
)
:
(
>
<
div
className=
"space-y-3"
>
<
option
value=
""
>
未设置
</
option
>
{
members
.
map
((
member
)
=>
(
{
projectStyleOptions
.
map
((
option
)
=>
(
<
div
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
key=
{
member
.
userId
}
{
option
.
label
}
className=
"rounded-xl border border-border bg-card p-4 flex items-center justify-between"
</
option
>
>
))
}
<
div
className=
"flex items-center gap-4"
>
</
select
>
<
div
className=
"w-10 h-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/40 flex items-center justify-center"
>
<
span
className=
"text-sm font-semibold text-primary"
>
{
member
.
username
?.[
0
]?.
toUpperCase
()
??
"U"
}
</
span
>
</
div
>
<
div
>
<
div
className=
"text-sm font-medium text-foreground mb-0.5"
>
{
member
.
username
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
{
member
.
email
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-4"
>
<
div
>
<
div
className=
"text-xs text-muted-foreground"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
画幅比例
</
label
>
加入于
{
member
.
joinedAt
?.
slice
(
0
,
10
)
}
</
div
>
<
select
<
select
value=
{
member
.
role
}
value=
{
projectForm
.
aspectRatio
}
onChange=
{
(
e
)
=>
handleRoleChange
(
member
.
userId
,
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setProjectForm
((
current
)
=>
({
...
current
,
aspectRatio
:
e
.
target
.
value
}))
}
disabled=
{
member
.
role
===
"owner"
||
updateMember
.
isPending
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
className=
"px-3 py-1.5 rounded-lg border border-border bg-card text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
>
{
ROLES
.
map
((
r
)
=>
(
<
option
value=
""
>
未设置
</
option
>
<
option
key=
{
r
}
value=
{
r
}
>
{
roleLabels
[
r
]
}
</
option
>
{
aspectRatioOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
))
}
</
select
>
</
select
>
{
member
.
role
!==
"owner"
&&
(
<
button
onClick=
{
()
=>
handleRemove
(
member
.
userId
)
}
disabled=
{
removeMember
.
isPending
}
className=
"p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
<
Trash2
className=
"w-4 h-4"
/>
</
button
>
)
}
</
div
>
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
{
/* Role Permissions */
}
<
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
清晰度
</
label
>
<
h2
className=
"text-lg font-semibold text-foreground mb-4"
>
角色权限说明
</
h2
>
<
select
<
div
className=
"grid grid-cols-3 gap-4"
>
value=
{
projectForm
.
resolution
}
{
Object
.
entries
(
rolePermissions
).
map
(([
key
,
perms
])
=>
(
onChange=
{
(
e
)
=>
setProjectForm
((
current
)
=>
({
...
current
,
resolution
:
e
.
target
.
value
}))
}
<
div
key=
{
key
}
className=
"rounded-xl border border-border bg-card p-4"
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
<
div
className=
"flex items-center gap-3 mb-3"
>
>
<
div
className=
"w-8 h-8 rounded-lg bg-accent flex items-center justify-center"
>
<
option
value=
""
>
未设置
</
option
>
<
Shield
className=
"w-4 h-4 text-primary"
/>
{
resolutionOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
<
h3
className=
"font-medium text-foreground"
>
{
roleLabels
[
key
]
}
</
h3
>
</
div
>
</
div
>
<
div
className=
"space-y-2"
>
{
perms
.
map
((
p
,
i
)
=>
(
<
div
className=
"flex items-center justify-end gap-3"
>
<
div
key=
{
i
}
className=
"flex items-center gap-2 text-sm text-muted-foreground"
>
<
button
<
div
className=
"w-1 h-1 rounded-full bg-primary"
/>
onClick=
{
()
=>
setEditingProject
(
false
)
}
{
p
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
</
div
>
>
))
}
取消
</
button
>
<
button
onClick=
{
handleSaveProject
}
disabled=
{
updateProject
.
isPending
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
updateProject
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Check
className=
"h-4 w-4"
/>
}
保存更新
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
))
}
)
:
(
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
[
{
label
:
"项目名称"
,
value
:
project
?.
name
||
"未设置"
},
{
label
:
"当前状态"
,
value
:
project
?.
status
||
"未设置"
},
{
label
:
"视觉风格"
,
value
:
project
?.
style
||
"未设置"
},
{
label
:
"画幅比例"
,
value
:
project
?.
aspectRatio
||
"未设置"
},
{
label
:
"清晰度"
,
value
:
project
?.
resolution
||
"未设置"
},
{
label
:
"项目描述"
,
value
:
project
?.
description
||
"暂无描述"
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
item
.
label
}
</
div
>
<
div
className=
"mt-3 text-sm leading-6 text-foreground"
>
{
item
.
value
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
</
div
>
</
div
>
{
/* Invite Modal */
}
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
{
showInviteModal
&&
(
<
div
className=
"mb-4"
>
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Team Access
</
div
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-md w-full"
>
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
成员权限管理
</
h2
>
<
div
className=
"flex items-center justify-between mb-6"
>
</
div
>
<
h2
className=
"text-xl font-semibold text-foreground"
>
添加成员
</
h2
>
<
button
onClick=
{
()
=>
setShowInviteModal
(
false
)
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
</
button
>
</
div
>
<
div
className=
"space-y-4 mb-6"
>
<
div
className=
"space-y-4"
>
<
div
>
{
members
.
length
===
0
?
(
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
用户名
</
label
>
<
div
className=
"rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"
>
<
input
当前还没有成员,点击右上角“添加成员”即可邀请新成员加入项目协作。
type=
"text"
value=
{
inviteForm
.
username
}
onChange=
{
(
e
)
=>
setInviteForm
({
...
inviteForm
,
username
:
e
.
target
.
value
})
}
placeholder=
"输入用户名"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
<
div
>
)
:
(
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
邮箱
</
label
>
members
.
map
((
member
)
=>
(
<
input
<
div
type=
"email"
key=
{
member
.
userId
}
value=
{
inviteForm
.
email
}
className=
"rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
onChange=
{
(
e
)
=>
setInviteForm
({
...
inviteForm
,
email
:
e
.
target
.
value
})
}
placeholder=
"member@example.com"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
初始密码
</
label
>
<
input
type=
"password"
value=
{
inviteForm
.
password
}
onChange=
{
(
e
)
=>
setInviteForm
({
...
inviteForm
,
password
:
e
.
target
.
value
})
}
placeholder=
"设置初始登录密码"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
角色
</
label
>
<
select
value=
{
inviteForm
.
role
}
onChange=
{
(
e
)
=>
setInviteForm
({
...
inviteForm
,
role
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
>
<
option
value=
"admin"
>
管理员
</
option
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
option
value=
"member"
>
成员
</
option
>
<
div
>
</
select
>
<
div
className=
"text-lg font-medium text-foreground"
>
{
member
.
username
}
</
div
>
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
member
.
email
}
</
div
>
{
inviteError
&&
<
p
className=
"text-sm text-destructive"
>
{
inviteError
}
</
p
>
}
</
div
>
<
button
onClick=
{
()
=>
handleRemove
(
member
.
userId
)
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<
Trash2
className=
"h-4 w-4"
/>
移除
</
button
>
</
div
>
<
div
className=
"mt-4 grid gap-4 md:grid-cols-[1fr_1fr]"
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
角色权限
</
label
>
<
select
value=
{
member
.
role
}
onChange=
{
(
e
)
=>
handleRoleChange
(
member
.
userId
,
e
.
target
.
value
)
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{
roles
.
map
((
role
)
=>
(
<
option
key=
{
role
}
value=
{
role
}
>
{
roleLabels
[
role
]
}
</
option
>
))
}
</
select
>
</
div
>
<
div
className=
"rounded-[20px] border border-primary/10 bg-primary/5 px-4 py-4"
>
<
div
className=
"flex items-center gap-2 text-sm font-medium text-foreground"
>
<
Shield
className=
"h-4 w-4 text-primary"
/>
权限说明
</
div
>
<
div
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
{
roleDescriptions
[(
member
.
role
as
Role
)
??
"member"
]
}
</
div
>
</
div
>
</
div
>
</
div
>
))
)
}
</
div
>
</
div
>
</
div
>
</
section
>
{
showInviteModal
?
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"w-full max-w-xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Invite Member
</
div
>
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
添加项目成员
</
h3
>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
为项目添加新的协作成员,并设置初始角色权限。
</
p
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
setShowInviteModal
(
false
)
}
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</
div
>
<
div
className=
"flex gap-3"
>
<
div
className=
"space-y-5"
>
<
button
<
div
>
onClick=
{
()
=>
setShowInviteModal
(
false
)
}
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
用户名
</
label
>
className=
"flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
<
input
>
value=
{
inviteForm
.
username
}
取消
onChange=
{
(
e
)
=>
setInviteForm
((
current
)
=>
({
...
current
,
username
:
e
.
target
.
value
}))
}
</
button
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<
button
placeholder=
"输入用户名"
onClick=
{
handleInvite
}
/>
disabled=
{
addMember
.
isPending
}
</
div
>
className=
"flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
邮箱
</
label
>
<
input
value=
{
inviteForm
.
email
}
onChange=
{
(
e
)
=>
setInviteForm
((
current
)
=>
({
...
current
,
email
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"member@example.com"
/>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
初始密码
</
label
>
<
input
type=
"password"
value=
{
inviteForm
.
password
}
onChange=
{
(
e
)
=>
setInviteForm
((
current
)
=>
({
...
current
,
password
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"设置初始登录密码"
/>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
角色权限
</
label
>
<
select
value=
{
inviteForm
.
role
}
onChange=
{
(
e
)
=>
setInviteForm
((
current
)
=>
({
...
current
,
role
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
>
{
addMember
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
UserPlus
className=
"w-4 h-4"
/>
}
{
roles
.
map
((
role
)
=>
(
添加
<
option
key=
{
role
}
value=
{
role
}
>
</
button
>
{
roleLabels
[
role
]
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
{
inviteError
?
(
<
div
className=
"rounded-[24px] border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700"
>
{
inviteError
}
</
div
>
)
:
null
}
</
div
>
<
div
className=
"mt-6 flex items-center justify-end gap-3"
>
<
button
onClick=
{
()
=>
setShowInviteModal
(
false
)
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
</
button
>
<
button
onClick=
{
handleInvite
}
disabled=
{
addMember
.
isPending
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
addMember
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
UserPlus
className=
"h-4 w-4"
/>
}
确认添加
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
)
:
null
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/PropsGeneration.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
import
{
ArrowRight
,
Box
,
Sparkles
,
Users
,
MapPin
,
Plus
,
Box
,
Edit2
,
Trash2
,
Upload
,
X
,
Check
,
RefreshCw
,
Loader2
,
Image
as
ImageIcon
,
Link2
,
Loader2
,
MapPin
,
Plus
,
Sparkles
,
Trash2
,
Users
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
use
GlobalAssets
,
useCreateAsset
,
useDeleteAsset
}
from
"../../hooks/useAssets"
;
import
{
use
CreateAsset
,
useDeleteAsset
,
useGlobalAssets
}
from
"../../hooks/useAssets"
;
import
type
{
GlobalAsset
}
from
"../../lib/api/assets"
;
import
type
{
GlobalAsset
}
from
"../../lib/api/assets"
;
type
SettingsTab
=
"characters"
|
"scenes"
|
"props"
;
type
SettingsTab
=
"characters"
|
"scenes"
|
"props"
;
interface
EditForm
{
interface
EditForm
{
id
?:
string
;
name
:
string
;
name
:
string
;
category
:
string
;
category
:
string
;
description
:
string
;
description
:
string
;
imageUrl
:
string
;
imageUrl
:
string
;
}
}
const
CATEGORIES
=
[
"首饰"
,
"文件"
,
"配饰
"
,
"文具"
,
"饮品"
,
"电子设备"
,
"武器"
,
"交通工具"
,
"其他"
];
const
categories
=
[
"饰品"
,
"文件"
,
"配件
"
,
"文具"
,
"饮品"
,
"电子设备"
,
"武器"
,
"交通工具"
,
"其他"
];
export
function
PropsGeneration
()
{
export
function
PropsGeneration
()
{
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
{
projectId
}
=
useParams
();
const
{
projectId
}
=
useParams
();
const
num
ProjectId
=
projectId
??
""
;
const
current
ProjectId
=
projectId
??
""
;
const
{
data
:
props
=
[],
isLoading
}
=
useGlobalAssets
(
"prop"
,
"personal"
);
const
{
data
:
props
=
[],
isLoading
}
=
useGlobalAssets
(
"prop"
,
"personal"
);
const
createAsset
=
useCreateAsset
(
"prop"
,
"personal"
);
const
createAsset
=
useCreateAsset
(
"prop"
,
"personal"
);
const
deleteAsset
=
useDeleteAsset
(
"prop"
,
"personal"
);
const
deleteAsset
=
useDeleteAsset
(
"prop"
,
"personal"
);
const
[
activeTab
,
setActiveTab
]
=
useState
<
SettingsTab
>
(
"props"
);
const
[
activeTab
]
=
useState
<
SettingsTab
>
(
"props"
);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
editForm
,
setEditForm
]
=
useState
<
EditForm
|
null
>
(
null
);
const
[
form
,
setForm
]
=
useState
<
EditForm
>
({
name
:
""
,
category
:
"配件"
,
description
:
""
,
imageUrl
:
""
,
});
const
settingsTabs
=
[
const
settingsTabs
=
[
{
id
:
"characters"
as
SettingsTab
,
label
:
"角色"
,
icon
:
Users
,
path
:
`/project/
${
projectId
}
/characters`
},
{
id
:
"characters"
as
SettingsTab
,
label
:
"角色"
,
icon
:
Users
,
path
:
`/project/
${
projectId
}
/characters`
},
...
@@ -38,263 +50,240 @@ export function PropsGeneration() {
...
@@ -38,263 +50,240 @@ export function PropsGeneration() {
{
id
:
"props"
as
SettingsTab
,
label
:
"道具"
,
icon
:
Box
,
path
:
`/project/
${
projectId
}
/props`
},
{
id
:
"props"
as
SettingsTab
,
label
:
"道具"
,
icon
:
Box
,
path
:
`/project/
${
projectId
}
/props`
},
];
];
const
openAdd
=
()
=>
{
const
handleCreate
=
async
()
=>
{
setEditForm
({
name
:
""
,
category
:
"配饰"
,
description
:
""
,
imageUrl
:
""
});
if
(
!
form
.
name
.
trim
())
return
;
setShowModal
(
true
);
};
const
openEdit
=
(
p
:
GlobalAsset
)
=>
{
setEditForm
({
id
:
p
.
id
,
name
:
p
.
name
,
category
:
p
.
tags
?.[
0
]
??
"其他"
,
description
:
p
.
description
??
""
,
imageUrl
:
p
.
imageUrl
??
""
,
});
setShowModal
(
true
);
};
const
closeModal
=
()
=>
{
setShowModal
(
false
);
setEditForm
(
null
);
};
const
handleSave
=
async
()
=>
{
if
(
!
editForm
||
!
editForm
.
name
.
trim
())
return
;
await
createAsset
.
mutateAsync
({
await
createAsset
.
mutateAsync
({
name
:
form
.
name
,
assetType
:
"prop"
,
assetType
:
"prop"
,
libraryType
:
"personal"
,
libraryType
:
"personal"
,
name
:
editForm
.
name
,
tags
:
form
.
category
,
description
:
editForm
.
description
,
description
:
form
.
description
,
tags
:
editForm
.
category
,
imageUrl
:
form
.
imageUrl
||
undefined
,
imageUrl
:
editForm
.
imageUrl
||
undefined
,
sourceProjectId
:
currentProjectId
||
undefined
,
sourceProjectId
:
numProjectId
||
undefined
,
});
});
closeModal
();
setShowModal
(
false
);
setForm
({
name
:
""
,
category
:
"配件"
,
description
:
""
,
imageUrl
:
""
});
};
};
const
handleDelete
=
async
(
id
:
string
)
=>
{
const
handleDelete
=
async
(
asset
:
GlobalAsset
)
=>
{
if
(
!
confirm
(
"确定要删除这个道具吗?"
))
return
;
if
(
!
confirm
(
`确定要删除道具“
${
asset
.
name
}
”吗?`
))
return
;
await
deleteAsset
.
mutateAsync
(
id
);
await
deleteAsset
.
mutateAsync
(
asset
.
id
);
};
};
const
withImage
=
props
.
filter
((
p
)
=>
p
.
imageUrl
).
length
;
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background relative"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"p-6 pb-24"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"max-w-7xl mx-auto"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
div
className=
"mb-6"
>
<
div
>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
<
Sparkles
className=
"h-3.5 w-3.5"
/>
<
Sparkles
className=
"w-4 h-4 text-white"
/>
Prop Setup
</
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
项目设定
</
h1
>
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
管理角色、场景和道具设定
</
p
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
道具资产工作台
</
h1
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
管理项目中复用频率较高的道具与物件资产,可以按类别沉淀,也可通过图片链接快速补充参考。
</
p
>
</
div
>
</
div
>
{
/* Tabs */
}
<
button
<
div
className=
"flex items-center justify-between mb-6"
>
onClick=
{
()
=>
setShowModal
(
true
)
}
<
div
className=
"flex items-center gap-2"
>
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
{
settingsTabs
.
map
((
tab
)
=>
{
>
const
Icon
=
tab
.
icon
;
<
Plus
className=
"h-4 w-4"
/>
return
(
新建道具
<
button
</
button
>
key=
{
tab
.
id
}
</
div
>
onClick=
{
()
=>
{
setActiveTab
(
tab
.
id
);
navigate
(
tab
.
path
);
}
}
className=
{
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
activeTab === tab.id
{
settingsTabs
.
map
((
tab
)
=>
{
? "bg-accent text-accent-foreground font-medium"
const
Icon
=
tab
.
icon
;
: "text-muted-foreground hover:text-foreground hover:bg-muted"
const
active
=
activeTab
===
tab
.
id
;
}`
}
>
return
(
<
Icon
className=
"w-4 h-4"
/>
<
button
{
tab
.
label
}
key=
{
tab
.
id
}
</
button
>
onClick=
{
()
=>
navigate
(
tab
.
path
)
}
);
className=
{
[
})
}
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all"
,
</
div
>
active
<
button
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
onClick=
{
openAdd
}
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 text-sm"
].
join
(
" "
)
}
>
<
Icon
className=
"h-4 w-4"
/>
{
tab
.
label
}
</
button
>
);
})
}
</
div
>
<
div
className=
"mt-6 grid gap-4 md:grid-cols-3"
>
{
[
{
label
:
"道具总数"
,
value
:
`${props.length}`
},
{
label
:
"含参考图"
,
value
:
`${props.filter((item) => !!item.imageUrl).length}`
},
{
label
:
"分类数"
,
value
:
`${new Set(props.flatMap((item) => item.tags)).size}`
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
>
<
Plus
className=
"w-4 h-4"
/>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
item
.
label
}
</
div
>
新增道具
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
item
.
value
}
</
div
>
</
button
>
</
div
>
</
div
>
))
}
</
div
>
{
/* Stats bar */
}
{
isLoading
?
(
<
div
className=
"flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"
>
<
div
className=
"flex items-center justify-center py-24"
>
<
div
className=
"flex items-center gap-6"
>
<
Loader2
className=
"h-8 w-8 animate-spin text-primary"
/>
<
div
className=
"flex items-center gap-2"
>
</
div
>
<
span
className=
"text-sm text-muted-foreground"
>
总计:
</
span
>
)
:
props
.
length
===
0
?
(
<
span
className=
"text-lg font-semibold text-foreground"
>
{
props
.
length
}
</
span
>
<
div
className=
"mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"
>
</
div
>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
div
className=
"flex items-center gap-2"
>
<
Box
className=
"h-7 w-7"
/>
<
span
className=
"text-sm text-muted-foreground"
>
有图片:
</
span
>
<
span
className=
"text-lg font-semibold text-green-600"
>
{
withImage
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm text-muted-foreground"
>
无图片:
</
span
>
<
span
className=
"text-lg font-semibold text-muted-foreground"
>
{
props
.
length
-
withImage
}
</
span
>
</
div
>
</
div
>
</
div
>
<
button
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有道具资产
</
h3
>
onClick=
{
()
=>
{}
}
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
className=
"p-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
为常用道具建立统一资产库,可以让后续人物、场景和分镜流程复用同一套视觉素材。
>
</
p
>
<
RefreshCw
className=
"w-4 h-4"
/>
</
button
>
</
div
>
</
div
>
)
:
(
<
div
className=
"mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4"
>
{
props
.
map
((
asset
)
=>
(
<
div
key=
{
asset
.
id
}
className=
"overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<
div
className=
"aspect-[4/5] bg-muted"
>
{
asset
.
imageUrl
?
(
<
img
src=
{
asset
.
imageUrl
}
alt=
{
asset
.
name
}
className=
"h-full w-full object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center"
>
<
ImageIcon
className=
"h-10 w-10 text-muted-foreground"
/>
</
div
>
)
}
</
div
>
{
/* Grid */
}
<
div
className=
"p-5"
>
{
isLoading
?
(
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"flex items-center justify-center py-20"
>
<
div
>
<
Loader2
className=
"w-8 h-8 animate-spin text-primary"
/>
<
div
className=
"text-lg font-semibold text-foreground"
>
{
asset
.
name
}
</
div
>
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
)
:
props
.
length
===
0
?
(
{
asset
.
tags
[
0
]
??
"未分类"
}
<
div
className=
"flex flex-col items-center justify-center py-20 text-center"
>
<
Box
className=
"w-12 h-12 text-muted-foreground/30 mb-4"
/>
<
p
className=
"text-muted-foreground mb-4"
>
暂无道具,点击"新增道具"开始添加
</
p
>
<
button
onClick=
{
openAdd
}
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm flex items-center gap-2"
>
<
Plus
className=
"w-4 h-4"
/>
新增道具
</
button
>
</
div
>
)
:
(
<
div
className=
"grid grid-cols-6 gap-4 mb-8"
>
{
props
.
map
((
prop
)
=>
(
<
div
key=
{
prop
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden group"
>
<
div
className=
"aspect-square relative bg-muted"
>
{
prop
.
imageUrl
?
(
<
img
src=
{
prop
.
imageUrl
}
alt=
{
prop
.
name
}
className=
"w-full h-full object-cover"
/>
)
:
(
<
div
className=
"w-full h-full flex items-center justify-center"
>
<
Box
className=
"w-10 h-10 text-muted-foreground/30"
/>
</
div
>
</
div
>
)
}
<
div
className=
"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1"
>
<
button
onClick=
{
()
=>
openEdit
(
prop
)
}
className=
"p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
>
<
Edit2
className=
"w-4 h-4 text-white"
/>
</
button
>
</
div
>
</
div
>
<
div
className=
"p-3"
>
<
div
className=
"flex items-center justify-between mb-1"
>
<
h3
className=
"font-medium text-foreground text-sm truncate flex-1"
>
{
prop
.
name
}
</
h3
>
<
button
onClick=
{
()
=>
handleDelete
(
prop
.
id
)
}
disabled=
{
deleteAsset
.
isPending
}
className=
"p-0.5 rounded hover:bg-destructive/10 transition-colors flex-shrink-0 disabled:opacity-50"
>
<
Trash2
className=
"w-3 h-3 text-destructive"
/>
</
button
>
</
div
>
</
div
>
{
prop
.
tags
?.[
0
]
&&
(
<
span
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
<
div
className=
"mb-2"
>
道具
<
span
className=
"px-2 py-0.5 rounded-md bg-accent text-primary text-xs"
>
{
prop
.
tags
[
0
]
}
</
span
>
</
span
>
</
div
>
)
}
{
prop
.
description
&&
(
<
p
className=
"text-xs text-muted-foreground line-clamp-2 leading-relaxed"
>
{
prop
.
description
}
</
p
>
)
}
</
div
>
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
</
div
>
{
/* Floating bottom bar */
}
<
p
className=
"mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground"
>
<
div
className=
"fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"
>
{
asset
.
description
||
"待补充道具说明、用途和适用场景。"
}
<
div
className=
"max-w-7xl mx-auto flex items-center justify-end gap-3"
>
</
p
>
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard`
)
}
<
button
className=
"px-6 py-2 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-md transition-all flex items-center gap-2 text-sm font-medium"
onClick=
{
()
=>
handleDelete
(
asset
)
}
>
className=
"mt-5 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
<
ArrowRight
className=
"w-4 h-4"
/>
>
进入分镜
<
Trash2
className=
"h-4 w-4"
/>
</
button
>
删除道具
</
div
>
</
button
>
</
div
>
</
div
>
</
div
>
))
}
</
div
>
)
}
</
section
>
{
/* Add / Edit Modal */
}
{
showModal
?
(
{
showModal
&&
editForm
&&
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"w-full max-w-3xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-lg w-full"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
>
<
h2
className=
"text-xl font-semibold text-foreground"
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Prop Editor
</
div
>
{
editForm
.
id
?
"编辑道具"
:
"新增道具"
}
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
新建道具资产
</
h3
>
</
h2
>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
<
button
onClick=
{
closeModal
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
录入名称、分类、描述和图片链接,让常用道具沉淀为可复用的项目资产。
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
<
div
className=
"space-y-
4
"
>
<
div
className=
"space-y-
5
"
>
<
div
className=
"grid g
rid-cols-2 gap-4
"
>
<
div
className=
"grid g
ap-4 md:grid-cols-2
"
>
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2
"
>
道具名称
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground
"
>
道具名称
</
label
>
<
input
<
input
type=
"text"
value=
{
form
.
name
}
value=
{
editForm
.
name
}
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
name
:
e
.
target
.
value
}))
}
onChange=
{
(
e
)
=>
setEditForm
({
...
editForm
,
name
:
e
.
target
.
value
})
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"输入道具名称"
placeholder=
"例如:角色工牌"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2"
>
道具
分类
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground"
>
分类
</
label
>
<
select
<
select
value=
{
editF
orm
.
category
}
value=
{
f
orm
.
category
}
onChange=
{
(
e
)
=>
set
EditForm
({
...
editForm
,
category
:
e
.
target
.
value
}
)
}
onChange=
{
(
e
)
=>
set
Form
((
current
)
=>
({
...
current
,
category
:
e
.
target
.
value
})
)
}
className=
"
w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer
"
className=
"
h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30
"
>
>
{
CATEGORIES
.
map
((
c
)
=>
<
option
key=
{
c
}
value=
{
c
}
>
{
c
}
</
option
>)
}
{
categories
.
map
((
category
)
=>
(
<
option
key=
{
category
}
value=
{
category
}
>
{
category
}
</
option
>
))
}
</
select
>
</
select
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2"
>
道具
描述
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground"
>
描述
</
label
>
<
textarea
<
textarea
value=
{
editForm
.
description
}
value=
{
form
.
description
}
onChange=
{
(
e
)
=>
setEditForm
({
...
editForm
,
description
:
e
.
target
.
value
})
}
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
description
:
e
.
target
.
value
}))
}
placeholder=
"描述道具的外观、用途和重要性"
className=
"min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
rows=
{
3
}
placeholder=
"描述道具的外观、用途和重要性。"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
图片 URL(可选)
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
图片链接
</
label
>
<
input
<
div
className=
"relative"
>
type=
"text"
<
Link2
className=
"pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
value=
{
editForm
.
imageUrl
}
<
input
onChange=
{
(
e
)
=>
setEditForm
({
...
editForm
,
imageUrl
:
e
.
target
.
value
})
}
value=
{
form
.
imageUrl
}
placeholder=
"https://example.com/prop.jpg"
onChange=
{
(
e
)
=>
setForm
((
current
)
=>
({
...
current
,
imageUrl
:
e
.
target
.
value
}))
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/>
placeholder=
"https://example.com/prop.jpg"
/>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex gap-3 mt-6 pt-6 border-t border-border"
>
<
div
className=
"mt-6 flex items-center justify-end gap-3"
>
<
button
onClick=
{
closeModal
}
className=
"flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
取消
</
button
>
</
button
>
<
button
<
button
onClick=
{
handle
Sav
e
}
onClick=
{
handle
Creat
e
}
disabled=
{
!
editForm
.
name
.
trim
()
||
createAsset
.
isPending
}
disabled=
{
createAsset
.
isPending
||
!
form
.
name
.
trim
()
}
className=
"
flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-5
0"
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-6
0"
>
>
{
createAsset
.
isPending
?
<
Loader2
className=
"
w-4 h-4 animate-spin"
/>
:
<
Check
className=
"w-4 h
-4"
/>
}
{
createAsset
.
isPending
?
<
Loader2
className=
"
h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w
-4"
/>
}
保存
保存
道具
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/RegisterPage.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
Link
}
from
"react-router"
;
import
{
Link
}
from
"react-router"
;
import
{
Building2
,
LockKeyhole
,
Mail
,
UserRound
}
from
"lucide-react"
;
import
{
Button
}
from
"../components/ui/button"
;
import
{
Button
}
from
"../components/ui/button"
;
import
{
Input
}
from
"../components/ui/input"
;
import
{
Input
}
from
"../components/ui/input"
;
import
{
Label
}
from
"../components/ui/label"
;
import
{
Label
}
from
"../components/ui/label"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
}
from
"../components/ui/card"
;
import
{
useAuth
}
from
"../../hooks/useAuth"
;
import
{
useAuth
}
from
"../../hooks/useAuth"
;
import
{
AuthShell
}
from
"../components/AuthShell"
;
export
function
RegisterPage
()
{
export
function
RegisterPage
()
{
const
{
register
,
registerPending
,
registerError
}
=
useAuth
();
const
{
register
,
registerPending
,
registerError
}
=
useAuth
();
...
@@ -26,78 +27,109 @@ export function RegisterPage() {
...
@@ -26,78 +27,109 @@ export function RegisterPage() {
};
};
return
(
return
(
<
div
className=
"min-h-screen flex items-center justify-center bg-background px-4"
>
<
AuthShell
<
Card
className=
"w-full max-w-sm"
>
title=
"创建企业账号"
<
CardHeader
className=
"text-center"
>
description=
"注册后即可创建团队空间,统一管理短剧项目、成员权限、资产沉淀与 AI 生成流程。"
<
CardTitle
className=
"text-2xl"
>
创建账号
</
CardTitle
>
footer=
{
<
CardDescription
>
免费开始使用 YaoAI Comic Studio
</
CardDescription
>
<
div
className=
"flex items-center justify-between gap-3"
>
</
CardHeader
>
<
span
className=
"text-xs text-muted-foreground"
>
支持个人试用,也支持企业团队空间接入
</
span
>
<
CardContent
>
<
div
>
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-4"
>
已有账号?
<
div
className=
"space-y-2"
>
<
Link
to=
"/login"
className=
"ml-1 font-medium text-primary hover:underline"
>
<
Label
htmlFor=
"username"
>
用户名
</
Label
>
立即登录
<
Input
</
Link
>
id=
"username"
</
div
>
placeholder=
"your-name"
</
div
>
required
}
value=
{
form
.
username
}
>
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
username
:
e
.
target
.
value
}))
}
<
form
onSubmit=
{
handleSubmit
}
className=
"space-y-5"
>
/>
<
div
className=
"space-y-2"
>
</
div
>
<
Label
htmlFor=
"username"
className=
"text-sm text-foreground"
>
<
div
className=
"space-y-2"
>
用户名
<
Label
htmlFor=
"email"
>
邮箱
</
Label
>
</
Label
>
<
Input
<
div
className=
"relative"
>
id=
"email"
<
UserRound
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
type=
"email"
<
Input
placeholder=
"you@example.com"
id=
"username"
required
placeholder=
"your-name"
value=
{
form
.
email
}
required
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
email
:
e
.
target
.
value
}))
}
value=
{
form
.
username
}
/>
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
username
:
e
.
target
.
value
}))
}
</
div
>
className=
"h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
<
div
className=
"space-y-2"
>
/>
<
Label
htmlFor=
"password"
>
密码
</
Label
>
</
div
>
<
Input
</
div
>
id=
"password"
type=
"password"
placeholder=
"至少 8 位"
required
minLength=
{
8
}
value=
{
form
.
password
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
password
:
e
.
target
.
value
}))
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
htmlFor=
"tenantName"
>
工作空间名称
<
span
className=
"text-muted-foreground text-xs"
>
(可选)
</
span
>
</
Label
>
<
Input
id=
"tenantName"
placeholder=
"我的工作室"
value=
{
form
.
tenantName
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
tenantName
:
e
.
target
.
value
}))
}
/>
</
div
>
{
registerError
&&
(
<
div
className=
"space-y-2"
>
<
p
className=
"text-sm text-destructive"
>
<
Label
htmlFor=
"email"
className=
"text-sm text-foreground"
>
{
registerError
instanceof
Error
?
registerError
.
message
:
"注册失败"
}
邮箱
</
p
>
</
Label
>
)
}
<
div
className=
"relative"
>
<
Mail
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
Input
id=
"email"
type=
"email"
placeholder=
"you@example.com"
required
value=
{
form
.
email
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
email
:
e
.
target
.
value
}))
}
className=
"h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</
div
>
</
div
>
<
Button
type=
"submit"
className=
"w-full"
disabled=
{
registerPending
}
>
<
div
className=
"space-y-2"
>
{
registerPending
?
"注册中..."
:
"注册"
}
<
Label
htmlFor=
"password"
className=
"text-sm text-foreground"
>
</
Button
>
密码
</
Label
>
<
div
className=
"relative"
>
<
LockKeyhole
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
Input
id=
"password"
type=
"password"
placeholder=
"至少 8 位"
required
minLength=
{
8
}
value=
{
form
.
password
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
password
:
e
.
target
.
value
}))
}
className=
"h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</
div
>
</
div
>
<
p
className=
"text-center text-sm text-muted-foreground"
>
<
div
className=
"space-y-2"
>
已有账号?
{
" "
}
<
div
className=
"flex items-center justify-between"
>
<
Link
to=
"/login"
className=
"text-primary underline-offset-4 hover:underline"
>
<
Label
htmlFor=
"tenantName"
className=
"text-sm text-foreground"
>
立即登录
工作空间名称
</
Link
>
</
Label
>
</
p
>
<
span
className=
"text-xs text-muted-foreground"
>
可选
</
span
>
</
form
>
</
div
>
</
CardContent
>
<
div
className=
"relative"
>
</
Card
>
<
Building2
className=
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
</
div
>
<
Input
id=
"tenantName"
placeholder=
"我的工作室"
value=
{
form
.
tenantName
}
onChange=
{
(
e
)
=>
setForm
((
f
)
=>
({
...
f
,
tenantName
:
e
.
target
.
value
}))
}
className=
"h-11 rounded-xl border-border/90 bg-white pl-10 shadow-sm"
/>
</
div
>
</
div
>
{
registerError
?
(
<
div
className=
"rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
>
{
registerError
instanceof
Error
?
registerError
.
message
:
"注册失败,请稍后重试。"
}
</
div
>
)
:
null
}
<
Button
type=
"submit"
className=
"h-12 w-full rounded-xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-base font-medium shadow-[0_16px_30px_rgba(15,116,216,0.22)] transition hover:translate-y-[-1px] hover:shadow-[0_20px_38px_rgba(15,116,216,0.26)]"
disabled=
{
registerPending
}
>
{
registerPending
?
"正在注册..."
:
"创建账号"
}
</
Button
>
</
form
>
</
AuthShell
>
);
);
}
}
doc/html/src/app/pages/SceneGeneration.tsx
View file @
75992d7b
import
{
use
State
,
useRef
}
from
"react"
;
import
{
use
Ref
,
useState
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
ArrowRight
,
Sparkles
,
Plus
,
Edit2
,
Users
,
MapPin
,
Box
,
Trash2
,
X
,
Check
,
RefreshCw
,
ImageIcon
,
Loader2
,
Upload
}
from
"lucide-react"
;
import
{
import
{
useScenes
,
useExtractScenes
,
useGenerateSceneImage
,
useDeleteScene
,
useSaveScene
,
useUploadSceneImage
}
from
"../../hooks/useAi"
;
Box
,
Image
as
ImageIcon
,
Loader2
,
MapPin
,
Plus
,
RefreshCw
,
Sparkles
,
Trash2
,
Upload
,
Users
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
import
{
useDeleteScene
,
useExtractScenes
,
useGenerateSceneImage
,
useSaveScene
,
useScenes
,
useUploadSceneImage
,
}
from
"../../hooks/useAi"
;
import
type
{
Scene
}
from
"../../lib/api/ai"
;
import
type
{
Scene
}
from
"../../lib/api/ai"
;
type
SettingsTab
=
"characters"
|
"scenes"
|
"props"
;
type
SettingsTab
=
"characters"
|
"scenes"
|
"props"
;
const
emptyForm
=
():
Partial
<
Scene
>
=>
({
const
emptyForm
=
():
Partial
<
Scene
>
=>
({
name
:
""
,
sceneType
:
"indoor"
,
description
:
""
,
name
:
""
,
sceneType
:
"indoor"
,
description
:
""
,
status
:
"draft"
,
});
});
export
function
SceneGeneration
()
{
export
function
SceneGeneration
()
{
...
@@ -23,15 +46,13 @@ export function SceneGeneration() {
...
@@ -23,15 +46,13 @@ export function SceneGeneration() {
const
deleteScene
=
useDeleteScene
(
pid
);
const
deleteScene
=
useDeleteScene
(
pid
);
const
[
activeTab
]
=
useState
<
SettingsTab
>
(
"scenes"
);
const
[
activeTab
]
=
useState
<
SettingsTab
>
(
"scenes"
);
const
[
generatingImageId
,
setGeneratingImageId
]
=
useState
<
string
|
null
>
(
null
);
// modal state
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
editingScene
,
setEditingScene
]
=
useState
<
Partial
<
Scene
>>
(
emptyForm
());
const
[
editingScene
,
setEditingScene
]
=
useState
<
Partial
<
Scene
>>
(
emptyForm
());
const
[
imagePreview
,
setImagePreview
]
=
useState
<
string
|
null
>
(
null
);
const
[
imagePreview
,
setImagePreview
]
=
useState
<
string
|
null
>
(
null
);
const
[
pendingFile
,
setPendingFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
pendingFile
,
setPendingFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
generatingImageId
,
setGeneratingImageId
]
=
useState
<
string
|
null
>
(
null
);
const
fileRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
settingsTabs
=
[
const
settingsTabs
=
[
{
id
:
"characters"
as
SettingsTab
,
label
:
"角色"
,
icon
:
Users
,
path
:
`/project/
${
projectId
}
/characters`
},
{
id
:
"characters"
as
SettingsTab
,
label
:
"角色"
,
icon
:
Users
,
path
:
`/project/
${
projectId
}
/characters`
},
...
@@ -40,41 +61,40 @@ export function SceneGeneration() {
...
@@ -40,41 +61,40 @@ export function SceneGeneration() {
];
];
const
handleExtract
=
()
=>
{
const
handleExtract
=
()
=>
{
extract
.
mutate
(
undefined
,
{
onError
:
(
e
)
=>
alert
(
"提取失败: "
+
(
e
as
Error
).
message
)
});
extract
.
mutate
(
undefined
,
{
onError
:
(
error
)
=>
alert
(
`提取场景失败:
${(
error
as
Error
).
message
}
`
),
});
};
};
const
openCreate
=
()
=>
{
const
openCreate
=
()
=>
{
setEditingScene
(
emptyForm
());
setEditingScene
(
emptyForm
());
setImagePreview
(
null
);
setPendingFile
(
null
);
setPendingFile
(
null
);
setImagePreview
(
null
);
setShowModal
(
true
);
setShowModal
(
true
);
};
};
const
openEdit
=
(
scene
:
Scene
)
=>
{
const
openEdit
=
(
scene
:
Scene
)
=>
{
setEditingScene
({
...
scene
});
setEditingScene
({
...
scene
});
setImagePreview
(
scene
.
imageUrl
??
null
);
setPendingFile
(
null
);
setPendingFile
(
null
);
setImagePreview
(
scene
.
imageUrl
??
null
);
setShowModal
(
true
);
setShowModal
(
true
);
};
};
const
handleFileSelect
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
f
=
e
.
target
.
files
?.[
0
];
if
(
!
f
)
return
;
setPendingFile
(
f
);
setImagePreview
(
URL
.
createObjectURL
(
f
));
};
const
handleSave
=
async
()
=>
{
const
handleSave
=
async
()
=>
{
if
(
!
editingScene
.
name
?.
trim
())
return
;
if
(
!
editingScene
.
name
?.
trim
())
return
;
setSaving
(
true
);
setSaving
(
true
);
try
{
try
{
const
saved
=
await
saveScene
.
mutateAsync
({
...
editingScene
,
status
:
editingScene
.
status
??
"draft"
});
const
saved
=
await
saveScene
.
mutateAsync
({
...
editingScene
,
status
:
editingScene
.
status
??
"draft"
,
});
if
(
pendingFile
)
{
if
(
pendingFile
)
{
await
uploadImage
.
mutateAsync
({
id
:
String
(
saved
.
id
),
file
:
pendingFile
});
await
uploadImage
.
mutateAsync
({
id
:
String
(
saved
.
id
),
file
:
pendingFile
});
}
}
setShowModal
(
false
);
setShowModal
(
false
);
}
catch
(
e
)
{
}
catch
(
e
rror
)
{
alert
(
"保存失败: "
+
(
e
as
Error
).
message
);
alert
(
`保存场景失败:
${(
error
as
Error
).
message
}
`
);
}
finally
{
}
finally
{
setSaving
(
false
);
setSaving
(
false
);
}
}
...
@@ -84,328 +104,287 @@ export function SceneGeneration() {
...
@@ -84,328 +104,287 @@ export function SceneGeneration() {
setGeneratingImageId
(
scene
.
id
);
setGeneratingImageId
(
scene
.
id
);
generateImage
.
mutate
(
scene
.
id
,
{
generateImage
.
mutate
(
scene
.
id
,
{
onSettled
:
()
=>
setGeneratingImageId
(
null
),
onSettled
:
()
=>
setGeneratingImageId
(
null
),
onError
:
(
e
)
=>
alert
(
"图片生成失败: "
+
(
e
as
Error
).
message
),
onError
:
(
e
rror
)
=>
alert
(
`场景图生成失败:
${(
error
as
Error
).
message
}
`
),
});
});
};
};
const
handleDelete
=
(
scene
:
Scene
)
=>
{
const
handleDelete
=
(
scene
:
Scene
)
=>
{
if
(
!
confirm
(
`确定要删除场景「
${
scene
.
name
}
」吗?`
))
return
;
if
(
!
confirm
(
`确定要删除场景“
${
scene
.
name
}
”吗?`
))
return
;
deleteScene
.
mutate
(
scene
.
id
,
{
onError
:
(
e
)
=>
alert
(
"删除失败: "
+
(
e
as
Error
).
message
)
});
deleteScene
.
mutate
(
scene
.
id
,
{
};
onError
:
(
error
)
=>
alert
(
`删除场景失败:
${(
error
as
Error
).
message
}
`
),
});
const
statusBadge
=
(
status
:
string
)
=>
{
if
(
status
===
"ready"
)
return
<
span
className=
"px-1.5 py-0.5 rounded text-xs bg-green-500/10 text-green-600"
>
已生成
</
span
>;
if
(
status
===
"generating"
)
return
<
span
className=
"px-1.5 py-0.5 rounded text-xs bg-blue-500/10 text-blue-600 flex items-center gap-1"
><
Loader2
className=
"w-3 h-3 animate-spin"
/>
生成中
</
span
>;
if
(
status
===
"failed"
)
return
<
span
className=
"px-1.5 py-0.5 rounded text-xs bg-red-500/10 text-red-600"
>
失败
</
span
>;
return
<
span
className=
"px-1.5 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-600"
>
待生成
</
span
>;
};
};
return
(
return
(
<
div
className=
"h-full overflow-auto bg-background relative"
>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"p-6 pb-24"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"max-w-7xl mx-auto"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
{
/* Header */
}
<
div
>
<
div
className=
"mb-6"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
Sparkles
className=
"h-3.5 w-3.5"
/>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
Scene Setup
<
Sparkles
className=
"w-4 h-4 text-white"
/>
</
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
项目设定
</
h1
>
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
管理角色、场景和道具设定
</
p
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
场景设定工作台
</
h1
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
管理项目场景资产,支持从剧本中提取场景、补充环境描述、上传参考图或直接由 AI 生成场景图。
</
p
>
</
div
>
</
div
>
{
/* Settings Navigation Tabs */
}
<
div
className=
"flex flex-wrap gap-3"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
button
<
div
className=
"flex items-center gap-2"
>
onClick=
{
handleExtract
}
{
settingsTabs
.
map
((
tab
)
=>
{
disabled=
{
extract
.
isPending
}
const
Icon
=
tab
.
icon
;
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
return
(
>
<
button
{
extract
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
RefreshCw
className=
"h-4 w-4"
/>
}
key=
{
tab
.
id
}
从剧本提取场景
onClick=
{
()
=>
navigate
(
tab
.
path
)
}
</
button
>
className=
{
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all ${
<
button
activeTab === tab.id
onClick=
{
openCreate
}
? "bg-accent text-accent-foreground font-medium"
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
>
}`
}
<
Plus
className=
"h-4 w-4"
/>
>
新建场景
<
Icon
className=
"w-4 h-4"
/>
</
button
>
{
tab
.
label
}
</
div
>
</
button
>
</
div
>
);
})
}
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
</
div
>
{
settingsTabs
.
map
((
tab
)
=>
{
<
div
className=
"flex items-center gap-2"
>
const
Icon
=
tab
.
icon
;
<
button
const
active
=
activeTab
===
tab
.
id
;
onClick=
{
openCreate
}
className=
"px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2 text-sm"
return
(
>
<
Plus
className=
"w-4 h-4"
/>
手动添加
</
button
>
<
button
<
button
onClick=
{
handleExtract
}
key=
{
tab
.
id
}
disabled=
{
extract
.
isPending
}
onClick=
{
()
=>
navigate
(
tab
.
path
)
}
className=
"px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60"
className=
{
[
"inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm transition-all"
,
active
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
].
join
(
" "
)
}
>
>
{
extract
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Sparkles
className=
"w-4 h-4"
/>
}
<
Icon
className=
"h-4 w-4"
/>
AI提取场景
{
tab
.
label
}
</
button
>
</
button
>
);
})
}
</
div
>
<
div
className=
"mt-6 grid gap-4 md:grid-cols-3"
>
{
[
{
label
:
"场景总数"
,
value
:
`${scenes.length}`
},
{
label
:
"室内场景"
,
value
:
`${scenes.filter((scene) => scene.sceneType === "indoor").length}`
,
},
{
label
:
"已有参考图"
,
value
:
`${scenes.filter((scene) => !!scene.imageUrl).length}`
,
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
item
.
label
}
</
div
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
item
.
value
}
</
div
>
</
div
>
</
div
>
</
div
>
))
}
</
div
>
{
/* Statistics */
}
{
isLoading
?
(
<
div
className=
"flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"
>
<
div
className=
"flex items-center justify-center py-24"
>
<
div
className=
"flex items-center gap-6"
>
<
Loader2
className=
"h-8 w-8 animate-spin text-primary"
/>
<
div
className=
"flex items-center gap-2"
>
</
div
>
<
span
className=
"text-sm text-muted-foreground"
>
总计:
</
span
>
)
:
scenes
.
length
===
0
?
(
<
span
className=
"text-lg font-semibold text-foreground"
>
{
scenes
.
length
}
</
span
>
<
div
className=
"mt-6 rounded-[32px] border border-dashed border-border bg-white/70 px-6 py-20 text-center"
>
</
div
>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
div
className=
"flex items-center gap-2"
>
<
MapPin
className=
"h-7 w-7"
/>
<
span
className=
"text-sm text-muted-foreground"
>
已生成图片:
</
span
>
<
span
className=
"text-lg font-semibold text-green-600"
>
{
scenes
.
filter
(
s
=>
s
.
status
===
"ready"
).
length
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm text-muted-foreground"
>
室内:
</
span
>
<
span
className=
"text-lg font-semibold text-foreground"
>
{
scenes
.
filter
(
s
=>
s
.
sceneType
===
"indoor"
).
length
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm text-muted-foreground"
>
室外:
</
span
>
<
span
className=
"text-lg font-semibold text-foreground"
>
{
scenes
.
filter
(
s
=>
s
.
sceneType
===
"outdoor"
).
length
}
</
span
>
</
div
>
</
div
>
</
div
>
<
h3
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有场景设定
</
h3
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
先从剧本里提取关键场景,或手动创建基础场景,后续再补充参考图和环境描述。
</
p
>
</
div
>
</
div
>
)
:
(
<
div
className=
"mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-3"
>
{
scenes
.
map
((
scene
)
=>
(
<
div
key=
{
scene
.
id
}
className=
"overflow-hidden rounded-[30px] border border-white/80 bg-white/82 shadow-[0_20px_50px_rgba(11,44,81,0.10)]"
>
<
div
className=
"aspect-[16/10] bg-muted"
>
{
scene
.
imageUrl
?
(
<
img
src=
{
scene
.
imageUrl
}
alt=
{
scene
.
name
}
className=
"h-full w-full object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center"
>
<
ImageIcon
className=
"h-10 w-10 text-muted-foreground"
/>
</
div
>
)
}
</
div
>
{
/* Empty State */
}
<
div
className=
"p-5"
>
{
!
isLoading
&&
scenes
.
length
===
0
&&
(
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-12 text-center"
>
<
div
>
<
MapPin
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
div
className=
"text-lg font-semibold text-foreground"
>
{
scene
.
name
}
</
div
>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
暂无场景
</
h3
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
手动添加场景,或点击「AI提取场景」从大纲和分集自动提取主要场景
</
p
>
{
scene
.
sceneType
===
"indoor"
?
"室内场景"
:
"室外场景"
}
<
div
className=
"flex items-center justify-center gap-3"
>
</
div
>
<
button
</
div
>
onClick=
{
openCreate
}
<
span
className=
"rounded-full bg-accent px-3 py-1 text-xs text-primary"
>
className=
"px-5 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all flex items-center gap-2"
{
scene
.
status
===
"ready"
?
"已完成"
:
"待完善"
}
>
</
span
>
<
Plus
className=
"w-4 h-4"
/>
</
div
>
手动添加
</
button
>
<
button
onClick=
{
handleExtract
}
disabled=
{
extract
.
isPending
}
className=
"px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 disabled:opacity-60"
>
{
extract
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Sparkles
className=
"w-4 h-4"
/>
}
AI提取场景
</
button
>
</
div
>
</
div
>
)
}
{
isLoading
&&
(
<
p
className=
"mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground"
>
<
div
className=
"flex items-center justify-center py-16"
>
{
scene
.
description
||
"待补充环境氛围、陈设与镜头相关描述。"
}
<
Loader2
className=
"w-8 h-8 animate-spin text-muted-foreground"
/>
</
p
>
</
div
>
)
}
{
/* Scene Grid */
}
<
div
className=
"mt-5 grid gap-2"
>
{
scenes
.
length
>
0
&&
(
<
button
<
div
className=
"grid grid-cols-3 gap-4 mb-8"
>
onClick=
{
()
=>
handleGenerateImage
(
scene
)
}
{
scenes
.
map
((
scene
)
=>
(
disabled=
{
generatingImageId
===
scene
.
id
}
<
div
key=
{
scene
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden group"
>
className=
"inline-flex items-center justify-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
{
/* Image */
}
>
<
div
className=
"aspect-video relative overflow-hidden bg-muted"
>
{
generatingImageId
===
scene
.
id
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
WandSparkles
className=
"h-4 w-4"
/>
}
{
scene
.
imageUrl
?
(
{
generatingImageId
===
scene
.
id
?
"正在生成..."
:
"AI 生成场景图"
}
<
img
src=
{
scene
.
imageUrl
}
alt=
{
scene
.
name
}
className=
"w-full h-full object-cover"
/>
</
button
>
)
:
(
<
div
className=
"w-full h-full flex flex-col items-center justify-center gap-2"
>
<
div
className=
"grid grid-cols-2 gap-2"
>
{
scene
.
status
===
"generating"
?
(
<
Loader2
className=
"w-8 h-8 text-primary animate-spin"
/>
)
:
(
<
ImageIcon
className=
"w-8 h-8 text-muted-foreground"
/>
)
}
</
div
>
)
}
<
div
className=
"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2"
>
<
button
<
button
onClick=
{
()
=>
handleGenerateImage
(
scene
)
}
onClick=
{
()
=>
openEdit
(
scene
)
}
disabled=
{
generatingImageId
===
scene
.
id
||
scene
.
status
===
"generating"
}
className=
"rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
className=
"p-2 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title=
"AI重新生成"
>
>
<
RefreshCw
className=
{
`w-5 h-5 text-white ${generatingImageId === scene.id ? "animate-spin" : ""}`
}
/>
编辑场景
</
button
>
</
button
>
</
div
>
<
div
className=
"absolute top-2 right-2 px-2 py-1 rounded-md bg-card/90 backdrop-blur-sm text-xs text-foreground border border-border"
>
{
scene
.
sceneType
===
"indoor"
?
"室内"
:
"室外"
}
</
div
>
<
div
className=
"absolute top-2 left-2"
>
{
statusBadge
(
scene
.
status
)
}
</
div
>
</
div
>
{
/* Info */
}
<
div
className=
"p-4"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
<
MapPin
className=
"w-4 h-4 text-primary flex-shrink-0"
/>
<
h3
className=
"font-medium text-foreground truncate"
>
{
scene
.
name
}
</
h3
>
</
div
>
<
div
className=
"flex items-center gap-1 flex-shrink-0"
>
<
button
onClick=
{
()
=>
openEdit
(
scene
)
}
className=
"p-1 rounded hover:bg-muted transition-colors"
>
<
Edit2
className=
"w-3.5 h-3.5 text-muted-foreground"
/>
</
button
>
<
button
onClick=
{
()
=>
handleDelete
(
scene
)
}
className=
"p-1 rounded hover:bg-destructive/10 transition-colors"
>
<
Trash2
className=
"w-3.5 h-3.5 text-destructive"
/>
</
button
>
</
div
>
</
div
>
<
p
className=
"text-sm text-muted-foreground mb-2 line-clamp-2"
>
{
scene
.
description
}
</
p
>
{
!
scene
.
imageUrl
&&
scene
.
status
!==
"generating"
&&
(
<
button
<
button
onClick=
{
()
=>
handleGenerateImage
(
scene
)
}
onClick=
{
()
=>
handleDelete
(
scene
)
}
disabled=
{
generatingImageId
===
scene
.
id
}
className=
"inline-flex items-center justify-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
className=
"w-full mt-1 px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center justify-center gap-1.5 disabled:opacity-50"
>
>
<
Sparkles
className=
"w-3 h-3
"
/>
<
Trash2
className=
"h-4 w-4
"
/>
生成场景图片
删除
</
button
>
</
button
>
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
))
}
</
div
>
</
div
>
))
}
)
}
</
div
>
</
div
>
)
}
</
div
>
</
section
>
{
/* Floating Bottom Bar */
}
<
div
className=
"fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"
>
<
div
className=
"max-w-7xl mx-auto flex items-center justify-end gap-3"
>
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard`
)
}
className=
"px-6 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center gap-2 text-sm font-medium"
>
<
span
>
前往分镜工作台
</
span
>
<
ArrowRight
className=
"w-5 h-5"
/>
</
button
>
</
div
>
</
div
>
{
/* Add/Edit Scene Modal */
}
{
showModal
?
(
{
showModal
&&
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"
>
<
div
className=
"w-full max-w-4xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
<
div
className=
"bg-card rounded-2xl border border-border w-full max-w-md max-h-[90vh] flex flex-col"
>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
{
/* Modal Header */
}
<
div
>
<
div
className=
"flex items-center justify-between p-6 border-b border-border flex-shrink-0"
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Scene Editor
</
div
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
{
editingScene
.
id
?
"编辑场景"
:
"手动添加场景"
}
{
editingScene
.
id
?
"编辑场景"
:
"新建场景"
}
</
h2
>
</
h3
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
补充场景名称、环境描述和参考图,为角色互动和镜头设计提供统一空间背景。
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<
X
className=
"h-4 w-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
{
/* Modal Body */
}
<
div
className=
"grid gap-6 xl:grid-cols-[1fr_320px]"
>
<
div
className=
"overflow-auto p-6 space-y-4 flex-1"
>
<
div
className=
"space-y-5"
>
{
/* Image Upload */
}
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
场景图片(可选)
</
label
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
场景名称
</
label
>
<
div
<
input
className=
"w-full aspect-video rounded-lg border-2 border-dashed border-border bg-muted flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
value=
{
editingScene
.
name
??
""
}
onClick=
{
()
=>
fileInputRef
.
current
?.
click
()
}
onChange=
{
(
e
)
=>
setEditingScene
((
current
)
=>
({
...
current
,
name
:
e
.
target
.
value
}))
}
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"例如:小区居民房"
/>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
场景类型
</
label
>
<
select
value=
{
editingScene
.
sceneType
??
"indoor"
}
onChange=
{
(
e
)
=>
setEditingScene
((
current
)
=>
({
...
current
,
sceneType
:
e
.
target
.
value
}))
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
<
option
value=
"indoor"
>
室内
</
option
>
<
option
value=
"outdoor"
>
室外
</
option
>
</
select
>
</
div
>
</
div
>
<
div
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
环境描述
</
label
>
<
textarea
value=
{
editingScene
.
description
??
""
}
onChange=
{
(
e
)
=>
setEditingScene
((
current
)
=>
({
...
current
,
description
:
e
.
target
.
value
}))
}
className=
"min-h-40 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder=
"描述场景的环境、氛围、陈设和镜头需要注意的空间信息。"
/>
</
div
>
</
div
>
<
div
className=
"rounded-[30px] border border-border/80 bg-white/70 p-5 shadow-sm"
>
<
div
className=
"mb-4 text-base font-medium text-foreground"
>
场景参考图
</
div
>
<
div
className=
"aspect-[4/5] overflow-hidden rounded-[24px] bg-muted"
>
{
imagePreview
?
(
{
imagePreview
?
(
<
img
src=
{
imagePreview
}
alt=
"
预览"
className=
"w-full h
-full object-cover"
/>
<
img
src=
{
imagePreview
}
alt=
"
场景参考"
className=
"h-full w
-full object-cover"
/>
)
:
(
)
:
(
<
div
className=
"text-center"
>
<
div
className=
"flex h-full items-center justify-center"
>
<
Upload
className=
"w-8 h-8 text-muted-foreground mx-auto mb-2"
/>
<
ImageIcon
className=
"h-8 w-8 text-muted-foreground"
/>
<
span
className=
"text-sm text-muted-foreground"
>
点击上传场景参考图
</
span
>
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
{
imagePreview
&&
(
<
button
onClick=
{
()
=>
{
setImagePreview
(
null
);
setPendingFile
(
null
);
}
}
className=
"mt-1 text-xs text-destructive hover:underline"
>
移除图片
</
button
>
)
}
<
input
ref=
{
fileInputRef
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
handleFileSelect
}
/>
</
div
>
{
/* Name */
}
<
button
<
div
>
onClick=
{
()
=>
fileRef
.
current
?.
click
()
}
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
场景名称
<
span
className=
"text-destructive"
>
*
</
span
></
label
>
className=
"mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
<
input
type=
"text"
value=
{
editingScene
.
name
??
""
}
onChange=
{
(
e
)
=>
setEditingScene
({
...
editingScene
,
name
:
e
.
target
.
value
})
}
placeholder=
"例如:小区居民房"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
{
/* Scene Type */
}
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
场景类型
</
label
>
<
select
value=
{
editingScene
.
sceneType
??
"indoor"
}
onChange=
{
(
e
)
=>
setEditingScene
({
...
editingScene
,
sceneType
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
>
>
<
option
value=
"indoor"
>
室内
</
option
>
<
Upload
className=
"h-4 w-4"
/
>
<
option
value=
"outdoor"
>
室外
</
option
>
上传参考图
</
select
>
</
button
>
</
div
>
<
input
ref=
{
fileRef
}
{
/* Description */
}
type=
"file"
<
div
>
accept=
"image/*"
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
场景描述
</
label
>
className=
"hidden"
<
textarea
onChange=
{
(
event
)
=>
{
value=
{
editingScene
.
description
??
""
}
const
file
=
event
.
target
.
files
?.[
0
];
onChange=
{
(
e
)
=>
setEditingScene
({
...
editingScene
,
description
:
e
.
target
.
value
})
}
if
(
!
file
)
return
;
placeholder=
"描述场景的环境、氛围、陈设等"
setPendingFile
(
file
);
rows=
{
3
}
setImagePreview
(
URL
.
createObjectURL
(
file
));
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
}
}
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Modal Footer */
}
<
div
className=
"mt-6 flex items-center justify-end gap-3"
>
<
div
className=
"flex gap-3 p-6 border-t border-border flex-shrink-0"
>
<
button
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"
flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors
"
className=
"
rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted
"
>
>
取消
取消
</
button
>
</
button
>
<
button
<
button
onClick=
{
handleSave
}
onClick=
{
handleSave
}
disabled=
{
!
editingScene
.
name
?.
trim
()
||
saving
}
disabled=
{
saving
||
!
editingScene
.
name
?.
trim
()
}
className=
"
flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-5
0"
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-6
0"
>
>
{
saving
?
<
Loader2
className=
"
w-4 h-4 animate-spin"
/>
:
<
Check
className=
"w-4 h
-4"
/>
}
{
saving
?
<
Loader2
className=
"
h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w
-4"
/>
}
保存
保存
场景
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
:
null
}
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/StoryboardWorkspace.tsx
View file @
75992d7b
import
{
use
State
,
useEffect
,
useMemo
}
from
"react"
;
import
{
use
Effect
,
useMemo
,
useState
}
from
"react"
;
import
{
use
Params
,
useNavigate
}
from
"react-router"
;
import
{
use
Navigate
,
useParams
}
from
"react-router"
;
import
{
import
{
Plus
,
Wand2
,
Play
,
Trash2
,
Copy
,
ArrowRight
,
Loader2
,
ArrowRight
,
Layers
,
Download
,
Check
,
X
,
Clapperboard
,
ChevronDown
,
Sparkles
,
Copy
,
Image
as
ImageIcon
,
Layers
,
Loader2
,
Plus
,
Sparkles
,
Trash2
,
Wand2
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
import
{
use
Storyboard
s
,
use
Character
s
,
useCreateStoryboard
,
useCreateStoryboard
,
useGenerateStoryboards
,
useUpdateStoryboard
,
useDeleteStoryboard
,
useDeleteStoryboard
,
useAssemblyTask
,
useEpisodes
,
useStartAssembly
,
useGenerateVideo
,
useVideoTasks
,
useGenerateStoryboardPrompt
,
useGenerateStoryboardPrompt
,
useCharacters
,
useGenerateStoryboards
,
useGenerateVideo
,
useScenes
,
useScenes
,
useEpisodes
,
useStoryboards
,
useUpdateStoryboard
,
useVideoTasks
,
}
from
"../../hooks/useAi"
;
}
from
"../../hooks/useAi"
;
import
type
{
Storyboard
,
Character
,
Scene
,
Episode
}
from
"../../lib/api/ai"
;
import
type
{
Storyboard
}
from
"../../lib/api/ai"
;
const
VIDEO_MODELS
=
[
const
videoModels
=
[
{
id
:
"doubao-seedance-2-0-fast"
,
label
:
"Seedance 2.0 Fast"
},
{
id
:
"doubao-seedance-2-0-fast"
,
label
:
"Seedance 2.0 Fast"
},
{
id
:
"doubao-seedance-2-0"
,
label
:
"Seedance 2.0"
},
{
id
:
"doubao-seedance-2-0"
,
label
:
"Seedance 2.0"
},
];
];
const
DURATION_OPTIONS
=
[
5
,
10
,
15
,
20
,
30
];
const
durationOptions
=
[
5
,
10
,
15
,
20
,
30
];
const
CREDITS_PER_VIDEO
=
2500
;
function
getTaskStatusLabel
(
status
?:
string
)
{
switch
(
status
)
{
case
"succeeded"
:
return
"已完成"
;
case
"running"
:
case
"submitted"
:
case
"pending"
:
return
"生成中"
;
case
"failed"
:
return
"失败"
;
default
:
return
"待生成"
;
}
}
function
getTaskStatusClass
(
status
?:
string
)
{
switch
(
status
)
{
case
"succeeded"
:
return
"bg-emerald-100 text-emerald-700"
;
case
"running"
:
case
"submitted"
:
case
"pending"
:
return
"bg-sky-100 text-sky-700"
;
case
"failed"
:
return
"bg-red-100 text-red-700"
;
default
:
return
"bg-slate-100 text-slate-600"
;
}
}
export
function
StoryboardWorkspace
()
{
export
function
StoryboardWorkspace
()
{
const
{
episodeId
:
urlEpisodeId
,
projectId
}
=
useParams
<
{
projectId
:
string
;
episodeId
?:
string
}
>
();
const
{
episodeId
:
urlEpisodeId
,
projectId
}
=
useParams
<
{
projectId
:
string
;
episodeId
?:
string
}
>
();
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
pid
=
projectId
??
""
;
const
pid
=
projectId
??
""
;
const
{
data
:
episodes
=
[],
isLoading
:
episodesLoading
}
=
useEpisodes
(
pid
);
const
{
data
:
episodes
=
[],
isLoading
:
loadingEpisodes
}
=
useEpisodes
(
pid
);
const
[
activeEpisodeId
,
setActiveEpisodeId
]
=
useState
<
string
>
(
""
);
const
[
activeEpisodeId
,
setActiveEpisodeId
]
=
useState
(
""
);
const
[
selectedStoryboardId
,
setSelectedStoryboardId
]
=
useState
(
""
);
const
[
selectedDraft
,
setSelectedDraft
]
=
useState
<
Storyboard
|
null
>
(
null
);
const
[
selectedModel
,
setSelectedModel
]
=
useState
(
videoModels
[
0
].
id
);
const
[
selectedDuration
,
setSelectedDuration
]
=
useState
(
15
);
const
[
promptDraft
,
setPromptDraft
]
=
useState
(
""
);
const
[
generatingVideoId
,
setGeneratingVideoId
]
=
useState
<
string
|
null
>
(
null
);
// Initialize active episode from URL param or first episode
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
urlEpisodeId
)
{
if
(
urlEpisodeId
)
{
setActiveEpisodeId
(
urlEpisodeId
);
setActiveEpisodeId
(
urlEpisodeId
);
}
else
if
(
episodes
.
length
>
0
&&
!
activeEpisodeId
)
{
}
else
if
(
episodes
.
length
>
0
&&
!
activeEpisodeId
)
{
setActiveEpisodeId
(
episodes
[
0
].
id
);
setActiveEpisodeId
(
episodes
[
0
].
id
);
}
}
},
[
urlEpisodeId
,
episodes
.
length
]);
},
[
urlEpisodeId
,
episodes
,
activeEpisodeId
]);
const
eid
=
activeEpisodeId
;
const
eid
=
activeEpisodeId
;
const
{
data
:
storyboards
=
[],
isLoading
}
=
useStoryboards
(
pid
,
eid
);
const
{
data
:
storyboards
=
[],
isLoading
:
loadingStoryboards
}
=
useStoryboards
(
pid
,
eid
);
const
{
data
:
videoTasks
=
[]
}
=
useVideoTasks
(
pid
);
const
{
data
:
videoTasks
=
[]
}
=
useVideoTasks
(
pid
);
const
{
data
:
characters
=
[]
}
=
useCharacters
(
pid
);
const
{
data
:
characters
=
[]
}
=
useCharacters
(
pid
);
const
{
data
:
scenes
=
[]
}
=
useScenes
(
pid
);
const
{
data
:
scenes
=
[]
}
=
useScenes
(
pid
);
const
{
data
:
assemblyTask
}
=
useAssemblyTask
(
pid
,
eid
);
const
generateSbs
=
useGenerateStoryboards
(
pid
);
const
createSb
=
useCreateStoryboard
(
pid
,
eid
);
const
updateSb
=
useUpdateStoryboard
(
pid
,
eid
);
const
deleteSb
=
useDeleteStoryboard
(
pid
,
eid
);
const
generateVideo
=
useGenerateVideo
(
pid
);
const
startAssembly
=
useStartAssembly
(
pid
,
eid
);
const
generatePrompt
=
useGenerateStoryboardPrompt
(
pid
);
const
[
selected
,
setSelected
]
=
useState
<
Storyboard
|
null
>
(
null
);
const
generateStoryboards
=
useGenerateStoryboards
(
pid
);
const
[
promptDraft
,
setPromptDraft
]
=
useState
(
""
);
const
createStoryboard
=
useCreateStoryboard
(
pid
,
eid
);
const
[
promptChanged
,
setPromptChanged
]
=
useState
(
false
);
const
updateStoryboard
=
useUpdateStoryboard
(
pid
,
eid
);
const
[
selectedModel
,
setSelectedModel
]
=
useState
(
VIDEO_MODELS
[
0
].
id
);
const
deleteStoryboard
=
useDeleteStoryboard
(
pid
,
eid
);
const
[
selectedDuration
,
setSelectedDuration
]
=
useState
(
15
);
const
generatePrompt
=
useGenerateStoryboardPrompt
(
pid
);
const
[
generatingVideoId
,
setGeneratingVideoId
]
=
useState
<
string
|
null
>
(
null
);
const
generateVideo
=
useGenerateVideo
(
pid
);
const
[
batchGenerating
,
setBatchGenerating
]
=
useState
(
false
);
const
getCharacterPrimaryImage
=
(
character
:
Character
)
=>
character
.
frontImageUrl
??
character
.
imageUrl
??
character
.
sideImageUrl
??
character
.
backImageUrl
??
null
;
// Reset selection when episode changes
useEffect
(()
=>
{
setSelected
(
null
);
setPromptDraft
(
""
);
setPromptChanged
(
false
);
},
[
activeEpisodeId
]);
// Sync prompt draft when selected storyboard changes
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
selected
)
{
if
(
storyboards
.
length
>
0
&&
!
selectedStoryboardId
)
{
const
draft
=
selected
.
startFramePrompt
||
selected
.
detailedDescription
||
""
;
setSelectedStoryboardId
(
storyboards
[
0
].
id
);
setPromptDraft
(
draft
);
setPromptChanged
(
false
);
}
}
},
[
selected
?.
id
]);
},
[
storyboards
,
selectedStoryboardId
]);
const
selectedStoryboard
=
useMemo
(
()
=>
storyboards
.
find
((
storyboard
)
=>
storyboard
.
id
===
selectedStoryboardId
)
??
null
,
[
storyboards
,
selectedStoryboardId
],
);
// Auto-select first storyboard when list loads
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
storyboards
.
length
>
0
&&
!
selected
)
{
if
(
!
selectedStoryboard
)
{
setSelected
(
storyboards
[
0
]);
setPromptDraft
(
""
);
setSelectedDraft
(
null
);
return
;
}
}
},
[
storyboards
.
length
]);
setSelectedDraft
(
selectedStoryboard
);
setPromptDraft
(
selectedStoryboard
.
startFramePrompt
||
selectedStoryboard
.
detailedDescription
||
""
);
const
getTaskForSb
=
(
sb
:
Storyboard
)
=>
{
},
[
selectedStoryboard
?.
id
]);
const
byId
=
videoTasks
.
filter
((
t
)
=>
t
.
storyboardId
===
sb
.
id
);
// 优先取已成功的任务;同一分镜多次生成时,最新的可能还在执行中
const
taskMap
=
useMemo
(()
=>
{
return
byId
.
find
((
t
)
=>
t
.
status
===
"succeeded"
)
const
map
=
new
Map
<
string
,
(
typeof
videoTasks
)[
number
]
>
();
??
byId
.
find
((
t
)
=>
t
.
status
===
"running"
||
t
.
status
===
"submitted"
||
t
.
status
===
"pending"
)
storyboards
.
forEach
((
storyboard
)
=>
{
??
byId
[
0
]
const
task
=
videoTasks
.
find
((
item
)
=>
item
.
storyboardId
===
storyboard
.
id
);
??
videoTasks
.
find
((
t
)
=>
t
.
episodeId
===
sb
.
episodeId
&&
t
.
storyboardId
==
null
);
if
(
task
)
map
.
set
(
storyboard
.
id
,
task
);
};
});
return
map
;
const
getDotColor
=
(
sb
:
Storyboard
)
=>
{
},
[
storyboards
,
videoTasks
]);
const
task
=
getTaskForSb
(
sb
);
if
(
!
task
)
return
"bg-gray-300 dark:bg-gray-600"
;
if
(
task
.
status
===
"succeeded"
)
return
"bg-green-500"
;
if
(
task
.
status
===
"failed"
)
return
"bg-red-400"
;
return
"bg-orange-400 animate-pulse"
;
};
const
handleEpisodeSelect
=
(
ep
:
Episode
)
=>
{
setActiveEpisodeId
(
ep
.
id
);
navigate
(
`/project/
${
pid
}
/storyboard/
${
ep
.
id
}
`
,
{
replace
:
true
});
};
const
handleGenerate
=
async
()
=>
{
const
handleGenerate
Storyboards
=
async
()
=>
{
if
(
!
eid
)
return
;
if
(
!
eid
)
return
;
const
result
=
await
generateSbs
.
mutateAsync
(
eid
);
const
created
=
await
generateStoryboards
.
mutateAsync
(
eid
);
if
(
result
.
length
>
0
)
setSelected
(
result
[
0
]);
if
(
created
.
length
>
0
)
{
setSelectedStoryboardId
(
created
[
0
].
id
);
}
};
};
const
handleCreate
=
async
()
=>
{
const
handleCreate
Storyboard
=
async
()
=>
{
const
newSb
=
await
createSb
.
mutateAsync
({
const
created
=
await
createStoryboard
.
mutateAsync
({
sequenceNum
:
storyboards
.
length
+
1
,
sequenceNum
:
storyboards
.
length
+
1
,
sceneNumber
:
`场景
${
storyboards
.
length
+
1
}
`
,
sceneNumber
:
`场景
${
storyboards
.
length
+
1
}
`
,
shortDescription
:
"新镜头"
,
shortDescription
:
"新镜头"
,
detailedDescription
:
""
,
detailedDescription
:
""
,
characters
:
""
,
characters
:
""
,
...
@@ -138,143 +152,56 @@ export function StoryboardWorkspace() {
...
@@ -138,143 +152,56 @@ export function StoryboardWorkspace() {
motionScript
:
""
,
motionScript
:
""
,
notes
:
""
,
notes
:
""
,
status
:
"draft"
,
status
:
"draft"
,
}
as
unknown
as
Storyboard
);
}
as
Storyboard
);
setSelected
(
newSb
);
setSelected
StoryboardId
(
created
.
id
);
};
};
const
handleDelete
=
async
(
sb
:
Storyboard
)
=>
{
const
handleDeleteStoryboard
=
async
(
storyboard
:
Storyboard
)
=>
{
if
(
!
confirm
(
`确定删除分镜 #
${
sb
.
sequenceNum
}
?`
))
return
;
if
(
!
confirm
(
`确定要删除分镜 #
${
storyboard
.
sequenceNum
}
吗?`
))
return
;
await
deleteSb
.
mutateAsync
(
sb
.
id
);
await
deleteStoryboard
.
mutateAsync
(
storyboard
.
id
);
if
(
selected
?.
id
===
sb
.
id
)
setSelected
(
storyboards
.
find
((
s
)
=>
s
.
id
!==
sb
.
id
)
??
null
);
if
(
selectedStoryboardId
===
storyboard
.
id
)
{
setSelectedStoryboardId
(
""
);
}
};
};
const
handleCopy
=
async
()
=>
{
const
handleCopy
Storyboard
=
async
()
=>
{
if
(
!
selected
)
return
;
if
(
!
selected
Storyboard
)
return
;
const
copy
=
await
createS
b
.
mutateAsync
({
const
copy
=
await
createS
toryboard
.
mutateAsync
({
...
selected
,
...
selected
Storyboard
,
id
:
undefined
,
id
:
undefined
,
sequenceNum
:
storyboards
.
length
+
1
,
sequenceNum
:
storyboards
.
length
+
1
,
sceneNumber
:
`
${
selected
.
sceneNumber
}
-副本`
,
sceneNumber
:
`
${
selected
Storyboard
.
sceneNumber
}
-副本`
,
status
:
"draft"
,
status
:
"draft"
,
}
as
unknown
as
Storyboard
);
}
as
unknown
as
Storyboard
);
setSelected
(
copy
);
setSelectedStoryboardId
(
copy
.
id
);
};
const
handleSavePrompt
=
async
()
=>
{
if
(
!
selected
||
!
promptChanged
)
return
;
await
updateSb
.
mutateAsync
({
id
:
selected
.
id
,
patch
:
{
startFramePrompt
:
promptDraft
}
});
setSelected
({
...
selected
,
startFramePrompt
:
promptDraft
});
setPromptChanged
(
false
);
};
};
const
handleGeneratePrompt
=
async
()
=>
{
const
handleSaveStoryboard
=
async
()
=>
{
if
(
!
selected
)
return
;
if
(
!
selectedDraft
)
return
;
const
generatedPrompt
=
await
generatePrompt
.
mutateAsync
(
selected
.
id
);
await
updateStoryboard
.
mutateAsync
({
setPromptDraft
(
generatedPrompt
);
id
:
selectedDraft
.
id
,
setPromptChanged
(
true
);
patch
:
{
};
...
selectedDraft
,
startFramePrompt
:
promptDraft
,
// 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。
},
const
refImages
=
useMemo
<
Array
<
{
label
:
string
;
name
:
string
;
imageUrl
:
string
|
null
;
imageTosKey
:
string
;
kind
:
"character"
|
"scene"
;
viewLabel
?:
string
;
}
>>
(()
=>
{
const
characterRefImages
=
characters
.
flatMap
((
character
)
=>
{
const
views
=
[
{
viewLabel
:
"正面"
,
imageUrl
:
character
.
frontImageUrl
??
character
.
imageUrl
??
null
,
imageTosKey
:
character
.
frontImageTosKey
??
character
.
imageTosKey
??
null
,
},
{
viewLabel
:
"侧面"
,
imageUrl
:
character
.
sideImageUrl
??
null
,
imageTosKey
:
character
.
sideImageTosKey
??
null
,
},
{
viewLabel
:
"背面"
,
imageUrl
:
character
.
backImageUrl
??
null
,
imageTosKey
:
character
.
backImageTosKey
??
null
,
},
];
return
views
.
filter
((
view
)
=>
!!
view
.
imageTosKey
)
.
map
((
view
)
=>
({
label
:
`
${
character
.
name
}
·
${
view
.
viewLabel
}
`
,
name
:
character
.
name
,
imageUrl
:
view
.
imageUrl
,
imageTosKey
:
view
.
imageTosKey
!
,
kind
:
"character"
as
const
,
viewLabel
:
view
.
viewLabel
,
}));
});
});
const
sceneRefImages
=
scenes
.
filter
((
scene
)
=>
!!
scene
.
imageTosKey
)
.
map
((
scene
)
=>
({
label
:
scene
.
name
,
name
:
scene
.
name
,
imageUrl
:
scene
.
imageUrl
,
imageTosKey
:
scene
.
imageTosKey
!
,
kind
:
"scene"
as
const
,
}));
return
[...
characterRefImages
,
...
sceneRefImages
];
},
[
characters
,
scenes
]);
const
insertMention
=
(
name
:
string
)
=>
{
const
mention
=
`@
${
name
}
`
;
setPromptDraft
((
prev
)
=>
(
prev
?
prev
+
" "
+
mention
:
mention
));
setPromptChanged
(
true
);
};
const
insertRefImage
=
(
idx
:
number
)
=>
{
const
tag
=
`@图
${
idx
+
1
}
`
;
setPromptDraft
((
prev
)
=>
(
prev
?
prev
+
" "
+
tag
:
tag
));
setPromptChanged
(
true
);
};
};
// 从 prompt 解析所有 @图N 引用(按数字顺序去重),返回对应的 imageTosKey 列表
const
handleGeneratePrompt
=
async
()
=>
{
const
parseRefImageKeys
=
(
text
:
string
):
string
[]
=>
{
if
(
!
selectedStoryboard
)
return
;
const
matches
=
[...
text
.
matchAll
(
/@图
(\d
+
)
/g
)];
const
generated
=
await
generatePrompt
.
mutateAsync
(
selectedStoryboard
.
id
);
const
seen
=
new
Set
<
number
>
();
setPromptDraft
(
generated
);
const
keys
:
string
[]
=
[];
for
(
const
m
of
matches
)
{
const
idx
=
parseInt
(
m
[
1
],
10
)
-
1
;
if
(
!
seen
.
has
(
idx
)
&&
refImages
[
idx
]?.
imageTosKey
)
{
seen
.
add
(
idx
);
keys
.
push
(
refImages
[
idx
].
imageTosKey
);
}
}
return
keys
;
};
};
const
handleGenerateVideo
=
async
()
=>
{
const
handleGenerateVideo
=
async
()
=>
{
if
(
!
selected
)
return
;
if
(
!
selected
Draft
||
!
promptDraft
.
trim
()
)
return
;
setGeneratingVideoId
(
selected
.
id
);
setGeneratingVideoId
(
selected
Draft
.
id
);
try
{
try
{
const
patch
:
Record
<
string
,
unknown
>
=
{};
if
(
promptChanged
)
patch
.
startFramePrompt
=
promptDraft
;
// 同步实际生成时长到分镜,确保时长统计准确
if
(
selectedDuration
!==
selected
.
durationSeconds
)
patch
.
durationSeconds
=
selectedDuration
;
if
(
Object
.
keys
(
patch
).
length
>
0
)
{
await
updateSb
.
mutateAsync
({
id
:
selected
.
id
,
patch
});
setSelected
({
...
selected
,
...
patch
});
setPromptChanged
(
false
);
}
const
currentPrompt
=
promptDraft
||
selected
.
startFramePrompt
;
const
imageKeys
=
parseRefImageKeys
(
currentPrompt
);
await
generateVideo
.
mutateAsync
({
await
generateVideo
.
mutateAsync
({
episodeId
:
selected
.
episodeId
,
episodeId
:
eid
,
storyboardId
:
selected
.
id
,
storyboardId
:
selectedDraft
.
id
,
prompt
:
currentPrompt
,
prompt
:
promptDraft
,
videoPrompt
:
selected
.
motionScript
||
currentPrompt
,
videoPrompt
:
promptDraft
,
imageKeys
:
imageKeys
.
length
>
0
?
imageKeys
:
null
,
duration
:
selectedDuration
,
duration
:
selectedDuration
,
});
});
}
finally
{
}
finally
{
...
@@ -282,454 +209,308 @@ export function StoryboardWorkspace() {
...
@@ -282,454 +209,308 @@ export function StoryboardWorkspace() {
}
}
};
};
const
handleBatchGenerate
=
async
()
=>
{
const
updateSelected
=
(
patch
:
Partial
<
Storyboard
>
)
=>
{
setBatchGenerating
(
true
);
if
(
!
selectedDraft
)
return
;
const
pending
=
storyboards
.
filter
((
sb
)
=>
{
setSelectedDraft
((
current
)
=>
(
current
?
{
...
current
,
...
patch
}
:
current
));
const
t
=
getTaskForSb
(
sb
);
return
!
t
||
t
.
status
===
"failed"
;
});
for
(
const
sb
of
pending
)
{
const
batchPrompt
=
sb
.
startFramePrompt
||
sb
.
detailedDescription
;
const
batchImageKeys
=
parseRefImageKeys
(
batchPrompt
);
await
generateVideo
.
mutateAsync
({
episodeId
:
sb
.
episodeId
,
storyboardId
:
sb
.
id
,
prompt
:
batchPrompt
,
videoPrompt
:
sb
.
motionScript
||
batchPrompt
,
imageKeys
:
batchImageKeys
.
length
>
0
?
batchImageKeys
:
null
,
duration
:
sb
.
durationSeconds
>
0
?
sb
.
durationSeconds
:
selectedDuration
,
});
}
setBatchGenerating
(
false
);
};
};
const
selectedTask
=
selected
?
getTaskForSb
(
selected
)
:
null
;
if
(
loadingEpisodes
)
{
return
(
return
(
<
div
className=
"flex h-full items-center justify-center"
>
<
div
className=
"h-full flex overflow-hidden bg-background"
>
<
Loader2
className=
"h-8 w-8 animate-spin text-primary"
/>
{
/* ─── Far Left: Episode list strip ─── */
}
<
div
className=
"flex-shrink-0 border-r border-border bg-muted/20 flex flex-col overflow-hidden"
style=
{
{
width
:
52
}
}
>
<
div
className=
"h-14 border-b border-border flex items-center justify-center flex-shrink-0"
>
<
span
className=
"text-[10px] text-muted-foreground font-medium"
>
集数
</
span
>
</
div
>
<
div
className=
"flex-1 overflow-y-auto py-1"
>
{
episodesLoading
?
(
<
div
className=
"flex items-center justify-center pt-4"
>
<
Loader2
className=
"w-3.5 h-3.5 animate-spin text-muted-foreground"
/>
</
div
>
)
:
episodes
.
length
===
0
?
(
<
div
className=
"px-1 pt-4 text-center"
>
<
span
className=
"text-[9px] text-muted-foreground leading-tight"
>
暂无集数
</
span
>
</
div
>
)
:
(
episodes
.
map
((
ep
)
=>
(
<
button
key=
{
ep
.
id
}
onClick=
{
()
=>
handleEpisodeSelect
(
ep
)
}
title=
{
ep
.
title
||
`第${ep.episodeNumber}集`
}
className=
{
`w-full flex flex-col items-center justify-center py-2 transition-colors ${
activeEpisodeId === ep.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`
}
>
<
span
className=
"text-[11px] font-semibold leading-none"
>
第
</
span
>
<
span
className=
"text-base font-bold leading-tight"
>
{
ep
.
episodeNumber
}
</
span
>
<
span
className=
"text-[11px] font-semibold leading-none"
>
集
</
span
>
</
button
>
))
)
}
</
div
>
</
div
>
</
div
>
);
{
/* ─── Left: storyboard list ─── */
}
}
<
div
className=
"flex flex-col flex-shrink-0 border-r border-border overflow-hidden"
style=
{
{
width
:
240
}
}
>
{
/* Header */
}
if
(
episodes
.
length
===
0
)
{
<
div
className=
"h-14 px-3 border-b border-border flex items-center gap-2 flex-shrink-0"
>
return
(
<
button
<
div
className=
"mx-auto max-w-[1180px]"
>
onClick=
{
handleCreate
}
<
div
className=
"rounded-[36px] border border-white/80 bg-white/82 px-6 py-20 text-center shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
disabled=
{
createSb
.
isPending
||
!
eid
}
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
className=
"flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs font-medium hover:shadow-md transition disabled:opacity-50"
<
Clapperboard
className=
"h-7 w-7"
/>
>
</
div
>
{
createSb
.
isPending
?
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
:
<
Plus
className=
"w-3 h-3"
/>
}
<
h2
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有可用分集
</
h2
>
新建分镜
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
</
button
>
请先完成大纲和分集生成,再进入分镜工作台。
</
p
>
<
button
<
button
onClick=
{
handleGenerate
}
onClick=
{
()
=>
navigate
(
`/project/${projectId}/outline`
)
}
disabled=
{
generateSbs
.
isPending
||
!
eid
}
className=
"mt-6 inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
title=
"AI批量生成分镜"
className=
"p-1.5 rounded-lg border border-border hover:bg-muted transition text-muted-foreground disabled:opacity-50"
>
>
{
generateSbs
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Wand2
className=
"w-4 h-4"
/>
}
返回大纲页
<
ArrowRight
className=
"h-4 w-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
);
}
{
/* List */
}
return
(
<
div
className=
"flex-1 overflow-y-auto py-1"
>
<
div
className=
"mx-auto max-w-[1480px]"
>
{
!
eid
?
(
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"px-3 py-8 text-center"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
p
className=
"text-xs text-muted-foreground"
>
请从左侧选择集数
</
p
>
<
div
>
</
div
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
)
:
isLoading
?
(
<
Layers
className=
"h-3.5 w-3.5"
/>
<
div
className=
"flex items-center justify-center py-10"
>
Storyboard Workspace
<
Loader2
className=
"w-5 h-5 animate-spin text-muted-foreground"
/>
</
div
>
)
:
storyboards
.
length
===
0
?
(
<
div
className=
"px-3 py-8 text-center"
>
<
p
className=
"text-xs text-muted-foreground"
>
点击「AI生成」或「新建分镜」开始
</
p
>
</
div
>
</
div
>
)
:
(
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
分镜工作台
</
h1
>
storyboards
.
map
((
sb
,
i
)
=>
(
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
<
div
选择分集后统一管理镜头描述、角色与场景线索、提示词和视频生成入口。
key=
{
sb
.
id
}
</
p
>
onClick=
{
()
=>
setSelected
(
sb
)
}
</
div
>
className=
{
`mx-2 my-0.5 rounded-lg px-3 py-2.5 cursor-pointer transition-colors border ${
selected?.id === sb.id
<
div
className=
"flex flex-wrap gap-3"
>
? "border-primary bg-accent"
<
button
: "border-transparent hover:bg-muted"
onClick=
{
handleGenerateStoryboards
}
}`
}
disabled=
{
!
eid
||
generateStoryboards
.
isPending
}
>
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
<
div
className=
"flex items-center justify-between mb-1"
>
>
<
span
className=
"text-xs font-semibold text-foreground"
>
{
generateStoryboards
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4"
/>
}
分镜
{
String
(
sb
.
sequenceNum
).
padStart
(
3
,
"0"
)
}
AI 生成分镜
</
span
>
</
button
>
<
span
className=
{
`w-2 h-2 rounded-full flex-shrink-0 ${getDotColor(sb)}`
}
/>
<
button
</
div
>
onClick=
{
handleCreateStoryboard
}
<
p
className=
"text-xs text-muted-foreground line-clamp-2 leading-relaxed"
>
disabled=
{
!
eid
}
{
sb
.
shortDescription
||
sb
.
sceneNumber
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
</
p
>
>
</
div
>
<
Plus
className=
"h-4 w-4"
/>
))
新建分镜
)
}
</
button
>
</
div
>
</
div
>
</
div
>
{
/* Bottom: batch generate */
}
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
<
div
className=
"p-3 border-t border-border flex-shrink-0"
>
{
episodes
.
map
((
episode
)
=>
(
<
button
<
button
onClick=
{
handleBatchGenerate
}
key=
{
episode
.
id
}
disabled=
{
batchGenerating
||
storyboards
.
length
===
0
}
onClick=
{
()
=>
{
className=
"w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-green-600 text-white text-xs font-medium hover:bg-green-700 transition disabled:opacity-50"
setActiveEpisodeId
(
episode
.
id
);
>
setSelectedStoryboardId
(
""
);
{
batchGenerating
?
<
Loader2
className=
"w-3.5 h-3.5 animate-spin"
/>
:
<
Play
className=
"w-3.5 h-3.5"
/>
}
navigate
(
`/project/${pid}/storyboard/${episode.id}`
,
{
replace
:
true
});
批量生成视频
}
}
</
button
>
className=
{
[
"rounded-2xl border px-4 py-3 text-sm transition-all"
,
activeEpisodeId
===
episode
.
id
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
].
join
(
" "
)
}
>
第
{
episode
.
episodeNumber
}
集
</
button
>
))
}
</
div
>
</
div
>
</
div
>
{
/* ─── Middle: storyboard detail + prompt edit ─── */
}
<
div
className=
"mt-6 grid gap-6 xl:grid-cols-[420px_1fr]"
>
<
div
className=
"flex flex-col border-r border-border overflow-hidden"
style=
{
{
width
:
400
}
}
>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-5 shadow-sm"
>
{
!
selected
?
(
<
div
className=
"mb-4 flex items-center justify-between"
>
<
div
className=
"flex-1 flex items-center justify-center"
>
<
p
className=
"text-sm text-muted-foreground"
>
选择左侧分镜查看详情
</
p
>
</
div
>
)
:
(
<>
{
/* Header */
}
<
div
className=
"h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0"
>
<
div
>
<
div
>
<
h2
className=
"text-base font-semibold text-foreground"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Storyboard List
</
div
>
分镜
{
String
(
selected
.
sequenceNum
).
padStart
(
3
,
"0"
)
}
<
div
className=
"mt-2 text-xl font-semibold text-foreground"
>
{
storyboards
.
length
}
个镜头
</
div
>
</
h2
>
<
p
className=
"text-xs text-muted-foreground"
>
编辑分镜详细信息
</
p
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
button
onClick=
{
handleCopy
}
disabled=
{
createSb
.
isPending
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition"
>
<
Copy
className=
"w-3.5 h-3.5"
/>
复制
</
button
>
<
button
onClick=
{
()
=>
handleDelete
(
selected
)
}
disabled=
{
deleteSb
.
isPending
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-200 text-xs text-destructive hover:bg-destructive/10 transition"
>
<
Trash2
className=
"w-3.5 h-3.5"
/>
删除
</
button
>
</
div
>
</
div
>
{
loadingStoryboards
?
<
Loader2
className=
"h-5 w-5 animate-spin text-primary"
/>
:
null
}
</
div
>
</
div
>
{
/* Prompt edit area */
}
<
div
className=
"space-y-3"
>
<
div
className=
"flex-1 overflow-y-auto px-5 py-4"
>
{
storyboards
.
length
===
0
?
(
<
div
className=
"flex items-center justify-between mb-2"
>
<
div
className=
"rounded-[24px] border border-dashed border-border bg-white px-4 py-12 text-center text-sm text-muted-foreground"
>
<
label
className=
"text-xs font-medium text-muted-foreground"
>
分镜描述
</
label
>
当前分集还没有分镜,可以先通过 AI 自动生成,或手动新建一个镜头。
<
div
className=
"flex items-center gap-2"
>
</
div
>
<
button
)
:
(
onClick=
{
handleGeneratePrompt
}
storyboards
.
map
((
storyboard
)
=>
{
disabled=
{
generatePrompt
.
isPending
}
const
task
=
taskMap
.
get
(
storyboard
.
id
);
className=
"text-xs text-violet-600 dark:text-violet-400 flex items-center gap-1 hover:underline disabled:opacity-50"
return
(
title=
"根据分镜剧本自动生成描述"
>
{
generatePrompt
.
isPending
?
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
:
<
Sparkles
className=
"w-3 h-3"
/>
}
AI生成描述
</
button
>
{
promptChanged
&&
(
<
button
<
button
onClick=
{
handleSavePrompt
}
key=
{
storyboard
.
id
}
disabled=
{
updateSb
.
isPending
}
onClick=
{
()
=>
setSelectedStoryboardId
(
storyboard
.
id
)
}
className=
"text-xs text-primary flex items-center gap-1 hover:underline"
className=
{
[
"w-full rounded-[24px] border px-4 py-4 text-left transition-all"
,
selectedStoryboardId
===
storyboard
.
id
?
"border-primary/20 bg-primary/5 shadow-[0_16px_28px_rgba(15,116,216,0.10)]"
:
"border-border/80 bg-white shadow-sm hover:border-primary/20"
,
].
join
(
" "
)
}
>
>
{
updateSb
.
isPending
?
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
:
<
Check
className=
"w-3 h-3"
/>
}
<
div
className=
"flex items-start justify-between gap-3"
>
保存
<
div
>
<
div
className=
"text-sm font-medium text-foreground"
>
#
{
storyboard
.
sequenceNum
}
{
storyboard
.
shortDescription
||
"未命名镜头"
}
</
div
>
<
div
className=
"mt-2 text-xs text-muted-foreground"
>
{
storyboard
.
sceneNumber
||
"未设置场景号"
}
</
div
>
</
div
>
<
span
className=
{
`rounded-full px-3 py-1 text-xs ${getTaskStatusClass(task?.status)}`
}
>
{
getTaskStatusLabel
(
task
?.
status
)
}
</
span
>
</
div
>
<
div
className=
"mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground"
>
{
storyboard
.
detailedDescription
||
"待补充镜头描述。"
}
</
div
>
</
button
>
</
button
>
)
}
);
</
div
>
})
</
div
>
<
textarea
className=
"w-full h-full min-h-[320px] px-3 py-3 rounded-lg border border-border bg-background text-sm text-foreground leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
value=
{
promptDraft
}
onChange=
{
(
e
)
=>
{
setPromptDraft
(
e
.
target
.
value
);
setPromptChanged
(
e
.
target
.
value
!==
(
selected
.
startFramePrompt
||
selected
.
detailedDescription
||
""
));
}
}
placeholder=
"在此输入或编辑分镜描述/提示词,AI 将根据此内容生成视频..."
/>
{
/* shortDescription */
}
{
selected
.
shortDescription
&&
(
<
p
className=
"mt-3 text-xs text-muted-foreground border-l-2 border-primary/30 pl-2"
>
{
selected
.
shortDescription
}
</
p
>
)
}
)
}
</
div
>
</
div
>
{
/* @提及:角色/场景名 */
}
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
{
(
characters
.
length
>
0
||
scenes
.
length
>
0
)
&&
(
{
selectedDraft
?
(
<
div
className=
"mt-3
"
>
<
div
className=
"space-y-5
"
>
<
div
className=
"text-[11px] text-muted-foreground mb-1.5"
>
@ 提及角色/场景
</
div
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
div
className=
"flex flex-wrap gap-1.5"
>
<
div
>
{
characters
.
map
((
c
)
=>
(
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Selected Shot
</
div
>
<
button
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
key=
{
c
.
id
}
#
{
selectedDraft
.
sequenceNum
}
{
selectedDraft
.
shortDescription
||
"未命名镜头"
}
onClick=
{
()
=>
insertMention
(
c
.
name
)
}
</
h2
>
className=
"flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700 text-[11px] text-violet-700 dark:text-violet-300 hover:bg-violet-100 transition"
</
div
>
>
<
div
className=
"flex flex-wrap gap-2"
>
{
getCharacterPrimaryImage
(
c
)
&&
<
img
src=
{
getCharacterPrimaryImage
(
c
)
??
""
}
className=
"w-3.5 h-3.5 rounded-full object-cover"
/>
}
<
button
@
{
c
.
name
}
onClick=
{
handleCopyStoryboard
}
</
button
>
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
))
}
>
{
scenes
.
map
((
s
)
=>
(
<
Copy
className=
"h-4 w-4"
/>
<
button
复制镜头
key=
{
s
.
id
}
</
button
>
onClick=
{
()
=>
insertMention
(
s
.
name
)
}
<
button
className=
"flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-700 text-[11px] text-sky-700 dark:text-sky-300 hover:bg-sky-100 transition"
onClick=
{
()
=>
handleDeleteStoryboard
(
selectedDraft
)
}
>
className=
"inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
{
s
.
imageUrl
&&
<
img
src=
{
s
.
imageUrl
}
className=
"w-3.5 h-3.5 rounded object-cover"
/>
}
>
@
{
s
.
name
}
<
Trash2
className=
"h-4 w-4"
/>
</
button
>
删除
))
}
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
{
/* @图N:参考图面板 */
}
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
refImages
.
length
>
0
&&
(
<
div
>
<
div
className=
"mt-3"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
镜头标题
</
label
>
<
div
className=
"text-[11px] text-muted-foreground mb-1.5"
>
<
input
@图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。
value=
{
selectedDraft
.
shortDescription
||
""
}
onChange=
{
(
e
)
=>
updateSelected
({
shortDescription
:
e
.
target
.
value
})
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
/>
</
div
>
</
div
>
<
div
className=
"flex gap-2 flex-wrap"
>
<
div
>
{
refImages
.
map
((
img
,
idx
)
=>
(
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
场景编号
</
label
>
<
button
<
input
key=
{
img
.
imageTosKey
}
value=
{
selectedDraft
.
sceneNumber
||
""
}
onClick=
{
()
=>
insertRefImage
(
idx
)
}
onChange=
{
(
e
)
=>
updateSelected
({
sceneNumber
:
e
.
target
.
value
})
}
className=
"flex flex-col items-center gap-0.5 group"
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
title=
{
`点击插入 @图${idx + 1}(${img.label})`
}
/>
>
<
div
className=
"w-12 h-12 rounded-lg overflow-hidden border border-border group-hover:border-primary transition bg-muted"
>
{
img
.
imageUrl
?
(
<
img
src=
{
img
.
imageUrl
}
alt=
{
img
.
label
}
className=
"w-full h-full object-cover"
/>
)
:
(
<
div
className=
"w-full h-full flex items-center justify-center text-muted-foreground text-[10px]"
>
无图
</
div
>
)
}
</
div
>
<
span
className=
"text-[10px] text-primary font-medium"
>
@图
{
idx
+
1
}
</
span
>
<
span
className=
"text-[9px] text-muted-foreground leading-none max-w-[56px] truncate"
>
{
img
.
label
}
</
span
>
<
span
className=
{
`text-[9px] leading-none ${
img.kind === "character" ? "text-violet-600" : "text-sky-600"
}`
}
>
{
img
.
kind
===
"character"
?
img
.
viewLabel
:
"场景"
}
</
span
>
</
button
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
{
/* Bottom: model + duration + generate */
}
<
div
>
<
div
className=
"px-4 py-3 border-t border-border flex items-center gap-2 flex-shrink-0 bg-card"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
详细描述
</
label
>
{
/* Model selector */
}
<
textarea
<
div
className=
"relative flex-1"
>
value=
{
selectedDraft
.
detailedDescription
||
""
}
<
select
onChange=
{
(
e
)
=>
updateSelected
({
detailedDescription
:
e
.
target
.
value
})
}
value=
{
selectedModel
}
className=
"min-h-28 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
onChange=
{
(
e
)
=>
setSelectedModel
(
e
.
target
.
value
)
}
/>
className=
"w-full appearance-none pl-3 pr-7 py-2 rounded-lg border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
</
div
>
>
{
VIDEO_MODELS
.
map
((
m
)
=>
(
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>
))
}
</
select
>
<
ChevronDown
className=
"absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none"
/>
</
div
>
{
/* Duration */
}
<
div
className=
"relative"
>
<
select
value=
{
selectedDuration
}
onChange=
{
(
e
)
=>
setSelectedDuration
(
Number
(
e
.
target
.
value
))
}
className=
"appearance-none pl-2 pr-6 py-2 rounded-lg border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer w-16"
>
{
DURATION_OPTIONS
.
map
((
d
)
=>
(
<
option
key=
{
d
}
value=
{
d
}
>
{
d
}
</
option
>
))
}
</
select
>
<
span
className=
"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none"
>
s
</
span
>
</
div
>
{
/* Generate video */
}
<
button
onClick=
{
handleGenerateVideo
}
disabled=
{
generatingVideoId
===
selected
.
id
||
!
promptDraft
}
className=
"flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs font-medium hover:shadow-md transition disabled:opacity-50 whitespace-nowrap"
>
{
generatingVideoId
===
selected
.
id
?
(
<
Loader2
className=
"w-3.5 h-3.5 animate-spin"
/>
)
:
(
<
Play
className=
"w-3.5 h-3.5"
/>
)
}
生成视频 (
{
CREDITS_PER_VIDEO
.
toLocaleString
()
}
积分)
</
button
>
</
div
>
</>
)
}
</
div
>
{
/* ─── Right: video preview ─── */
}
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
className=
"flex-1 flex flex-col overflow-hidden min-w-0"
>
<
div
>
<
div
className=
"h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
角色线索
</
label
>
<
span
className=
"text-sm font-medium text-foreground"
>
视频预览
</
span
>
<
textarea
{
/* Assembly controls */
}
value=
{
selectedDraft
.
characters
||
""
}
<
div
className=
"flex items-center gap-2"
>
onChange=
{
(
e
)
=>
updateSelected
({
characters
:
e
.
target
.
value
})
}
{
assemblyTask
?.
status
===
"succeeded"
&&
assemblyTask
.
downloadUrl
&&
(
className=
"min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
<
a
placeholder=
{
characters
.
map
((
character
)
=>
character
.
name
).
join
(
"、"
)
||
"可填写角色名称或动作描述"
}
href=
{
assemblyTask
.
downloadUrl
}
/>
target=
"_blank"
</
div
>
rel=
"noreferrer"
<
div
>
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition"
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
场景线索
</
label
>
>
<
textarea
<
Download
className=
"w-3.5 h-3.5"
/>
value=
{
selectedDraft
.
notes
||
""
}
下载合集
onChange=
{
(
e
)
=>
updateSelected
({
notes
:
e
.
target
.
value
})
}
</
a
>
className=
"min-h-24 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
)
}
placeholder=
{
scenes
.
map
((
scene
)
=>
scene
.
name
).
join
(
"、"
)
||
"可填写场景、机位或备注信息"
}
<
button
/>
onClick=
{
()
=>
startAssembly
.
mutate
()
}
</
div
>
disabled=
{
startAssembly
.
isPending
||
assemblyTask
?.
status
===
"running"
||
assemblyTask
?.
status
===
"pending"
||
storyboards
.
length
===
0
}
</
div
>
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-xs text-foreground hover:bg-muted transition disabled:opacity-50"
>
{
startAssembly
.
isPending
||
assemblyTask
?.
status
===
"running"
?
(
<
Loader2
className=
"w-3.5 h-3.5 animate-spin"
/>
)
:
(
<
Layers
className=
"w-3.5 h-3.5"
/>
)
}
合成本集
</
button
>
<
button
onClick=
{
()
=>
navigate
(
`/project/${projectId}/video`
)
}
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-xs text-foreground hover:bg-muted transition"
>
视频管理
<
ArrowRight
className=
"w-3.5 h-3.5"
/>
</
button
>
</
div
>
</
div
>
<
div
className=
"flex-1 overflow-y-auto p-5"
>
<
div
className=
"rounded-[28px] border border-primary/10 bg-primary/5 p-5"
>
{
!
selected
?
(
<
div
className=
"mb-3 flex items-center justify-between gap-4"
>
<
div
className=
"h-full flex items-center justify-center"
>
<
div
>
<
p
className=
"text-sm text-muted-foreground"
>
选择分镜后查看视频
</
p
>
<
div
className=
"text-sm font-medium text-foreground"
>
视频提示词
</
div
>
</
div
>
<
div
className=
"mt-1 text-sm text-muted-foreground"
>
可手动编辑,也可基于当前分镜内容自动生成。
</
div
>
)
:
selectedTask
?.
status
===
"succeeded"
&&
selectedTask
.
resultVideoUrl
?
(
/* Video ready */
<
div
className=
"space-y-4"
>
<
div
className=
"rounded-xl overflow-hidden bg-black aspect-video"
>
<
video
key=
{
selectedTask
.
resultVideoUrl
}
src=
{
selectedTask
.
resultVideoUrl
}
controls
autoPlay=
{
false
}
className=
"w-full h-full object-contain"
/>
</
div
>
{
/* Thumbnail strip */
}
<
div
className=
"flex gap-2 overflow-x-auto pb-1"
>
{
Array
.
from
({
length
:
Math
.
min
(
Math
.
ceil
(
selectedDuration
/
4
),
6
)
}).
map
((
_
,
i
)
=>
(
<
div
key=
{
i
}
className=
"flex-shrink-0 w-20 rounded-lg bg-muted overflow-hidden relative"
style=
{
{
aspectRatio
:
"16/9"
}
}
>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
<
span
className=
"text-[10px] text-muted-foreground"
>
{
(
i
+
1
)
*
Math
.
floor
(
selectedDuration
/
Math
.
min
(
Math
.
ceil
(
selectedDuration
/
4
),
6
))
}
s
</
span
>
</
div
>
</
div
>
<
button
onClick=
{
handleGeneratePrompt
}
disabled=
{
generatePrompt
.
isPending
}
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{
generatePrompt
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Wand2
className=
"h-4 w-4"
/>
}
自动生成提示词
</
button
>
</
div
>
</
div
>
))
}
<
textarea
</
div
>
value=
{
promptDraft
}
<
a
onChange=
{
(
e
)
=>
setPromptDraft
(
e
.
target
.
value
)
}
href=
{
selectedTask
.
resultVideoUrl
}
className=
"min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
target=
"_blank"
placeholder=
"在这里填写或编辑用于视频生成的提示词。"
rel=
"noreferrer"
/>
className=
"inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
</
div
>
>
<
Download
className=
"w-3.5 h-3.5"
/>
<
div
className=
"grid gap-4 md:grid-cols-[1fr_160px_160px]"
>
下载此镜头视频
<
div
>
</
a
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
视频模型
</
label
>
</
div
>
<
select
)
:
selectedTask
?.
status
===
"running"
||
selectedTask
?.
status
===
"submitted"
||
selectedTask
?.
status
===
"pending"
?
(
value=
{
selectedModel
}
/* Generating */
onChange=
{
(
e
)
=>
setSelectedModel
(
e
.
target
.
value
)
}
<
div
className=
"h-full flex flex-col items-center justify-center gap-4"
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
<
div
className=
"w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center"
>
>
<
Loader2
className=
"w-8 h-8 text-yellow-600 animate-spin"
/>
{
videoModels
.
map
((
model
)
=>
(
</
div
>
<
option
key=
{
model
.
id
}
value=
{
model
.
id
}
>
<
div
className=
"text-center"
>
{
model
.
label
}
<
p
className=
"text-sm font-medium text-foreground mb-1"
>
视频生成中...
</
p
>
</
option
>
<
p
className=
"text-xs text-muted-foreground"
>
通常需要 30-60 秒,请稍候
</
p
>
))
}
</
div
>
</
select
>
</
div
>
</
div
>
)
:
selectedTask
?.
status
===
"failed"
?
(
<
div
>
/* Failed */
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
时长
</
label
>
<
div
className=
"rounded-xl border border-red-200 bg-red-50 p-6"
>
<
select
<
div
className=
"flex items-center gap-2 mb-2"
>
value=
{
selectedDuration
}
<
X
className=
"w-4 h-4 text-red-500"
/>
onChange=
{
(
e
)
=>
setSelectedDuration
(
Number
(
e
.
target
.
value
))
}
<
span
className=
"text-sm font-medium text-red-700"
>
生成失败
</
span
>
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{
durationOptions
.
map
((
duration
)
=>
(
<
option
key=
{
duration
}
value=
{
duration
}
>
{
duration
}
秒
</
option
>
))
}
</
select
>
</
div
>
<
div
className=
"flex items-end"
>
<
button
onClick=
{
handleSaveStoryboard
}
disabled=
{
updateStoryboard
.
isPending
}
className=
"inline-flex h-12 w-full items-center justify-center gap-2 rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{
updateStoryboard
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4"
/>
}
保存分镜
</
button
>
</
div
>
</
div
>
<
button
onClick=
{
handleGenerateVideo
}
disabled=
{
!
promptDraft
.
trim
()
||
generatingVideoId
===
selectedDraft
.
id
}
className=
"inline-flex h-14 w-full items-center justify-center gap-2 rounded-[22px] bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 text-base font-medium text-white shadow-[0_18px_34px_rgba(15,116,216,0.20)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
generatingVideoId
===
selectedDraft
.
id
?
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
:
<
Sparkles
className=
"h-5 w-5"
/>
}
生成该分镜视频
</
button
>
</
div
>
</
div
>
<
p
className=
"text-xs text-red-600"
>
{
selectedTask
.
errorMessage
||
"未知错误"
}
</
p
>
)
:
(
<
button
<
div
className=
"rounded-[24px] border border-dashed border-border bg-white px-4 py-16 text-center text-sm text-muted-foreground"
>
onClick=
{
handleGenerateVideo
}
请选择左侧分镜卡片,或先生成当前分集的镜头列表。
className=
"mt-3 text-xs text-red-600 hover:underline"
>
重新生成
</
button
>
</
div
>
)
:
(
/* No video yet */
<
div
className=
"h-full flex flex-col items-center justify-center gap-4"
>
<
div
className=
"w-full max-w-sm aspect-video rounded-xl border-2 border-dashed border-border bg-muted/30 flex flex-col items-center justify-center gap-3"
>
<
Play
className=
"w-12 h-12 text-muted-foreground/30"
/>
<
p
className=
"text-sm text-muted-foreground"
>
尚未生成视频
</
p
>
<
p
className=
"text-xs text-muted-foreground/70"
>
编辑左侧提示词后点击「生成视频」
</
p
>
</
div
>
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/StyleSelection.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
ArrowRight
,
Check
,
Loader2
}
from
"lucide-react"
;
import
{
ArrowRight
,
Check
,
Loader2
}
from
"lucide-react"
;
import
{
useUpdateProject
}
from
"../../hooks/useProjects"
;
import
{
useUpdateProject
}
from
"../../hooks/useProjects"
;
...
@@ -20,10 +20,9 @@ export function StyleSelection() {
...
@@ -20,10 +20,9 @@ export function StyleSelection() {
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"mx-auto max-w-6xl"
>
<
div
className=
"mx-auto max-w-6xl"
>
<
div
className=
"mb-8 text-center"
>
<
div
className=
"mb-8 text-center"
>
<
h1
className=
"mb-2 text-3xl font-semibold text-foreground"
>
选择项目视觉风格
</
h1
>
<
h1
className=
"mb-2 text-3xl font-semibold text-foreground"
>
閫夋嫨椤圭洰瑙嗚椋庢牸
</
h1
>
<
p
className=
"text-sm text-muted-foreground"
>
<
p
className=
"text-sm text-muted-foreground"
>
这里选择的是视觉呈现方式,不是题材类型。AI 会根据所选风格影响人物、场景与后续图像生成。
杩欓噷閫夋嫨鐨勬槸瑙嗚鍛堢幇鏂瑰紡锛屼笉鏄鏉愮被鍨嬨€侫I 浼氭牴鎹墍閫夐鏍煎奖鍝嶄汉鐗┿€佸満鏅笌鍚庣画鍥惧儚鐢熸垚銆?
</
p
>
</
p
>
</
div
>
</
div
>
<
div
className=
"mb-8 grid grid-cols-3 gap-4"
>
<
div
className=
"mb-8 grid grid-cols-3 gap-4"
>
...
@@ -61,13 +60,13 @@ export function StyleSelection() {
...
@@ -61,13 +60,13 @@ export function StyleSelection() {
<
button
<
button
onClick=
{
handleContinue
}
onClick=
{
handleContinue
}
disabled=
{
!
selectedStyle
||
updateProject
.
isPending
}
disabled=
{
!
selectedStyle
||
updateProject
.
isPending
}
className=
"flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#
5b5ff9] to-[#8b5ff9
] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
className=
"flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#
0f74d8] to-[#1ea5ff
] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
>
>
{
updateProject
.
isPending
?
(
{
updateProject
.
isPending
?
(
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
)
:
(
)
:
(
<>
<>
<
span
>
继续
</
span
>
<
span
>
缁х画
</
span
>
<
ArrowRight
className=
"h-5 w-5"
/>
<
ArrowRight
className=
"h-5 w-5"
/>
</>
</>
)
}
)
}
...
@@ -76,3 +75,4 @@ export function StyleSelection() {
...
@@ -76,3 +75,4 @@ export function StyleSelection() {
</
div
>
</
div
>
);
);
}
}
doc/html/src/app/pages/TeamManagement.tsx
View file @
75992d7b
import
{
useState
}
from
"react"
;
import
{
use
Memo
,
use
State
}
from
"react"
;
import
{
import
{
Users
,
FolderKanban
,
UserPlus
,
Loader2
,
Mail
,
Pencil
,
Plus
,
Search
,
Search
,
Edit2
,
Trash2
,
Shield
,
Shield
,
Check
,
Trash2
,
Loader2
,
Users
,
X
,
X
,
UserMinus
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
import
{
useTeamMembers
,
useAddMember
,
useAddMember
,
useUpdateMember
,
useCreateGroup
,
useDeleteGroup
,
useRemoveMember
,
useRemoveMember
,
useTeamGroups
,
useTeamGroups
,
use
CreateGroup
,
use
TeamMembers
,
useUpdateGroup
,
useUpdateGroup
,
useDeleteGroup
,
useUpdateMember
,
useAddGroupMember
,
useRemoveGroupMember
,
}
from
"../../hooks/useTeam"
;
}
from
"../../hooks/useTeam"
;
import
type
{
TeamMember
,
TeamGroup
}
from
"../../lib/api/team"
;
type
TabType
=
"employees"
|
"groups"
|
"permissions"
;
const
roleLabels
:
Record
<
string
,
string
>
=
{
const
roleLabels
:
Record
<
string
,
string
>
=
{
owner
:
"所有者"
,
owner
:
"所有者"
,
...
@@ -34,488 +29,450 @@ const roleLabels: Record<string, string> = {
...
@@ -34,488 +29,450 @@ const roleLabels: Record<string, string> = {
};
};
export
function
TeamManagement
()
{
export
function
TeamManagement
()
{
const
[
activeTab
,
setActiveTab
]
=
useState
<
TabType
>
(
"employees"
);
const
[
searchTerm
,
setSearchTerm
]
=
useState
(
""
);
// ---- add member modal ----
const
[
showAddMemberModal
,
setShowAddMemberModal
]
=
useState
(
false
);
const
[
formUsername
,
setFormUsername
]
=
useState
(
""
);
const
[
formEmail
,
setFormEmail
]
=
useState
(
""
);
const
[
formPassword
,
setFormPassword
]
=
useState
(
""
);
const
[
formRole
,
setFormRole
]
=
useState
(
"member"
);
const
[
formPosition
,
setFormPosition
]
=
useState
(
""
);
// ---- edit member modal ----
const
[
editingMember
,
setEditingMember
]
=
useState
<
TeamMember
|
null
>
(
null
);
const
[
editRole
,
setEditRole
]
=
useState
(
""
);
const
[
editPosition
,
setEditPosition
]
=
useState
(
""
);
// ---- add group modal ----
const
[
showAddGroupModal
,
setShowAddGroupModal
]
=
useState
(
false
);
const
[
formGroupName
,
setFormGroupName
]
=
useState
(
""
);
const
[
formGroupDesc
,
setFormGroupDesc
]
=
useState
(
""
);
// ---- edit group modal ----
const
[
editingGroup
,
setEditingGroup
]
=
useState
<
TeamGroup
|
null
>
(
null
);
const
[
editGroupName
,
setEditGroupName
]
=
useState
(
""
);
const
[
editGroupDesc
,
setEditGroupDesc
]
=
useState
(
""
);
// ---- manage group members modal ----
const
[
managingGroupId
,
setManagingGroupId
]
=
useState
<
number
|
null
>
(
null
);
const
{
data
:
members
=
[],
isLoading
:
loadingMembers
}
=
useTeamMembers
();
const
{
data
:
members
=
[],
isLoading
:
loadingMembers
}
=
useTeamMembers
();
const
{
data
:
groups
=
[],
isLoading
:
loadingGroups
}
=
useTeamGroups
();
const
{
data
:
groups
=
[],
isLoading
:
loadingGroups
}
=
useTeamGroups
();
const
addMember
=
useAddMember
();
const
addMember
=
useAddMember
();
const
updateMember
=
useUpdateMember
();
const
updateMember
=
useUpdateMember
();
const
removeMember
=
useRemoveMember
();
const
removeMember
=
useRemoveMember
();
const
createGroup
=
useCreateGroup
();
const
createGroup
=
useCreateGroup
();
const
updateGroup
=
useUpdateGroup
();
const
updateGroup
=
useUpdateGroup
();
const
deleteGroup
=
useDeleteGroup
();
const
deleteGroup
=
useDeleteGroup
();
const
addGroupMember
=
useAddGroupMember
();
const
removeGroupMember
=
useRemoveGroupMember
();
const
tabs
=
[
{
id
:
"employees"
as
TabType
,
label
:
"员工管理"
,
icon
:
Users
},
{
id
:
"groups"
as
TabType
,
label
:
"小组管理"
,
icon
:
Users
},
{
id
:
"permissions"
as
TabType
,
label
:
"权限说明"
,
icon
:
Shield
},
];
const
filteredMembers
=
members
.
filter
(
(
m
)
=>
m
.
username
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
())
||
m
.
email
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
())
);
const
filteredGroups
=
groups
.
filter
((
g
)
=>
const
[
searchTerm
,
setSearchTerm
]
=
useState
(
""
);
g
.
name
.
toLowerCase
().
includes
(
searchTerm
.
toLowerCase
())
const
[
showMemberModal
,
setShowMemberModal
]
=
useState
(
false
);
);
const
[
showGroupModal
,
setShowGroupModal
]
=
useState
(
false
);
const
[
editingMemberId
,
setEditingMemberId
]
=
useState
<
number
|
null
>
(
null
);
const
[
editingGroupId
,
setEditingGroupId
]
=
useState
<
number
|
null
>
(
null
);
const
[
memberForm
,
setMemberForm
]
=
useState
({
username
:
""
,
email
:
""
,
password
:
""
,
role
:
"member"
,
position
:
""
,
});
const
[
groupForm
,
setGroupForm
]
=
useState
({
name
:
""
,
description
:
""
,
});
const
filteredMembers
=
useMemo
(()
=>
{
return
members
.
filter
((
member
)
=>
{
if
(
!
searchTerm
)
return
true
;
const
target
=
`
${
member
.
username
}
${
member
.
email
}
${
member
.
position
??
""
}
`.toLowerCase();
return target.includes(searchTerm.toLowerCase());
});
}, [members, searchTerm]);
const openCreateMember = () => {
setEditingMemberId(null);
setMemberForm({ username: "", email: "", password: "", role: "member", position: "" });
setShowMemberModal(true);
};
const
memberById
=
Object
.
fromEntries
(
members
.
map
((
m
)
=>
[
m
.
userId
,
m
]));
const openEditMember = (member: (typeof members)[number]) => {
const
managingGroup
=
managingGroupId
!==
null
?
groups
.
find
((
g
)
=>
g
.
id
===
managingGroupId
)
??
null
:
null
;
setEditingMemberId(member.userId);
setMemberForm({
username: member.username,
email: member.email,
password: "",
role: member.role,
position: member.position ?? "",
});
setShowMemberModal(true);
};
// handlers
const openCreateGroup = () => {
const
handleAddMember
=
async
()
=>
{
setEditingGroupId(null);
await
addMember
.
mutateAsync
({
username
:
formUsername
,
email
:
formEmail
,
password
:
formPassword
,
role
:
formRole
,
position
:
formPosition
});
setGroupForm({ name: "", description: "" });
setFormUsername
(
""
);
setFormEmail
(
""
);
setFormPassword
(
""
);
setFormRole
(
"member"
);
setFormPosition
(
""
);
setShowGroupModal(true);
setShowAddMemberModal
(
false
);
};
};
const
openEditMember
=
(
m
:
TeamMember
)
=>
{
const openEditGroup = (group: (typeof groups)[number]) => {
setEditingMember
(
m
);
setEditingGroupId(group.id);
setEditRole
(
m
.
role
);
setGroupForm({
setEditPosition
(
m
.
position
??
""
);
name: group.name,
description: group.description ?? "",
});
setShowGroupModal(true);
};
};
const
handleEditMember
=
async
()
=>
{
const handleSaveMember = async () => {
if
(
!
editingMember
)
return
;
if (!memberForm.username.trim() || !memberForm.email.trim()) return;
await
updateMember
.
mutateAsync
({
userId
:
editingMember
.
userId
,
patch
:
{
role
:
editRole
,
position
:
editPosition
}
});
setEditingMember
(
null
);
if (editingMemberId) {
await updateMember.mutateAsync({
userId: editingMemberId,
patch: { role: memberForm.role, position: memberForm.position },
});
} else {
await addMember.mutateAsync(memberForm);
}
setShowMemberModal(false);
};
};
const
handleAddGroup
=
async
()
=>
{
const handleRemoveMember = async (userId: number) => {
await
createGroup
.
mutateAsync
({
name
:
formGroupName
,
description
:
formGroupDesc
});
if (!confirm("确定要移除该成员吗?")) return;
setFormGroupName
(
""
);
setFormGroupDesc
(
""
);
await removeMember.mutateAsync(userId);
setShowAddGroupModal
(
false
);
};
};
const
openEditGroup
=
(
g
:
TeamGroup
)
=>
{
const handleSaveGroup = async () => {
setEditingGroup
(
g
);
if (!groupForm.name.trim()) return;
setEditGroupName
(
g
.
name
);
setEditGroupDesc
(
g
.
description
??
""
);
if (editingGroupId) {
await updateGroup.mutateAsync({
groupId: editingGroupId,
patch: groupForm,
});
} else {
await createGroup.mutateAsync(groupForm);
}
setShowGroupModal(false);
};
};
const
handleEditGroup
=
async
()
=>
{
const handleDeleteGroup = async (groupId: number) => {
if
(
!
editingGroup
)
return
;
if (!confirm("确定要删除该小组吗?")) return;
await
updateGroup
.
mutateAsync
({
groupId
:
editingGroup
.
id
,
patch
:
{
name
:
editGroupName
,
description
:
editGroupDesc
}
});
await deleteGroup.mutateAsync(groupId);
setEditingGroup
(
null
);
};
};
const
addBtnLabel
=
activeTab
===
"employees"
?
"添加成员"
:
activeTab
===
"groups"
?
"创建小组"
:
""
;
const loading = loadingMembers || loadingGroups;
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
return (
<
div
className=
"h-full flex flex-col bg-background"
>
<div className="mx-auto max-w-[1440px]">
{
/* Header */
}
<section className="rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl">
<
div
className=
"border-b border-border bg-card px-6 py-4"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<
div
className=
"flex items-center justify-between mb-4"
>
<div>
<div>
<
h1
className=
"text-2xl font-semibold text-foreground mb-1"
>
团队管理
</
h1
>
<div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72">
<
p
className=
"text-sm text-muted-foreground"
>
管理团队成员、小组和权限
</
p
>
<Users className="h-3.5 w-3.5" />
Team Admin
</div>
<h1 className="mt-4 text-3xl font-semibold text-foreground">团队与小组管理</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
维护团队成员、角色权限与小组结构,让创作流程中的协作边界更清晰。
</p>
</div>
</div>
{
addBtnLabel
&&
(
<div className="flex flex-wrap gap-3">
<button
<button
onClick=
{
()
=>
activeTab
===
"employees"
?
setShowAddMemberModal
(
true
)
:
setShowAddGroupModal
(
true
)
}
onClick={
openCreateGroup
}
className=
"
px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2
"
className="
inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary
"
>
>
<
UserPlus
className=
"w-4 h
-4"
/>
<
FolderKanban className="h-4 w
-4" />
{
addBtnLabel
}
新建小组
</button>
</button>
)
}
<button
onClick={openCreateMember}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]"
>
<Plus className="h-4 w-4" />
添加成员
</button>
</div>
</div>
</div>
<
div
className=
"flex gap-1"
>
<div className="mt-6 grid gap-4 md:grid-cols-4">
{
tabs
.
map
((
tab
)
=>
(
{[
<
button
key=
{
tab
.
id
}
onClick=
{
()
=>
setActiveTab
(
tab
.
id
)
}
{ label: "团队成员", value: `
$
{
members
.
length
}
` },
className=
{
`px-4 py-2 rounded-lg text-sm flex items-center gap-2 transition-all ${
{ label: "管理员", value: `
$
{
members
.
filter
((
member
)
=>
member
.
role
===
"admin"
).
length
}
` },
activeTab === tab.id ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted"
{ label: "所有者", value: `
$
{
members
.
filter
((
member
)
=>
member
.
role
===
"owner"
).
length
}
` },
}`
}
>
{ label: "小组数量", value: `
$
{
groups
.
length
}
` },
<
tab
.
icon
className=
"w-4 h-4"
/>
].map((item) => (
{
tab
.
label
}
<div
</
button
>
key={item.label}
className="rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">{item.label}</div>
<div className="mt-3 text-2xl font-semibold text-foreground">{item.value}</div>
</div>
))}
))}
</div>
</div>
</
div
>
{
/* Content */
}
<div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<
div
className=
"flex-1 overflow-auto p-6"
>
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
<
div
className=
"max-w-7xl mx-auto"
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-4">
<
div
className=
"mb-6"
>
<div>
<
div
className=
"relative max-w-md"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Members</div>
<
Search
className=
"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<h2 className="mt-2 text-2xl font-semibold text-foreground">成员列表</h2>
<
input
type=
"text"
value=
{
searchTerm
}
onChange=
{
(
e
)
=>
setSearchTerm
(
e
.
target
.
value
)
}
</div>
placeholder=
"搜索..."
<div className="relative w-full max-w-sm">
className=
"w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-card text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="搜索成员"
/>
</div>
</div>
</div>
</
div
>
{
/* Employees Tab */
}
<div className="space-y-4">
{
activeTab
===
"employees"
&&
(
<
div
className=
"space-y-3"
>
{
loadingMembers
&&
<
div
className=
"text-center py-8"
><
Loader2
className=
"w-6 h-6 animate-spin mx-auto text-muted-foreground"
/></
div
>
}
{filteredMembers.map((member) => (
{filteredMembers.map((member) => (
<
div
key=
{
member
.
userId
}
className=
"rounded-xl border border-border bg-card p-4 flex items-center justify-between"
>
<div
<
div
className=
"flex items-center gap-4"
>
key={member.userId}
<
div
className=
"w-12 h-12 rounded-full bg-accent flex items-center justify-center text-primary font-semibold text-lg"
>
className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
{
member
.
username
.
charAt
(
0
).
toUpperCase
()
}
>
</
div
>
<div className="flex flex-wrap items-start justify-between gap-4"
>
<div>
<div>
<
div
className=
"
flex items-center gap-3 mb-1"
>
<div className="
text-lg font-medium text-foreground">{member.username}</div
>
<
span
className=
"font-medium text-foreground"
>
{
member
.
username
}
</
span
>
<div className="mt-2 inline-flex items-center gap-2 text-sm text-muted-foreground"
>
{
member
.
position
&&
<
span
className=
"text-xs text-muted-foreground"
>
{
member
.
position
}
</
span
>
}
<Mail className="h-4 w-4" />
<
span
className=
"px-2 py-0.5 rounded bg-accent text-primary text-xs"
>
{
roleLabels
[
member
.
role
]
??
member
.
role
}
</
span
>
{member.email}
{
member
.
status
===
1
</div>
?
<
span
className=
"px-2 py-0.5 rounded bg-green-100 text-green-700 text-xs"
>
已启用
</
span
>
<div className="mt-2 text-sm text-muted-foreground"
>
:
<
span
className=
"px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-xs"
>
已禁用
</
span
>
}
岗位:{member.position || "未设置"
}
</div>
</div>
<
div
className=
"text-sm text-muted-foreground"
>
{
member
.
email
}
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
{
member
.
role
!==
"owner"
&&
(
<>
<
button
onClick=
{
()
=>
openEditMember
(
member
)
}
className=
"px-3 py-1.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1"
>
<
Edit2
className=
"w-3 h-3"
/>
编辑
</
button
>
<
button
onClick=
{
()
=>
removeMember
.
mutate
(
member
.
userId
)
}
className=
"p-1.5 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors"
>
<
Trash2
className=
"w-4 h-4"
/>
</
button
>
</>
)
}
</
div
>
</
div
>
))
}
{
!
loadingMembers
&&
filteredMembers
.
length
===
0
&&
(
<
div
className=
"text-center py-16 text-muted-foreground"
>
暂无成员
</
div
>
)
}
</
div
>
)
}
{
/* Groups Tab */
}
{
activeTab
===
"groups"
&&
(
<
div
className=
"grid grid-cols-2 gap-4"
>
{
loadingGroups
&&
<
div
className=
"col-span-2 text-center py-8"
><
Loader2
className=
"w-6 h-6 animate-spin mx-auto text-muted-foreground"
/></
div
>
}
{
filteredGroups
.
map
((
group
)
=>
(
<
div
key=
{
group
.
id
}
className=
"rounded-xl border border-border bg-card p-5"
>
<
div
className=
"flex items-start justify-between mb-4"
>
<
div
>
<
h3
className=
"font-medium text-foreground mb-1"
>
{
group
.
name
}
</
h3
>
{
group
.
description
&&
<
p
className=
"text-sm text-muted-foreground"
>
{
group
.
description
}
</
p
>
}
</div>
</div>
<
div
className=
"flex items-center gap-1"
>
<div className="flex flex-wrap gap-2">
<
button
onClick=
{
()
=>
openEditGroup
(
group
)
}
<span className="inline-flex items-center gap-2 rounded-full bg-accent px-3 py-1 text-xs text-primary">
className=
"p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<Shield className="h-3.5 w-3.5" />
<
Edit2
className=
"w-4 h-4"
/>
{roleLabels[member.role] ?? member.role}
</span>
<button
onClick={() => openEditMember(member)}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Pencil className="h-4 w-4" />
编辑
</button>
</button>
<
button
onClick=
{
()
=>
deleteGroup
.
mutate
(
group
.
id
)
}
<button
className=
"p-1.5 rounded-lg hover:bg-destructive/10 text-destructive transition-colors"
>
onClick={() => handleRemoveMember(member.userId)}
<
Trash2
className=
"w-4 h-4"
/>
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
移除
</button>
</button>
</div>
</div>
</div>
</div>
<
div
className=
"mb-4"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
div
className=
"text-xs text-muted-foreground"
>
成员 (
{
group
.
memberIds
.
length
}
)
</
div
>
<
button
onClick=
{
()
=>
setManagingGroupId
(
group
.
id
)
}
className=
"text-xs text-primary hover:underline flex items-center gap-1"
>
<
UserPlus
className=
"w-3 h-3"
/>
管理成员
</
button
>
</
div
>
<
div
className=
"flex flex-wrap gap-1.5"
>
{
group
.
memberIds
.
map
((
uid
)
=>
{
const
m
=
memberById
[
uid
];
return
(
<
span
key=
{
uid
}
className=
"px-2.5 py-1 rounded-lg bg-muted text-foreground text-xs flex items-center gap-1"
>
{
m
?
m
.
username
:
`#${uid}`
}
<
button
onClick=
{
()
=>
removeGroupMember
.
mutate
({
groupId
:
group
.
id
,
userId
:
uid
})
}
className=
"text-muted-foreground hover:text-destructive transition-colors"
>
<
X
className=
"w-3 h-3"
/>
</
button
>
</
span
>
);
})
}
{
group
.
memberIds
.
length
===
0
&&
(
<
span
className=
"text-xs text-muted-foreground"
>
暂无成员
</
span
>
)
}
</
div
>
</
div
>
</div>
</div>
))}
))}
{
!
loadingGroups
&&
filteredGroups
.
length
===
0
&&
(
<
div
className=
"col-span-2 text-center py-16 text-muted-foreground"
>
暂无小组
</
div
>
{filteredMembers.length === 0 ? (
)
}
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
没有找到匹配的成员。
</div>
) : null}
</div>
</div>
)
}
</div>
{
/* Permissions Tab */
}
<div className="rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm">
{
activeTab
===
"permissions"
&&
(
<div className="mb-4">
<
div
className=
"grid grid-cols-2 gap-4"
>
<div className="text-xs uppercase tracking-[0.24em] text-primary/70">Groups</div>
{
[
<h2 className="mt-2 text-2xl font-semibold text-foreground">小组结构</h2>
{
name
:
"owner(所有者)"
,
description
:
"最高权限,可管理成员、计费、所有功能"
,
includes
:
[
"成员管理"
,
"计费管理"
,
"项目管理"
,
"资产库"
,
"全部操作"
]
},
</div>
{
name
:
"admin(管理员)"
,
description
:
"可管理成员和项目,但不能操作计费"
,
includes
:
[
"成员管理"
,
"项目管理"
,
"资产库"
,
"生成操作"
]
},
{
name
:
"member(成员)"
,
description
:
"可操作已授权的项目功能"
,
includes
:
[
"项目查看"
,
"分镜编辑"
,
"视频生成"
,
"资产库查看"
]
},
<div className="space-y-4">
].
map
((
role
,
idx
)
=>
(
{groups.length === 0 ? (
<
div
key=
{
idx
}
className=
"rounded-xl border border-border bg-card p-5"
>
<div className="rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground">
<
div
className=
"flex items-start gap-3 mb-4"
>
当前还没有小组,建议按职能或项目阶段划分小组,便于成员协作。
<
div
className=
"w-10 h-10 rounded-lg bg-accent flex items-center justify-center flex-shrink-0"
>
</div>
<
Shield
className=
"w-5 h-5 text-primary"
/>
) : (
</
div
>
groups.map((group) => (
<
div
>
<div
<
h3
className=
"font-medium text-foreground mb-1"
>
{
role
.
name
}
</
h3
>
key={group.id}
<
p
className=
"text-sm text-muted-foreground"
>
{
role
.
description
}
</
p
>
className="rounded-[24px] border border-border/80 bg-white px-5 py-5 shadow-sm"
</
div
>
>
</
div
>
<div className="flex items-start justify-between gap-4">
<
div
>
<div>
<
div
className=
"text-xs text-muted-foreground mb-2"
>
包含权限
</
div
>
<div className="text-lg font-medium text-foreground">{group.name}</div>
<
div
className=
"space-y-2"
>
<div className="mt-2 text-sm leading-6 text-muted-foreground">
{
role
.
includes
.
map
((
item
)
=>
(
{group.description || "暂无描述"}
<
div
key=
{
item
}
className=
"flex items-center gap-2 text-sm text-foreground"
>
</div>
<
Check
className=
"w-4 h-4 text-green-500"
/>
{
item
}
<div className="mt-2 text-sm text-muted-foreground">
成员数:{group.memberCount ?? 0}
</div>
</div>
))
}
</div>
<div className="flex gap-2">
<button
onClick={() => openEditGroup(group)}
className="inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<Pencil className="h-4 w-4" />
编辑
</button>
<button
onClick={() => handleDeleteGroup(group.id)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
>
<Trash2 className="h-4 w-4" />
删除
</button>
</div>
</div>
</div>
</div>
</div>
</
div
>
))
)
)
}
)}
</div>
</div>
)
}
</div>
</div>
</div>
</
div
>
</
section
>
{
/* ---- Add Member Modal ---- */
}
{showMemberModal ? (
{
showAddMemberModal
&&
(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<div className="w-full max-w-xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-lg w-full"
>
<div className="mb-6 flex items-start justify-between gap-4">
<
h2
className=
"text-xl font-semibold text-foreground mb-6"
>
添加团队成员
</
h2
>
<
div
className=
"space-y-4 mb-6"
>
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
用户名
</
label
>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Member Editor</div>
<
input
value=
{
formUsername
}
onChange=
{
(
e
)
=>
setFormUsername
(
e
.
target
.
value
)
}
type=
"text"
placeholder=
"输入用户名"
<h3 className="mt-2 text-2xl font-semibold text-foreground">
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
{editingMemberId ? "编辑成员" : "添加成员"}
</h3>
</div>
</div>
<button
onClick={() => setShowMemberModal(false)}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-5">
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
邮箱
</
label
>
<label className="mb-2 block text-sm font-medium text-foreground">用户名</label>
<
input
value=
{
formEmail
}
onChange=
{
(
e
)
=>
setFormEmail
(
e
.
target
.
value
)
}
type=
"email"
placeholder=
"输入邮箱"
<input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
value={memberForm.username}
onChange={(e) => setMemberForm((current) => ({ ...current, username: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入用户名"
disabled={editingMemberId !== null}
/>
</div>
</div>
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
初始密码
</
label
>
<label className="mb-2 block text-sm font-medium text-foreground">邮箱</label>
<
input
value=
{
formPassword
}
onChange=
{
(
e
)
=>
setFormPassword
(
e
.
target
.
value
)
}
type=
"password"
placeholder=
"设置初始密码"
<input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
value={memberForm.email}
onChange={(e) => setMemberForm((current) => ({ ...current, email: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入邮箱"
disabled={editingMemberId !== null}
/>
</div>
</div>
<
div
className=
"grid grid-cols-2 gap-4"
>
{editingMemberId === null ? (
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
角色
</
label
>
<label className="mb-2 block text-sm font-medium text-foreground">初始密码</label>
<
select
value=
{
formRole
}
onChange=
{
(
e
)
=>
setFormRole
(
e
.
target
.
value
)
}
<input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
type="password"
<
option
value=
"member"
>
成员
</
option
>
value={memberForm.password}
<
option
value=
"admin"
>
管理员
</
option
>
onChange={(e) => setMemberForm((current) => ({ ...current, password: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="设置初始密码"
/>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">角色权限</label>
<select
value={memberForm.role}
onChange={(e) => setMemberForm((current) => ({ ...current, role: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
{Object.entries(roleLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</select>
</div>
</div>
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
职位
</
label
>
<label className="mb-2 block text-sm font-medium text-foreground">岗位</label>
<
input
value=
{
formPosition
}
onChange=
{
(
e
)
=>
setFormPosition
(
e
.
target
.
value
)
}
type=
"text"
placeholder=
"如:分镜师"
<input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
value={memberForm.position}
onChange={(e) => setMemberForm((current) => ({ ...current, position: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="如:分镜师"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<
div
className=
"flex gap-3"
>
<
button
onClick=
{
()
=>
setShowAddMemberModal
(
false
)
}
<div className="mt-6 flex items-center justify-end gap-3">
className=
"flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
取消
</
button
>
<button
<
button
onClick=
{
handleAddMember
}
disabled=
{
addMember
.
isPending
||
!
formUsername
||
!
formEmail
||
!
formPassword
}
onClick={() => setShowMemberModal(false)}
className=
"flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
{
addMember
.
isPending
&&
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
}
确定
>
取消
</button>
</button>
</
div
>
<button
</
div
>
onClick={handleSaveMember}
</
div
>
disabled={addMember.isPending || updateMember.isPending}
)
}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
/* ---- Edit Member Modal ---- */
}
{addMember.isPending || updateMember.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{
editingMember
&&
(
保存成员
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-md w-full"
>
<
h2
className=
"text-xl font-semibold text-foreground mb-2"
>
编辑成员
</
h2
>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
{
editingMember
.
username
}
·
{
editingMember
.
email
}
</
p
>
<
div
className=
"space-y-4 mb-6"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
角色
</
label
>
<
select
value=
{
editRole
}
onChange=
{
(
e
)
=>
setEditRole
(
e
.
target
.
value
)
}
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<
option
value=
"member"
>
成员
</
option
>
<
option
value=
"admin"
>
管理员
</
option
>
</
select
>
</
div
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
职位
</
label
>
<
input
value=
{
editPosition
}
onChange=
{
(
e
)
=>
setEditPosition
(
e
.
target
.
value
)
}
type=
"text"
placeholder=
"如:分镜师"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
</
div
>
<
div
className=
"flex gap-3"
>
<
button
onClick=
{
()
=>
setEditingMember
(
null
)
}
className=
"flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
取消
</
button
>
<
button
onClick=
{
handleEditMember
}
disabled=
{
updateMember
.
isPending
}
className=
"flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{
updateMember
.
isPending
&&
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
}
保存
</button>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
) : null}
{
/* ---- Add Group Modal ---- */
}
{showGroupModal ? (
{
showAddGroupModal
&&
(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm">
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<div className="w-full max-w-xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl">
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-md w-full"
>
<div className="mb-6 flex items-start justify-between gap-4">
<
h2
className=
"text-xl font-semibold text-foreground mb-6"
>
创建小组
</
h2
>
<
div
className=
"space-y-4 mb-6"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
小组名称
</
label
>
<
input
value=
{
formGroupName
}
onChange=
{
(
e
)
=>
setFormGroupName
(
e
.
target
.
value
)
}
type=
"text"
placeholder=
"输入小组名称"
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</
div
>
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
描述
</
label
>
<div className="text-xs uppercase tracking-[0.28em] text-primary/70">Group Editor</div>
<
textarea
value=
{
formGroupDesc
}
onChange=
{
(
e
)
=>
setFormGroupDesc
(
e
.
target
.
value
)
}
rows=
{
3
}
placeholder=
"输入小组描述"
<h3 className="mt-2 text-2xl font-semibold text-foreground">
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
{editingGroupId ? "编辑小组" : "新建小组"}
</h3>
</div>
</div>
</
div
>
<button
<
div
className=
"flex gap-3"
>
onClick={() => setShowGroupModal(false)}
<
button
onClick=
{
()
=>
setShowAddGroupModal
(
false
)
}
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary"
className=
"flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
取消
</
button
>
>
<
button
onClick=
{
handleAddGroup
}
disabled=
{
createGroup
.
isPending
||
!
formGroupName
.
trim
()
}
<X className="h-4 w-4" />
className=
"flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{
createGroup
.
isPending
&&
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
}
创建
</button>
</button>
</div>
</div>
</
div
>
</
div
>
<div className="space-y-5">
)
}
{
/* ---- Edit Group Modal ---- */
}
{
editingGroup
&&
(
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-md w-full"
>
<
h2
className=
"text-xl font-semibold text-foreground mb-6"
>
编辑小组
</
h2
>
<
div
className=
"space-y-4 mb-6"
>
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
小组名称
</
label
>
<label className="mb-2 block text-sm font-medium text-foreground">小组名称</label>
<
input
value=
{
editGroupName
}
onChange=
{
(
e
)
=>
setEditGroupName
(
e
.
target
.
value
)
}
type=
"text"
<input
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
value={groupForm.name}
onChange={(e) => setGroupForm((current) => ({ ...current, name: e.target.value }))}
className="h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入小组名称"
/>
</div>
</div>
<div>
<div>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
描述
</
label
>
<label className="mb-2 block text-sm font-medium text-foreground">小组描述</label>
<
textarea
value=
{
editGroupDesc
}
onChange=
{
(
e
)
=>
setEditGroupDesc
(
e
.
target
.
value
)
}
rows=
{
3
}
<textarea
className=
"w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
value={groupForm.description}
onChange={(e) => setGroupForm((current) => ({ ...current, description: e.target.value }))}
className="min-h-32 w-full rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm leading-6 text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]"
placeholder="输入小组描述"
/>
</div>
</div>
</div>
</div>
<
div
className=
"flex gap-3"
>
<
button
onClick=
{
()
=>
setEditingGroup
(
null
)
}
className=
"flex-1 px-4 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
取消
</
button
>
<
button
onClick=
{
handleEditGroup
}
disabled=
{
updateGroup
.
isPending
||
!
editGroupName
.
trim
()
}
className=
"flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{
updateGroup
.
isPending
&&
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
}
保存
</
button
>
</
div
>
</
div
>
</
div
>
)
}
{
/* ---- Manage Group Members Modal ---- */
}
{
managingGroup
&&
(
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 max-w-lg w-full"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
h2
className=
"text-xl font-semibold text-foreground"
>
管理小组成员 ·
{
managingGroup
.
name
}
</
h2
>
<
button
onClick=
{
()
=>
setManagingGroupId
(
null
)
}
className=
"p-1.5 rounded-lg hover:bg-muted transition-colors"
>
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
</
button
>
</
div
>
<
div
className=
"mb-4"
>
<div className="mt-6 flex items-center justify-end gap-3">
<
div
className=
"text-sm font-medium text-foreground mb-3"
>
当前成员
</
div
>
<button
{
managingGroup
.
memberIds
.
length
===
0
onClick={() => setShowGroupModal(false)}
?
<
p
className=
"text-sm text-muted-foreground"
>
暂无成员
</
p
>
className="rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
:
(
>
<
div
className=
"space-y-2"
>
取消
{
managingGroup
.
memberIds
.
map
((
uid
)
=>
{
</button>
const
m
=
memberById
[
uid
];
<button
return
(
onClick={handleSaveGroup}
<
div
key=
{
uid
}
className=
"flex items-center justify-between rounded-lg bg-muted px-3 py-2"
>
disabled={createGroup.isPending || updateGroup.isPending}
<
span
className=
"text-sm text-foreground"
>
{
m
?
`${m.username} (${m.email})`
:
`#${uid}`
}
</
span
>
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
<
button
onClick=
{
()
=>
removeGroupMember
.
mutate
({
groupId
:
managingGroup
.
id
,
userId
:
uid
})
}
>
className=
"p-1 rounded hover:bg-destructive/10 text-destructive transition-colors"
>
{createGroup.isPending || updateGroup.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
<
UserMinus
className=
"w-4 h-4"
/>
保存小组
</
button
>
</
div
>
);
})
}
</
div
>
)
}
</
div
>
<
div
>
<
div
className=
"text-sm font-medium text-foreground mb-3"
>
添加成员
</
div
>
<
div
className=
"space-y-2"
>
{
members
.
filter
((
m
)
=>
!
managingGroup
.
memberIds
.
includes
(
m
.
userId
))
.
map
((
m
)
=>
(
<
div
key=
{
m
.
userId
}
className=
"flex items-center justify-between rounded-lg border border-border px-3 py-2"
>
<
span
className=
"text-sm text-foreground"
>
{
m
.
username
}
<
span
className=
"text-muted-foreground"
>
(
{
m
.
email
}
)
</
span
></
span
>
<
button
onClick=
{
()
=>
addGroupMember
.
mutate
({
groupId
:
managingGroup
.
id
,
userId
:
m
.
userId
})
}
className=
"px-3 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-xs"
>
添加
</
button
>
</
div
>
))
}
{
members
.
filter
((
m
)
=>
!
managingGroup
.
memberIds
.
includes
(
m
.
userId
)).
length
===
0
&&
(
<
p
className=
"text-sm text-muted-foreground"
>
所有成员均已在小组中
</
p
>
)
}
</
div
>
</
div
>
<
div
className=
"mt-6 flex justify-end"
>
<
button
onClick=
{
()
=>
setManagingGroupId
(
null
)
}
className=
"px-6 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm"
>
完成
</button>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
)
: null
}
</div>
</div>
);
);
}
}
doc/html/src/app/pages/UsageManagement.tsx
View file @
75992d7b
import
{
use
State
,
useMemo
}
from
"react"
;
import
{
use
Memo
,
useState
}
from
"react"
;
import
{
import
{
Sparkles
,
TrendingUp
,
Calendar
,
Calendar
,
Filter
,
Filter
,
Users
,
Film
,
Image
as
ImageIcon
,
Image
as
ImageIcon
,
X
,
Check
,
Loader2
,
Loader2
,
Sparkles
,
TrendingUp
,
Users
,
Video
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
import
{
LineChart
,
Line
,
XAxis
,
YAxis
,
CartesianGrid
,
CartesianGrid
,
Tooltip
,
Legend
,
Legend
,
Line
,
LineChart
,
ResponsiveContainer
,
ResponsiveContainer
,
Tooltip
,
XAxis
,
YAxis
,
}
from
"recharts"
;
}
from
"recharts"
;
import
{
useBalance
,
use
Records
,
usePurchase
}
from
"../../hooks/useUsage"
;
import
{
useBalance
,
use
Purchase
,
useRecords
}
from
"../../hooks/useUsage"
;
import
type
{
BillingRecord
}
from
"../../lib/api/usage"
;
import
type
{
BillingRecord
}
from
"../../lib/api/usage"
;
const
opLabels
:
Record
<
string
,
string
>
=
{
const
op
eration
Labels
:
Record
<
string
,
string
>
=
{
outline_generate
:
"大纲生成"
,
outline_generate
:
"大纲生成"
,
episode_generate
:
"分集生成"
,
episode_generate
:
"分集生成"
,
image_generate
:
"图片生成"
,
image_generate
:
"图片生成"
,
video_generate
:
"视频生成"
,
video_generate
:
"视频生成"
,
assembly
:
"视频合成"
,
assembly
:
"视频合成"
,
recharge
:
"充值"
,
recharge
:
"
积分
充值"
,
};
};
const
op
Icons
:
Record
<
string
,
typeof
Film
>
=
{
const
op
erationIcons
:
Record
<
string
,
typeof
Sparkles
>
=
{
outline_generate
:
Film
,
outline_generate
:
WandSparkles
,
episode_generate
:
Film
,
episode_generate
:
Sparkles
,
image_generate
:
ImageIcon
,
image_generate
:
ImageIcon
,
video_generate
:
Film
,
video_generate
:
Video
,
assembly
:
Film
,
assembly
:
Video
,
recharge
:
Sparkles
,
recharge
:
Sparkles
,
};
};
const
PACKAGES
=
[
const
packages
=
[
{
id
:
"starter"
,
label
:
"入门包"
,
credits
:
1
_000
,
price
:
"¥9.9"
,
desc
:
"适合个人试用"
},
{
id
:
"starter"
,
label
:
"入门包"
,
credits
:
1000
,
price
:
"¥9.9"
,
desc
:
"适合个人试用"
},
{
id
:
"basic"
,
label
:
"基础包"
,
credits
:
5
_000
,
price
:
"¥39"
,
desc
:
"适合小团队"
},
{
id
:
"basic"
,
label
:
"基础包"
,
credits
:
5000
,
price
:
"¥39"
,
desc
:
"适合小团队"
},
{
id
:
"pro"
,
label
:
"专业包"
,
credits
:
20
_000
,
price
:
"¥129"
,
desc
:
"最受欢迎
"
},
{
id
:
"pro"
,
label
:
"专业包"
,
credits
:
20000
,
price
:
"¥129"
,
desc
:
"适合持续生产
"
},
{
id
:
"enterprise"
,
label
:
"企业包"
,
credits
:
100
_000
,
price
:
"¥499"
,
desc
:
"适合大规模
制作"
},
{
id
:
"enterprise"
,
label
:
"企业包"
,
credits
:
100000
,
price
:
"¥499"
,
desc
:
"适合大规模内容
制作"
},
];
];
function
buildDailyConsumptionData
(
records
:
BillingRecord
[])
{
const
result
:
Array
<
{
date
:
string
;
actual
:
number
;
predicted
:
number
}
>
=
[];
const
today
=
new
Date
();
const
consumptionMap
=
new
Map
<
string
,
number
>
();
records
.
forEach
((
record
)
=>
{
if
(
record
.
operation
===
"recharge"
)
return
;
const
key
=
record
.
createdAt
.
slice
(
5
,
10
);
const
current
=
consumptionMap
.
get
(
key
)
??
0
;
consumptionMap
.
set
(
key
,
current
+
Math
.
abs
(
Number
(
record
.
credits
)));
});
for
(
let
offset
=
29
;
offset
>=
0
;
offset
-=
1
)
{
const
date
=
new
Date
(
today
);
date
.
setDate
(
today
.
getDate
()
-
offset
);
const
key
=
`
${
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
"0"
)}
-
${
String
(
date
.
getDate
()).
padStart
(
2
,
"0"
)}
`
;
const
actual
=
consumptionMap
.
get
(
key
)
??
0
;
result
.
push
({
date
:
`
${
date
.
getMonth
()
+
1
}
/
${
date
.
getDate
()}
`
,
actual
,
predicted
:
Math
.
round
(
actual
*
1.08
+
200
),
});
}
return
result
;
}
export
function
UsageManagement
()
{
export
function
UsageManagement
()
{
const
[
dateRange
,
setDateRange
]
=
useState
({
start
:
""
,
end
:
""
});
const
[
dateRange
,
setDateRange
]
=
useState
({
start
:
""
,
end
:
""
});
const
[
selectedType
,
setSelectedType
]
=
useState
(
""
);
const
[
selectedType
,
setSelectedType
]
=
useState
(
""
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
showPurchaseModal
,
setShowPurchaseModal
]
=
useState
(
false
);
const
[
showPurchaseModal
,
setShowPurchaseModal
]
=
useState
(
false
);
const
[
selectedPackage
,
setSelectedPackage
]
=
useState
(
"pro"
);
const
[
selectedPackage
,
setSelectedPackage
]
=
useState
(
"pro"
);
const
[
purchased
,
setPurchased
]
=
useState
(
false
);
const
PAGE_SIZE
=
20
;
const
pageSize
=
20
;
const
{
data
:
balance
}
=
useBalance
();
const
{
data
:
balance
}
=
useBalance
();
const
{
data
:
recordsPage
}
=
useRecords
(
PAGE_SIZE
,
page
*
PAGE_SIZE
);
const
{
data
:
recordsPage
}
=
useRecords
(
pageSize
,
page
*
pageSize
);
const
purchase
=
usePurchase
();
const
purchase
=
usePurchase
();
const
records
:
BillingRecord
[]
=
recordsPage
?.
items
??
[];
const
records
=
recordsPage
?.
items
??
[];
const
totalRecords
=
recordsPage
?.
total
??
0
;
const
totalRecords
=
recordsPage
?.
total
??
0
;
const
filteredRecords
=
records
.
filter
((
r
)
=>
{
const
filteredRecords
=
records
.
filter
((
record
)
=>
{
if
(
selectedType
&&
r
.
operation
!==
selectedType
)
return
false
;
if
(
selectedType
&&
record
.
operation
!==
selectedType
)
{
if
(
dateRange
.
start
&&
r
.
createdAt
<
dateRange
.
start
+
"T00:00:00"
)
return
false
;
return
false
;
if
(
dateRange
.
end
&&
r
.
createdAt
>
dateRange
.
end
+
"T23:59:59"
)
return
false
;
}
if
(
dateRange
.
start
&&
record
.
createdAt
<
`
${
dateRange
.
start
}
T00:00:00`
)
{
return
false
;
}
if
(
dateRange
.
end
&&
record
.
createdAt
>
`
${
dateRange
.
end
}
T23:59:59`
)
{
return
false
;
}
return
true
;
return
true
;
});
});
// Generate daily consumption data for the past 30 days
const
chartData
=
useMemo
(()
=>
buildDailyConsumptionData
(
records
),
[
records
]);
const
dailyConsumptionData
=
useMemo
(()
=>
{
const
data
=
[];
const
baseDate
=
new
Date
(
"2026-04-12"
);
for
(
let
i
=
29
;
i
>=
0
;
i
--
)
{
const
date
=
new
Date
(
baseDate
);
date
.
setDate
(
baseDate
.
getDate
()
-
i
);
const
month
=
date
.
getMonth
()
+
1
;
const
day
=
date
.
getDate
();
const
dateStr
=
`
${
month
}
/
${
day
}
`
;
// Deterministic generation based on index to ensure uniqueness
const
totalRecharged
=
Number
(
balance
?.
totalRecharged
??
0
);
const
baseConsumption
=
50000
+
(
i
*
1234
)
%
30000
;
const
totalSpent
=
Number
(
balance
?.
totalSpent
??
0
);
const
variance
=
((
i
*
7
)
%
10000
)
-
5000
;
const
remaining
=
Number
(
balance
?.
balance
??
0
);
const
actual
=
baseConsumption
+
variance
;
const
predicted
=
baseConsumption
+
((
i
*
3
)
%
5000
)
-
2500
;
data
.
push
({
date
:
dateStr
,
actual
,
predicted
,
});
}
return
data
;
const
handlePurchase
=
async
()
=>
{
},
[]);
await
purchase
.
mutateAsync
(
selectedPackage
);
setShowPurchaseModal
(
false
);
const
remainingCredits
=
balance
?.
balance
??
0
;
};
const
usedCredits
=
balance
?.
totalSpent
??
0
;
const
totalRecharged
=
balance
?.
totalRecharged
??
0
;
return
(
return
(
<>
<
div
className=
"mx-auto max-w-[1440px]"
>
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"max-w-7xl mx-auto"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
>
<
div
>
<
h1
className=
"text-2xl font-semibold text-foreground mb-1"
>
用量统计
</
h1
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
p
className=
"text-sm text-muted-foreground"
>
查看和管理积分使用情况
</
p
>
<
TrendingUp
className=
"h-3.5 w-3.5"
/>
Usage Analytics
</
div
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
用量统计与积分管理
</
h1
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
跟踪项目生产过程中的积分消耗、充值记录和操作趋势,帮助团队评估当前产能与预算投入。
</
p
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
()
=>
{
setShowPurchaseModal
(
true
);
setPurchased
(
false
);
}
}
onClick=
{
()
=>
setShowPurchaseModal
(
true
)
}
className=
"
px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2
"
className=
"
inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px]
"
>
>
<
Sparkles
className=
"
w-4 h
-4"
/>
<
Sparkles
className=
"
h-4 w
-4"
/>
购买积分资源包
购买积分资源包
</
button
>
</
button
>
</
div
>
</
div
>
{
/* Overview Cards */
}
<
div
className=
"mt-6 grid gap-4 md:grid-cols-3"
>
<
div
className=
"grid grid-cols-3 gap-4 mb-6"
>
{
[
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
{
label
:
"剩余积分"
,
value
:
remaining
.
toLocaleString
(),
hint
:
`累计充值 ${totalRecharged.toLocaleString()}`
},
<
div
className=
"flex items-center gap-3 mb-3"
>
{
label
:
"已消耗积分"
,
value
:
totalSpent
.
toLocaleString
(),
hint
:
`共 ${totalRecords} 条记录`
},
<
div
className=
"w-10 h-10 rounded-lg bg-accent flex items-center justify-center"
>
{
label
:
"本页记录数"
,
value
:
`${records.length}`
,
hint
:
`第 ${page + 1} / ${Math.max(1, Math.ceil(totalRecords / pageSize))} 页`
},
<
Sparkles
className=
"w-5 h-5 text-primary"
/>
].
map
((
item
,
index
)
=>
(
</
div
>
<
div
<
div
className=
"text-xs text-muted-foreground"
>
剩余积分
</
div
>
key=
{
item
.
label
}
</
div
>
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
<
div
className=
"text-2xl font-semibold text-foreground mb-1"
>
>
{
remainingCredits
.
toLocaleString
()
}
<
div
className=
"mb-3 flex h-12 w-12 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
</
div
>
{
index
===
0
?
<
Sparkles
className=
"h-5 w-5"
/>
:
index
===
1
?
<
TrendingUp
className=
"h-5 w-5"
/>
:
<
Users
className=
"h-5 w-5"
/>
}
<
div
className=
"text-xs text-muted-foreground"
>
累计充值
{
Number
(
totalRecharged
).
toLocaleString
()
}
积分
</
div
>
</
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
div
className=
"flex items-center gap-3 mb-3"
>
<
div
className=
"w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center"
>
<
TrendingUp
className=
"w-5 h-5 text-red-600"
/>
</
div
>
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
已消耗
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
item
.
label
}
</
div
>
</
div
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
item
.
value
}
</
div
>
<
div
className=
"text-2xl font-semibold text-foreground mb-1"
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
item
.
hint
}
</
div
>
{
Number
(
usedCredits
).
toLocaleString
()
}
</
div
>
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
))
}
共
{
totalRecords
}
条记录
</
div
>
</
div
>
<
div
className=
"rounded-xl border border-border bg-card p-5"
>
<
div
className=
"flex items-center gap-3 mb-3"
>
<
div
className=
"w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center"
>
<
Users
className=
"w-5 h-5 text-blue-600"
/>
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
本页记录数
</
div
>
</
div
>
<
div
className=
"text-2xl font-semibold text-foreground mb-1"
>
{
records
.
length
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
第
{
page
+
1
}
页 / 共
{
Math
.
max
(
1
,
Math
.
ceil
(
totalRecords
/
PAGE_SIZE
))
}
页
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Filters */
}
<
div
className=
"mt-6 rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
className=
"mb-6 p-4 rounded-xl border border-border bg-card"
>
<
div
className=
"mb-4 flex items-center gap-2"
>
<
div
className=
"flex items-center gap-2 mb-3"
>
<
Filter
className=
"h-4 w-4 text-muted-foreground"
/>
<
Filter
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
className=
"text-sm font-medium text-foreground"
>
筛选条件
</
span
>
<
span
className=
"text-sm font-medium text-foreground"
>
筛选条件
</
span
>
</
div
>
</
div
>
<
div
className=
"grid grid-cols-3 gap-4"
>
<
div
className=
"grid gap-4 xl:grid-cols-3"
>
<
div
>
<
div
>
<
label
className=
"
block text-xs text-muted-foreground mb-2
"
>
开始日期
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground
"
>
开始日期
</
label
>
<
div
className=
"relative"
>
<
div
className=
"relative"
>
<
Calendar
className=
"
absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4
text-muted-foreground"
/>
<
Calendar
className=
"
pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2
text-muted-foreground"
/>
<
input
<
input
type=
"date"
type=
"date"
value=
{
dateRange
.
start
}
value=
{
dateRange
.
start
}
onChange=
{
(
e
)
=>
setDateRange
(
{
...
dateRange
,
start
:
e
.
target
.
value
}
)
}
onChange=
{
(
e
)
=>
setDateRange
(
(
current
)
=>
({
...
current
,
start
:
e
.
target
.
value
})
)
}
className=
"
w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20
"
className=
"
h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]
"
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"
block text-xs text-muted-foreground mb-2
"
>
结束日期
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground
"
>
结束日期
</
label
>
<
div
className=
"relative"
>
<
div
className=
"relative"
>
<
Calendar
className=
"
absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4
text-muted-foreground"
/>
<
Calendar
className=
"
pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2
text-muted-foreground"
/>
<
input
<
input
type=
"date"
type=
"date"
value=
{
dateRange
.
end
}
value=
{
dateRange
.
end
}
onChange=
{
(
e
)
=>
setDateRange
(
{
...
dateRange
,
end
:
e
.
target
.
value
}
)
}
onChange=
{
(
e
)
=>
setDateRange
(
(
current
)
=>
({
...
current
,
end
:
e
.
target
.
value
})
)
}
className=
"
w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20
"
className=
"
h-12 w-full rounded-2xl border border-border/80 bg-white pl-11 pr-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30 focus:shadow-[0_0_0_4px_rgba(15,116,216,0.08)]
"
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
>
<
div
>
<
label
className=
"
block text-xs text-muted-foreground mb-2
"
>
操作类型
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground
"
>
操作类型
</
label
>
<
select
<
select
value=
{
selectedType
}
value=
{
selectedType
}
onChange=
{
(
e
)
=>
{
setSelectedType
(
e
.
target
.
value
);
setPage
(
0
);
}
}
onChange=
{
(
e
)
=>
{
className=
"w-full px-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer"
setSelectedType
(
e
.
target
.
value
);
setPage
(
0
);
}
}
className=
"h-12 w-full rounded-2xl border border-border/80 bg-white px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/30"
>
>
<
option
value=
""
>
全部类型
</
option
>
<
option
value=
""
>
全部类型
</
option
>
{
Object
.
entries
(
opLabels
).
map
(([
k
,
v
])
=>
(
{
Object
.
entries
(
operationLabels
).
map
(([
value
,
label
])
=>
(
<
option
key=
{
k
}
value=
{
k
}
>
{
v
}
</
option
>
<
option
key=
{
value
}
value=
{
value
}
>
{
label
}
</
option
>
))
}
))
}
</
select
>
</
select
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Credits Consumption Chart */
}
<
div
className=
"mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"
>
<
div
className=
"mb-6 p-6 rounded-xl border border-border bg-card"
>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
className=
"mb-5"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Consumption Trend
</
div
>
<
h2
className=
"font-medium text-foreground mb-1"
>
积分消耗趋势
</
h2
>
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
积分消耗趋势
</
h2
>
<
p
className=
"text-xs text-muted-foreground"
>
每日积分消耗对比(最近30天)
</
p
>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
基于最近 30 天的消耗记录,观察项目生产过程中积分使用的波动情况。
</
p
>
</
div
>
</
div
>
</
div
>
<
ResponsiveContainer
width=
"100%"
height=
{
320
}
>
<
ResponsiveContainer
width=
"100%"
height=
{
320
}
>
<
LineChart
<
LineChart
data=
{
chartData
}
margin=
{
{
top
:
5
,
right
:
20
,
left
:
10
,
bottom
:
5
}
}
>
data=
{
dailyConsumptionData
}
<
CartesianGrid
strokeDasharray=
"3 3"
stroke=
"#e5e7eb"
/>
margin=
{
{
top
:
5
,
right
:
30
,
left
:
20
,
bottom
:
5
}
}
<
XAxis
dataKey=
"date"
tick=
{
{
fill
:
"#6b7280"
,
fontSize
:
12
}
}
stroke=
"#9ca3af"
/>
>
<
YAxis
tick=
{
{
fill
:
"#6b7280"
,
fontSize
:
12
}
}
stroke=
"#9ca3af"
/>
<
CartesianGrid
key=
"grid"
strokeDasharray=
"3 3"
stroke=
"#e5e7eb"
/>
<
XAxis
key=
"xaxis"
dataKey=
"date"
tick=
{
{
fill
:
"#6b7280"
,
fontSize
:
12
}
}
stroke=
"#9ca3af"
/>
<
YAxis
key=
"yaxis"
tick=
{
{
fill
:
"#6b7280"
,
fontSize
:
12
}
}
stroke=
"#9ca3af"
tickFormatter=
{
(
value
)
=>
`${(value / 1000).toFixed(0)}k`
}
/>
<
Tooltip
<
Tooltip
key=
"tooltip"
contentStyle=
{
{
contentStyle=
{
{
backgroundColor
:
"#ffffff"
,
backgroundColor
:
"#ffffff"
,
border
:
"1px solid #e5e7eb"
,
border
:
"1px solid #e5e7eb"
,
borderRadius
:
"
8
px"
,
borderRadius
:
"
12
px"
,
fontSize
:
"12px"
,
fontSize
:
"12px"
,
}
}
}
}
formatter=
{
(
value
:
number
)
=>
[
value
.
toLocaleString
(),
""
]
}
labelStyle=
{
{
color
:
"#111827"
,
fontWeight
:
500
}
}
/>
<
Legend
key=
"legend"
wrapperStyle=
{
{
fontSize
:
"12px"
}
}
iconType=
"line"
/>
/>
<
Legend
/>
<
Line
<
Line
key=
"actual-line"
type=
"monotone"
type=
"monotone"
dataKey=
"actual"
dataKey=
"actual"
stroke=
"#5b5ff9"
strokeWidth=
{
2
}
name=
"实际消耗"
name=
"实际消耗"
dot=
{
{
fill
:
"#5b5ff9"
,
r
:
3
}
}
stroke=
"#0f74d8"
activeDot=
{
{
r
:
5
}
}
strokeWidth=
{
3
}
isAnimationActive=
{
false
}
dot=
{
{
fill
:
"#0f74d8"
,
r
:
3
}
}
/>
/>
<
Line
<
Line
key=
"predicted-line"
type=
"monotone"
type=
"monotone"
dataKey=
"predicted"
dataKey=
"predicted"
stroke=
"#94a3b8"
name=
"预测趋势"
stroke=
"#1ea5ff"
strokeWidth=
{
2
}
strokeWidth=
{
2
}
strokeDasharray=
"5 5"
strokeDasharray=
"6 6"
name=
"预计消耗"
dot=
{
false
}
dot=
{
{
fill
:
"#94a3b8"
,
r
:
3
}
}
activeDot=
{
{
r
:
5
}
}
isAnimationActive=
{
false
}
/>
/>
</
LineChart
>
</
LineChart
>
</
ResponsiveContainer
>
</
ResponsiveContainer
>
</
div
>
{
/* Usage Table */
}
<
div
className=
"rounded-xl border border-border bg-card overflow-hidden"
>
<
div
className=
"p-4 border-b border-border"
>
<
h2
className=
"font-medium text-foreground"
>
消耗明细
</
h2
>
</
div
>
</
div
>
<
div
className=
"overflow-x-auto"
>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
table
className=
"w-full"
>
<
div
className=
"mb-5"
>
<
thead
className=
"bg-muted/50"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Billing Records
</
div
>
<
tr
>
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
最近记录
</
h2
>
<
th
className=
"px-4 py-3 text-left text-xs font-medium text-muted-foreground"
>
操作类型
</
th
>
</
div
>
<
th
className=
"px-4 py-3 text-left text-xs font-medium text-muted-foreground"
>
项目ID
</
th
>
<
th
className=
"px-4 py-3 text-left text-xs font-medium text-muted-foreground"
>
消耗积分
</
th
>
<
div
className=
"space-y-3"
>
<
th
className=
"px-4 py-3 text-left text-xs font-medium text-muted-foreground"
>
模式
</
th
>
{
filteredRecords
.
length
===
0
?
(
<
th
className=
"px-4 py-3 text-left text-xs font-medium text-muted-foreground"
>
时间
</
th
>
<
div
className=
"rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"
>
</
tr
>
当前筛选条件下暂无记录。
</
thead
>
</
div
>
<
tbody
className=
"divide-y divide-border"
>
)
:
(
{
filteredRecords
.
length
===
0
&&
(
filteredRecords
.
map
((
record
)
=>
{
<
tr
>
const
Icon
=
operationIcons
[
record
.
operation
]
??
Sparkles
;
<
td
colSpan=
{
5
}
className=
"px-4 py-8 text-center text-sm text-muted-foreground"
>
暂无数据
</
td
>
const
credits
=
Number
(
record
.
credits
);
</
tr
>
)
}
{
filteredRecords
.
map
((
record
)
=>
{
const
Icon
=
opIcons
[
record
.
operation
]
??
Film
;
const
isCharge
=
record
.
operation
!==
"recharge"
;
return
(
return
(
<
tr
key=
{
record
.
id
}
className=
"hover:bg-muted/30 transition-colors"
>
<
div
<
td
className=
"px-4 py-3"
>
key=
{
record
.
id
}
<
div
className=
"flex items-center gap-2"
>
className=
"rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm"
<
Icon
className=
"w-4 h-4 text-muted-foreground"
/>
>
<
span
className=
"text-sm text-foreground"
>
{
opLabels
[
record
.
operation
]
??
record
.
operation
}
</
span
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
<
Icon
className=
"h-4 w-4"
/>
</
div
>
<
div
>
<
div
className=
"text-sm font-medium text-foreground"
>
{
operationLabels
[
record
.
operation
]
??
record
.
operation
}
</
div
>
<
div
className=
"mt-1 text-xs text-muted-foreground"
>
{
new
Date
(
record
.
createdAt
).
toLocaleString
(
"zh-CN"
)
}
</
div
>
</
div
>
</
div
>
<
div
className=
{
credits
<
0
?
"text-sm font-medium text-red-600"
:
"text-sm font-medium text-emerald-600"
}
>
{
credits
>
0
?
"+"
:
""
}
{
credits
.
toLocaleString
()
}
</
div
>
</
div
>
</
td
>
</
div
>
<
td
className=
"px-4 py-3 text-sm text-muted-foreground"
>
{
record
.
projectId
??
"—"
}
</
td
>
</
div
>
<
td
className=
"px-4 py-3"
>
<
span
className=
{
`text-sm font-medium ${isCharge ? "text-destructive" : "text-green-600"}`
}
>
{
isCharge
?
"-"
:
"+"
}{
Number
(
record
.
credits
).
toLocaleString
()
}
</
span
>
</
td
>
<
td
className=
"px-4 py-3 text-xs text-muted-foreground"
>
{
record
.
billingMode
}
</
td
>
<
td
className=
"px-4 py-3 text-sm text-muted-foreground"
>
{
record
.
createdAt
?.
replace
(
"T"
,
" "
).
slice
(
0
,
16
)
}
</
td
>
</
tr
>
);
);
})
}
})
</
tbody
>
)
}
</
table
>
</
div
>
</
div
>
{
totalRecords
>
PAGE_SIZE
&&
(
<
div
className=
"mt-5 flex items-center justify-between"
>
<
div
className=
"flex items-center justify-center gap-3 p-4 border-t border-border"
>
<
button
<
button
onClick=
{
()
=>
setPage
((
current
)
=>
Math
.
max
(
current
-
1
,
0
))
}
disabled=
{
page
===
0
}
disabled=
{
page
===
0
}
onClick=
{
()
=>
setPage
(
p
=>
p
-
1
)
}
className=
"rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
className=
"px-3 py-1.5 rounded-lg border border-border bg-card text-sm text-foreground hover:bg-muted disabled:opacity-40 transition-colors"
>
>
上一页
上一页
</
button
>
</
button
>
<
span
className=
"text-sm text-muted-foreground"
>
<
div
className=
"text-sm text-muted-foreground"
>
{
page
+
1
}
/
{
Math
.
ceil
(
totalRecords
/
PAGE_SIZE
)
}
第
{
page
+
1
}
页 / 共
{
Math
.
max
(
1
,
Math
.
ceil
(
totalRecords
/
pageSize
))
}
页
</
span
>
</
div
>
<
button
<
button
disabled=
{
(
page
+
1
)
*
PAGE_SIZE
>=
totalRecords
}
onClick=
{
()
=>
setPage
((
current
)
=>
(
current
+
1
<
Math
.
ceil
(
totalRecords
/
pageSize
)
?
current
+
1
:
current
))
}
onClick=
{
()
=>
setPage
(
p
=>
p
+
1
)
}
disabled=
{
page
+
1
>=
Math
.
ceil
(
totalRecords
/
pageSize
)
}
className=
"
px-3 py-1.5 rounded-lg border border-border bg-card text-sm text-foreground hover:bg-muted disabled:opacity-40 transition-colors
"
className=
"
rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60
"
>
>
下一页
下一页
</
button
>
</
button
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
</
div
>
{
/* Purchase Modal */
}
{
showPurchaseModal
&&
(
<
div
className=
"fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-6"
>
<
div
className=
"bg-card rounded-2xl border border-border p-6 w-full max-w-2xl"
>
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
>
<
h2
className=
"text-xl font-semibold text-foreground"
>
购买积分资源包
</
h2
>
<
p
className=
"text-sm text-muted-foreground mt-0.5"
>
积分到账后立即可用,永久有效
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setShowPurchaseModal
(
false
)
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
</
button
>
</
div
>
</
div
>
</
div
>
</
section
>
{
purchased
?
(
{
showPurchaseModal
?
(
<
div
className=
"py-12 text-center"
>
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4 backdrop-blur-sm"
>
<
div
className=
"w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4"
>
<
div
className=
"w-full max-w-4xl rounded-[34px] border border-white/80 bg-white/94 p-6 shadow-[0_36px_80px_rgba(12,43,78,0.24)] backdrop-blur-xl"
>
<
Check
className=
"w-8 h-8 text-green-600"
/>
<
div
className=
"mb-6 flex items-start justify-between gap-4"
>
<
div
>
<
div
className=
"text-xs uppercase tracking-[0.28em] text-primary/70"
>
Purchase Credits
</
div
>
<
h3
className=
"mt-2 text-2xl font-semibold text-foreground"
>
选择积分资源包
</
h3
>
<
p
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
选择适合团队当前阶段的积分包,平台会在购买完成后立即同步余额。
</
p
>
</
div
>
</
div
>
<
h3
className=
"text-lg font-semibold text-foreground mb-2"
>
购买成功!
</
h3
>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
{
PACKAGES
.
find
(
p
=>
p
.
id
===
selectedPackage
)?.
credits
.
toLocaleString
()
}
积分已到账
</
p
>
<
button
<
button
onClick=
{
()
=>
setShowPurchaseModal
(
false
)
}
onClick=
{
()
=>
setShowPurchaseModal
(
false
)
}
className=
"
px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm
"
className=
"
flex h-11 w-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground transition hover:bg-accent hover:text-primary
"
>
>
完成
<
X
className=
"h-4 w-4"
/>
</
button
>
</
button
>
</
div
>
</
div
>
)
:
(
<>
<
div
className=
"grid grid-cols-2 gap-3 mb-6"
>
{
PACKAGES
.
map
((
pkg
)
=>
(
<
button
key=
{
pkg
.
id
}
onClick=
{
()
=>
setSelectedPackage
(
pkg
.
id
)
}
className=
{
`rounded-xl border-2 p-4 text-left transition-all ${
selectedPackage === pkg.id
? "border-primary bg-accent"
: "border-border bg-background hover:border-primary/40"
}`
}
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
span
className=
"font-semibold text-foreground"
>
{
pkg
.
label
}
</
span
>
{
pkg
.
id
===
"pro"
&&
(
<
span
className=
"px-2 py-0.5 rounded-full bg-primary text-white text-xs"
>
推荐
</
span
>
)
}
</
div
>
<
div
className=
"text-2xl font-bold text-primary mb-1"
>
{
pkg
.
credits
.
toLocaleString
()
}
<
span
className=
"text-sm font-normal text-muted-foreground"
>
积分
</
span
>
</
div
>
<
div
className=
"flex items-center justify-between"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
pkg
.
desc
}
</
span
>
<
span
className=
"text-lg font-semibold text-foreground"
>
{
pkg
.
price
}
</
span
>
</
div
>
</
button
>
))
}
</
div
>
<
div
className=
"flex items-center gap-3 p-4 rounded-xl bg-muted mb-6"
>
<
div
className=
"grid gap-4 md:grid-cols-2 xl:grid-cols-4"
>
<
Sparkles
className=
"w-5 h-5 text-primary flex-shrink-0"
/>
{
packages
.
map
((
pkg
)
=>
(
<
div
className=
"text-sm text-muted-foreground"
>
当前余额:
<
span
className=
"font-semibold text-foreground"
>
{
Number
(
balance
?.
balance
??
0
).
toLocaleString
()
}
</
span
>
积分
·
购买后变为:
<
span
className=
"font-semibold text-foreground"
>
{
(
Number
(
balance
?.
balance
??
0
)
+
(
PACKAGES
.
find
(
p
=>
p
.
id
===
selectedPackage
)?.
credits
??
0
)).
toLocaleString
()
}
</
span
>
积分
</
div
>
</
div
>
<
div
className=
"flex gap-3"
>
<
button
onClick=
{
()
=>
setShowPurchaseModal
(
false
)
}
className=
"flex-1 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm"
>
取消
</
button
>
<
button
<
button
onClick=
{
async
()
=>
{
key=
{
pkg
.
id
}
await
purchase
.
mutateAsync
(
selectedPackage
);
onClick=
{
()
=>
setSelectedPackage
(
pkg
.
id
)
}
setPurchased
(
true
);
className=
{
[
}
}
"rounded-[28px] border p-5 text-left transition-all"
,
disabled=
{
purchase
.
isPending
}
selectedPackage
===
pkg
.
id
className=
"flex-1 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 text-sm disabled:opacity-70"
?
"border-primary/20 bg-primary/5 shadow-[0_20px_40px_rgba(15,116,216,0.12)]"
:
"border-border/80 bg-white shadow-sm hover:border-primary/20"
,
].
join
(
" "
)
}
>
>
{
purchase
.
isPending
?
<
Loader2
className=
"w-4 h-4 animate-spin"
/>
:
<
Sparkles
className=
"w-4 h-4"
/>
}
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
pkg
.
label
}
</
div
>
确认购买
{
PACKAGES
.
find
(
p
=>
p
.
id
===
selectedPackage
)?.
price
}
<
div
className=
"mt-3 text-3xl font-semibold text-foreground"
>
{
pkg
.
credits
.
toLocaleString
()
}
</
div
>
<
div
className=
"mt-2 text-sm text-muted-foreground"
>
{
pkg
.
desc
}
</
div
>
<
div
className=
"mt-5 text-lg font-medium text-primary"
>
{
pkg
.
price
}
</
div
>
</
button
>
</
button
>
</
div
>
))
}
</>
</
div
>
)
}
<
div
className=
"mt-6 flex items-center justify-end gap-3"
>
<
button
onClick=
{
()
=>
setShowPurchaseModal
(
false
)
}
className=
"rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:bg-muted"
>
取消
</
button
>
<
button
onClick=
{
handlePurchase
}
disabled=
{
purchase
.
isPending
}
className=
"inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-5 py-3 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
>
{
purchase
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Sparkles
className=
"h-4 w-4"
/>
}
确认购买
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
:
null
}
)
}
</
div
>
</>
);
);
}
}
doc/html/src/app/pages/VideoGeneration.tsx
View file @
75992d7b
import
{
use
State
,
useEffect
,
useRef
}
from
"react"
;
import
{
use
Effect
,
useMemo
,
useState
}
from
"react"
;
import
{
use
Params
,
useNavigate
}
from
"react-router"
;
import
{
use
Navigate
,
useParams
}
from
"react-router"
;
import
{
import
{
Loader2
,
Play
,
Pause
,
Download
,
Trash2
,
Edit2
,
Clapperboard
,
SkipBack
,
SkipForward
,
Check
,
Film
,
Download
,
Film
,
Loader2
,
PlayCircle
,
Trash2
,
Video
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
import
{
useVideoTasks
,
useEpisodes
,
useStoryboards
,
useAssemblyTask
,
useAssemblyTask
,
useStartAssembly
,
useDeleteVideoTask
,
useDeleteVideoTask
,
useEpisodes
,
useStartAssembly
,
useStoryboards
,
useVideoTasks
,
}
from
"../../hooks/useAi"
;
}
from
"../../hooks/useAi"
;
import
type
{
AiTask
,
Episode
,
Storyboard
}
from
"../../lib/api/ai"
;
import
type
{
AiTask
}
from
"../../lib/api/ai"
;
function
fmt
(
secs
:
number
)
{
function
getTaskLabel
(
status
:
string
)
{
const
m
=
Math
.
floor
(
secs
/
60
);
switch
(
status
)
{
const
s
=
secs
%
60
;
case
"succeeded"
:
return
`
${
m
}
:
${
String
(
s
).
padStart
(
2
,
"0"
)}
`
;
return
"已完成"
;
case
"running"
:
case
"submitted"
:
case
"pending"
:
return
"生成中"
;
case
"failed"
:
return
"失败"
;
default
:
return
status
;
}
}
}
function
timeAgo
(
dateStr
:
string
)
{
const
diff
=
Date
.
now
()
-
new
Date
(
dateStr
).
getTime
();
function
getTaskClass
(
status
:
string
)
{
const
h
=
Math
.
floor
(
diff
/
3600000
);
switch
(
status
)
{
if
(
h
<
1
)
return
"刚刚"
;
case
"succeeded"
:
if
(
h
<
24
)
return
`
${
h
}
小时前`
;
return
"bg-emerald-100 text-emerald-700"
;
return
`
${
Math
.
floor
(
h
/
24
)}
天前`
;
case
"running"
:
case
"submitted"
:
case
"pending"
:
return
"bg-sky-100 text-sky-700"
;
case
"failed"
:
return
"bg-red-100 text-red-700"
;
default
:
return
"bg-slate-100 text-slate-600"
;
}
}
}
export
function
VideoGeneration
()
{
export
function
VideoGeneration
()
{
...
@@ -32,439 +54,223 @@ export function VideoGeneration() {
...
@@ -32,439 +54,223 @@ export function VideoGeneration() {
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
pid
=
projectId
??
""
;
const
pid
=
projectId
??
""
;
const
{
data
:
episodes
=
[],
isLoading
:
epLoading
}
=
useEpisodes
(
pid
);
const
{
data
:
episodes
=
[],
isLoading
:
loadingEpisodes
}
=
useEpisodes
(
pid
);
const
{
data
:
allTasks
=
[]
}
=
useVideoTasks
(
pid
);
const
{
data
:
allTasks
=
[]
}
=
useVideoTasks
(
pid
);
const
[
activeEpisodeId
,
setActiveEpisodeId
]
=
useState
(
""
);
const
[
activeEpId
,
setActiveEpId
]
=
useState
<
string
>
(
""
);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
episodes
.
length
>
0
&&
!
activeEpId
)
{
if
(
episodes
.
length
>
0
&&
!
activeEp
isode
Id
)
{
setActiveEpId
(
episodes
[
0
].
id
);
setActiveEp
isode
Id
(
episodes
[
0
].
id
);
}
}
},
[
episodes
.
length
]);
},
[
episodes
,
activeEpisodeId
]);
const
{
data
:
storyboards
=
[]
}
=
useStoryboards
(
pid
,
activeEpId
);
const
{
data
:
storyboards
=
[]
}
=
useStoryboards
(
pid
,
activeEp
isode
Id
);
const
{
data
:
assemblyTask
}
=
useAssemblyTask
(
pid
,
activeEpId
);
const
{
data
:
assemblyTask
}
=
useAssemblyTask
(
pid
,
activeEp
isode
Id
);
const
startAssembly
=
useStartAssembly
(
pid
,
activeEpId
);
const
startAssembly
=
useStartAssembly
(
pid
,
activeEp
isode
Id
);
const
deleteTask
=
useDeleteVideoTask
(
pid
);
const
deleteTask
=
useDeleteVideoTask
(
pid
);
// Tasks for active episode
const
episodeTasks
=
useMemo
(
const
epTasks
=
allTasks
.
filter
((
t
)
=>
t
.
episodeId
===
activeEpId
);
()
=>
allTasks
.
filter
((
task
)
=>
task
.
episodeId
===
activeEpisodeId
),
[
allTasks
,
activeEpisodeId
],
const
getTaskForSb
=
(
sb
:
Storyboard
):
AiTask
|
undefined
=>
{
);
const
byId
=
epTasks
.
filter
((
t
)
=>
t
.
storyboardId
===
sb
.
id
);
return
byId
.
find
((
t
)
=>
t
.
status
===
"succeeded"
)
??
byId
.
find
((
t
)
=>
t
.
status
===
"running"
||
t
.
status
===
"submitted"
||
t
.
status
===
"pending"
)
??
byId
[
0
];
};
// Succeeded shots in sequence order
const
orderedShots
=
storyboards
.
map
((
sb
)
=>
({
sb
,
task
:
getTaskForSb
(
sb
)
}))
.
filter
(({
task
})
=>
task
?.
status
===
"succeeded"
&&
task
.
resultVideoUrl
);
// Stats
const
totalShots
=
storyboards
.
length
;
const
doneShots
=
orderedShots
.
length
;
const
pendingShots
=
epTasks
.
filter
(
(
t
)
=>
t
.
status
===
"pending"
||
t
.
status
===
"submitted"
||
t
.
status
===
"running"
).
length
;
// Use actual storyboard durationSeconds (updated when video is generated)
const
totalDuration
=
storyboards
.
reduce
((
s
,
sb
)
=>
s
+
(
sb
.
durationSeconds
||
0
),
0
);
// ─── Player state ───
// "assembly" = show assembled video, "shot" = show individual storyboard video
const
videoRef
=
useRef
<
HTMLVideoElement
>
(
null
);
const
[
playing
,
setPlaying
]
=
useState
(
false
);
const
[
playerMode
,
setPlayerMode
]
=
useState
<
"assembly"
|
"shot"
>
(
"assembly"
);
const
[
currentShotIdx
,
setCurrentShotIdx
]
=
useState
(
0
);
const
hasAssembly
=
assemblyTask
?.
status
===
"succeeded"
&&
!!
assemblyTask
.
downloadUrl
;
// Current video URL to show in player
const
currentVideoUrl
=
playerMode
===
"assembly"
&&
hasAssembly
?
assemblyTask
!
.
downloadUrl
!
:
orderedShots
[
currentShotIdx
]?.
task
?.
resultVideoUrl
??
null
;
const
currentShotLabel
=
playerMode
===
"shot"
?
`分镜
${
String
(
orderedShots
[
currentShotIdx
]?.
sb
.
sequenceNum
??
1
).
padStart
(
3
,
"0"
)}
`
: "整集";
// Sync video src when currentVideoUrl changes
const
completedTasks
=
episodeTasks
.
filter
((
task
)
=>
task
.
status
===
"succeeded"
&&
task
.
resultVideoUrl
);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.pause();
setPlaying(false);
v.load();
}, [currentVideoUrl]);
const togglePlay = () => {
const
stats
=
[
const v = videoRef.current;
{
label
:
"分镜数量"
,
value
:
`
${
storyboards
.
length
}
`
},
if (!v) return;
{
label
:
"已完成视频"
,
value
:
`
${
completedTasks
.
length
}
`
},
if (v.paused) { v.play(); setPlaying(true); }
{
else { v.pause(); setPlaying(false); }
label
:
"合成状态"
,
};
value
:
assemblyTask
?.
status
===
"succeeded"
?
"已完成"
:
assemblyTask
?.
status
===
"failed"
?
"失败"
:
assemblyTask
?.
status
===
"running"
?
"进行中"
:
"未开始"
,
},
];
const
selectShot = (idx: number
) => {
const
handleDelete
=
async
(
task
:
AiTask
)
=>
{
setCurrentShotIdx(idx)
;
if
(
!
confirm
(
"确定要删除该视频任务吗?"
))
return
;
setPlayerMode("shot"
);
await
deleteTask
.
mutateAsync
(
task
.
id
);
};
};
const handleDelete = async (taskId: string) => {
if
(
loadingEpisodes
)
{
if (!confirm("确定删除此视频?")) return;
return
(
await deleteTask.mutateAsync(taskId);
<
div
className=
"flex h-full items-center justify-center"
>
};
<
Loader2
className=
"h-8 w-8 animate-spin text-primary"
/>
</
div
>
);
}
// Episode stats helper
if
(
episodes
.
length
===
0
)
{
const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0;
return
(
const epDoneCount = (ep: Episode) =>
<
div
className=
"mx-auto max-w-[1180px]"
>
allTasks.filter((t) => t.episodeId === ep.id && t.status === "succeeded").length;
<
div
className=
"rounded-[36px] border border-white/80 bg-white/82 px-6 py-20 text-center shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary"
>
<
Video
className=
"h-7 w-7"
/>
</
div
>
<
h2
className=
"mt-5 text-2xl font-semibold text-foreground"
>
还没有可用分集
</
h2
>
<
p
className=
"mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground"
>
请先完成分集和分镜流程,再在这里查看视频任务与成片合成结果。
</
p
>
</
div
>
</
div
>
);
}
return
(
return
(
<div className="h-full flex overflow-hidden bg-background">
<
div
className=
"mx-auto max-w-[1480px]"
>
<
section
className=
"rounded-[36px] border border-white/80 bg-white/82 p-6 shadow-[0_24px_60px_rgba(11,44,81,0.12)] backdrop-blur-xl"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-4"
>
<
div
>
<
div
className=
"inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs uppercase tracking-[0.24em] text-primary/72"
>
<
Film
className=
"h-3.5 w-3.5"
/>
Video Pipeline
</
div
>
<
h1
className=
"mt-4 text-3xl font-semibold text-foreground"
>
视频生成与合成
</
h1
>
<
p
className=
"mt-3 max-w-3xl text-sm leading-7 text-muted-foreground"
>
查看当前分集下的镜头视频任务,完成后可直接发起整集合成并下载结果。
</
p
>
</
div
>
{/* ─── Left: Episode list ─── */}
<
button
<div className="flex-shrink-0 border-r border-border flex flex-col" style={{ width: 180 }}>
onClick=
{
()
=>
navigate
(
`/project/${projectId}/storyboard/${activeEpisodeId}`
)
}
<div className="h-14 px-4 border-b border-border flex items-center flex-shrink-0">
className=
"inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-5 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
<span className="text-sm font-medium text-foreground">选集</span>
>
<
Clapperboard
className=
"h-4 w-4"
/>
返回分镜工作台
</
button
>
</
div
>
</
div
>
<div className="flex-1 overflow-y-auto py-2">
{epLoading ? (
<
div
className=
"mt-6 flex flex-wrap gap-3"
>
<div className="flex justify-center pt-6">
{
episodes
.
map
((
episode
)
=>
(
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<
button
</div>
key=
{
episode
.
id
}
) : episodes.length === 0 ? (
onClick=
{
()
=>
setActiveEpisodeId
(
episode
.
id
)
}
<p className="text-xs text-muted-foreground text-center pt-6 px-3">暂无分集</p>
className=
{
[
) : (
"rounded-2xl border px-4 py-3 text-sm transition-all"
,
episodes.map((ep) => {
activeEpisodeId
===
episode
.
id
const total = epShotCount(ep);
?
"border-primary/20 bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] text-white shadow-[0_16px_32px_rgba(15,116,216,0.20)]"
const done = epDoneCount(ep);
:
"border-border/80 bg-white text-muted-foreground hover:border-primary/20 hover:text-foreground"
,
const isActive = ep.id === activeEpId;
].
join
(
" "
)
}
return (
>
<button
第
{
episode
.
episodeNumber
}
集
key={ep.id}
</
button
>
onClick={() => { setActiveEpId(ep.id); setPlayerMode("assembly"); setCurrentShotIdx(0); }}
))
}
className={`
w
-
full
text
-
left
px
-
4
py
-
3
border
-
b
border
-
border
/
50
transition
-
colors
$
{
isActive
?
"bg-accent"
:
"hover:bg-muted"
}
`}
>
<div className="flex items-center justify-between mb-0.5">
<span className={`
text
-
sm
font
-
semibold
$
{
isActive
?
"text-primary"
:
"text-foreground"
}
`}>
第{ep.episodeNumber}集
</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{total}个分镜</span>
<span className={`
text
-
[
11
px
]
$
{
done
>
0
?
"text-green-600"
:
"text-muted-foreground"
}
`}>
{done}/{total}完成
</span>
</div>
</button>
);
})
)}
</
div
>
</
div
>
</div>
{/* ─── Right: Main content ─── */}
<
div
className=
"mt-6 grid gap-4 md:grid-cols-3"
>
<div className="flex-1 overflow-y-auto">
{
stats
.
map
((
stat
)
=>
(
<div className="p-6 space-y-6">
<
div
key=
{
stat
.
label
}
className=
"rounded-[28px] border border-border/80 bg-white/78 px-5 py-5 shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
{
stat
.
label
}
</
div
>
<
div
className=
"mt-3 text-2xl font-semibold text-foreground"
>
{
stat
.
value
}
</
div
>
</
div
>
))
}
</
div
>
{/* Section 1: 视频播放器 */}
<
div
className=
"mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"
>
<div>
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<div className="
flex items-center justify-between mb-1
">
<
div
className=
"
mb-4 flex items-center justify-between
"
>
<
div
>
<
div
>
<h2 className="text-lg font-semibold text-foreground">整集视频</h2>
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Shot Videos
</
div
>
<p className="text-xs text-muted-foreground">点击下方分镜缩略图可切换单镜预览</p>
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
镜头视频任务
</
h2
>
</div>
<div className="flex items-center gap-2">
{/* Assembly controls */}
{hasAssembly && (
<button
onClick={() => setPlayerMode("assembly")}
className={`
flex
items
-
center
gap
-
1.5
px
-
3
py
-
1.5
rounded
-
lg
text
-
xs
transition
$
{
playerMode
===
"assembly"
?
"bg-primary text-primary-foreground"
:
"border border-border text-foreground hover:bg-muted"
}
`}
>
<Film className="w-3.5 h-3.5" />
整集
</button>
)}
{(!assemblyTask || assemblyTask.status === "failed") && (
<button
onClick={() => startAssembly.mutate()}
disabled={startAssembly.isPending || doneShots === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition disabled:opacity-50"
>
{startAssembly.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Film className="w-3.5 h-3.5" />}
合成整集
</button>
)}
{(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />合成中...
</span>
)}
{hasAssembly && (
<a
href={assemblyTask!.downloadUrl!}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition"
>
<Download className="w-3.5 h-3.5" />
导出整集
</a>
)}
</
div
>
</
div
>
</
div
>
</
div
>
{/* Player */}
<
div
className=
"space-y-4"
>
<div className="rounded-xl overflow-hidden bg-black relative" style={{ aspectRatio: "16/9", maxHeight: 440 }}>
{
episodeTasks
.
length
===
0
?
(
{currentVideoUrl ? (
<
div
className=
"rounded-[24px] border border-dashed border-border bg-white px-5 py-12 text-center text-sm text-muted-foreground"
>
<>
当前分集还没有视频任务,请先回到分镜工作台生成镜头视频。
{/* Badge */}
</
div
>
<div className="absolute top-3 left-3 z-10 bg-black/60 text-white text-xs px-2.5 py-1.5 rounded-lg backdrop-blur-sm">
)
:
(
<div className="text-[10px] opacity-70 leading-none mb-0.5">
episodeTasks
.
map
((
task
)
=>
(
{playerMode === "assembly" ? "整集合成" : "分镜预览"}
<
div
</div>
key=
{
task
.
id
}
<div className="font-medium">{currentShotLabel}</div>
className=
"rounded-[24px] border border-border/80 bg-white px-4 py-4 shadow-sm"
</div>
{/* Duration badge */}
<div className="absolute top-3 right-3 z-10 bg-black/60 text-white text-xs px-2.5 py-1.5 rounded-lg backdrop-blur-sm font-mono">
{fmt(totalDuration)}
</div>
<video
ref={videoRef}
src={currentVideoUrl}
className="w-full h-full object-contain"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
// Auto-advance to next shot in shot mode
if (playerMode === "shot" && currentShotIdx < orderedShots.length - 1) {
setCurrentShotIdx((i) => i + 1);
}
}}
/>
{/* Play/Pause overlay */}
<button
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center group"
>
>
<div className={`
w
-
14
h
-
14
rounded
-
full
bg
-
white
/
20
backdrop
-
blur
-
sm
flex
items
-
center
justify
-
center
group
-
hover
:
bg
-
white
/
30
transition
$
{
playing
?
"opacity-0 group-hover:opacity-100"
:
""
}
`}>
<
div
className=
"flex items-start justify-between gap-4"
>
{playing
<
div
>
? <Pause className="w-7 h-7 text-white" />
<
div
className=
"text-sm font-medium text-foreground"
>
: <Play className="w-7 h-7 text-white ml-0.5" />}
任务
{
task
.
id
.
slice
(
0
,
8
)
}
</
div
>
<
div
className=
"mt-2 text-xs text-muted-foreground"
>
{
new
Date
(
task
.
createdAt
).
toLocaleString
(
"zh-CN"
)
}
</
div
>
</
div
>
<
span
className=
{
`rounded-full px-3 py-1 text-xs ${getTaskClass(task.status)}`
}
>
{
getTaskLabel
(
task
.
status
)
}
</
span
>
</
div
>
</
div
>
</button>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3">
<Film className="w-12 h-12 text-white/20" />
<p className="text-sm text-white/50">
{assemblyTask?.status === "running" || assemblyTask?.status === "pending"
? "整集视频合成中..."
: doneShots === 0
? "暂无已完成的分镜视频"
: "整集视频尚未合成"}
</p>
</div>
)}
</div>
{/* Controls row */}
{
task
.
resultVideoUrl
?
(
<div className="flex items-center gap-3 mt-3">
<
video
src=
{
task
.
resultVideoUrl
}
controls
className=
"mt-4 aspect-video w-full rounded-2xl bg-muted object-cover"
/>
<button
)
:
(
onClick={() => {
<
div
className=
"mt-4 flex aspect-video items-center justify-center rounded-2xl bg-muted"
>
const idx = Math.max(0, currentShotIdx - 1);
<
PlayCircle
className=
"h-10 w-10 text-muted-foreground"
/>
selectShot(idx);
</
div
>
}}
)
}
disabled={orderedShots.length === 0 || (playerMode === "shot" && currentShotIdx === 0)}
className="p-1.5 rounded-lg hover:bg-muted transition text-muted-foreground disabled:opacity-30"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
disabled={!currentVideoUrl}
className="w-8 h-8 rounded-full bg-primary flex items-center justify-center hover:bg-primary/90 transition disabled:opacity-40"
>
{playing
? <Pause className="w-4 h-4 text-primary-foreground" />
: <Play className="w-4 h-4 text-primary-foreground ml-0.5" />}
</button>
<button
onClick={() => {
const idx = Math.min(orderedShots.length - 1, currentShotIdx + 1);
selectShot(idx);
}}
disabled={orderedShots.length === 0 || (playerMode === "shot" && currentShotIdx >= orderedShots.length - 1)}
className="p-1.5 rounded-lg hover:bg-muted transition text-muted-foreground disabled:opacity-30"
>
<SkipForward className="w-4 h-4" />
</button>
<span className="text-xs text-muted-foreground ml-1">
{playerMode === "shot" && orderedShots.length > 0
? `
$
{
currentShotIdx
+
1
}
/ ${orderedShots.length}
`
:
`共
${
orderedShots
.
length
}
个分镜`
}
<
/
span
>
</
div
>
{
/* Thumbnail strip — click to switch player to that shot */
}
<
div
className=
"mt-4 flex justify-end"
>
{
orderedShots
.
length
>
0
&&
(
<
button
<
div
className=
"flex gap-2 mt-3 overflow-x-auto pb-1"
>
onClick=
{
()
=>
handleDelete
(
task
)
}
{
orderedShots
.
map
(({
sb
,
task
},
i
)
=>
(
className=
"inline-flex items-center gap-2 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 transition hover:bg-red-100"
<
button
>
key=
{
sb
.
id
}
<
Trash2
className=
"h-4 w-4"
/>
onClick=
{
()
=>
selectShot
(
i
)
}
删除任务
className=
{
`flex-shrink-0 relative rounded-lg overflow-hidden border-2 transition ${
</
button
>
playerMode === "shot" && currentShotIdx === i
? "border-primary"
: "border-transparent hover:border-border"
}`
}
style=
{
{
width
:
100
,
aspectRatio
:
"16/9"
}
}
>
<
video
src=
{
task
!
.
resultVideoUrl
!
}
className=
"w-full h-full object-cover"
muted
/>
<
div
className=
"absolute bottom-0 left-0 right-0 flex items-end justify-between px-1.5 py-1 bg-gradient-to-t from-black/70"
>
<
span
className=
"text-[10px] text-white font-medium"
>
{
String
(
sb
.
sequenceNum
).
padStart
(
3
,
"0"
)
}
</
span
>
<
span
className=
"text-[10px] text-white/80"
>
{
sb
.
durationSeconds
}
s
</
span
>
</
div
>
</
div
>
</
button
>
</
div
>
))
}
))
</
div
>
)
}
)}
</
div
>
</
div
>
</
div
>
{
/* Section 2: Stats */
}
<
div
className=
"rounded-[32px] border border-white/80 bg-white/78 p-6 shadow-sm"
>
<
div
className=
"grid grid-cols-4 gap-4"
>
<
div
className=
"mb-4"
>
{
[
<
div
className=
"text-xs uppercase tracking-[0.24em] text-primary/70"
>
Assembly
</
div
>
{
label
:
"总分镜数"
,
value
:
totalShots
,
color
:
"text-foreground"
},
<
h2
className=
"mt-2 text-2xl font-semibold text-foreground"
>
整集合成
</
h2
>
{
label
:
"已完成"
,
value
:
doneShots
,
color
:
"text-green-600"
},
</
div
>
{
label
:
"生成中"
,
value
:
pendingShots
,
color
:
"text-orange-500"
},
{
label
:
"总时长"
,
value
:
fmt
(
totalDuration
),
color
:
"text-foreground"
},
].
map
((
s
)
=>
(
<
div
key=
{
s
.
label
}
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
className=
{
`text-2xl font-bold mb-1 ${s.color}`
}
>
{
s
.
value
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
{
s
.
label
}
</
div
>
</
div
>
))
}
</
div
>
{
/* Section 3: 分镜管理 */
}
<
div
className=
"rounded-[28px] border border-primary/10 bg-primary/5 p-5"
>
<
div
>
<
div
className=
"text-sm leading-6 text-muted-foreground"
>
<
h3
className=
"text-base font-semibold text-foreground mb-4"
>
分镜管理
</
h3
>
当前分集下共有
{
completedTasks
.
length
}
个可用镜头视频。确认镜头视频已经生成完成后,可以发起整集合成任务。
{
storyboards
.
length
===
0
?
(
<
div
className=
"text-center py-12 text-sm text-muted-foreground"
>
{
!
activeEpId
?
"请从左侧选择集数"
:
"该集暂无分镜"
}
</
div
>
</
div
>
)
:
(
<
button
<
div
className=
"grid grid-cols-3 gap-4"
>
onClick=
{
()
=>
startAssembly
.
mutate
()
}
{
storyboards
.
map
((
sb
)
=>
{
disabled=
{
completedTasks
.
length
===
0
||
startAssembly
.
isPending
}
const
task
=
getTaskForSb
(
sb
);
className=
"mt-5 inline-flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#0f74d8_0%,#1ea5ff_100%)] px-4 text-sm font-medium text-white shadow-[0_16px_28px_rgba(15,116,216,0.18)] transition hover:translate-y-[-1px] disabled:cursor-not-allowed disabled:opacity-60"
const
hasVideo
=
task
?.
status
===
"succeeded"
&&
task
.
resultVideoUrl
;
>
const
isPending
=
task
?.
status
===
"pending"
||
task
?.
status
===
"submitted"
||
task
?.
status
===
"running"
;
{
startAssembly
.
isPending
?
<
Loader2
className=
"h-4 w-4 animate-spin"
/>
:
<
Film
className=
"h-4 w-4"
/>
}
return
(
发起整集合成
<
div
key=
{
sb
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden"
>
</
button
>
{
/* Thumbnail — click plays in top player */
}
</
div
>
<
button
className=
"relative bg-black w-full"
style=
{
{
aspectRatio
:
"16/9"
}
}
onClick=
{
()
=>
{
if
(
!
hasVideo
)
return
;
const
idx
=
orderedShots
.
findIndex
((
s
)
=>
s
.
sb
.
id
===
sb
.
id
);
if
(
idx
>=
0
)
selectShot
(
idx
);
window
.
scrollTo
({
top
:
0
,
behavior
:
"smooth"
});
}
}
>
{
hasVideo
?
(
<
video
src=
{
task
!
.
resultVideoUrl
!
}
className=
"w-full h-full object-cover"
muted
/>
)
:
isPending
?
(
<
div
className=
"w-full h-full flex flex-col items-center justify-center gap-1"
>
<
Loader2
className=
"w-6 h-6 animate-spin text-white/40"
/>
<
span
className=
"text-[10px] text-white/40"
>
生成中
</
span
>
</
div
>
)
:
(
<
div
className=
"w-full h-full flex items-center justify-center"
>
<
Play
className=
"w-8 h-8 text-white/20"
/>
</
div
>
)
}
{
/* Badges */
}
<
div
className=
"absolute top-2 left-2 bg-black/60 text-white text-[11px] font-medium px-1.5 py-0.5 rounded backdrop-blur-sm"
>
分镜
{
String
(
sb
.
sequenceNum
).
padStart
(
3
,
"0"
)
}
</
div
>
{
sb
.
durationSeconds
>
0
&&
(
<
div
className=
"absolute top-2 right-2 bg-black/60 text-white text-[11px] px-1.5 py-0.5 rounded backdrop-blur-sm"
>
{
sb
.
durationSeconds
}
s
</
div
>
)
}
{
hasVideo
&&
<
div
className=
"absolute bottom-2 right-2 w-2 h-2 rounded-full bg-green-400"
/>
}
{
isPending
&&
<
div
className=
"absolute bottom-2 right-2 w-2 h-2 rounded-full bg-orange-400 animate-pulse"
/>
}
{
/* Play hint overlay */
}
{
hasVideo
&&
(
<
div
className=
"absolute inset-0 bg-black/0 hover:bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition"
>
<
Play
className=
"w-8 h-8 text-white drop-shadow"
/>
</
div
>
)
}
</
button
>
{
/* Footer */
}
<
div
className=
"mt-5 rounded-[28px] border border-border/80 bg-white p-5 shadow-sm"
>
<
div
className=
"px-3 py-2.5 flex items-center justify-between"
>
<
div
className=
"text-sm font-medium text-foreground"
>
当前状态
</
div
>
<
span
className=
"text-[11px] text-muted-foreground"
>
<
div
className=
"mt-2 text-sm leading-6 text-muted-foreground"
>
{
task
?
timeAgo
(
task
.
createdAt
)
:
"未生成"
}
{
assemblyTask
?.
status
?
getTaskLabel
(
assemblyTask
.
status
)
:
"尚未开始合成"
}
</
span
>
<
div
className=
"flex items-center gap-3"
>
<
button
onClick=
{
()
=>
navigate
(
`/project/${pid}/storyboard/${activeEpId}`
)
}
className=
"flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<
Edit2
className=
"w-3 h-3"
/>
编辑
</
button
>
{
hasVideo
&&
(
<
a
href=
{
task
!
.
resultVideoUrl
!
}
target=
"_blank"
rel=
"noreferrer"
className=
"flex items-center gap-1 text-[11px] text-foreground hover:text-primary"
>
<
Download
className=
"w-3 h-3"
/>
下载
</
a
>
)
}
{
task
&&
(
<
button
onClick=
{
()
=>
handleDelete
(
task
.
id
)
}
disabled=
{
deleteTask
.
isPending
}
className=
"text-destructive hover:text-red-700 disabled:opacity-40"
>
<
Trash2
className=
"w-3.5 h-3.5"
/>
</
button
>
)
}
</
div
>
</
div
>
</
div
>
);
})
}
</
div
>
</
div
>
)
}
{
assemblyTask
?.
downloadUrl
?
(
<
a
href=
{
assemblyTask
.
downloadUrl
}
target=
"_blank"
rel=
"noreferrer"
className=
"mt-5 inline-flex items-center gap-2 rounded-2xl border border-border/80 bg-white px-4 py-3 text-sm text-foreground shadow-sm transition hover:border-primary/20 hover:text-primary"
>
<
Download
className=
"h-4 w-4"
/>
下载合成结果
</
a
>
)
:
null
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
/
div
>
</
section
>
</
div
>
</
div
>
);
);
}
}
doc/html/src/lib/brand.ts
0 → 100644
View file @
75992d7b
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
=
[
"品牌化工作台与中科集团统一视觉体系"
,
"覆盖剧本、设定、分镜、视频的完整生产链路"
,
"支持团队协作、资产沉淀与项目进度追踪"
,
];
doc/html/src/styles/fonts.css
View file @
75992d7b
:root
{
--font-sans-brand
:
"Avenir Next"
,
"Segoe UI"
,
"PingFang SC"
,
"Hiragino Sans GB"
,
"Microsoft YaHei"
,
"Noto Sans SC"
,
sans-serif
;
}
doc/html/src/styles/theme.css
View file @
75992d7b
...
@@ -2,43 +2,43 @@
...
@@ -2,43 +2,43 @@
:root
{
:root
{
--font-size
:
14px
;
--font-size
:
14px
;
--background
:
#
f8f9fa
;
--background
:
#
eef4f9
;
--foreground
:
#1
a1a1a
;
--foreground
:
#1
2263f
;
--card
:
#ffffff
;
--card
:
rgba
(
255
,
255
,
255
,
0.92
)
;
--card-foreground
:
#1
a1a1a
;
--card-foreground
:
#1
2263f
;
--popover
:
#ffffff
;
--popover
:
#ffffff
;
--popover-foreground
:
#1
a1a1a
;
--popover-foreground
:
#1
2263f
;
--primary
:
#
5b5ff9
;
--primary
:
#
0f74d8
;
--primary-foreground
:
#ffffff
;
--primary-foreground
:
#ffffff
;
--secondary
:
#
f1f3f5
;
--secondary
:
#
d9e7f4
;
--secondary-foreground
:
#1
a1a1a
;
--secondary-foreground
:
#1
63b62
;
--muted
:
#f
8f9fa
;
--muted
:
#f
4f8fc
;
--muted-foreground
:
#
6c757d
;
--muted-foreground
:
#
5d6f84
;
--accent
:
#e
7f5ff
;
--accent
:
#e
5f1fb
;
--accent-foreground
:
#
1971c2
;
--accent-foreground
:
#
0f4d8f
;
--destructive
:
#
f03e3e
;
--destructive
:
#
d95050
;
--destructive-foreground
:
#ffffff
;
--destructive-foreground
:
#ffffff
;
--border
:
rgba
(
0
,
0
,
0
,
0.06
);
--border
:
rgba
(
16
,
74
,
132
,
0.12
);
--input
:
rgba
(
0
,
0
,
0
,
0.06
);
--input
:
rgba
(
16
,
74
,
132
,
0.12
);
--input-background
:
#ffffff
;
--input-background
:
rgba
(
255
,
255
,
255
,
0.92
)
;
--switch-background
:
#
dee2e6
;
--switch-background
:
#
c3d4e5
;
--font-weight-medium
:
500
;
--font-weight-medium
:
500
;
--font-weight-normal
:
400
;
--font-weight-normal
:
400
;
--ring
:
#
5b5ff9
;
--ring
:
#
0f74d8
;
--chart-1
:
#
5b5ff9
;
--chart-1
:
#
0f74d8
;
--chart-2
:
#2
2b8cf
;
--chart-2
:
#2
8a5ea
;
--chart-3
:
#
51cf66
;
--chart-3
:
#
2fbe93
;
--chart-4
:
#ff
6b6b
;
--chart-4
:
#ff
8b52
;
--chart-5
:
#f
cc419
;
--chart-5
:
#f
2c84b
;
--radius
:
0.75
rem
;
--radius
:
1
rem
;
--sidebar
:
#ffffff
;
--sidebar
:
rgba
(
255
,
255
,
255
,
0.86
)
;
--sidebar-foreground
:
#1
a1a1a
;
--sidebar-foreground
:
#1
2263f
;
--sidebar-primary
:
#
5b5ff9
;
--sidebar-primary
:
#
0f74d8
;
--sidebar-primary-foreground
:
#ffffff
;
--sidebar-primary-foreground
:
#ffffff
;
--sidebar-accent
:
#
f8f9fa
;
--sidebar-accent
:
#
eff5fb
;
--sidebar-accent-foreground
:
#1
a1a1a
;
--sidebar-accent-foreground
:
#1
23c67
;
--sidebar-border
:
rgba
(
0
,
0
,
0
,
0.06
);
--sidebar-border
:
rgba
(
16
,
74
,
132
,
0.12
);
--sidebar-ring
:
#
5b5ff9
;
--sidebar-ring
:
#
0f74d8
;
}
}
.dark
{
.dark
{
...
@@ -126,6 +126,26 @@
...
@@ -126,6 +126,26 @@
body
{
body
{
@apply
bg-background
text-foreground;
@apply
bg-background
text-foreground;
font-family
:
var
(
--font-sans-brand
);
min-height
:
100vh
;
background-image
:
radial-gradient
(
circle
at
top
left
,
rgba
(
30
,
165
,
255
,
0.18
),
transparent
26%
),
radial-gradient
(
circle
at
bottom
right
,
rgba
(
12
,
59
,
106
,
0.12
),
transparent
24%
),
linear-gradient
(
180deg
,
rgba
(
255
,
255
,
255
,
0.72
),
rgba
(
255
,
255
,
255
,
0.72
));
background-attachment
:
fixed
;
}
body
::before
{
content
:
""
;
position
:
fixed
;
inset
:
0
;
pointer-events
:
none
;
background-image
:
linear-gradient
(
rgba
(
15
,
87
,
157
,
0.035
)
1px
,
transparent
1px
),
linear-gradient
(
90deg
,
rgba
(
15
,
87
,
157
,
0.035
)
1px
,
transparent
1px
);
background-size
:
28px
28px
;
mask-image
:
linear-gradient
(
180deg
,
rgba
(
0
,
0
,
0
,
0.7
),
transparent
92%
);
z-index
:
-1
;
}
}
/**
/**
...
@@ -178,4 +198,9 @@
...
@@ -178,4 +198,9 @@
font-weight
:
var
(
--font-weight-normal
);
font-weight
:
var
(
--font-weight-normal
);
line-height
:
1.5
;
line-height
:
1.5
;
}
}
::selection
{
background
:
rgba
(
15
,
116
,
216
,
0.18
);
color
:
var
(
--foreground
);
}
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment