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