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
175f406c
Commit
175f406c
authored
Apr 21, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
项目编辑风格和三视图
parent
1e9c434e
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
1194 additions
and
392 deletions
+1194
-392
CharacterGeneration.tsx
doc/html/src/app/pages/CharacterGeneration.tsx
+274
-142
Dashboard.tsx
doc/html/src/app/pages/Dashboard.tsx
+73
-7
NewProject.tsx
doc/html/src/app/pages/NewProject.tsx
+255
-85
OutlineGeneration.tsx
doc/html/src/app/pages/OutlineGeneration.tsx
+349
-78
ProjectDetail.tsx
doc/html/src/app/pages/ProjectDetail.tsx
+5
-2
ProjectSettings.tsx
doc/html/src/app/pages/ProjectSettings.tsx
+22
-2
StoryboardWorkspace.tsx
doc/html/src/app/pages/StoryboardWorkspace.tsx
+67
-8
StyleSelection.tsx
doc/html/src/app/pages/StyleSelection.tsx
+24
-63
useAi.ts
doc/html/src/hooks/useAi.ts
+5
-1
ai.ts
doc/html/src/lib/api/ai.ts
+27
-4
projects.ts
doc/html/src/lib/api/projects.ts
+7
-0
projectStyles.ts
doc/html/src/lib/projectStyles.ts
+86
-0
No files found.
doc/html/src/app/pages/CharacterGeneration.tsx
View file @
175f406c
import
{
use
State
,
useRef
}
from
"react"
;
import
{
use
Ref
,
useState
,
type
ChangeEvent
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
ArrowRight
,
Sparkles
,
Plus
,
Edit2
,
Trash2
,
X
,
Check
,
Users
,
MapPin
,
Box
,
RefreshCw
,
ImageIcon
,
Loader2
,
Upload
}
from
"lucide-react"
;
import
{
useCharacters
,
useExtractCharacters
,
useGenerateCharacterImage
,
useDeleteCharacter
,
useSaveCharacter
,
useUploadCharacterImage
}
from
"../../hooks/useAi"
;
import
{
ArrowRight
,
Check
,
Edit2
,
ImageIcon
,
Loader2
,
MapPin
,
Plus
,
RefreshCw
,
Sparkles
,
Trash2
,
Upload
,
Users
,
X
,
Box
,
}
from
"lucide-react"
;
import
{
useCharacters
,
useDeleteCharacter
,
useExtractCharacters
,
useGenerateCharacterImage
,
useSaveCharacter
,
useUploadCharacterImage
,
}
from
"../../hooks/useAi"
;
import
type
{
Character
}
from
"../../lib/api/ai"
;
type
SettingsTab
=
"characters"
|
"scenes"
|
"props"
;
type
CharacterViewType
=
"front"
|
"side"
|
"back"
;
type
ImageState
=
Record
<
CharacterViewType
,
string
|
null
>
;
type
PendingFiles
=
Record
<
CharacterViewType
,
File
|
null
>
;
const
ROLE_TYPES
=
[
{
value
:
"female_lead"
,
label
:
"女主角"
},
...
...
@@ -13,10 +38,48 @@ const ROLE_TYPES = [
{
value
:
"supporting"
,
label
:
"配角"
},
];
const
CHARACTER_VIEWS
:
Array
<
{
type
:
CharacterViewType
;
label
:
string
;
shortLabel
:
string
}
>
=
[
{
type
:
"front"
,
label
:
"正面"
,
shortLabel
:
"正"
},
{
type
:
"side"
,
label
:
"侧面"
,
shortLabel
:
"侧"
},
{
type
:
"back"
,
label
:
"背面"
,
shortLabel
:
"背"
},
];
const
emptyForm
=
():
Partial
<
Character
>
=>
({
name
:
""
,
roleType
:
"supporting"
,
gender
:
"female"
,
age
:
""
,
personality
:
""
,
costume
:
""
,
visualHint
:
""
,
name
:
""
,
roleType
:
"supporting"
,
gender
:
"female"
,
age
:
""
,
personality
:
""
,
costume
:
""
,
visualHint
:
""
,
});
const
emptyImageState
=
():
ImageState
=>
({
front
:
null
,
side
:
null
,
back
:
null
,
});
const
emptyPendingFiles
=
():
PendingFiles
=>
({
front
:
null
,
side
:
null
,
back
:
null
,
});
function
getCharacterViewUrl
(
character
:
Partial
<
Character
>
,
viewType
:
CharacterViewType
)
{
if
(
viewType
===
"front"
)
{
return
character
.
frontImageUrl
??
character
.
imageUrl
??
null
;
}
if
(
viewType
===
"side"
)
{
return
character
.
sideImageUrl
??
null
;
}
return
character
.
backImageUrl
??
null
;
}
function
hasCompleteThreeViews
(
character
:
Partial
<
Character
>
)
{
return
CHARACTER_VIEWS
.
every
((
view
)
=>
!!
getCharacterViewUrl
(
character
,
view
.
type
));
}
export
function
CharacterGeneration
()
{
const
navigate
=
useNavigate
();
const
{
projectId
}
=
useParams
();
...
...
@@ -31,14 +94,17 @@ export function CharacterGeneration() {
const
[
activeTab
]
=
useState
<
SettingsTab
>
(
"characters"
);
const
[
generatingImageId
,
setGeneratingImageId
]
=
useState
<
string
|
null
>
(
null
);
// modal state
const
[
showModal
,
setShowModal
]
=
useState
(
false
);
const
[
editingChar
,
setEditingChar
]
=
useState
<
Partial
<
Character
>>
(
emptyForm
());
const
[
imagePreview
,
setImagePreview
]
=
useState
<
string
|
null
>
(
null
);
const
[
pendingFile
,
setPendingFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
imagePreview
s
,
setImagePreviews
]
=
useState
<
ImageState
>
(
emptyImageState
()
);
const
[
pendingFile
s
,
setPendingFiles
]
=
useState
<
PendingFiles
>
(
emptyPendingFiles
()
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
fileInputRefs
=
useRef
<
Record
<
CharacterViewType
,
HTMLInputElement
|
null
>>
({
front
:
null
,
side
:
null
,
back
:
null
,
});
const
settingsTabs
=
[
{
id
:
"characters"
as
SettingsTab
,
label
:
"角色"
,
icon
:
Users
,
path
:
`/project/
${
projectId
}
/characters`
},
...
...
@@ -46,77 +112,116 @@ export function CharacterGeneration() {
{
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
=
()
=>
{
extract
.
mutate
(
undefined
,
{
onError
:
(
e
)
=>
alert
(
"提取失败: "
+
(
e
as
Error
).
message
)
});
extract
.
mutate
(
undefined
,
{
onError
:
(
error
)
=>
alert
(
`提取失败:
${(
error
as
Error
).
message
}
`
),
});
};
const
openCreate
=
()
=>
{
setEditingChar
(
emptyForm
());
setImagePreview
(
null
);
setPendingFile
(
null
);
setImagePreview
s
(
emptyImageState
()
);
setPendingFile
s
(
emptyPendingFiles
()
);
setShowModal
(
true
);
};
const
openEdit
=
(
char
:
Character
)
=>
{
setEditingChar
({
...
char
});
setImagePreview
(
char
.
imageUrl
??
null
);
setPendingFile
(
null
);
const
openEdit
=
(
character
:
Character
)
=>
{
setEditingChar
({
...
character
});
setImagePreviews
({
front
:
getCharacterViewUrl
(
character
,
"front"
),
side
:
getCharacterViewUrl
(
character
,
"side"
),
back
:
getCharacterViewUrl
(
character
,
"back"
),
});
setPendingFiles
(
emptyPendingFiles
());
setShowModal
(
true
);
};
const
handleFileSelect
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
f
=
e
.
target
.
files
?.[
0
];
if
(
!
f
)
return
;
setPendingFile
(
f
);
setImagePreview
(
URL
.
createObjectURL
(
f
));
const
handleFileSelect
=
(
viewType
:
CharacterViewType
,
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
e
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
setPendingFiles
((
current
)
=>
({
...
current
,
[
viewType
]:
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
()
=>
{
if
(
!
editingChar
.
name
?.
trim
())
return
;
setSaving
(
true
);
try
{
const
saved
=
await
saveCharacter
.
mutateAsync
({
...
editingChar
,
status
:
editingChar
.
status
??
"draft"
});
if
(
pendingFile
)
{
await
uploadImage
.
mutateAsync
({
id
:
String
(
saved
.
id
),
file
:
pendingFile
});
for
(
const
view
of
CHARACTER_VIEWS
)
{
const
pendingFile
=
pendingFiles
[
view
.
type
];
if
(
!
pendingFile
)
continue
;
await
uploadImage
.
mutateAsync
({
id
:
String
(
saved
.
id
),
file
:
pendingFile
,
viewType
:
view
.
type
});
}
setShowModal
(
false
);
}
catch
(
e
)
{
alert
(
"保存失败: "
+
(
e
as
Error
).
message
);
}
catch
(
e
rror
)
{
alert
(
`保存失败:
${(
error
as
Error
).
message
}
`
);
}
finally
{
setSaving
(
false
);
}
};
const
handleGenerateImage
=
async
(
char
:
Character
)
=>
{
setGeneratingImageId
(
char
.
id
);
generateImage
.
mutate
(
char
.
id
,
{
const
handleGenerateImage
=
async
(
char
acter
:
Character
)
=>
{
setGeneratingImageId
(
char
acter
.
id
);
generateImage
.
mutate
(
char
acter
.
id
,
{
onSettled
:
()
=>
setGeneratingImageId
(
null
),
onError
:
(
e
)
=>
alert
(
"图片生成失败: "
+
(
e
as
Error
).
message
),
onError
:
(
e
rror
)
=>
alert
(
`三视图生成失败:
${(
error
as
Error
).
message
}
`
),
});
};
const
handleDelete
=
(
char
:
Character
)
=>
{
if
(
!
confirm
(
`确定要删除角色「
${
char
.
name
}
」吗?`
))
return
;
deleteChar
.
mutate
(
char
.
id
,
{
onError
:
(
e
)
=>
alert
(
"删除失败: "
+
(
e
as
Error
).
message
)
});
};
const
roleTypeLabel
:
Record
<
string
,
string
>
=
{
"女主"
:
"女主角"
,
"男主"
:
"男主角"
,
"反派"
:
"反派"
,
"配角"
:
"配角"
,
"female_lead"
:
"女主角"
,
"male_lead"
:
"男主角"
,
"villain"
:
"反派"
,
"supporting"
:
"配角"
,
"antagonist"
:
"反派"
,
};
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
>;
const
handleDelete
=
(
character
:
Character
)
=>
{
if
(
!
confirm
(
`确定要删除角色「
${
character
.
name
}
」吗?`
))
return
;
deleteChar
.
mutate
(
character
.
id
,
{
onError
:
(
error
)
=>
alert
(
`删除失败:
${(
error
as
Error
).
message
}
`
),
});
};
return
(
<
div
className=
"h-full overflow-auto bg-background relative"
>
<
div
className=
"p-6 pb-24"
>
<
div
className=
"max-w-7xl mx-auto"
>
{
/* Header */
}
<
div
className=
"mb-6"
>
<
div
className=
"flex items-center gap-3 mb-2"
>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
...
...
@@ -124,10 +229,9 @@ export function CharacterGeneration() {
</
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
项目设定
</
h1
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
管理角色、场景和道具设定
</
p
>
<
p
className=
"text-sm text-muted-foreground"
>
管理角色、场景和道具设定
。角色形象已升级为标准三视图。
</
p
>
</
div
>
{
/* Settings Navigation Tabs */
}
<
div
className=
"flex items-center justify-between mb-6"
>
<
div
className=
"flex items-center gap-2"
>
{
settingsTabs
.
map
((
tab
)
=>
{
...
...
@@ -148,6 +252,7 @@ export function CharacterGeneration() {
);
})
}
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
button
onClick=
{
openCreate
}
...
...
@@ -167,34 +272,30 @@ export function CharacterGeneration() {
</
div
>
</
div
>
{
/* Statistics */
}
<
div
className=
"flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"
>
<
div
className=
"flex items-center gap-6"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm text-muted-foreground"
>
总计:
</
span
>
<
span
className=
"text-sm text-muted-foreground"
>
角色总数
</
span
>
<
span
className=
"text-lg font-semibold text-foreground"
>
{
characters
.
length
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm text-muted-foreground"
>
已生成图片:
</
span
>
<
span
className=
"text-lg font-semibold text-green-600"
>
{
characters
.
filter
(
c
=>
c
.
status
===
"ready"
).
length
}
</
span
>
<
span
className=
"text-sm text-muted-foreground"
>
三视图完整
</
span
>
<
span
className=
"text-lg font-semibold text-green-600"
>
{
generatedCount
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm text-muted-foreground"
>
生成中:
</
span
>
<
span
className=
"text-lg font-semibold text-blue-600"
>
{
characters
.
filter
(
c
=>
c
.
status
===
"generating"
).
length
}
</
span
>
<
span
className=
"text-sm text-muted-foreground"
>
生成中
</
span
>
<
span
className=
"text-lg font-semibold text-blue-600"
>
{
generatingCount
}
</
span
>
</
div
>
</
div
>
</
div
>
{
/* Empty State */
}
{
!
isLoading
&&
characters
.
length
===
0
&&
(
<
div
className=
"rounded-xl border border-dashed border-border bg-card p-12 text-center"
>
<
Users
className=
"w-12 h-12 text-muted-foreground mx-auto mb-4"
/>
<
h3
className=
"text-lg font-medium text-foreground mb-2"
>
暂无角色
</
h3
>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
手动添加角色,或点击「AI提取角色」从大纲和分集自动提取主要角色
</
p
>
<
p
className=
"text-sm text-muted-foreground mb-6"
>
角色设定页现在支持三视图展示。你可以手动添加角色,或从大纲和分集里自动提取。
</
p
>
<
div
className=
"flex items-center justify-center gap-3"
>
<
button
onClick=
{
openCreate
}
...
...
@@ -221,30 +322,44 @@ export function CharacterGeneration() {
</
div
>
)
}
{
/* Character Grid */
}
{
characters
.
length
>
0
&&
(
<
div
className=
"grid grid-cols-2 gap-6"
>
{
characters
.
map
((
char
)
=>
(
<
div
key=
{
char
.
id
}
className=
"rounded-xl border border-border bg-card overflow-hidden"
>
{
/* Info Section */
}
{
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"
>
{
cha
r
.
name
}
</
h3
>
<
div
className=
"flex items-center gap-2
"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
roleTypeLabel
[
char
.
roleType
]
??
cha
r
.
roleType
}
</
span
>
{
statusBadge
(
cha
r
.
status
)
}
<
h3
className=
"text-lg font-semibold text-foreground mb-1"
>
{
characte
r
.
name
}
</
h3
>
<
div
className=
"flex items-center gap-2 flex-wrap
"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
roleTypeLabel
[
character
.
roleType
]
??
characte
r
.
roleType
}
</
span
>
{
statusBadge
(
characte
r
.
status
)
}
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-1"
>
<
button
onClick=
{
()
=>
openEdit
(
cha
r
)
}
onClick=
{
()
=>
openEdit
(
characte
r
)
}
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
(
cha
r
)
}
onClick=
{
()
=>
handleDelete
(
characte
r
)
}
className=
"p-2 rounded-lg border border-border bg-card text-destructive hover:bg-destructive/10 transition-colors"
>
<
Trash2
className=
"w-4 h-4"
/>
...
...
@@ -254,78 +369,86 @@ export function CharacterGeneration() {
<
div
className=
"space-y-1.5 text-sm"
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
性别:
</
span
>
<
span
className=
"text-foreground"
>
{
char
.
gender
===
"male"
?
"男"
:
char
.
gender
===
"female"
?
"女"
:
cha
r
.
gender
}
</
span
>
<
span
className=
"text-muted-foreground w-16"
>
性别
</
span
>
<
span
className=
"text-foreground"
>
{
character
.
gender
===
"male"
?
"男"
:
character
.
gender
===
"female"
?
"女"
:
characte
r
.
gender
}
</
span
>
</
div
>
<
div
className=
"flex"
>
<
span
className=
"text-muted-foreground w-16"
>
年龄:
</
span
>
<
span
className=
"text-foreground"
>
{
char
.
age
}
</
span
>
<
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"
>
{
char
.
personality
}
</
span
>
<
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"
>
{
char
.
costume
}
</
span
>
<
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"
>
{
char
.
visualHint
}
</
span
>
<
span
className=
"text-muted-foreground w-16"
>
外貌
</
span
>
<
span
className=
"text-foreground line-clamp-1"
>
{
character
.
visualHint
||
"未填写"
}
</
span
>
</
div
>
</
div
>
</
div
>
{
/* Image Section */
}
<
div
className=
"p-5 bg-muted/20"
>
<
div
className=
"flex items-center justify-between mb-3"
>
<
span
className=
"text-xs text-muted-foreground font-medium"
>
角色形象
</
span
>
<
div
>
<
span
className=
"text-xs text-muted-foreground font-medium"
>
角色三视图
</
span
>
<
p
className=
"text-[11px] text-muted-foreground mt-0.5"
>
正面 + 侧面 + 背面,用于完整展示角色造型。
</
p
>
</
div
>
<
button
onClick=
{
()
=>
handleGenerateImage
(
cha
r
)
}
disabled=
{
generatingImageId
===
char
.
id
||
char
.
status
===
"generating"
}
onClick=
{
()
=>
handleGenerateImage
(
characte
r
)
}
disabled=
{
isGenerating
}
className=
"px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1.5 disabled:opacity-50"
>
{
generatingImageId
===
char
.
id
||
char
.
status
===
"generating"
?
(
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
)
:
(
<
RefreshCw
className=
"w-3 h-3"
/>
)
}
{
char
.
imageUrl
?
"重新生成"
:
"AI生成图片"
}
{
isGenerating
?
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
:
<
RefreshCw
className=
"w-3 h-3"
/>
}
{
hasCompleteThreeViews
(
character
)
?
"重新生成三视图"
:
"AI生成三视图"
}
</
button
>
</
div
>
<
div
className=
"aspect-square rounded-lg overflow-hidden bg-white border border-border"
>
{
char
.
imageUrl
?
(
<
img
src=
{
char
.
imageUrl
}
alt=
{
char
.
name
}
className=
"w-full h-full object-cover"
/>
<
div
className=
"grid grid-cols-3 gap-3"
>
{
CHARACTER_VIEWS
.
map
((
view
)
=>
{
const
imageUrl
=
getCharacterViewUrl
(
character
,
view
.
type
);
return
(
<
div
key=
{
view
.
type
}
>
<
div
className=
"text-[11px] text-muted-foreground mb-1 text-center"
>
{
view
.
label
}
</
div
>
<
div
className=
"aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border"
>
{
imageUrl
?
(
<
img
src=
{
imageUrl
}
alt=
{
`${character.name}-${view.label}`
}
className=
"w-full h-full object-cover"
/>
)
:
(
<
div
className=
"w-full h-full flex flex-col items-center justify-center gap-2"
>
{
char
.
status
===
"generating"
?
(
{
isGenerating
?
(
<>
<
Loader2
className=
"w-8 h-8
text-primary animate-spin"
/>
<
span
className=
"text-sm text-muted-foreground"
>
AI生成中...
</
span
>
<
Loader2
className=
"w-6 h-6
text-primary animate-spin"
/>
<
span
className=
"text-xs text-muted-foreground"
>
生成中
</
span
>
</>
)
:
(
<>
<
ImageIcon
className=
"w-8 h-8
text-muted-foreground"
/>
<
span
className=
"text-sm 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
>
{
cha
r
.
imagePrompt
&&
(
<
p
className=
"mt-2 text-xs text-muted-foreground line-clamp-2"
>
{
cha
r
.
imagePrompt
}
</
p
>
{
characte
r
.
imagePrompt
&&
(
<
p
className=
"mt-3 text-xs text-muted-foreground line-clamp-3"
>
{
characte
r
.
imagePrompt
}
</
p
>
)
}
</
div
>
</
div
>
))
}
);
})
}
</
div
>
)
}
</
div
>
</
div
>
{
/* Floating Bottom Bar */
}
<
div
className=
"fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"
>
<
div
className=
"max-w-7xl mx-auto flex items-center justify-end gap-3"
>
<
button
...
...
@@ -338,68 +461,78 @@ export function CharacterGeneration() {
</
div
>
</
div
>
{
/* Add/Edit Character Modal */
}
{
showModal
&&
(
<
div
className=
"fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"
>
<
div
className=
"bg-card rounded-2xl border border-border w-full max-w-lg max-h-[90vh] flex flex-col"
>
{
/* Modal Header */
}
<
div
className=
"bg-card rounded-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col"
>
<
div
className=
"flex items-center justify-between p-6 border-b border-border flex-shrink-0"
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
{
editingChar
.
id
?
"编辑角色"
:
"手动添加角色"
}
</
h2
>
<
div
>
<
h2
className=
"text-lg font-semibold text-foreground"
>
{
editingChar
.
id
?
"编辑角色"
:
"手动添加角色"
}
</
h2
>
<
p
className=
"text-sm text-muted-foreground mt-1"
>
支持上传正面、侧面、背面三张标准视角图。
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
className=
"p-2 hover:bg-muted rounded-lg transition-colors"
>
<
X
className=
"w-5 h-5 text-muted-foreground"
/>
</
button
>
</
div
>
{
/* Modal Body */
}
<
div
className=
"overflow-auto p-6 space-y-4 flex-1"
>
{
/* Image Upload */
}
<
div
className=
"overflow-auto p-6 space-y-5 flex-1"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
角色图片(可选)
</
label
>
<
div
className=
"flex items-start gap-4"
>
<
label
className=
"block text-sm font-medium text-foreground mb-3"
>
角色三视图上传(可选)
</
label
>
<
div
className=
"grid grid-cols-3 gap-4"
>
{
CHARACTER_VIEWS
.
map
((
view
)
=>
(
<
div
key=
{
view
.
type
}
className=
"rounded-xl border border-border bg-muted/20 p-3"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
span
className=
"text-sm font-medium text-foreground"
>
{
view
.
label
}
</
span
>
<
span
className=
"text-[11px] text-muted-foreground"
>
{
view
.
shortLabel
}
视图
</
span
>
</
div
>
<
div
className=
"w-24 h-24 rounded-lg border-2 border-dashed border-border bg-muted flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden flex-shrink-0
"
onClick=
{
()
=>
fileInputRef
.
current
?.
click
()
}
className=
"aspect-[3/4] rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden
"
onClick=
{
()
=>
fileInputRefs
.
current
[
view
.
type
]
?.
click
()
}
>
{
imagePreview
?
(
<
img
src=
{
imagePreview
}
alt=
"预览"
className=
"w-full h-full object-cover"
/>
{
imagePreviews
[
view
.
type
]
?
(
<
img
src=
{
imagePreviews
[
view
.
type
]
??
""
}
alt=
{
`${view.label}预览`
}
className=
"w-full h-full object-cover"
/>
)
:
(
<
div
className=
"text-center
"
>
<
Upload
className=
"w-5 h-5 text-muted-foreground mx-auto mb-1
"
/>
<
span
className=
"text-xs text-muted-foreground"
>
上传图片
</
span
>
<
div
className=
"text-center px-4
"
>
<
Upload
className=
"w-5 h-5 text-muted-foreground mx-auto mb-2
"
/>
<
span
className=
"text-xs text-muted-foreground"
>
点击上传
{
view
.
label
}
</
span
>
</
div
>
)
}
</
div
>
<
div
className=
"text-xs text-muted-foreground mt-1"
>
<
p
>
点击上传角色参考图
</
p
>
<
p
className=
"mt-1"
>
支持 JPG、PNG,建议正方形比例
</
p
>
{
imagePreview
&&
(
<
button
onClick=
{
()
=>
{
setImagePreview
(
null
);
setPendingFile
(
null
);
}
}
className=
"mt-2 text-destructive hover:underline"
>
移除图片
<
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
ref=
{
(
node
)
=>
{
fileInputRefs
.
current
[
view
.
type
]
=
node
;
}
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
(
e
)
=>
handleFileSelect
(
view
.
type
,
e
)
}
/>
</
div
>
))
}
</
div
>
<
input
ref=
{
fileInputRef
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
handleFileSelect
}
/>
</
div
>
{
/* Name */
}
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
角色名称
<
span
className=
"text-destructive"
>
*
</
span
></
label
>
<
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=
"例如:乔
月
"
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
>
{
/* Role Type + Gender */
}
<
div
className=
"grid grid-cols-2 gap-3"
>
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
角色类型
</
label
>
...
...
@@ -408,7 +541,11 @@ export function CharacterGeneration() {
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
(
r
=>
<
option
key=
{
r
.
value
}
value=
{
r
.
value
}
>
{
r
.
label
}
</
option
>)
}
{
ROLE_TYPES
.
map
((
role
)
=>
(
<
option
key=
{
role
.
value
}
value=
{
role
.
value
}
>
{
role
.
label
}
</
option
>
))
}
</
select
>
</
div
>
<
div
>
...
...
@@ -424,7 +561,6 @@ export function CharacterGeneration() {
</
div
>
</
div
>
{
/* Age */
}
<
div
>
<
label
className=
"block text-sm font-medium text-foreground mb-1"
>
年龄
</
label
>
<
input
...
...
@@ -436,44 +572,40 @@ export function CharacterGeneration() {
/>
</
div
>
{
/* Personality */
}
<
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=
"例如:善良坚韧,
忍辱负重但内心强大
"
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
>
{
/* Visual Hint */
}
<
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=
"例如:
长发,圆脸,气质温婉
"
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
>
{
/* Costume */
}
<
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=
"例如:
家居休闲装,朴素整洁
"
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
>
{
/* Modal Footer */
}
<
div
className=
"flex gap-3 p-6 border-t border-border flex-shrink-0"
>
<
button
onClick=
{
()
=>
setShowModal
(
false
)
}
...
...
doc/html/src/app/pages/Dashboard.tsx
View file @
175f406c
...
...
@@ -9,6 +9,14 @@ import { useTeamMembers } from "../../hooks/useTeam";
import
{
projectsApi
}
from
"../../lib/api/projects"
;
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
{
...
...
@@ -48,6 +56,9 @@ function EditModal({
const
qc
=
useQueryClient
();
const
updateProject
=
useUpdateProject
(
project
.
id
);
const
[
name
,
setName
]
=
useState
(
project
.
name
);
const
[
style
,
setStyle
]
=
useState
(
project
.
style
??
""
);
const
[
aspectRatio
,
setAspectRatio
]
=
useState
(
project
.
aspectRatio
??
""
);
const
[
resolution
,
setResolution
]
=
useState
(
project
.
resolution
??
""
);
const
[
coverFile
,
setCoverFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
coverPreview
,
setCoverPreview
]
=
useState
<
string
>
(
project
.
coverUrl
??
""
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
...
...
@@ -64,11 +75,12 @@ function EditModal({
if
(
!
name
.
trim
())
return
;
setSaving
(
true
);
try
{
// Update name if changed
if
(
name
.
trim
()
!==
project
.
name
)
{
await
updateProject
.
mutateAsync
({
name
:
name
.
trim
()
});
}
// Upload new cover if selected
await
updateProject
.
mutateAsync
({
name
:
name
.
trim
(),
style
:
style
||
undefined
,
aspectRatio
:
aspectRatio
||
undefined
,
resolution
:
resolution
||
undefined
,
});
if
(
coverFile
)
{
await
projectsApi
.
uploadAsset
(
project
.
id
,
coverFile
,
"cover"
);
qc
.
invalidateQueries
({
queryKey
:
[
"projects"
]
});
...
...
@@ -137,6 +149,54 @@ function EditModal({
maxLength=
{
200
}
/>
</
div
>
<
div
className=
"grid grid-cols-3 gap-3"
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
视觉风格
</
label
>
<
select
value=
{
style
}
onChange=
{
(
e
)
=>
setStyle
(
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"
>
<
option
value=
""
>
未设置
</
option
>
{
projectStyleOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
}
</
select
>
</
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
画幅比例
</
label
>
<
select
value=
{
aspectRatio
}
onChange=
{
(
e
)
=>
setAspectRatio
(
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"
>
<
option
value=
""
>
未设置
</
option
>
{
aspectRatioOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
</
select
>
</
div
>
<
div
>
<
label
className=
"block text-xs text-muted-foreground mb-2"
>
清晰度
</
label
>
<
select
value=
{
resolution
}
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"
>
<
option
value=
""
>
未设置
</
option
>
{
resolutionOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
{
option
.
helper
??
""
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
</
div
>
{
/* Footer */
}
...
...
@@ -249,7 +309,11 @@ export function Dashboard() {
updatedAt
:
p
.
updatedAt
?.
slice
(
0
,
10
)
??
""
,
createdAt
:
p
.
createdAt
?.
slice
(
0
,
10
)
??
""
,
coverUrl
:
p
.
coverUrl
,
tags
:
p
.
style
?
[
p
.
style
]
:
[],
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
)
=>
{
...
...
@@ -435,7 +499,9 @@ export function Dashboard() {
{
project
.
tags
.
length
>
0
&&
(
<
div
className=
"flex flex-wrap gap-1.5 mb-3"
>
{
project
.
tags
.
slice
(
0
,
3
).
map
((
tag
)
=>
(
<
span
key=
{
tag
}
className=
"px-2 py-0.5 rounded bg-muted text-xs text-foreground"
>
{
tag
}
</
span
>
<
span
key=
{
tag
.
key
}
className=
"px-2 py-0.5 rounded bg-muted text-xs text-foreground"
>
{
tag
.
label
}
</
span
>
))
}
</
div
>
)
}
...
...
doc/html/src/app/pages/NewProject.tsx
View file @
175f406c
import
{
useState
}
from
"react"
;
import
{
useState
,
type
ChangeEvent
,
type
MouseEvent
}
from
"react"
;
import
{
useNavigate
}
from
"react-router"
;
import
{
Upload
,
FileText
,
ArrowRight
,
Sparkles
,
Image
as
ImageIcon
,
X
}
from
"lucide-react"
;
import
{
AlertCircle
,
ArrowRight
,
FileText
,
Image
as
ImageIcon
,
Loader2
,
Sparkles
,
Upload
,
X
,
}
from
"lucide-react"
;
import
{
Progress
}
from
"../components/ui/progress"
;
import
{
useCreateProject
}
from
"../../hooks/useProjects"
;
import
{
projectsApi
}
from
"../../lib/api/projects"
;
import
{
aspectRatioOptions
,
projectStyleOptions
,
resolutionOptions
}
from
"../../lib/projectStyles"
;
type
SubmitStage
=
"idle"
|
"creating"
|
"uploading"
|
"redirecting"
;
const
submitStageMeta
:
Record
<
Exclude
<
SubmitStage
,
"idle"
>
,
{
title
:
string
;
description
:
string
;
progress
:
number
}
>
=
{
creating
:
{
title
:
"正在创建项目"
,
description
:
"项目基础信息正在写入,请稍候。"
,
progress
:
28
,
},
uploading
:
{
title
:
"正在上传素材"
,
description
:
"如果你上传了剧本或封面,系统会先完成上传再进入大纲页。"
,
progress
:
72
,
},
redirecting
:
{
title
:
"准备进入剧本大纲"
,
description
:
"马上跳转到“生成剧本大纲”,可以继续解析已上传剧本,或手动输入内容。"
,
progress
:
96
,
},
};
export
function
NewProject
()
{
const
navigate
=
useNavigate
();
...
...
@@ -10,138 +44,242 @@ export function NewProject() {
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
);
const
[
submitStage
,
setSubmitStage
]
=
useState
<
SubmitStage
>
(
"idle"
);
const
[
submitError
,
setSubmitError
]
=
useState
<
string
|
null
>
(
null
);
const
createProject
=
useCreateProject
();
const
handleScriptChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
[
0
])
{
const
f
=
e
.
target
.
files
[
0
];
setScriptFile
(
f
);
// 仅当用户未手动填写名称时,自动用文件名填充
const
isSubmitting
=
submitStage
!==
"idle"
;
const
hasUpload
=
!!
scriptFile
||
!!
coverFile
;
const
handleScriptChange
=
(
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
!
e
.
target
.
files
?.[
0
]
||
isSubmitting
)
return
;
const
file
=
e
.
target
.
files
[
0
];
setScriptFile
(
file
);
if
(
!
projectName
.
trim
())
{
setProjectName
(
f
.
name
.
replace
(
/
\.[^/
.
]
+$/
,
""
));
}
setProjectName
(
file
.
name
.
replace
(
/
\.[^/
.
]
+$/
,
""
));
}
};
const
handleCoverChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
[
0
])
{
const
f
=
e
.
target
.
files
[
0
];
setCoverFile
(
f
);
setCoverPreview
(
URL
.
createObjectURL
(
f
));
}
const
handleCoverChange
=
(
e
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
!
e
.
target
.
files
?.[
0
]
||
isSubmitting
)
return
;
const
file
=
e
.
target
.
files
[
0
];
setCoverFile
(
file
);
setCoverPreview
((
prev
)
=>
{
if
(
prev
)
URL
.
revokeObjectURL
(
prev
);
return
URL
.
createObjectURL
(
file
);
});
};
const
removeCover
=
(
e
:
React
.
MouseEvent
)
=>
{
const
removeCover
=
(
e
:
MouseEvent
)
=>
{
e
.
preventDefault
();
if
(
isSubmitting
)
return
;
setCoverFile
(
null
);
if
(
coverPreview
)
URL
.
revokeObjectURL
(
coverPreview
);
setCoverPreview
(
null
);
};
const
handleSubmit
=
async
()
=>
{
const
name
=
projectName
.
trim
()
||
(
scriptFile
?
scriptFile
.
name
.
replace
(
/
\.[^/
.
]
+$/
,
""
)
:
"新项目"
);
const
project
=
await
createProject
.
mutateAsync
({
name
});
if
(
isSubmitting
)
return
;
const
name
=
projectName
.
trim
()
||
(
scriptFile
?
scriptFile
.
name
.
replace
(
/
\.[^/
.
]
+$/
,
""
)
:
"新短剧项目"
);
try
{
setSubmitError
(
null
);
setSubmitStage
(
"creating"
);
const
project
=
await
createProject
.
mutateAsync
({
name
,
style
,
aspectRatio
,
resolution
,
});
if
(
hasUpload
)
{
setSubmitStage
(
"uploading"
);
await
Promise
.
all
([
scriptFile
?
projectsApi
.
uploadAsset
(
project
.
id
,
scriptFile
,
"script"
).
catch
(()
=>
{})
:
Promise
.
resolve
(),
coverFile
?
projectsApi
.
uploadAsset
(
project
.
id
,
coverFile
,
"cover"
).
catch
(()
=>
{})
:
Promise
.
resolve
(),
scriptFile
?
projectsApi
.
uploadAsset
(
project
.
id
,
scriptFile
,
"script"
)
:
Promise
.
resolve
(),
coverFile
?
projectsApi
.
uploadAsset
(
project
.
id
,
coverFile
,
"cover"
)
:
Promise
.
resolve
(),
]);
}
setSubmitStage
(
"redirecting"
);
navigate
(
`/project/
${
project
.
id
}
/outline`
);
navigate
(
`/project/
${
project
.
id
}
/outline`
,
{
state
:
{
fromNewProject
:
true
,
projectName
:
name
,
aspectRatio
,
resolution
,
style
,
},
});
}
catch
(
error
)
{
setSubmitStage
(
"idle"
);
setSubmitError
((
error
as
Error
)?.
message
??
"创建项目失败,请重试"
);
}
};
const
canSubmit
=
!!
scriptFile
&&
!
createProject
.
isPending
;
const
canSubmit
=
!
isSubmitting
;
const
activeSubmitMeta
=
submitStage
===
"idle"
?
null
:
submitStageMeta
[
submitStage
];
return
(
<
div
className=
"h-full overflow-auto bg-background"
>
<
div
className=
"max-w-4xl mx-auto p-8"
>
{
/* Header */
}
<
div
className=
"relative h-full overflow-auto bg-background"
>
{
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]"
>
<
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"
>
{
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"
>
<
span
>
创建项目
</
span
>
<
span
>
{
submitStage
===
"creating"
?
"进行中"
:
"已准备"
}
</
span
>
</
div
>
<
div
className=
"flex items-center justify-between text-foreground"
>
<
span
>
上传素材
</
span
>
<
span
>
{
!
hasUpload
?
"已跳过"
:
submitStage
===
"uploading"
?
"进行中"
:
submitStage
===
"redirecting"
?
"已完成"
:
"等待中"
}
</
span
>
</
div
>
<
div
className=
"flex items-center justify-between text-foreground"
>
<
span
>
进入大纲页
</
span
>
<
span
>
{
submitStage
===
"redirecting"
?
"进行中"
:
"等待中"
}
</
span
>
</
div
>
</
div
>
<
p
className=
"mt-4 text-xs text-muted-foreground"
>
处理中已锁定按钮,避免重复点击创建多个项目。
</
p
>
</
div
>
</
div
>
)
}
<
div
className=
"mx-auto max-w-4xl p-8"
>
<
div
className=
"mb-8 text-center"
>
<
h1
className=
"text-3xl font-semibold text-foreground mb-2"
>
翻开剧本,创作精品短剧
</
h1
>
<
p
className=
"text-sm text-muted-foreground"
>
上传您的剧本,让AI为您生成完整的制作方案
</
p
>
<
h1
className=
"mb-2 text-3xl font-semibold text-foreground"
>
创建短剧项目
</
h1
>
<
p
className=
"text-sm text-muted-foreground"
>
可以先上传剧本直接进入解析流程,也可以跳过上传,在下一页手动输入内容生成大纲。
</
p
>
</
div
>
{
/* Project Name */
}
{
submitError
&&
(
<
div
className=
"mb-5 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4"
>
<
AlertCircle
className=
"mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
<
p
className=
"text-sm text-red-700"
>
{
submitError
}
</
p
>
</
div
>
)
}
<
div
className=
"mb-5"
>
<
label
className=
"
block text-sm font-medium text-foreground mb-2
"
>
项目名称
</
label
>
<
label
className=
"
mb-2 block text-sm font-medium text-foreground
"
>
项目名称
</
label
>
<
input
type=
"text"
value=
{
projectName
}
onChange=
{
(
e
)
=>
setProjectName
(
e
.
target
.
value
)
}
placeholder=
"输入项目名称,或上传剧本后自动填充"
className=
"w-full px-4 py-3 rounded-xl border border-border bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
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
>
{
/* Cover + Script Row */
}
<
div
className=
"flex gap-4 mb-6"
>
{
/* Cover Image */
}
<
div
className=
"mb-6 flex gap-4"
>
<
div
className=
"flex-shrink-0"
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
封面图片
<
span
className=
"text-muted-foreground font-normal ml-1"
>
(可选)
</
span
></
label
>
<
div
className=
"relative w-28 h-40 rounded-xl border-2 border-dashed border-border bg-card overflow-hidden flex items-center justify-center"
>
<
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
}
className=
"absolute inset-0 w-full h-full opacity-0 cursor-pointer"
disabled=
{
isSubmitting
}
className=
"absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{
coverPreview
?
(
<>
<
img
src=
{
coverPreview
}
alt=
"封面预览"
className=
"
w-full h
-full object-cover"
/>
<
img
src=
{
coverPreview
}
alt=
"封面预览"
className=
"
h-full w
-full object-cover"
/>
<
button
onClick=
{
removeCover
}
className=
"absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 flex items-center justify-center hover:bg-black/80 transition-colors z-10"
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=
"
w-3 h
-3 text-white"
/>
<
X
className=
"
h-3 w
-3 text-white"
/>
</
button
>
</>
)
:
(
<
div
className=
"
text-center p-2
"
>
<
ImageIcon
className=
"
w-6 h-6 text-muted-foreground mx-auto mb-1
"
/>
<
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
>
{
/* Script Upload */
}
<
div
className=
"flex-1"
>
<
label
className=
"block text-sm font-medium text-foreground mb-2"
>
剧本文件
<
span
className=
"text-destructive ml-0.5"
>
*
</
span
></
label
>
<
div
className=
"relative rounded-xl border-2 border-dashed border-border bg-card h-40 flex items-center justify-center"
>
<
label
className=
"mb-2 block text-sm font-medium text-foreground"
>
剧本文件
<
span
className=
"ml-1 font-normal text-muted-foreground"
>
(可选)
</
span
>
</
label
>
<
div
className=
"relative flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-border bg-card"
>
<
input
type=
"file"
accept=
".txt,.doc,.docx,.pdf,.md"
onChange=
{
handleScriptChange
}
className=
"absolute inset-0 w-full h-full opacity-0 cursor-pointer"
disabled=
{
isSubmitting
}
className=
"absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
{
scriptFile
?
(
<
div
className=
"flex flex-col items-center gap-3"
>
<
div
className=
"
w-12 h-12 rounded-xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center
"
>
<
FileText
className=
"
w-6 h
-6 text-white"
/>
<
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=
"text-foreground font-medium text-sm mb-0.5"
>
{
scriptFile
.
name
}
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
{
(
scriptFile
.
size
/
1024
).
toFixed
(
1
)
}
KB
</
div
>
<
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
>
<
button
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
setScriptFile
(
null
);
}
}
className=
"text-xs text-primary hover:underline"
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
if
(
!
isSubmitting
)
setScriptFile
(
null
);
}
}
disabled=
{
isSubmitting
}
className=
"text-xs text-primary hover:underline disabled:no-underline disabled:opacity-50"
>
重新上传
</
button
>
</
div
>
)
:
(
<
div
className=
"flex flex-col items-center gap-3"
>
<
div
className=
"
w-12 h-12 rounded-xl bg-muted flex items-center justify-center
"
>
<
Upload
className=
"
w-6 h
-6 text-muted-foreground"
/>
<
div
className=
"
flex h-12 w-12 items-center justify-center rounded-xl bg-muted
"
>
<
Upload
className=
"
h-6 w
-6 text-muted-foreground"
/>
</
div
>
<
div
className=
"text-center"
>
<
div
className=
"text-sm text-foreground mb-0.5"
>
点击或拖拽剧本文件至此
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
支持 doc、docx、txt、pdf、md,不超过 20M
</
div
>
<
div
className=
"mb-0.5 text-sm text-foreground"
>
点击或拖拽剧本文件到这里
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
支持 doc、docx、txt、pdf、md,大小不超过 20M;也可以先跳过,下一页手动输入剧本。
</
div
>
</
div
>
</
div
>
)
}
...
...
@@ -149,45 +287,77 @@ export function NewProject() {
</
div
>
</
div
>
{
/* Options */
}
<
div
className=
"grid grid-cols-3 gap-4 mb-6"
>
<
button
className=
"rounded-xl border border-border bg-card p-4 text-left hover:border-primary transition-colors"
>
<
div
className=
"text-sm font-medium text-foreground mb-1"
>
16:9
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
横屏
</
div
>
</
button
>
<
button
className=
"rounded-xl border border-border bg-card p-4 text-left hover:border-primary transition-colors"
>
<
div
className=
"text-sm font-medium text-foreground mb-1"
>
720p
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
标清
</
div
>
</
button
>
<
button
className=
"rounded-xl border border-border bg-card p-4 text-left hover:border-primary transition-colors"
>
<
div
className=
"text-sm font-medium text-foreground mb-1"
>
2D日漫
</
div
>
<
div
className=
"text-xs text-muted-foreground"
>
风格
</
div
>
</
button
>
<
div
className=
"mb-6 grid grid-cols-3 gap-4"
>
<
div
className=
"rounded-xl border border-border bg-card p-4"
>
<
div
className=
"mb-2 text-xs text-muted-foreground"
>
画幅比例
</
div
>
<
select
value=
{
aspectRatio
}
onChange=
{
(
e
)
=>
setAspectRatio
(
e
.
target
.
value
)
}
disabled=
{
isSubmitting
}
className=
"w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{
aspectRatioOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
·
{
option
.
helper
}
</
option
>
))
}
</
select
>
</
div
>
<
div
className=
"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=
"mb-2 text-xs text-muted-foreground"
>
视觉风格
</
div
>
<
select
value=
{
style
}
onChange=
{
(
e
)
=>
setStyle
(
e
.
target
.
value
)
}
disabled=
{
isSubmitting
}
className=
"w-full cursor-pointer rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{
projectStyleOptions
.
map
((
option
)
=>
(
<
option
key=
{
option
.
value
}
value=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
}
</
select
>
</
div
>
</
div
>
{
/* AI Info */
}
<
div
className=
"rounded-xl border border-primary/20 bg-accent/50 p-4 mb-6"
>
<
div
className=
"mb-6 rounded-xl border border-primary/20 bg-accent/50 p-4"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"
w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-
0"
>
<
Sparkles
className=
"
w-5 h
-5 text-primary"
/>
<
div
className=
"
flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/1
0"
>
<
Sparkles
className=
"
h-5 w
-5 text-primary"
/>
</
div
>
<
div
>
<
h3
className=
"
text-sm font-medium text-foreground mb-1"
>
AI
智能处理
</
h3
>
<
p
className=
"text-sm
text-muted-foreground leading-relaxe
d"
>
上传剧本后,AI将自动为您生成:大纲、分集、角色分析、场景规划、道具清单、服装设计建议,并支持多人协作编辑和审核
。
<
h3
className=
"
mb-1 text-sm font-medium text-foreground"
>
AI
智能处理
</
h3
>
<
p
className=
"text-sm
leading-relaxed text-muted-foregroun
d"
>
如果上传了剧本,下一页会继续自动解析;如果没有上传,也可以直接手动输入剧本内容生成大纲。这里选择的视觉风格会继续影响后续人物与场景的 AI 生成效果
。
</
p
>
</
div
>
</
div
>
</
div
>
{
/* Submit Button */
}
<
button
onClick=
{
handleSubmit
}
disabled=
{
!
canSubmit
}
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
disabled:hover:shadow-none"
className=
"
flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50
disabled:hover:shadow-none"
>
<
span
>
{
createProject
.
isPending
?
"创建
中..."
:
"创建新短剧项目"
}
</
span
>
<
ArrowRight
className=
"w-5 h-5"
/>
<
span
>
{
isSubmitting
?
activeSubmitMeta
?.
title
??
"处理
中..."
:
"创建新短剧项目"
}
</
span
>
{
isSubmitting
?
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
:
<
ArrowRight
className=
"h-5 w-5"
/>
}
</
button
>
</
div
>
</
div
>
...
...
doc/html/src/app/pages/OutlineGeneration.tsx
View file @
175f406c
import
{
use
State
,
useEffect
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
use
Effect
,
useMemo
,
useState
}
from
"react"
;
import
{
use
Location
,
use
Navigate
,
useParams
}
from
"react-router"
;
import
{
ArrowRight
,
Loader2
,
Sparkles
,
RefreshCw
,
AlertCircle
,
FileText
,
Users
,
MapPin
,
Film
,
CheckCircle2
,
AlertCircle
,
ArrowRight
,
CheckCircle2
,
FileText
,
Film
,
Loader2
,
MapPin
,
RefreshCw
,
Sparkles
,
Users
,
}
from
"lucide-react"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useOutline
,
useGenerateOutline
,
useGenerateEpisodes
}
from
"../../hooks/useAi"
;
import
{
projectsApi
,
ScriptInfoDTO
}
from
"../../lib/api/projects"
;
import
{
Progress
}
from
"../components/ui/progress"
;
import
{
useGenerateEpisodes
,
useGenerateOutline
,
useOutline
}
from
"../../hooks/useAi"
;
import
{
projectsApi
,
type
ScriptInfoDTO
}
from
"../../lib/api/projects"
;
type
ProcessingStep
=
{
label
:
string
;
hint
:
string
;
progress
:
number
;
};
type
ProcessingState
=
{
title
:
string
;
description
:
string
;
steps
:
ProcessingStep
[];
};
type
OutlineLocationState
=
{
fromNewProject
?:
boolean
;
projectName
?:
string
;
};
const
checkingOutlineState
:
ProcessingState
=
{
title
:
"正在检查项目状态"
,
description
:
"先确认这个项目是否已经有可用大纲。"
,
steps
:
[
{
label
:
"检查已有大纲"
,
hint
:
"正在确认是否需要重新解析上传的剧本。"
,
progress
:
12
,
},
],
};
const
parsingScriptState
:
ProcessingState
=
{
title
:
"正在解析上传的剧本"
,
description
:
"系统正在读取文档内容,并抽取剧情结构、人物与场景信息。"
,
steps
:
[
{
label
:
"读取剧本文件"
,
hint
:
"正在加载上传的 Word、PDF 或文本内容。"
,
progress
:
24
,
},
{
label
:
"分析剧情结构"
,
hint
:
"正在识别主线冲突、题材和整体节奏。"
,
progress
:
52
,
},
{
label
:
"整理角色与场景"
,
hint
:
"正在生成可预览的结构化摘要。"
,
progress
:
82
,
},
],
};
const
readingScriptState
:
ProcessingState
=
{
title
:
"正在准备剧本文本"
,
description
:
"未拿到结构化摘要,正在回退为读取原始剧本文本。"
,
steps
:
[
{
label
:
"读取原始文本"
,
hint
:
"正在提取上传剧本中的正文内容。"
,
progress
:
36
,
},
{
label
:
"填充编辑器"
,
hint
:
"马上就可以在这里直接生成大纲。"
,
progress
:
74
,
},
],
};
const
importingScriptState
:
ProcessingState
=
{
title
:
"正在导入剧本数据"
,
description
:
"系统会把剧本拆解为大纲、分集、角色、场景和分镜,请稍候。"
,
steps
:
[
{
label
:
"解析整体结构"
,
hint
:
"正在理解故事主线与题材风格。"
,
progress
:
24
,
},
{
label
:
"生成大纲与分集"
,
hint
:
"正在生成故事梗概和每集节奏。"
,
progress
:
56
,
},
{
label
:
"写入角色与场景"
,
hint
:
"正在把结果保存到项目工作区。"
,
progress
:
88
,
},
],
};
const
generatingOutlineState
:
ProcessingState
=
{
title
:
"AI 正在生成剧本大纲"
,
description
:
"正在根据剧本文本组织故事主线、题材和概要描述。"
,
steps
:
[
{
label
:
"理解剧本主线"
,
hint
:
"正在提取核心矛盾和人物关系。"
,
progress
:
30
,
},
{
label
:
"归纳故事结构"
,
hint
:
"正在组织题材、世界观和章节节奏。"
,
progress
:
62
,
},
{
label
:
"输出大纲结果"
,
hint
:
"正在整理可直接预览的大纲内容。"
,
progress
:
90
,
},
],
};
function
formatElapsed
(
seconds
:
number
)
{
const
mins
=
Math
.
floor
(
seconds
/
60
);
const
secs
=
seconds
%
60
;
return
mins
>
0
?
`
${
mins
}
分
${
secs
}
秒`
:
`
${
secs
}
秒`
;
}
function
ProcessingScreen
({
state
,
elapsedSeconds
,
projectName
}:
{
state
:
ProcessingState
;
elapsedSeconds
:
number
;
projectName
?:
string
;
})
{
const
[
activeStep
,
setActiveStep
]
=
useState
(
0
);
useEffect
(()
=>
{
setActiveStep
(
0
);
if
(
state
.
steps
.
length
<=
1
)
return
undefined
;
const
timer
=
window
.
setInterval
(()
=>
{
setActiveStep
((
current
)
=>
Math
.
min
(
current
+
1
,
state
.
steps
.
length
-
1
));
},
1800
);
return
()
=>
window
.
clearInterval
(
timer
);
},
[
state
]);
const
progressValue
=
state
.
steps
[
Math
.
min
(
activeStep
,
state
.
steps
.
length
-
1
)]?.
progress
??
0
;
return
(
<
div
className=
"h-full bg-background flex items-center justify-center p-6"
>
<
div
className=
"w-full max-w-2xl rounded-3xl border border-border bg-card shadow-xl p-8"
>
<
div
className=
"flex items-start gap-4 mb-6"
>
<
div
className=
"w-16 h-16 rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center flex-shrink-0"
>
<
Loader2
className=
"w-8 h-8 text-white animate-spin"
/>
</
div
>
<
div
className=
"flex-1"
>
<
div
className=
"flex items-center justify-between gap-4 mb-2"
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
{
state
.
title
}
</
h1
>
<
span
className=
"text-xs text-muted-foreground whitespace-nowrap"
>
已用时
{
formatElapsed
(
elapsedSeconds
)
}
</
span
>
</
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
className=
"rounded-2xl border border-primary/15 bg-primary/5 p-5 mb-6"
>
<
div
className=
"flex items-center justify-between text-sm mb-3"
>
<
span
className=
"text-foreground font-medium"
>
{
state
.
steps
[
Math
.
min
(
activeStep
,
state
.
steps
.
length
-
1
)]?.
label
}
</
span
>
<
span
className=
"text-primary"
>
{
progressValue
}
%
</
span
>
</
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"
>
{
state
.
steps
.
map
((
step
,
index
)
=>
{
const
isDone
=
index
<
activeStep
;
const
isCurrent
=
index
===
activeStep
;
return
(
<
div
key=
{
step
.
label
}
className=
"flex items-start gap-3 rounded-xl border border-border/70 bg-background px-4 py-3"
>
<
div
className=
"mt-0.5"
>
{
isDone
?
(
<
CheckCircle2
className=
"w-5 h-5 text-green-500"
/>
)
:
isCurrent
?
(
<
Loader2
className=
"w-5 h-5 text-primary animate-spin"
/>
)
:
(
<
div
className=
"w-5 h-5 rounded-full border-2 border-border"
/>
)
}
</
div
>
<
div
>
<
div
className=
"text-sm font-medium text-foreground"
>
{
step
.
label
}
</
div
>
<
div
className=
"text-xs text-muted-foreground mt-0.5"
>
{
step
.
hint
}
</
div
>
</
div
>
</
div
>
);
})
}
</
div
>
<
p
className=
"text-xs text-muted-foreground mt-5"
>
当前为前端阶段性实时反馈。如果后端后续提供解析进度接口,这里可以进一步切成真实百分比。
</
p
>
</
div
>
</
div
>
);
}
export
function
OutlineGeneration
()
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
{
projectId
}
=
useParams
<
{
projectId
:
string
}
>
();
const
pid
=
projectId
??
""
;
const
qc
=
useQueryClient
();
const
outlineState
=
location
.
state
as
OutlineLocationState
|
null
;
const
projectNameFromState
=
outlineState
?.
projectName
;
const
{
data
:
outline
,
isLoading
:
loadingOutline
}
=
useOutline
(
pid
);
const
generateOutline
=
useGenerateOutline
(
pid
);
const
generateEpisodes
=
useGenerateEpisodes
(
pid
);
// 解析结果(预览卡片)
const
[
scriptInfo
,
setScriptInfo
]
=
useState
<
ScriptInfoDTO
|
null
>
(
null
);
const
[
importing
,
setImporting
]
=
useState
(
false
);
const
[
importError
,
setImportError
]
=
useState
<
string
|
null
>
(
null
);
// 手动输入模式(AI 生成大纲)
const
[
script
,
setScript
]
=
useState
(
""
);
const
[
showManual
,
setShowManual
]
=
useState
(
false
);
const
[
loadingScript
,
setLoadingScript
]
=
useState
(
false
);
const
[
preparingState
,
setPreparingState
]
=
useState
<
"idle"
|
"parsing"
|
"reading"
>
(
"idle"
);
const
[
importing
,
setImporting
]
=
useState
(
false
);
const
[
importError
,
setImportError
]
=
useState
<
string
|
null
>
(
null
);
const
[
elapsedSeconds
,
setElapsedSeconds
]
=
useState
(
0
);
// 进入页面时:先尝试解析文档(有 doc/docx 上传时)
useEffect
(()
=>
{
if
(
outline
||
loadingOutline
)
return
;
if
(
!
pid
||
outline
||
loadingOutline
)
return
;
let
cancelled
=
false
;
setPreparingState
(
"parsing"
);
setImportError
(
null
);
projectsApi
.
parseScript
(
pid
)
.
then
((
info
)
=>
setScriptInfo
(
info
))
.
catch
(()
=>
{
// 没有上传文档,尝试预填文本内容
.
then
((
info
)
=>
{
if
(
cancelled
)
return
;
setScriptInfo
(
info
);
setShowManual
(
false
);
})
.
catch
(
async
()
=>
{
if
(
cancelled
)
return
;
setPreparingState
(
"reading"
);
setLoadingScript
(
true
);
projectsApi
.
readScriptContent
(
pid
)
.
then
((
text
)
=>
{
if
(
text
)
setScript
(
text
.
slice
(
0
,
8000
));
})
.
catch
(()
=>
{})
.
finally
(()
=>
setLoadingScript
(
false
));
try
{
const
text
=
await
projectsApi
.
readScriptContent
(
pid
);
if
(
cancelled
)
return
;
if
(
text
)
{
setScript
(
text
.
slice
(
0
,
8000
));
}
}
catch
{
// Keep manual editor empty so the user can paste script content manually.
}
finally
{
if
(
!
cancelled
)
{
setLoadingScript
(
false
);
}
}
})
.
finally
(()
=>
{
if
(
!
cancelled
)
{
setPreparingState
(
"idle"
);
}
});
return
()
=>
{
cancelled
=
true
;
};
},
[
pid
,
outline
,
loadingOutline
]);
// 一键导入
const
handleImport
=
async
()
=>
{
if
(
!
pid
||
importing
)
return
;
setImporting
(
true
);
setImportError
(
null
);
try
{
await
projectsApi
.
importScript
(
pid
);
// 刷新相关查询缓存
await
qc
.
invalidateQueries
({
queryKey
:
[
"outline"
,
pid
]
});
await
qc
.
invalidateQueries
({
queryKey
:
[
"episodes"
,
pid
]
});
await
qc
.
invalidateQueries
({
queryKey
:
[
"characters"
,
pid
]
});
await
qc
.
invalidateQueries
({
queryKey
:
[
"scenes"
,
pid
]
});
navigate
(
`/project/
${
projectId
}
/episodes`
);
}
catch
(
e
)
{
setImportError
((
e
as
Error
)?.
message
??
"导入失败,请重试"
);
}
catch
(
e
rror
)
{
setImportError
((
e
rror
as
Error
)?.
message
??
"导入失败,请重试"
);
}
finally
{
setImporting
(
false
);
}
};
// AI 生成大纲
const
handleGenerate
=
async
()
=>
{
if
(
!
script
.
trim
())
return
;
setImportError
(
null
);
await
generateOutline
.
mutateAsync
(
script
);
setShowManual
(
false
);
};
// 已有大纲 → 生成分集
const
handleContinue
=
async
()
=>
{
if
(
!
outline
)
return
;
await
generateEpisodes
.
mutateAsync
();
navigate
(
`/project/
${
projectId
}
/episodes`
);
};
const
isWorking
=
generateOutline
.
isPending
||
generateEpisodes
.
isPending
||
loadingOutline
||
importing
;
const
processingState
=
useMemo
<
ProcessingState
|
null
>
(()
=>
{
if
(
!
outline
&&
loadingOutline
)
return
checkingOutlineState
;
if
(
importing
)
return
importingScriptState
;
if
(
generateOutline
.
isPending
)
return
generatingOutlineState
;
if
(
preparingState
===
"parsing"
)
return
parsingScriptState
;
if
(
preparingState
===
"reading"
)
return
readingScriptState
;
return
null
;
},
[
generateOutline
.
isPending
,
importing
,
loadingOutline
,
outline
,
preparingState
]);
useEffect
(()
=>
{
if
(
!
processingState
||
outline
)
{
setElapsedSeconds
(
0
);
return
undefined
;
}
setElapsedSeconds
(
0
);
const
timer
=
window
.
setInterval
(()
=>
{
setElapsedSeconds
((
current
)
=>
current
+
1
);
},
1000
);
return
()
=>
window
.
clearInterval
(
timer
);
},
[
outline
,
processingState
]);
// ── 全屏加载态 ─────────────────────────────────────────────────────────────
if
(
isWorking
&&
!
outline
)
{
if
(
processingState
&&
!
outline
)
{
return
(
<
div
className=
"h-full flex items-center justify-center bg-background"
>
<
div
className=
"text-center"
>
<
div
className=
"w-20 h-20 rounded-2xl bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center mb-6 mx-auto"
>
<
Loader2
className=
"w-10 h-10 text-white animate-spin"
/>
</
div
>
<
h2
className=
"text-xl font-semibold text-foreground mb-2"
>
{
importing
?
"正在导入剧本数据..."
:
"AI正在生成大纲..."
}
</
h2
>
<
p
className=
"text-sm text-muted-foreground"
>
{
importing
?
"解析大纲、分集、角色、场景、分镜,请稍候"
:
"分析剧本结构,提取核心情节"
}
</
p
>
</
div
>
</
div
>
<
ProcessingScreen
state=
{
processingState
}
elapsedSeconds=
{
elapsedSeconds
}
projectName=
{
projectNameFromState
}
/>
);
}
// ── 无大纲 → 输入/导入界面 ─────────────────────────────────────────────────
if
(
!
outline
&&
!
loadingOutline
)
{
if
(
!
outline
)
{
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"max-w-3xl mx-auto"
>
...
...
@@ -107,10 +359,12 @@ export function OutlineGeneration() {
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b5ff9] to-[#8b5ff9] flex items-center justify-center"
>
<
Sparkles
className=
"w-4 h-4 text-white"
/>
</
div
>
<
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
生成剧本大纲
</
h1
>
<
p
className=
"text-sm text-muted-foreground"
>
先预览剧本解析结果,再决定直接导入或手动生成大纲。
</
p
>
</
div
>
</
div
>
{
/* 错误提示 */
}
{
(
importError
||
generateOutline
.
isError
)
&&
(
<
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"
/>
...
...
@@ -120,37 +374,31 @@ export function OutlineGeneration() {
</
div
>
)
}
{
/* ── 文档解析预览卡片 ── */
}
{
scriptInfo
&&
!
showManual
?
(
<
div
className=
"rounded-xl border border-primary/40 bg-primary/5 p-6 mb-4"
>
<
div
className=
"flex items-start justify-between mb-4"
>
<
div
className=
"flex items-center gap-2"
>
<
FileText
className=
"w-5 h-5 text-primary"
/>
<
span
className=
"text-sm font-medium text-primary"
>
已识别
剧本文档
</
span
>
<
span
className=
"text-sm font-medium text-primary"
>
已识别
上传剧本
</
span
>
</
div
>
<
CheckCircle2
className=
"w-5 h-5 text-green-500"
/>
</
div
>
<
h2
className=
"text-xl font-bold text-foreground mb-1"
>
{
scriptInfo
.
title
||
"(未识别标题)"
}
</
h2
>
<
p
className=
"text-sm text-muted-foreground mb-4"
>
{
scriptInfo
.
genre
}
</
p
>
<
h2
className=
"text-xl font-bold text-foreground mb-1"
>
{
scriptInfo
.
title
||
"未识别标题"
}
</
h2
>
<
p
className=
"text-sm text-muted-foreground mb-4"
>
{
scriptInfo
.
genre
||
"待补充题材"
}
</
p
>
{
scriptInfo
.
synopsis
&&
(
<
p
className=
"text-sm text-foreground/80 bg-background/60 rounded-lg p-3 mb-4 leading-relaxed"
>
{
scriptInfo
.
synopsis
.
length
>
120
?
scriptInfo
.
synopsis
.
slice
(
0
,
120
)
+
"..."
:
scriptInfo
.
synopsis
}
{
scriptInfo
.
synopsis
.
length
>
120
?
`${scriptInfo.synopsis.slice(0, 120)}
...
`
:
scriptInfo
.
synopsis
}
</
p
>
)
}
{
/* 统计数字 */
}
<
div
className=
"grid grid-cols-4 gap-3 mb-5"
>
{
[
{
icon
:
<
Film
className=
"w-4 h-4"
/>,
label
:
"集数"
,
value
:
`${scriptInfo.episodeCount} 集`
},
{
icon
:
<
Users
className=
"w-4 h-4"
/>,
label
:
"角色"
,
value
:
`${scriptInfo.characterCount} 个`
},
{
icon
:
<
MapPin
className=
"w-4 h-4"
/>,
label
:
"场景"
,
value
:
`${scriptInfo.sceneCount} 个`
},
{
icon
:
<
Sparkles
className=
"w-4 h-4"
/>,
label
:
"分镜脚本"
,
value
:
`${scriptInfo.storyboardCount}
个
`
},
{
icon
:
<
MapPin
className=
"w-4 h-4"
/>,
label
:
"场景"
,
value
:
`${scriptInfo.sceneCount} 个`
},
{
icon
:
<
Sparkles
className=
"w-4 h-4"
/>,
label
:
"分镜脚本"
,
value
:
`${scriptInfo.storyboardCount}
条
`
},
].
map
(({
icon
,
label
,
value
})
=>
(
<
div
key=
{
label
}
className=
"rounded-lg bg-background border border-border p-3 text-center"
>
<
div
className=
"flex justify-center mb-1 text-primary"
>
{
icon
}
</
div
>
...
...
@@ -160,16 +408,22 @@ export function OutlineGeneration() {
))
}
</
div
>
{
/* 一键导入 */
}
<
button
onClick=
{
handleImport
}
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"
>
{
importing
?
(
<><
Loader2
className=
"w-5 h-5 animate-spin"
/><
span
>
导入中...
</
span
></>
<>
<
Loader2
className=
"w-5 h-5 animate-spin"
/>
<
span
>
导入中...
</
span
>
</>
)
:
(
<><
Sparkles
className=
"w-5 h-5"
/><
span
>
一键导入大纲 · 分集 · 角色 · 分镜
</
span
><
ArrowRight
className=
"w-4 h-4"
/></>
<>
<
Sparkles
className=
"w-5 h-5"
/>
<
span
>
一键导入大纲 · 分集 · 角色 · 分镜
</
span
>
<
ArrowRight
className=
"w-4 h-4"
/>
</>
)
}
</
button
>
...
...
@@ -181,18 +435,18 @@ export function OutlineGeneration() {
</
button
>
</
div
>
)
:
(
/* ── 手动输入 / AI 生成模式 ── */
<
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"
/>
正在加载已上传剧本...
<
Loader2
className=
"w-3 h-3 animate-spin"
/>
正在加载上传剧本...
</
span
>
)
}
{
!
loadingScript
&&
script
&&
(
<
span
className=
"text-xs text-green-600"
>
✓ 已自动填入上传的剧
本
</
span
>
<
span
className=
"text-xs text-green-600"
>
已自动带入上传剧本文
本
</
span
>
)
}
{
scriptInfo
&&
(
<
button
...
...
@@ -204,13 +458,14 @@ export function OutlineGeneration() {
)
}
</
div
>
</
div
>
<
textarea
className=
"w-full h-64 p-4 rounded-lg border border-border bg-background text-sm text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
placeholder=
"将您的剧本文本粘贴到此处,AI将自动生成大纲..."
placeholder=
"将您的剧本文本粘贴到此处,AI
将自动生成大纲..."
value=
{
script
}
onChange=
{
(
e
)
=>
setScript
(
e
.
target
.
value
)
}
/>
<
p
className=
"text-xs text-muted-foreground mt-2"
>
支持中英文
,建议 500~5000 字
</
p
>
<
p
className=
"text-xs text-muted-foreground mt-2"
>
支持中英文
剧本,建议正文长度 500 到 5000 字。
</
p
>
<
button
onClick=
{
handleGenerate
}
...
...
@@ -218,9 +473,15 @@ export function OutlineGeneration() {
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
></>
<>
<
Loader2
className=
"w-5 h-5 animate-spin"
/>
<
span
>
生成中...
</
span
>
</>
)
:
(
<><
Sparkles
className=
"w-5 h-5"
/><
span
>
AI 生成大纲
</
span
></>
<>
<
Sparkles
className=
"w-5 h-5"
/>
<
span
>
AI 生成大纲
</
span
>
</>
)
}
</
button
>
</
div
>
...
...
@@ -230,7 +491,6 @@ export function OutlineGeneration() {
);
}
// ── 已有大纲 → 展示 + 生成分集 ──────────────────────────────────────────
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"max-w-5xl mx-auto"
>
...
...
@@ -242,13 +502,14 @@ export function OutlineGeneration() {
</
div
>
<
h1
className=
"text-2xl font-semibold text-foreground"
>
剧本大纲
</
h1
>
</
div
>
<
p
className=
"text-sm text-muted-foreground"
>
AI
已为您生成剧本大纲
</
p
>
<
p
className=
"text-sm text-muted-foreground"
>
AI
已为您生成当前项目的大纲结果。
</
p
>
</
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"
/>
重新生成
<
RefreshCw
className=
"w-4 h-4"
/>
重新生成
</
button
>
</
div
>
...
...
@@ -264,11 +525,15 @@ export function OutlineGeneration() {
<
button
onClick=
{
handleGenerate
}
disabled=
{
!
script
.
trim
()
||
generateOutline
.
isPending
}
className=
"px-4 py-2 rounded-lg bg-primary text-white text-sm disabled:opacity-50"
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
onClick=
{
()
=>
setShowManual
(
false
)
}
className=
"px-4 py-2 rounded-lg border border-border text-sm"
>
取消
</
button
>
</
div
>
...
...
@@ -286,20 +551,20 @@ export function OutlineGeneration() {
<
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
}
</
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
}
</
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
}
</
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
>
<
span
className=
"px-3 py-1 rounded-lg bg-accent text-primary text-sm"
>
{
outline
.
episodeCount
}
集
</
span
>
</
div
>
</
div
>
...
...
@@ -309,9 +574,15 @@ export function OutlineGeneration() {
className=
"w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50"
>
{
generateEpisodes
.
isPending
?
(
<><
Loader2
className=
"w-5 h-5 animate-spin"
/><
span
>
生成分集中...
</
span
></>
<>
<
Loader2
className=
"w-5 h-5 animate-spin"
/>
<
span
>
生成分集中...
</
span
>
</>
)
:
(
<><
span
>
生成分集内容
</
span
><
ArrowRight
className=
"w-5 h-5"
/></>
<>
<
span
>
生成分集内容
</
span
>
<
ArrowRight
className=
"w-5 h-5"
/>
</>
)
}
</
button
>
</
div
>
...
...
doc/html/src/app/pages/ProjectDetail.tsx
View file @
175f406c
...
...
@@ -271,6 +271,9 @@ function CharactersTab({
onNavigateChars
:
()
=>
void
;
onNavigateScenes
:
()
=>
void
;
})
{
const
getMainCharacterImage
=
(
char
:
import
(
"../../lib/api/ai"
).
Character
)
=>
char
.
frontImageUrl
??
char
.
imageUrl
??
char
.
sideImageUrl
??
char
.
backImageUrl
??
null
;
return
(
<
div
className=
"space-y-8"
>
{
/* Characters */
}
...
...
@@ -306,8 +309,8 @@ function CharactersTab({
className=
"rounded-xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors"
>
<
div
className=
"w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center"
>
{
char
.
imageUrl
?
(
<
img
src=
{
char
.
imageUrl
}
alt=
{
char
.
name
}
className=
"w-full h-full object-cover"
/>
{
getMainCharacterImage
(
char
)
?
(
<
img
src=
{
getMainCharacterImage
(
char
)
??
""
}
alt=
{
char
.
name
}
className=
"w-full h-full object-cover"
/>
)
:
(
<
ImageIcon
className=
"w-8 h-8 text-muted-foreground"
/>
)
}
...
...
doc/html/src/app/pages/ProjectSettings.tsx
View file @
175f406c
...
...
@@ -5,6 +5,14 @@ import {
}
from
"lucide-react"
;
import
{
useTeamMembers
,
useUpdateMember
,
useRemoveMember
,
useAddMember
}
from
"../../hooks/useTeam"
;
import
{
useProject
,
useUpdateProject
}
from
"../../hooks/useProjects"
;
import
{
aspectRatioOptions
,
getAspectRatioLabel
,
getProjectStyleLabel
,
getResolutionLabel
,
projectStyleOptions
,
resolutionOptions
,
}
from
"../../lib/projectStyles"
;
const
ROLES
=
[
"owner"
,
"admin"
,
"member"
]
as
const
;
type
Role
=
typeof
ROLES
[
number
];
...
...
@@ -37,10 +45,22 @@ export function ProjectSettings() {
const
[
inviteError
,
setInviteError
]
=
useState
(
""
);
const
[
editingProject
,
setEditingProject
]
=
useState
(
false
);
const
[
projectForm
,
setProjectForm
]
=
useState
({
name
:
""
,
description
:
""
});
const
[
projectForm
,
setProjectForm
]
=
useState
({
name
:
""
,
description
:
""
,
style
:
""
,
aspectRatio
:
""
,
resolution
:
""
,
});
const
openEditProject
=
()
=>
{
setProjectForm
({
name
:
project
?.
name
??
""
,
description
:
project
?.
description
??
""
});
setProjectForm
({
name
:
project
?.
name
??
""
,
description
:
project
?.
description
??
""
,
style
:
project
?.
style
??
""
,
aspectRatio
:
project
?.
aspectRatio
??
""
,
resolution
:
project
?.
resolution
??
""
,
});
setEditingProject
(
true
);
};
...
...
doc/html/src/app/pages/StoryboardWorkspace.tsx
View file @
175f406c
import
{
useState
,
useEffect
}
from
"react"
;
import
{
useState
,
useEffect
,
useMemo
}
from
"react"
;
import
{
useParams
,
useNavigate
}
from
"react-router"
;
import
{
Plus
,
Wand2
,
Play
,
Trash2
,
Copy
,
...
...
@@ -69,6 +69,9 @@ export function StoryboardWorkspace() {
const
[
generatingVideoId
,
setGeneratingVideoId
]
=
useState
<
string
|
null
>
(
null
);
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
);
...
...
@@ -171,12 +174,59 @@ export function StoryboardWorkspace() {
setPromptChanged
(
true
);
};
// 所有有 imageTosKey 的角色/场景,按顺序编号为 图1, 图2...
const
refImages
:
Array
<
{
label
:
string
;
name
:
string
;
imageUrl
:
string
|
null
;
imageTosKey
:
string
}
>
=
[
...
characters
.
filter
((
c
)
=>
c
.
imageTosKey
).
map
((
c
)
=>
({
label
:
c
.
name
,
name
:
c
.
name
,
imageUrl
:
c
.
imageUrl
,
imageTosKey
:
c
.
imageTosKey
!
})),
...
scenes
.
filter
((
s
)
=>
s
.
imageTosKey
).
map
((
s
)
=>
({
label
:
s
.
name
,
name
:
s
.
name
,
imageUrl
:
s
.
imageUrl
,
imageTosKey
:
s
.
imageTosKey
!
})),
// 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。
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
));
...
...
@@ -459,7 +509,7 @@ export function StoryboardWorkspace() {
onClick=
{
()
=>
insertMention
(
c
.
name
)
}
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"
>
{
c
.
imageUrl
&&
<
img
src=
{
c
.
imageUrl
}
className=
"w-3.5 h-3.5 rounded-full object-cover"
/>
}
{
getCharacterPrimaryImage
(
c
)
&&
<
img
src=
{
getCharacterPrimaryImage
(
c
)
??
""
}
className=
"w-3.5 h-3.5 rounded-full object-cover"
/>
}
@
{
c
.
name
}
</
button
>
))
}
...
...
@@ -480,7 +530,9 @@ export function StoryboardWorkspace() {
{
/* @图N:参考图面板 */
}
{
refImages
.
length
>
0
&&
(
<
div
className=
"mt-3"
>
<
div
className=
"text-[11px] text-muted-foreground mb-1.5"
>
@图N 引用参考图(生成视频时跳过文生图,直接用此图)
</
div
>
<
div
className=
"text-[11px] text-muted-foreground mb-1.5"
>
@图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。
</
div
>
<
div
className=
"flex gap-2 flex-wrap"
>
{
refImages
.
map
((
img
,
idx
)
=>
(
<
button
...
...
@@ -497,7 +549,14 @@ export function StoryboardWorkspace() {
)
}
</
div
>
<
span
className=
"text-[10px] text-primary font-medium"
>
@图
{
idx
+
1
}
</
span
>
<
span
className=
"text-[9px] text-muted-foreground leading-none max-w-[48px] truncate"
>
{
img
.
label
}
</
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
>
...
...
doc/html/src/app/pages/StyleSelection.tsx
View file @
175f406c
import
{
useState
}
from
"react"
;
import
{
useNavigate
,
useParams
}
from
"react-router"
;
import
{
Check
,
ArrowRight
,
Loader2
}
from
"lucide-react"
;
import
{
ArrowRight
,
Check
,
Loader2
}
from
"lucide-react"
;
import
{
useUpdateProject
}
from
"../../hooks/useProjects"
;
const
styles
=
[
{
id
:
"urban"
,
name
:
"都市情感"
,
description
:
"现代都市背景,情感纠葛,职场爱情"
,
image
:
"https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=400&h=300&fit=crop"
,
},
{
id
:
"fantasy"
,
name
:
"玄幻修仙"
,
description
:
"仙侠世界观,修炼升级,恢弘场景"
,
image
:
"https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=400&h=300&fit=crop"
,
},
{
id
:
"romance"
,
name
:
"甜宠恋爱"
,
description
:
"校园青春,浪漫爱情,轻松甜蜜"
,
image
:
"https://images.unsplash.com/photo-1522529599102-193c0d76b5b6?w=400&h=300&fit=crop"
,
},
{
id
:
"historical"
,
name
:
"古装宫廷"
,
description
:
"古代背景,宫廷权谋,历史传奇"
,
image
:
"https://images.unsplash.com/photo-1548198264-8fc644a0c8f1?w=400&h=300&fit=crop"
,
},
{
id
:
"suspense"
,
name
:
"悬疑推理"
,
description
:
"烧脑剧情,逻辑推理,紧张刺激"
,
image
:
"https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=400&h=300&fit=crop"
,
},
{
id
:
"comedy"
,
name
:
"轻喜剧"
,
description
:
"幽默搞笑,轻松愉快,生活趣事"
,
image
:
"https://images.unsplash.com/photo-1511632765486-a01980e01a18?w=400&h=300&fit=crop"
,
},
];
import
{
projectStyleOptions
}
from
"../../lib/projectStyles"
;
export
function
StyleSelection
()
{
const
navigate
=
useNavigate
();
...
...
@@ -56,58 +18,57 @@ export function StyleSelection() {
return
(
<
div
className=
"h-full overflow-auto bg-background p-6"
>
<
div
className=
"max-w-6xl mx-auto"
>
{
/* Header */
}
<
div
className=
"mx-auto max-w-6xl"
>
<
div
className=
"mb-8 text-center"
>
<
h1
className=
"text-3xl font-semibold text-foreground mb-2"
>
选择剧集风格
</
h1
>
<
p
className=
"text-sm text-muted-foreground"
>
选择一个风格,AI将根据风格生成相应的大纲和场景
</
p
>
<
h1
className=
"mb-2 text-3xl font-semibold text-foreground"
>
选择项目视觉风格
</
h1
>
<
p
className=
"text-sm text-muted-foreground"
>
这里选择的是视觉呈现方式,不是题材类型。AI 会根据所选风格影响人物、场景与后续图像生成。
</
p
>
</
div
>
{
/* Style Grid */
}
<
div
className=
"grid grid-cols-3 gap-4 mb-8"
>
{
styles
.
map
((
style
)
=>
(
<
div
className=
"mb-8 grid grid-cols-3 gap-4"
>
{
projectStyleOptions
.
map
((
style
)
=>
(
<
button
key=
{
style
.
id
}
onClick=
{
()
=>
setSelectedStyle
(
style
.
id
)
}
className=
{
`group relative
rounded-xl overflow-hidden transition-all border
${
selectedStyle === style.
id
key=
{
style
.
value
}
onClick=
{
()
=>
setSelectedStyle
(
style
.
value
)
}
className=
{
`group relative
overflow-hidden rounded-xl border transition-all
${
selectedStyle === style.
value
? "border-primary shadow-md"
: "border-border hover:border-primary/50"
}`
}
>
<
div
className=
"
aspect-[4/3] relative
overflow-hidden"
>
<
div
className=
"
relative aspect-[4/3]
overflow-hidden"
>
<
img
src=
{
style
.
image
}
alt=
{
style
.
name
}
className=
"
w-full h-full object-cover group-hover:scale-105 transition-transform
"
alt=
{
style
.
label
}
className=
"
h-full w-full object-cover transition-transform group-hover:scale-105
"
/>
{
selectedStyle
===
style
.
id
&&
(
<
div
className=
"absolute
top-3 right-3 w-6 h-6 rounded-full bg-primary flex items-center justify-center
"
>
<
Check
className=
"
w-4 h
-4 text-white"
/>
{
selectedStyle
===
style
.
value
&&
(
<
div
className=
"absolute
right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-primary
"
>
<
Check
className=
"
h-4 w
-4 text-white"
/>
</
div
>
)
}
</
div
>
<
div
className=
"
p-4 bg-card
"
>
<
h3
className=
"
font-medium text-foreground mb-1"
>
{
style
.
name
}
</
h3
>
<
div
className=
"
bg-card p-4 text-left
"
>
<
h3
className=
"
mb-1 font-medium text-foreground"
>
{
style
.
label
}
</
h3
>
<
p
className=
"text-sm text-muted-foreground"
>
{
style
.
description
}
</
p
>
</
div
>
</
button
>
))
}
</
div
>
{
/* Continue Button */
}
<
button
onClick=
{
handleContinue
}
disabled=
{
!
selectedStyle
||
updateProject
.
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 disabled:cursor-not-allowed
disabled:hover:shadow-none"
className=
"
flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50
disabled:hover:shadow-none"
>
{
updateProject
.
isPending
?
(
<
Loader2
className=
"
w-5 h
-5 animate-spin"
/>
<
Loader2
className=
"
h-5 w
-5 animate-spin"
/>
)
:
(
<>
<
span
>
继续
</
span
>
<
ArrowRight
className=
"
w-5 h
-5"
/>
<
ArrowRight
className=
"
h-5 w
-5"
/>
</>
)
}
</
button
>
...
...
doc/html/src/hooks/useAi.ts
View file @
175f406c
...
...
@@ -22,7 +22,11 @@ export function useGenerateCharacterImage(projectId: string) {
}
export
function
useUploadCharacterImage
(
projectId
:
string
)
{
const
qc
=
useQueryClient
();
return
useMutation
({
mutationFn
:
({
id
,
file
}:
{
id
:
string
;
file
:
File
})
=>
aiApi
.
uploadCharacterImage
(
projectId
,
id
,
file
),
onSuccess
:
()
=>
qc
.
invalidateQueries
({
queryKey
:
charactersKey
(
projectId
)
})
});
return
useMutation
({
mutationFn
:
({
id
,
file
,
viewType
}:
{
id
:
string
;
file
:
File
;
viewType
:
"front"
|
"side"
|
"back"
})
=>
aiApi
.
uploadCharacterImage
(
projectId
,
id
,
file
,
viewType
),
onSuccess
:
()
=>
qc
.
invalidateQueries
({
queryKey
:
charactersKey
(
projectId
)
}),
});
}
export
function
useDeleteCharacter
(
projectId
:
string
)
{
const
qc
=
useQueryClient
();
...
...
doc/html/src/lib/api/ai.ts
View file @
175f406c
...
...
@@ -56,6 +56,12 @@ export interface Character {
imagePrompt
:
string
;
imageUrl
:
string
|
null
;
imageTosKey
:
string
|
null
;
frontImageUrl
?:
string
|
null
;
frontImageTosKey
?:
string
|
null
;
sideImageUrl
?:
string
|
null
;
sideImageTosKey
?:
string
|
null
;
backImageUrl
?:
string
|
null
;
backImageTosKey
?:
string
|
null
;
status
:
string
;
createdAt
:
string
;
}
...
...
@@ -160,13 +166,26 @@ export const aiApi = {
return
r
.
data
.
data
;
},
generateCharacterImage
:
async
(
projectId
:
string
,
characterId
:
string
):
Promise
<
Character
>
=>
{
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/characters/
${
characterId
}
/generate-image`
);
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/characters/
${
characterId
}
/generate-image`
,
undefined
,
{
timeout
:
180
_000
}
);
return
r
.
data
.
data
;
},
uploadCharacterImage
:
async
(
projectId
:
string
,
characterId
:
string
,
file
:
File
):
Promise
<
Character
>
=>
{
uploadCharacterImage
:
async
(
projectId
:
string
,
characterId
:
string
,
file
:
File
,
viewType
:
"front"
|
"side"
|
"back"
=
"front"
):
Promise
<
Character
>
=>
{
const
form
=
new
FormData
();
form
.
append
(
"file"
,
file
);
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/characters/
${
characterId
}
/upload-image`
,
form
,
{
headers
:
{
"Content-Type"
:
undefined
}
});
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/characters/
${
characterId
}
/upload-image?viewType=
${
viewType
}
`
,
form
,
{
headers
:
{
"Content-Type"
:
undefined
}
}
);
return
r
.
data
.
data
;
},
deleteCharacter
:
async
(
projectId
:
string
,
characterId
:
string
):
Promise
<
void
>
=>
{
...
...
@@ -187,7 +206,11 @@ export const aiApi = {
return
r
.
data
.
data
;
},
generateSceneImage
:
async
(
projectId
:
string
,
sceneId
:
string
):
Promise
<
Scene
>
=>
{
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/scenes/
${
sceneId
}
/generate-image`
);
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/scenes/
${
sceneId
}
/generate-image`
,
undefined
,
{
timeout
:
120
_000
}
);
return
r
.
data
.
data
;
},
uploadSceneImage
:
async
(
projectId
:
string
,
sceneId
:
string
,
file
:
File
):
Promise
<
Scene
>
=>
{
...
...
doc/html/src/lib/api/projects.ts
View file @
175f406c
...
...
@@ -3,12 +3,17 @@ import { apiClient } from "./client";
export
interface
ProjectCreatePayload
{
name
:
string
;
description
?:
string
;
style
?:
string
;
aspectRatio
?:
string
;
resolution
?:
string
;
}
export
interface
ProjectUpdatePayload
{
name
?:
string
;
description
?:
string
;
style
?:
string
;
aspectRatio
?:
string
;
resolution
?:
string
;
}
export
interface
ProjectDTO
{
...
...
@@ -17,6 +22,8 @@ export interface ProjectDTO {
description
?:
string
;
status
:
"draft"
|
"processing"
|
"completed"
|
"failed"
;
style
?:
string
;
aspectRatio
?:
string
;
resolution
?:
string
;
coverUrl
?:
string
;
assetCount
:
number
;
createdAt
:
string
;
...
...
doc/html/src/lib/projectStyles.ts
0 → 100644
View file @
175f406c
export
interface
SelectOption
{
value
:
string
;
label
:
string
;
helper
?:
string
;
}
export
interface
ProjectStyleOption
{
value
:
string
;
label
:
string
;
description
:
string
;
image
:
string
;
}
export
const
aspectRatioOptions
:
SelectOption
[]
=
[
{
value
:
"16:9"
,
label
:
"16:9"
,
helper
:
"横屏"
},
{
value
:
"9:16"
,
label
:
"9:16"
,
helper
:
"竖屏"
},
{
value
:
"1:1"
,
label
:
"1:1"
,
helper
:
"方屏"
},
];
export
const
resolutionOptions
:
SelectOption
[]
=
[
{
value
:
"720p"
,
label
:
"720p"
,
helper
:
"标清"
},
{
value
:
"1080p"
,
label
:
"1080p"
,
helper
:
"高清"
},
{
value
:
"4K"
,
label
:
"4K"
,
helper
:
"超清"
},
];
export
const
projectStyleOptions
:
ProjectStyleOption
[]
=
[
{
value
:
"live_action_drama"
,
label
:
"真人短剧视觉"
,
description
:
"真人演员质感,贴近短剧成片观感,服装与人物比例更写实。"
,
image
:
"https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=400&h=300&fit=crop"
,
},
{
value
:
"japanese_anime"
,
label
:
"2D日漫"
,
description
:
"日系动画角色设计,线条干净,赛璐璐上色,角色辨识度高。"
,
image
:
"https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=400&h=300&fit=crop"
,
},
{
value
:
"korean_webtoon"
,
label
:
"2D韩漫都市"
,
description
:
"韩漫都市感,时装感更强,光影柔和,适合情感与都市题材。"
,
image
:
"https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?w=400&h=300&fit=crop"
,
},
{
value
:
"chinese_anime"
,
label
:
"2D国漫"
,
description
:
"国漫人物设定,造型更利落,兼顾东方气质与动画表现力。"
,
image
:
"https://images.unsplash.com/photo-1514539079130-25950c84af65?w=400&h=300&fit=crop"
,
},
{
value
:
"chinese_fantasy_3d"
,
label
:
"3D国风仙侠"
,
description
:
"三维国风与仙侠质感,服饰层次更丰富,适合奇幻与古风项目。"
,
image
:
"https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&h=300&fit=crop"
,
},
{
value
:
"cg_cinematic"
,
label
:
"CG电影感"
,
description
:
"高质感 CG 角色设定,偏电影海报与游戏宣传级别的视觉效果。"
,
image
:
"https://images.unsplash.com/photo-1516035069371-29a1b244cc32?w=400&h=300&fit=crop"
,
},
];
const
projectStyleLabelMap
=
new
Map
(
projectStyleOptions
.
map
((
option
)
=>
[
option
.
value
,
option
.
label
]));
const
aspectRatioLabelMap
=
new
Map
(
aspectRatioOptions
.
map
((
option
)
=>
[
option
.
value
,
option
.
helper
?
`
${
option
.
label
}
${
option
.
helper
}
`
:
option
.
label
])
);
const
resolutionLabelMap
=
new
Map
(
resolutionOptions
.
map
((
option
)
=>
[
option
.
value
,
option
.
helper
?
`
${
option
.
label
}
${
option
.
helper
}
`
:
option
.
label
])
);
export
function
getProjectStyleLabel
(
style
?:
string
|
null
):
string
{
if
(
!
style
)
return
"未设置"
;
return
projectStyleLabelMap
.
get
(
style
)
??
style
;
}
export
function
getAspectRatioLabel
(
aspectRatio
?:
string
|
null
):
string
{
if
(
!
aspectRatio
)
return
"未设置"
;
return
aspectRatioLabelMap
.
get
(
aspectRatio
)
??
aspectRatio
;
}
export
function
getResolutionLabel
(
resolution
?:
string
|
null
):
string
{
if
(
!
resolution
)
return
"未设置"
;
return
resolutionLabelMap
.
get
(
resolution
)
??
resolution
;
}
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