create project
This commit is contained in:
29
web/.gitignore
vendored
Normal file
29
web/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Drama Generator - AI 短剧生成平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
web/nginx.conf
Normal file
23
web/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
39
web/package.json
Normal file
39
web/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "drama-generator-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "vue-tsc --noEmit --skipLibCheck && vite build",
|
||||
"build:skip": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"axios": "^1.6.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"element-plus": "^2.5.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
2317
web/pnpm-lock.yaml
generated
Normal file
2317
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web/public/ffmpeg/ffmpeg-core.js
Normal file
21
web/public/ffmpeg/ffmpeg-core.js
Normal file
File diff suppressed because one or more lines are too long
BIN
web/public/ffmpeg/ffmpeg-core.wasm
Normal file
BIN
web/public/ffmpeg/ffmpeg-core.wasm
Normal file
Binary file not shown.
17
web/src/App.vue
Normal file
17
web/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<!-- <AppLayout>
|
||||
<router-view />
|
||||
</AppLayout> -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/components/common/AppLayout.vue'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
36
web/src/api/ai.ts
Normal file
36
web/src/api/ai.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
AIServiceConfig,
|
||||
AIServiceType,
|
||||
CreateAIConfigRequest,
|
||||
TestConnectionRequest,
|
||||
UpdateAIConfigRequest
|
||||
} from '../types/ai'
|
||||
import request from '../utils/request'
|
||||
|
||||
export const aiAPI = {
|
||||
list(serviceType?: AIServiceType) {
|
||||
return request.get<AIServiceConfig[]>('/ai-configs', {
|
||||
params: { service_type: serviceType }
|
||||
})
|
||||
},
|
||||
|
||||
create(data: CreateAIConfigRequest) {
|
||||
return request.post<AIServiceConfig>('/ai-configs', data)
|
||||
},
|
||||
|
||||
get(id: number) {
|
||||
return request.get<AIServiceConfig>(`/ai-configs/${id}`)
|
||||
},
|
||||
|
||||
update(id: number, data: UpdateAIConfigRequest) {
|
||||
return request.put<AIServiceConfig>(`/ai-configs/${id}`, data)
|
||||
},
|
||||
|
||||
delete(id: number) {
|
||||
return request.delete(`/ai-configs/${id}`)
|
||||
},
|
||||
|
||||
testConnection(data: TestConnectionRequest) {
|
||||
return request.post('/ai-configs/test', data)
|
||||
}
|
||||
}
|
||||
47
web/src/api/asset.ts
Normal file
47
web/src/api/asset.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
Asset,
|
||||
AssetCollection,
|
||||
AssetTag,
|
||||
CreateAssetRequest,
|
||||
ListAssetsParams,
|
||||
UpdateAssetRequest
|
||||
} from '../types/asset'
|
||||
import request from '../utils/request'
|
||||
|
||||
export const assetAPI = {
|
||||
createAsset(data: CreateAssetRequest) {
|
||||
return request.post<Asset>('/assets', data)
|
||||
},
|
||||
|
||||
updateAsset(id: number, data: UpdateAssetRequest) {
|
||||
return request.put<Asset>(`/assets/${id}`, data)
|
||||
},
|
||||
|
||||
getAsset(id: number) {
|
||||
return request.get<Asset>(`/assets/${id}`)
|
||||
},
|
||||
|
||||
listAssets(params: ListAssetsParams) {
|
||||
return request.get<{
|
||||
items: Asset[]
|
||||
pagination: {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
}>('/assets', { params })
|
||||
},
|
||||
|
||||
deleteAsset(id: number) {
|
||||
return request.delete(`/assets/${id}`)
|
||||
},
|
||||
|
||||
importFromImage(imageGenId: number) {
|
||||
return request.post<Asset>(`/assets/import/image/${imageGenId}`)
|
||||
},
|
||||
|
||||
importFromVideo(videoGenId: number) {
|
||||
return request.post<Asset>(`/assets/import/video/${videoGenId}`)
|
||||
}
|
||||
}
|
||||
109
web/src/api/character-library.ts
Normal file
109
web/src/api/character-library.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export interface CharacterLibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
category?: string
|
||||
image_url: string
|
||||
description?: string
|
||||
tags?: string
|
||||
source_type: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateLibraryItemRequest {
|
||||
name: string
|
||||
category?: string
|
||||
image_url: string
|
||||
description?: string
|
||||
tags?: string
|
||||
source_type?: string
|
||||
}
|
||||
|
||||
export interface CharacterLibraryQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: string
|
||||
source_type?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export const characterLibraryAPI = {
|
||||
// 获取角色库列表
|
||||
list(params?: CharacterLibraryQuery) {
|
||||
return request.get<{
|
||||
items: CharacterLibraryItem[]
|
||||
pagination: {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
}>('/character-library', { params })
|
||||
},
|
||||
|
||||
// 创建角色库项
|
||||
create(data: CreateLibraryItemRequest) {
|
||||
return request.post<CharacterLibraryItem>('/character-library', data)
|
||||
},
|
||||
|
||||
// 获取角色库项详情
|
||||
get(id: string) {
|
||||
return request.get<CharacterLibraryItem>(`/character-library/${id}`)
|
||||
},
|
||||
|
||||
// 删除角色库项
|
||||
delete(id: string) {
|
||||
return request.delete(`/character-library/${id}`)
|
||||
},
|
||||
|
||||
// 上传角色图片
|
||||
uploadCharacterImage(characterId: string, imageUrl: string) {
|
||||
return request.put(`/characters/${characterId}/image`, { image_url: imageUrl })
|
||||
},
|
||||
|
||||
// 从角色库应用形象
|
||||
applyFromLibrary(characterId: string, libraryItemId: string) {
|
||||
return request.put(`/characters/${characterId}/image-from-library`, {
|
||||
library_item_id: libraryItemId
|
||||
})
|
||||
},
|
||||
|
||||
// 将角色添加到角色库
|
||||
addCharacterToLibrary(characterId: string, category?: string) {
|
||||
return request.post<CharacterLibraryItem>(`/characters/${characterId}/add-to-library`, {
|
||||
category
|
||||
})
|
||||
},
|
||||
|
||||
// AI生成角色形象
|
||||
generateCharacterImage(characterId: string, model?: string) {
|
||||
return request.post<{ image_url: string }>(`/characters/${characterId}/generate-image`, {
|
||||
model
|
||||
})
|
||||
},
|
||||
|
||||
// 批量生成角色形象
|
||||
batchGenerateCharacterImages(characterIds: string[], model?: string) {
|
||||
return request.post<{ message: string; count: number }>('/characters/batch-generate-images', {
|
||||
character_ids: characterIds,
|
||||
model
|
||||
})
|
||||
},
|
||||
|
||||
// 更新角色信息
|
||||
updateCharacter(characterId: number, data: {
|
||||
name?: string
|
||||
appearance?: string
|
||||
personality?: string
|
||||
description?: string
|
||||
}) {
|
||||
return request.put(`/characters/${characterId}`, data)
|
||||
},
|
||||
|
||||
// 删除角色
|
||||
deleteCharacter(characterId: number) {
|
||||
return request.delete(`/characters/${characterId}`)
|
||||
}
|
||||
}
|
||||
119
web/src/api/drama.ts
Normal file
119
web/src/api/drama.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
CreateDramaRequest,
|
||||
Drama,
|
||||
DramaListQuery,
|
||||
DramaStats,
|
||||
UpdateDramaRequest
|
||||
} from '../types/drama'
|
||||
import request from '../utils/request'
|
||||
|
||||
export const dramaAPI = {
|
||||
list(params?: DramaListQuery) {
|
||||
return request.get<{
|
||||
items: Drama[]
|
||||
pagination: {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
}>('/dramas', { params })
|
||||
},
|
||||
|
||||
create(data: CreateDramaRequest) {
|
||||
return request.post<Drama>('/dramas', data)
|
||||
},
|
||||
|
||||
get(id: string) {
|
||||
return request.get<Drama>(`/dramas/${id}`)
|
||||
},
|
||||
|
||||
update(id: string, data: UpdateDramaRequest) {
|
||||
return request.put<Drama>(`/dramas/${id}`, data)
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
return request.delete(`/dramas/${id}`)
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return request.get<DramaStats>('/dramas/stats')
|
||||
},
|
||||
|
||||
saveOutline(id: string, data: { title: string; summary: string; genre?: string; tags?: string[] }) {
|
||||
return request.put(`/dramas/${id}/outline`, data)
|
||||
},
|
||||
|
||||
getCharacters(dramaId: string) {
|
||||
return request.get(`/dramas/${dramaId}/characters`)
|
||||
},
|
||||
|
||||
saveCharacters(id: string, data: any[], episodeId?: string) {
|
||||
return request.put(`/dramas/${id}/characters`, {
|
||||
characters: data,
|
||||
episode_id: episodeId ? parseInt(episodeId) : undefined
|
||||
})
|
||||
},
|
||||
|
||||
saveEpisodes(id: string, data: any[]) {
|
||||
return request.put(`/dramas/${id}/episodes`, { episodes: data })
|
||||
},
|
||||
|
||||
saveProgress(id: string, data: { current_step: string; step_data?: any }) {
|
||||
return request.put(`/dramas/${id}/progress`, data)
|
||||
},
|
||||
|
||||
generateStoryboard(episodeId: string) {
|
||||
return request.post(`/episodes/${episodeId}/storyboards`)
|
||||
},
|
||||
|
||||
getBackgrounds(episodeId: string) {
|
||||
return request.get(`/images/episode/${episodeId}/backgrounds`)
|
||||
},
|
||||
|
||||
extractBackgrounds(episodeId: string) {
|
||||
return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`)
|
||||
},
|
||||
|
||||
batchGenerateBackgrounds(episodeId: string) {
|
||||
return request.post(`/images/episode/${episodeId}/batch`)
|
||||
},
|
||||
|
||||
generateSingleBackground(backgroundId: number, dramaId: string, prompt: string) {
|
||||
return request.post('/images', {
|
||||
background_id: backgroundId,
|
||||
drama_id: dramaId,
|
||||
prompt: prompt
|
||||
})
|
||||
},
|
||||
|
||||
getStoryboards(episodeId: string) {
|
||||
return request.get(`/episodes/${episodeId}/storyboards`)
|
||||
},
|
||||
|
||||
updateStoryboard(storyboardId: string, data: any) {
|
||||
return request.put(`/storyboards/${storyboardId}`, data)
|
||||
},
|
||||
|
||||
updateScene(sceneId: string, data: {
|
||||
background_id?: string;
|
||||
characters?: string[];
|
||||
location?: string;
|
||||
time?: string;
|
||||
action?: string;
|
||||
dialogue?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
}) {
|
||||
return request.put(`/scenes/${sceneId}`, data)
|
||||
},
|
||||
|
||||
generateSceneImage(data: { scene_id: string; prompt?: string; model?: string }) {
|
||||
return request.post('/scenes/generate-image', data)
|
||||
},
|
||||
|
||||
// 完成集数制作(触发视频合成)
|
||||
finalizeEpisode(episodeId: string, timelineData?: any) {
|
||||
return request.post(`/episodes/${episodeId}/finalize`, timelineData || {})
|
||||
}
|
||||
}
|
||||
99
web/src/api/frame.ts
Normal file
99
web/src/api/frame.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
// 帧类型
|
||||
export type FrameType = 'first' | 'key' | 'last' | 'panel' | 'action'
|
||||
|
||||
// 单帧提示词
|
||||
export interface SingleFramePrompt {
|
||||
prompt: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 多帧提示词
|
||||
export interface MultiFramePrompt {
|
||||
layout: string // horizontal_3, grid_2x2 等
|
||||
frames: SingleFramePrompt[]
|
||||
}
|
||||
|
||||
// 帧提示词响应
|
||||
export interface FramePromptResponse {
|
||||
frame_type: FrameType
|
||||
single_frame?: SingleFramePrompt
|
||||
multi_frame?: MultiFramePrompt
|
||||
}
|
||||
|
||||
// 生成帧提示词请求
|
||||
export interface GenerateFramePromptRequest {
|
||||
frame_type: FrameType
|
||||
panel_count?: number // 分镜板格数,默认3
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定类型的帧提示词
|
||||
*/
|
||||
export function generateFramePrompt(
|
||||
storyboardId: number,
|
||||
data: GenerateFramePromptRequest
|
||||
): Promise<FramePromptResponse> {
|
||||
return request.post<FramePromptResponse>(`/storyboards/${storyboardId}/frame-prompt`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成首帧提示词
|
||||
*/
|
||||
export function generateFirstFrame(storyboardId: number): Promise<FramePromptResponse> {
|
||||
return generateFramePrompt(storyboardId, { frame_type: 'first' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成关键帧提示词
|
||||
*/
|
||||
export function generateKeyFrame(storyboardId: number): Promise<FramePromptResponse> {
|
||||
return generateFramePrompt(storyboardId, { frame_type: 'key' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成尾帧提示词
|
||||
*/
|
||||
export function generateLastFrame(storyboardId: number): Promise<FramePromptResponse> {
|
||||
return generateFramePrompt(storyboardId, { frame_type: 'last' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分镜板(3格组合)
|
||||
*/
|
||||
export function generatePanelFrames(
|
||||
storyboardId: number,
|
||||
panelCount: number = 3
|
||||
): Promise<FramePromptResponse> {
|
||||
return generateFramePrompt(storyboardId, {
|
||||
frame_type: 'panel',
|
||||
panel_count: panelCount
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成动作序列(5格)
|
||||
*/
|
||||
export function generateActionSequence(storyboardId: number): Promise<FramePromptResponse> {
|
||||
return generateFramePrompt(storyboardId, { frame_type: 'action' })
|
||||
}
|
||||
|
||||
// 帧提示词记录(从数据库查询)
|
||||
export interface FramePromptRecord {
|
||||
id: number
|
||||
storyboard_id: number
|
||||
frame_type: FrameType
|
||||
prompt: string
|
||||
description?: string
|
||||
layout?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询镜头的所有已生成帧提示词
|
||||
*/
|
||||
export function getStoryboardFramePrompts(storyboardId: number): Promise<{ frame_prompts: FramePromptRecord[] }> {
|
||||
return request.get<{ frame_prompts: FramePromptRecord[] }>(`/storyboards/${storyboardId}/frame-prompts`)
|
||||
}
|
||||
42
web/src/api/generation.ts
Normal file
42
web/src/api/generation.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Character, Episode } from '../types/drama'
|
||||
import type {
|
||||
GenerateCharactersRequest,
|
||||
GenerateEpisodesRequest,
|
||||
GenerateOutlineRequest,
|
||||
OutlineResult
|
||||
} from '../types/generation'
|
||||
import request from '../utils/request'
|
||||
|
||||
export const generationAPI = {
|
||||
generateOutline(data: GenerateOutlineRequest) {
|
||||
return request.post<OutlineResult>('/generation/outline', data)
|
||||
},
|
||||
|
||||
generateCharacters(data: GenerateCharactersRequest) {
|
||||
return request.post<{ task_id: string; status: string; message: string }>('/generation/characters', data)
|
||||
},
|
||||
|
||||
generateEpisodes(data: GenerateEpisodesRequest) {
|
||||
return request.post<Episode[]>('/generation/episodes', data)
|
||||
},
|
||||
|
||||
generateStoryboard(episodeId: string) {
|
||||
return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`)
|
||||
},
|
||||
|
||||
getTaskStatus(taskId: string) {
|
||||
return request.get<{
|
||||
id: string
|
||||
type: string
|
||||
status: string
|
||||
progress: number
|
||||
message?: string
|
||||
error?: string
|
||||
result?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at?: string
|
||||
}>(`/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
}
|
||||
40
web/src/api/image.ts
Normal file
40
web/src/api/image.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
GenerateImageRequest,
|
||||
ImageGeneration,
|
||||
ImageGenerationListParams
|
||||
} from '../types/image'
|
||||
import request from '../utils/request'
|
||||
|
||||
export const imageAPI = {
|
||||
generateImage(data: GenerateImageRequest) {
|
||||
return request.post<ImageGeneration>('/images', data)
|
||||
},
|
||||
|
||||
generateForScene(sceneId: number) {
|
||||
return request.post<ImageGeneration[]>(`/images/scene/${sceneId}`)
|
||||
},
|
||||
|
||||
batchGenerateForEpisode(episodeId: number) {
|
||||
return request.post<ImageGeneration[]>(`/images/episode/${episodeId}/batch`)
|
||||
},
|
||||
|
||||
getImage(id: number) {
|
||||
return request.get<ImageGeneration>(`/images/${id}`)
|
||||
},
|
||||
|
||||
listImages(params: ImageGenerationListParams) {
|
||||
return request.get<{
|
||||
items: ImageGeneration[]
|
||||
pagination: {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
}>('/images', { params })
|
||||
},
|
||||
|
||||
deleteImage(id: number) {
|
||||
return request.delete(`/images/${id}`)
|
||||
}
|
||||
}
|
||||
44
web/src/api/video.ts
Normal file
44
web/src/api/video.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
GenerateVideoRequest,
|
||||
VideoGeneration,
|
||||
VideoGenerationListParams
|
||||
} from '../types/video'
|
||||
import request from '../utils/request'
|
||||
|
||||
export const videoAPI = {
|
||||
generateVideo(data: GenerateVideoRequest) {
|
||||
return request.post<VideoGeneration>('/videos', data)
|
||||
},
|
||||
|
||||
generateFromImage(imageGenId: number) {
|
||||
return request.post<VideoGeneration>(`/videos/image/${imageGenId}`)
|
||||
},
|
||||
|
||||
batchGenerateForEpisode(episodeId: number) {
|
||||
return request.post<VideoGeneration[]>(`/videos/episode/${episodeId}/batch`)
|
||||
},
|
||||
|
||||
getVideoGeneration(id: number) {
|
||||
return request.get<VideoGeneration>(`/videos/${id}`)
|
||||
},
|
||||
|
||||
getVideo(id: number) {
|
||||
return request.get<VideoGeneration>(`/videos/${id}`)
|
||||
},
|
||||
|
||||
listVideos(params: VideoGenerationListParams) {
|
||||
return request.get<{
|
||||
items: VideoGeneration[]
|
||||
pagination: {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
}>('/videos', { params })
|
||||
},
|
||||
|
||||
deleteVideo(id: number) {
|
||||
return request.delete(`/videos/${id}`)
|
||||
}
|
||||
}
|
||||
65
web/src/api/videoMerge.ts
Normal file
65
web/src/api/videoMerge.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export interface SceneClip {
|
||||
scene_id: string
|
||||
video_url: string
|
||||
start_time: number
|
||||
end_time: number
|
||||
duration: number
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface MergeVideoRequest {
|
||||
episode_id: string
|
||||
drama_id: string
|
||||
title: string
|
||||
scenes: SceneClip[]
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface VideoMerge {
|
||||
id: number
|
||||
episode_id: string
|
||||
drama_id: string
|
||||
title: string
|
||||
provider: string
|
||||
model?: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
scenes: SceneClip[]
|
||||
merged_url?: string
|
||||
duration?: number
|
||||
task_id?: string
|
||||
error_msg?: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export const videoMergeAPI = {
|
||||
async mergeVideos(data: MergeVideoRequest): Promise<VideoMerge> {
|
||||
const response = await request.post<{ merge: VideoMerge }>('/video-merges', data)
|
||||
return response.merge
|
||||
},
|
||||
|
||||
async getMerge(mergeId: number): Promise<VideoMerge> {
|
||||
const response = await request.get<{ merge: VideoMerge }>(`/video-merges/${mergeId}`)
|
||||
return response.merge
|
||||
},
|
||||
|
||||
async listMerges(params: {
|
||||
episode_id?: string
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<{ merges: VideoMerge[]; total: number }> {
|
||||
const response = await request.get<{ merges: VideoMerge[]; total: number }>('/video-merges', { params })
|
||||
return {
|
||||
merges: response.merges || [],
|
||||
total: response.total || 0
|
||||
}
|
||||
},
|
||||
|
||||
async deleteMerge(mergeId: number): Promise<void> {
|
||||
await request.delete(`/video-merges/${mergeId}`)
|
||||
}
|
||||
}
|
||||
17
web/src/assets/styles/element/index.scss
Normal file
17
web/src/assets/styles/element/index.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
/*just override what you need*/
|
||||
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
|
||||
$bg-color: (
|
||||
'page': #0a0a0a,
|
||||
'': #141414,
|
||||
'overlay': #1d1e1f,
|
||||
),
|
||||
$fill-color: (
|
||||
'': #262727,
|
||||
'light': #1d1e1f,
|
||||
'lighter': #141414,
|
||||
'extra-light': #191919,
|
||||
'dark': #3a3a3a,
|
||||
'darker': #4a4a4a,
|
||||
'blank': #1a1a1a,
|
||||
)
|
||||
);
|
||||
1136
web/src/assets/styles/main.css
Normal file
1136
web/src/assets/styles/main.css
Normal file
File diff suppressed because it is too large
Load Diff
64
web/src/components/LanguageSwitcher.vue
Normal file
64
web/src/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="language-switcher">
|
||||
<el-icon><Switch /></el-icon>
|
||||
<span class="lang-text">{{ currentLangText }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh-CN" :disabled="currentLang === 'zh-CN'">
|
||||
🇨🇳 简体中文
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="en-US" :disabled="currentLang === 'en-US'">
|
||||
🇺🇸 English
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setLanguage } from '@/locales'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const currentLang = ref(locale.value)
|
||||
|
||||
const currentLangText = computed(() => {
|
||||
return currentLang.value === 'zh-CN' ? '中文' : 'English'
|
||||
})
|
||||
|
||||
const handleCommand = (lang: string) => {
|
||||
setLanguage(lang)
|
||||
currentLang.value = lang
|
||||
ElMessage.success(
|
||||
lang === 'zh-CN'
|
||||
? '语言已切换为中文'
|
||||
: 'Language switched to English'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-switcher:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.lang-text {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
801
web/src/components/common/AIConfigDialog.vue
Normal file
801
web/src/components/common/AIConfigDialog.vue
Normal file
@@ -0,0 +1,801 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="$t('aiConfig.title')"
|
||||
width="900px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="ai-config-dialog"
|
||||
>
|
||||
<!-- Dialog Header Actions -->
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<span class="dialog-title">{{ $t('aiConfig.title') }}</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="success" size="small" @click="showQuickSetupDialog">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span>一键配置火宝</span>
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>{{ $t('aiConfig.addConfig') }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="config-tabs">
|
||||
<el-tab-pane :label="$t('aiConfig.tabs.text')" name="text">
|
||||
<ConfigList
|
||||
:configs="configs"
|
||||
:loading="loading"
|
||||
:show-test-button="true"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggleActive"
|
||||
@test="handleTest"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('aiConfig.tabs.image')" name="image">
|
||||
<ConfigList
|
||||
:configs="configs"
|
||||
:loading="loading"
|
||||
:show-test-button="false"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggleActive"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('aiConfig.tabs.video')" name="video">
|
||||
<ConfigList
|
||||
:configs="configs"
|
||||
:loading="loading"
|
||||
:show-test-button="false"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggleActive"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Quick Setup Dialog -->
|
||||
<el-dialog
|
||||
v-model="quickSetupVisible"
|
||||
title="一键配置"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<div class="quick-setup-info">
|
||||
<p>将自动创建以下配置:</p>
|
||||
<ul>
|
||||
<li><strong>文本服务</strong>: {{ providerConfigs.text[1].models[0] }}</li>
|
||||
<li><strong>图片服务</strong>: {{ providerConfigs.image[1].models[0] }}</li>
|
||||
<li><strong>视频服务</strong>: {{ providerConfigs.video[1].models[0] }}</li>
|
||||
</ul>
|
||||
<p class="quick-setup-tip">Base URL: https://api.chatfire.site/v1</p>
|
||||
</div>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="quickSetupApiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入 ChatFire API Key"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="quick-setup-footer">
|
||||
<a
|
||||
href="https://api.chatfire.site/login?inviteCode=C4453345"
|
||||
target="_blank"
|
||||
class="register-link"
|
||||
>
|
||||
没有 API Key?点击注册
|
||||
</a>
|
||||
<div class="footer-buttons">
|
||||
<el-button @click="quickSetupVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleQuickSetup" :loading="quickSetupLoading">
|
||||
确认配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Edit/Create Sub-Dialog -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
:title="isEdit ? $t('aiConfig.editConfig') : $t('aiConfig.addConfig')"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item :label="$t('aiConfig.form.name')" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="$t('aiConfig.form.namePlaceholder')" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.provider')" prop="provider">
|
||||
<el-select
|
||||
v-model="form.provider"
|
||||
:placeholder="$t('aiConfig.form.providerPlaceholder')"
|
||||
@change="handleProviderChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.id"
|
||||
:label="provider.name"
|
||||
:value="provider.id"
|
||||
:disabled="provider.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.providerTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.priority')" prop="priority">
|
||||
<el-input-number
|
||||
v-model="form.priority"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.priorityTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.model')" prop="model">
|
||||
<el-select
|
||||
v-model="form.model"
|
||||
:placeholder="$t('aiConfig.form.modelPlaceholder')"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in availableModels"
|
||||
:key="model"
|
||||
:label="model"
|
||||
:value="model"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.modelTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.baseUrl')" prop="base_url">
|
||||
<el-input v-model="form.base_url" :placeholder="$t('aiConfig.form.baseUrlPlaceholder')" />
|
||||
<div class="form-tip">
|
||||
{{ $t('aiConfig.form.baseUrlTip') }}
|
||||
<br>
|
||||
{{ $t('aiConfig.form.fullEndpoint') }}: {{ fullEndpointExample }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.apiKey')" prop="api_key">
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="$t('aiConfig.form.apiKeyPlaceholder')"
|
||||
/>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.apiKeyTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isEdit" :label="$t('aiConfig.form.isActive')">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="quick-setup-footer">
|
||||
<a
|
||||
href="https://api.chatfire.site/login?inviteCode=C4453345"
|
||||
target="_blank"
|
||||
class="register-link"
|
||||
>
|
||||
没有 API Key?点击注册
|
||||
</a>
|
||||
<div class="footer-buttons">
|
||||
<el-button @click="editDialogVisible = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button v-if="form.service_type === 'text'" @click="testConnection" :loading="testing">{{ $t('aiConfig.actions.test') }}</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
{{ isEdit ? $t('common.save') : $t('common.create') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus, MagicStick } from '@element-plus/icons-vue'
|
||||
import { aiAPI } from '@/api/ai'
|
||||
import type { AIServiceConfig, AIServiceType, CreateAIConfigRequest, UpdateAIConfigRequest } from '@/types/ai'
|
||||
import ConfigList from '@/views/settings/components/ConfigList.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const activeTab = ref<AIServiceType>('text')
|
||||
const loading = ref(false)
|
||||
const configs = ref<AIServiceConfig[]>([])
|
||||
const editDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref<number>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const testing = ref(false)
|
||||
const quickSetupVisible = ref(false)
|
||||
const quickSetupApiKey = ref('')
|
||||
const quickSetupLoading = ref(false)
|
||||
|
||||
const form = reactive<CreateAIConfigRequest & { is_active?: boolean, provider?: string }>({
|
||||
service_type: 'text',
|
||||
provider: '',
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: [],
|
||||
priority: 0,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
// Provider configs
|
||||
interface ProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
models: string[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
|
||||
text: [
|
||||
{ id: 'openai', name: 'OpenAI', models: ['gpt-5.2', 'gemini-3-pro-preview'] },
|
||||
{
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'doubao-seed-1-8-251228',
|
||||
'kimi-k2-thinking',
|
||||
'gemini-3-pro',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
models: ['gemini-2.5-pro', 'gemini-3-pro-preview']
|
||||
}
|
||||
],
|
||||
image: [
|
||||
{
|
||||
id: 'volcengine',
|
||||
name: '火山引擎',
|
||||
models: ['doubao-seedream-4-5-251128', 'doubao-seedream-4-0-250828']
|
||||
},
|
||||
{
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: ['doubao-seedream-4-5-251128', 'nano-banana-pro']
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
models: ['gemini-3-pro-image-preview']
|
||||
},
|
||||
{ id: 'openai', name: 'OpenAI', models: ['dall-e-3', 'dall-e-2'] }
|
||||
],
|
||||
video: [
|
||||
{
|
||||
id: 'volces',
|
||||
name: '火山引擎',
|
||||
models: [
|
||||
'doubao-seedance-1-5-pro-251215',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'doubao-seedance-1-0-pro-250528',
|
||||
'doubao-seedance-1-0-pro-fast-251015'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'doubao-seedance-1-5-pro-251215',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'doubao-seedance-1-0-pro-250528',
|
||||
'doubao-seedance-1-0-pro-fast-251015',
|
||||
'sora',
|
||||
'sora-pro'
|
||||
]
|
||||
},
|
||||
{ id: 'openai', name: 'OpenAI', models: ['sora-2', 'sora-2-pro'] }
|
||||
]
|
||||
}
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
return providerConfigs[form.service_type] || []
|
||||
})
|
||||
|
||||
const availableModels = computed(() => {
|
||||
if (!form.provider) return []
|
||||
const provider = availableProviders.value.find(p => p.id === form.provider)
|
||||
return provider?.models || []
|
||||
})
|
||||
|
||||
const fullEndpointExample = computed(() => {
|
||||
const baseUrl = form.base_url || 'https://api.example.com'
|
||||
const provider = form.provider
|
||||
const serviceType = form.service_type
|
||||
|
||||
let endpoint = ''
|
||||
|
||||
if (serviceType === 'text') {
|
||||
if (provider === 'gemini' || provider === 'google') {
|
||||
endpoint = '/v1beta/models/{model}:generateContent'
|
||||
} else {
|
||||
endpoint = '/chat/completions'
|
||||
}
|
||||
} else if (serviceType === 'image') {
|
||||
if (provider === 'gemini' || provider === 'google') {
|
||||
endpoint = '/v1beta/models/{model}:generateContent'
|
||||
} else {
|
||||
endpoint = '/images/generations'
|
||||
}
|
||||
} else if (serviceType === 'video') {
|
||||
if (provider === 'chatfire') {
|
||||
endpoint = '/video/generations'
|
||||
} else if (provider === 'doubao' || provider === 'volcengine' || provider === 'volces') {
|
||||
endpoint = '/contents/generations/tasks'
|
||||
} else if (provider === 'openai') {
|
||||
endpoint = '/videos'
|
||||
} else {
|
||||
endpoint = '/video/generations'
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl + endpoint
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
|
||||
provider: [{ required: true, message: '请选择厂商', trigger: 'change' }],
|
||||
base_url: [
|
||||
{ required: true, message: '请输入 Base URL', trigger: 'blur' },
|
||||
{ type: 'url', message: '请输入正确的 URL 格式', trigger: 'blur' }
|
||||
],
|
||||
api_key: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
|
||||
model: [{
|
||||
required: true,
|
||||
message: '请至少选择一个模型',
|
||||
trigger: 'change',
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
callback()
|
||||
} else if (typeof value === 'string' && value.length > 0) {
|
||||
callback()
|
||||
} else {
|
||||
callback(new Error('请至少选择一个模型'))
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
const loadConfigs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = await aiAPI.list(activeTab.value)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateConfigName = (provider: string, serviceType: AIServiceType): string => {
|
||||
const providerNames: Record<string, string> = {
|
||||
'chatfire': 'ChatFire',
|
||||
'openai': 'OpenAI',
|
||||
'gemini': 'Gemini',
|
||||
'google': 'Google'
|
||||
}
|
||||
|
||||
const serviceNames: Record<AIServiceType, string> = {
|
||||
'text': '文本',
|
||||
'image': '图片',
|
||||
'video': '视频'
|
||||
}
|
||||
|
||||
const randomNum = Math.floor(Math.random() * 10000).toString().padStart(4, '0')
|
||||
const providerName = providerNames[provider] || provider
|
||||
const serviceName = serviceNames[serviceType] || serviceType
|
||||
|
||||
return `${providerName}-${serviceName}-${randomNum}`
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
editingId.value = undefined
|
||||
resetForm()
|
||||
form.service_type = activeTab.value
|
||||
form.provider = 'chatfire'
|
||||
form.base_url = 'https://api.chatfire.site/v1'
|
||||
form.name = generateConfigName('chatfire', activeTab.value)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (config: AIServiceConfig) => {
|
||||
isEdit.value = true
|
||||
editingId.value = config.id
|
||||
|
||||
Object.assign(form, {
|
||||
service_type: config.service_type,
|
||||
provider: config.provider || 'chatfire',
|
||||
name: config.name,
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: Array.isArray(config.model) ? config.model : [config.model],
|
||||
priority: config.priority || 0,
|
||||
is_active: config.is_active
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (config: AIServiceConfig) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该配置吗?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await aiAPI.delete(config.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadConfigs()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (config: AIServiceConfig) => {
|
||||
try {
|
||||
const newActiveState = !config.is_active
|
||||
await aiAPI.update(config.id, { is_active: newActiveState })
|
||||
ElMessage.success(newActiveState ? '已启用配置' : '已禁用配置')
|
||||
await loadConfigs()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
await aiAPI.testConnection({
|
||||
base_url: form.base_url,
|
||||
api_key: form.api_key,
|
||||
model: form.model,
|
||||
provider: form.provider
|
||||
})
|
||||
ElMessage.success('连接测试成功!')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '连接测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (config: AIServiceConfig) => {
|
||||
testing.value = true
|
||||
try {
|
||||
await aiAPI.testConnection({
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: config.model,
|
||||
provider: config.provider
|
||||
})
|
||||
ElMessage.success('连接测试成功!')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '连接测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value && editingId.value) {
|
||||
const updateData: UpdateAIConfigRequest = {
|
||||
name: form.name,
|
||||
provider: form.provider,
|
||||
base_url: form.base_url,
|
||||
api_key: form.api_key,
|
||||
model: form.model,
|
||||
priority: form.priority,
|
||||
is_active: form.is_active
|
||||
}
|
||||
await aiAPI.update(editingId.value, updateData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await aiAPI.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
editDialogVisible.value = false
|
||||
loadConfigs()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabChange = (tabName: string | number) => {
|
||||
activeTab.value = tabName as AIServiceType
|
||||
loadConfigs()
|
||||
}
|
||||
|
||||
const handleProviderChange = () => {
|
||||
form.model = []
|
||||
|
||||
if (form.provider === 'gemini' || form.provider === 'google') {
|
||||
form.base_url = 'https://api.chatfire.site'
|
||||
} else {
|
||||
form.base_url = 'https://api.chatfire.site/v1'
|
||||
}
|
||||
|
||||
if (!isEdit.value) {
|
||||
form.name = generateConfigName(form.provider, form.service_type)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
const serviceType = form.service_type || 'text'
|
||||
Object.assign(form, {
|
||||
service_type: serviceType,
|
||||
provider: '',
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: [],
|
||||
priority: 0,
|
||||
is_active: true
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const showQuickSetupDialog = () => {
|
||||
quickSetupApiKey.value = ''
|
||||
quickSetupVisible.value = true
|
||||
}
|
||||
|
||||
const handleQuickSetup = async () => {
|
||||
if (!quickSetupApiKey.value.trim()) {
|
||||
ElMessage.warning('请输入 API Key')
|
||||
return
|
||||
}
|
||||
|
||||
quickSetupLoading.value = true
|
||||
const baseUrl = 'https://api.chatfire.site/v1'
|
||||
const apiKey = quickSetupApiKey.value.trim()
|
||||
|
||||
try {
|
||||
// 创建文本配置
|
||||
const textProvider = providerConfigs.text.find(p => p.id === 'chatfire')!
|
||||
await aiAPI.create({
|
||||
service_type: 'text',
|
||||
provider: 'chatfire',
|
||||
name: generateConfigName('chatfire', 'text'),
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: [textProvider.models[0]],
|
||||
priority: 0
|
||||
})
|
||||
|
||||
// 创建图片配置
|
||||
const imageProvider = providerConfigs.image.find(p => p.id === 'chatfire')!
|
||||
await aiAPI.create({
|
||||
service_type: 'image',
|
||||
provider: 'chatfire',
|
||||
name: generateConfigName('chatfire', 'image'),
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: [imageProvider.models[0]],
|
||||
priority: 0
|
||||
})
|
||||
|
||||
// 创建视频配置
|
||||
const videoProvider = providerConfigs.video.find(p => p.id === 'chatfire')!
|
||||
await aiAPI.create({
|
||||
service_type: 'video',
|
||||
provider: 'chatfire',
|
||||
name: generateConfigName('chatfire', 'video'),
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: [videoProvider.models[0]],
|
||||
priority: 0
|
||||
})
|
||||
|
||||
ElMessage.success('一键配置成功!已创建文本、图片、视频三个服务配置')
|
||||
quickSetupVisible.value = false
|
||||
loadConfigs()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '配置失败')
|
||||
} finally {
|
||||
quickSetupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load configs when dialog opens
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
loadConfigs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-config-dialog :deep(.el-dialog__header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-setup-info {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quick-setup-tip {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-setup-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-config-dialog :deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-tabs {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .ai-config-dialog :deep(.el-dialog) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark .ai-config-dialog :deep(.el-dialog__header) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-form-item__label) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__wrapper) {
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 1px var(--border-primary) inset;
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__inner) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-select .el-input__wrapper) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
103
web/src/components/common/ActionButton.vue
Normal file
103
web/src/components/common/ActionButton.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<!-- Minimalist action button with icon and optional tooltip -->
|
||||
<!-- 简约操作按钮,带图标和可选提示 -->
|
||||
<el-tooltip
|
||||
v-if="tooltip"
|
||||
:content="tooltip"
|
||||
placement="top"
|
||||
:show-after="500"
|
||||
>
|
||||
<button
|
||||
:class="['action-button', variant, { disabled }]"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<el-icon :size="size">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<button
|
||||
v-else
|
||||
:class="['action-button', variant, { disabled }]"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<el-icon :size="size">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
/**
|
||||
* ActionButton - Minimalist icon button for actions
|
||||
* 操作按钮 - 简约图标按钮用于各种操作
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
icon: Component
|
||||
tooltip?: string
|
||||
variant?: 'default' | 'primary' | 'danger'
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
size: 16,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.dark .action-button.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.action-button.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-button.disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
125
web/src/components/common/AppLayout.vue
Normal file
125
web/src/components/common/AppLayout.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<!-- Global Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<router-link to="/" class="logo">
|
||||
<span class="logo-text">🎬 AI Drama</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<el-button @click="showAIConfig = true" class="header-btn">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span class="btn-text">{{ $t('drama.aiConfig') }}</span>
|
||||
</el-button>
|
||||
<!-- <el-button :icon="Setting" circle @click="showAIConfig = true" :title="$t('aiConfig.title')" /> -->
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="app-main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- AI Config Dialog -->
|
||||
<AIConfigDialog v-model="showAIConfig" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import AIConfigDialog from './AIConfigDialog.vue'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const showAIConfig = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-4);
|
||||
height: 56px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-btn {
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);
|
||||
}
|
||||
|
||||
.header-btn.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(14, 165, 233, 0.45);
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .app-header {
|
||||
background: rgba(26, 33, 41, 0.95);
|
||||
}
|
||||
</style>
|
||||
210
web/src/components/common/BaseCard.vue
Normal file
210
web/src/components/common/BaseCard.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<!-- Base Card Component - Reusable card with modern design -->
|
||||
<!-- 基础卡片组件 - 现代设计的可复用卡片 -->
|
||||
<div
|
||||
:class="[
|
||||
'base-card',
|
||||
`variant-${variant}`,
|
||||
{ 'is-hoverable': hoverable, 'is-clickable': clickable }
|
||||
]"
|
||||
@click="clickable ? $emit('click') : undefined"
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
@keydown.enter="clickable ? $emit('click') : undefined"
|
||||
>
|
||||
<!-- Card Header / 卡片头部 -->
|
||||
<div v-if="$slots.header || title" class="card-header">
|
||||
<slot name="header">
|
||||
<div class="header-content">
|
||||
<div v-if="icon" class="header-icon">
|
||||
<el-icon :size="iconSize" :color="iconColor">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="card-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.headerActions" class="header-actions">
|
||||
<slot name="headerActions"></slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Card Body / 卡片内容 -->
|
||||
<div :class="['card-body', { 'no-padding': noPadding }]">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Card Footer / 卡片底部 -->
|
||||
<div v-if="$slots.footer" class="card-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
/**
|
||||
* BaseCard - Reusable card component with modern design
|
||||
* 基础卡片组件 - 现代设计的可复用卡片
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
icon?: Component
|
||||
iconSize?: number
|
||||
iconColor?: string
|
||||
variant?: 'default' | 'elevated' | 'outlined' | 'ghost'
|
||||
hoverable?: boolean
|
||||
clickable?: boolean
|
||||
noPadding?: boolean
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
iconSize: 20,
|
||||
hoverable: false,
|
||||
clickable: false,
|
||||
noPadding: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Card Container / 卡片容器 */
|
||||
.base-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
transition: all var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Variants / 变体样式 */
|
||||
.variant-default {
|
||||
border: 1px solid var(--border-primary);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.variant-elevated {
|
||||
border: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.variant-outlined {
|
||||
border: 1px solid var(--border-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.variant-ghost {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Hover & Clickable States / 悬停和可点击状态 */
|
||||
.is-hoverable:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.is-clickable:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.is-clickable:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.is-clickable:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
/* Card Header / 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: var(--accent-light);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Card Body / 卡片内容 */
|
||||
.card-body {
|
||||
padding: var(--space-5);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-body.no-padding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Card Footer / 卡片底部 */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments / 深色模式调整 */
|
||||
.dark .card-footer {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
235
web/src/components/common/CreateDramaDialog.vue
Normal file
235
web/src/components/common/CreateDramaDialog.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<!-- Create Drama Dialog / 创建短剧弹窗 -->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="$t('drama.createNew')"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
class="create-dialog"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="dialog-desc">{{ $t('drama.createDesc') }}</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="create-form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item :label="$t('drama.projectName')" prop="title" required>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
:placeholder="$t('drama.projectNamePlaceholder')"
|
||||
size="large"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('drama.projectDesc')" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('drama.projectDescPlaceholder')"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button size="large" @click="handleClose">
|
||||
{{ $t('common.cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<el-icon v-if="!loading"><Plus /></el-icon>
|
||||
{{ $t('drama.createNew') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { CreateDramaRequest } from '@/types/drama'
|
||||
|
||||
/**
|
||||
* CreateDramaDialog - Reusable dialog for creating new drama projects
|
||||
* 创建短剧弹窗 - 可复用的创建短剧项目弹窗
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'created': [id: string]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
// v-model binding / 双向绑定
|
||||
const visible = ref(props.modelValue)
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
})
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// Form data / 表单数据
|
||||
const form = reactive<CreateDramaRequest>({
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Validation rules / 验证规则
|
||||
const rules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入项目标题', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '标题长度在 1 到 100 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// Reset form when dialog closes / 关闭时重置表单
|
||||
const handleClosed = () => {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// Close dialog / 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// Submit form / 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
const drama = await dramaAPI.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
visible.value = false
|
||||
emit('created', drama.id)
|
||||
// Navigate to drama detail page / 跳转到短剧详情页
|
||||
router.push(`/dramas/${drama.id}`)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '创建失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========================================
|
||||
Dialog Styles / 弹窗样式
|
||||
======================================== */
|
||||
.create-dialog :deep(.el-dialog) {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.create-dialog :deep(.el-dialog__header) {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.create-dialog :deep(.el-dialog__title) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.create-dialog :deep(.el-dialog__body) {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog-desc {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Styles / 表单样式
|
||||
======================================== */
|
||||
.create-form :deep(.el-form-item) {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.create-form :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.create-form :deep(.el-input__wrapper),
|
||||
.create-form :deep(.el-textarea__inner) {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 0 0 1px var(--border-primary) inset;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.create-form :deep(.el-input__wrapper:hover),
|
||||
.create-form :deep(.el-textarea__inner:hover) {
|
||||
box-shadow: 0 0 0 1px var(--border-secondary) inset;
|
||||
}
|
||||
|
||||
.create-form :deep(.el-input__wrapper.is-focus),
|
||||
.create-form :deep(.el-textarea__inner:focus) {
|
||||
box-shadow: 0 0 0 2px var(--accent) inset;
|
||||
}
|
||||
|
||||
.create-form :deep(.el-input__inner),
|
||||
.create-form :deep(.el-textarea__inner) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.create-form :deep(.el-input__inner::placeholder),
|
||||
.create-form :deep(.el-textarea__inner::placeholder) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.create-form :deep(.el-input__count) {
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Footer Styles / 底部样式
|
||||
======================================== */
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dialog-footer .el-button {
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
130
web/src/components/common/EmptyState.vue
Normal file
130
web/src/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<!-- Empty State Component - Display when no data available -->
|
||||
<!-- 空状态组件 - 无数据时的展示 -->
|
||||
<div :class="['empty-state', `size-${size}`]">
|
||||
<div class="empty-icon" :class="{ 'has-animation': animated }">
|
||||
<el-icon :size="iconSize">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="empty-title">{{ title }}</h3>
|
||||
<p v-if="description" class="empty-description">{{ description }}</p>
|
||||
<div v-if="$slots.default" class="empty-actions">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { FolderOpened } from '@element-plus/icons-vue'
|
||||
|
||||
/**
|
||||
* EmptyState - Display when no data is available
|
||||
* 空状态组件 - 无数据时的占位展示
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
animated?: boolean
|
||||
}>(), {
|
||||
icon: FolderOpened,
|
||||
size: 'medium',
|
||||
animated: true
|
||||
})
|
||||
|
||||
// Icon size based on component size / 根据组件尺寸设置图标大小
|
||||
const iconSize = computed(() => {
|
||||
const sizes = {
|
||||
small: 32,
|
||||
medium: 48,
|
||||
large: 64
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
}
|
||||
|
||||
/* Size variants / 尺寸变体 */
|
||||
.size-small {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.size-small .empty-title {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.size-small .empty-description {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.size-large {
|
||||
padding: var(--space-12) var(--space-6);
|
||||
}
|
||||
|
||||
.size-large .empty-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Icon / 图标 */
|
||||
.empty-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin-bottom: var(--space-4);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-icon.has-animation {
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(14, 165, 233, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Title / 标题 */
|
||||
.empty-title {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Description / 描述 */
|
||||
.empty-description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Actions / 操作区 */
|
||||
.empty-actions {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
223
web/src/components/common/PageHeader.vue
Normal file
223
web/src/components/common/PageHeader.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<!-- Page header component with title, subtitle and action buttons -->
|
||||
<!-- 页面头部组件,包含标题、副标题和操作按钮 -->
|
||||
<header :class="['page-header', { 'with-back': showBack, 'with-border': showBorder }]">
|
||||
<div class="header-content">
|
||||
<!-- Back button section / 返回按钮区域 -->
|
||||
<div v-if="showBack" class="header-nav">
|
||||
<button class="back-btn" @click="handleBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>{{ backText }}</span>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- Title section / 标题区域 -->
|
||||
<div class="header-text">
|
||||
<div class="title-row">
|
||||
<div v-if="$slots.icon" class="title-icon">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<h1 class="header-title">{{ title }}
|
||||
|
||||
<p v-if="subtitle" class="header-subtitle">{{ subtitle }}</p>
|
||||
</h1>
|
||||
<slot name="badge"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions section / 操作区域 -->
|
||||
<div class="header-actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra content / 额外内容 -->
|
||||
<div v-if="$slots.extra" class="header-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
/**
|
||||
* PageHeader - Reusable page header component
|
||||
* 页面头部组件 - 可复用的页面头部
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
showBack?: boolean
|
||||
backText?: string
|
||||
showBorder?: boolean
|
||||
}>(), {
|
||||
showBack: false,
|
||||
backText: '返回',
|
||||
showBorder: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Handle back navigation / 处理返回导航
|
||||
const handleBack = () => {
|
||||
emit('back')
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.page-header.with-border {
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header.with-back .header-content {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation / 导航 */
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.back-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 2rem;
|
||||
background: var(--border-primary);
|
||||
}
|
||||
|
||||
/* Title / 标题 */
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
|
||||
border-radius: var(--radius-lg);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
max-width: 480px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Actions / 操作 */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra / 额外内容 */
|
||||
.header-extra {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
</style>
|
||||
178
web/src/components/common/ProjectCard.vue
Normal file
178
web/src/components/common/ProjectCard.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<!-- Project card component - Compact design with hover actions -->
|
||||
<!-- 项目卡片组件 - 紧凑设计,悬停显示操作 -->
|
||||
<article
|
||||
class="project-card"
|
||||
@click="$emit('click')"
|
||||
tabindex="0"
|
||||
@keydown.enter="$emit('click')"
|
||||
>
|
||||
<!-- Gradient header with icon / 渐变头部区域 -->
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Film /></el-icon>
|
||||
<!-- Hover actions / 悬停操作区 -->
|
||||
<div class="hover-actions" @click.stop>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content / 卡片内容 -->
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
<p v-if="description" class="card-description">{{ description }}</p>
|
||||
|
||||
<!-- Footer section / 底部区域 -->
|
||||
<div class="card-footer">
|
||||
<span class="meta-time">{{ formattedDate }}</span>
|
||||
<span class="episode-label">共 {{ episodeCount }} 集</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Film } from '@element-plus/icons-vue'
|
||||
|
||||
/**
|
||||
* ProjectCard - Reusable project/drama card component
|
||||
* 项目卡片组件 - 可复用的项目展示卡片
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
updatedAt: string
|
||||
episodeCount?: number
|
||||
}>(), {
|
||||
description: '',
|
||||
episodeCount: 0
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
// Format date / 格式化日期
|
||||
const formattedDate = computed(() => {
|
||||
const date = new Date(props.updatedAt)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Card Container / 卡片容器 */
|
||||
.project-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.project-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Card Header / 卡片头部 */
|
||||
.card-header {
|
||||
position: relative;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Hover Actions / 悬停操作区 */
|
||||
.hover-actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.project-card:hover .hover-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Body Section / 内容区域 */
|
||||
.card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Footer Section / 底部区域 */
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-time,
|
||||
.episode-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:deep(.action-button) {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
background: var(--bg-secondary) !important;
|
||||
border: none !important;
|
||||
}
|
||||
</style>
|
||||
180
web/src/components/common/StatCard.vue
Normal file
180
web/src/components/common/StatCard.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<!-- Stat Card Component - Display statistics with modern design -->
|
||||
<!-- 统计卡片组件 - 现代设计的统计数据展示 -->
|
||||
<div :class="['stat-card', `variant-${variant}`]">
|
||||
<div class="stat-icon" :style="{ background: iconBg }">
|
||||
<el-icon :size="24" :color="iconColor">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">{{ label }}</span>
|
||||
<div class="stat-value-row">
|
||||
<span class="stat-value" :style="{ color: valueColor }">{{ formattedValue }}</span>
|
||||
<span v-if="suffix" class="stat-suffix">{{ suffix }}</span>
|
||||
</div>
|
||||
<span v-if="description" class="stat-description">{{ description }}</span>
|
||||
</div>
|
||||
<div v-if="trend !== undefined" :class="['stat-trend', trendDirection]">
|
||||
<el-icon :size="14">
|
||||
<component :is="trendIcon" />
|
||||
</el-icon>
|
||||
<span>{{ Math.abs(trend) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { TrendCharts, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
|
||||
|
||||
/**
|
||||
* StatCard - Display statistics with trend indicator
|
||||
* 统计卡片 - 带趋势指示器的统计数据展示
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string
|
||||
value: number | string
|
||||
icon: Component
|
||||
iconColor?: string
|
||||
iconBg?: string
|
||||
valueColor?: string
|
||||
suffix?: string
|
||||
description?: string
|
||||
trend?: number
|
||||
variant?: 'default' | 'compact'
|
||||
}>(), {
|
||||
iconColor: 'var(--accent)',
|
||||
iconBg: 'var(--accent-light)',
|
||||
valueColor: 'var(--accent)',
|
||||
variant: 'default'
|
||||
})
|
||||
|
||||
// Format large numbers / 格式化大数字
|
||||
const formattedValue = computed(() => {
|
||||
if (typeof props.value === 'string') return props.value
|
||||
if (props.value >= 1000000) {
|
||||
return (props.value / 1000000).toFixed(1) + 'M'
|
||||
}
|
||||
if (props.value >= 1000) {
|
||||
return (props.value / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return props.value.toString()
|
||||
})
|
||||
|
||||
// Trend direction / 趋势方向
|
||||
const trendDirection = computed(() => {
|
||||
if (props.trend === undefined) return ''
|
||||
return props.trend >= 0 ? 'up' : 'down'
|
||||
})
|
||||
|
||||
// Trend icon / 趋势图标
|
||||
const trendIcon = computed(() => {
|
||||
if (props.trend === undefined) return TrendCharts
|
||||
return props.trend >= 0 ? ArrowUp : ArrowDown
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-secondary);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
/* Compact variant / 紧凑变体 */
|
||||
.variant-compact {
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.variant-compact .stat-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.variant-compact .stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Icon / 图标 */
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--radius-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Content / 内容 */
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-suffix {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Trend / 趋势 */
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-trend.up {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-trend.down {
|
||||
background: var(--error-light);
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
100
web/src/components/common/ThemeToggle.vue
Normal file
100
web/src/components/common/ThemeToggle.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<!-- Theme toggle button for switching between light/dark mode -->
|
||||
<!-- 主题切换按钮,用于切换浅色/深色模式 -->
|
||||
<button
|
||||
class="theme-toggle"
|
||||
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<transition name="icon-fade" mode="out-in">
|
||||
<el-icon v-if="isDark" key="moon" :size="18">
|
||||
<Moon />
|
||||
</el-icon>
|
||||
<el-icon v-else key="sun" :size="18">
|
||||
<Sunny />
|
||||
</el-icon>
|
||||
</transition>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
|
||||
/**
|
||||
* ThemeToggle - Dark/Light mode toggle button
|
||||
* 主题切换按钮 - 深色/浅色模式切换
|
||||
*/
|
||||
const isDark = ref(false)
|
||||
|
||||
// Initialize theme from localStorage or system preference
|
||||
// 从 localStorage 或系统偏好初始化主题
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
isDark.value = savedTheme === 'dark'
|
||||
} else {
|
||||
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
applyTheme()
|
||||
})
|
||||
|
||||
// Toggle between dark and light mode / 切换深色和浅色模式
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
applyTheme()
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// Apply theme to document / 应用主题到文档
|
||||
const applyTheme = () => {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Icon transition / 图标过渡动画 */
|
||||
.icon-fade-enter-active,
|
||||
.icon-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg) scale(0.8);
|
||||
}
|
||||
|
||||
.icon-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.8);
|
||||
}
|
||||
</style>
|
||||
22
web/src/components/common/index.ts
Normal file
22
web/src/components/common/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Common UI Components barrel export
|
||||
* 通用 UI 组件统一导出
|
||||
*/
|
||||
|
||||
// Layout Components / 布局组件
|
||||
export { default as PageHeader } from './PageHeader.vue'
|
||||
export { default as BaseCard } from './BaseCard.vue'
|
||||
export { default as StatCard } from './StatCard.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
|
||||
// Interactive Components / 交互组件
|
||||
export { default as ProjectCard } from './ProjectCard.vue'
|
||||
export { default as ThemeToggle } from './ThemeToggle.vue'
|
||||
export { default as ActionButton } from './ActionButton.vue'
|
||||
|
||||
// Dialog Components / 弹窗组件
|
||||
export { default as CreateDramaDialog } from './CreateDramaDialog.vue'
|
||||
export { default as AIConfigDialog } from './AIConfigDialog.vue'
|
||||
|
||||
// Layout Components / 布局组件
|
||||
export { default as AppLayout } from './AppLayout.vue'
|
||||
1509
web/src/components/editor/StoryboardEditor.vue
Normal file
1509
web/src/components/editor/StoryboardEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
2476
web/src/components/editor/VideoTimelineEditor.vue
Normal file
2476
web/src/components/editor/VideoTimelineEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
711
web/src/locales/en-US.ts
Normal file
711
web/src/locales/en-US.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
export default {
|
||||
nav: {
|
||||
home: 'Home',
|
||||
characters: 'Characters',
|
||||
storyboard: 'Storyboard',
|
||||
videos: 'Videos',
|
||||
assets: 'Assets',
|
||||
settings: 'Settings',
|
||||
dramas: 'Drama Projects'
|
||||
},
|
||||
dashboard: {
|
||||
title: '🎬 Drama Generator',
|
||||
welcome: 'Welcome to AI Drama Generation Platform',
|
||||
subtitle: 'One-stop drama creation tool from script to video',
|
||||
stats: {
|
||||
projects: 'Drama Projects',
|
||||
images: 'Generated Images',
|
||||
videos: 'Generated Videos',
|
||||
tasks: 'Processing Tasks'
|
||||
},
|
||||
quickStart: 'Quick Start',
|
||||
actions: {
|
||||
newProject: 'Create New Project',
|
||||
newProjectDesc: 'Start a brand new drama project',
|
||||
myProjects: 'My Projects',
|
||||
myProjectsDesc: 'View and manage existing projects'
|
||||
}
|
||||
},
|
||||
common: {
|
||||
create: 'Create',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
search: 'Search',
|
||||
filter: 'Filter',
|
||||
reset: 'Reset',
|
||||
submit: 'Submit',
|
||||
close: 'Close',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
selectAll: 'Select All',
|
||||
loading: 'Loading...',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
actions: 'Actions',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
createdAt: 'Created At',
|
||||
updatedAt: 'Updated At',
|
||||
perPage: 'Per Page'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
aiConfig: 'AI Configuration',
|
||||
general: 'General',
|
||||
language: 'Language',
|
||||
theme: 'Theme'
|
||||
},
|
||||
aiConfig: {
|
||||
title: 'AI Service Configuration',
|
||||
addConfig: 'Add Configuration',
|
||||
editConfig: 'Edit Configuration',
|
||||
back: 'Back',
|
||||
empty: 'No configurations yet, click Add Configuration to get started',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
endpoint: 'Endpoint',
|
||||
queryEndpoint: 'Query Endpoint',
|
||||
tabs: {
|
||||
text: 'Text Generation',
|
||||
image: 'Image Generation',
|
||||
video: 'Video Generation'
|
||||
},
|
||||
form: {
|
||||
name: 'Configuration Name',
|
||||
namePlaceholder: 'e.g., OpenAI GPT-4',
|
||||
provider: 'Provider',
|
||||
providerPlaceholder: 'Select a provider',
|
||||
providerTip: 'Select AI service provider',
|
||||
priority: 'Priority',
|
||||
priorityTip: 'Higher values have higher priority. For the same model, higher priority configurations are used first',
|
||||
model: 'Model',
|
||||
modelPlaceholder: 'Enter or select model name',
|
||||
modelTip: 'Enter model name directly or select from list, supports multiple models',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlPlaceholder: 'https://api.openai.com',
|
||||
baseUrlTip: 'API service base address, e.g., Chatfire: https://api.chatfire.site/v1, Gemini: https://generativelanguage.googleapis.com (no /v1 needed)',
|
||||
fullEndpoint: 'Full endpoint path',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
apiKeyTip: 'Your API key',
|
||||
isActive: 'Active Status'
|
||||
},
|
||||
actions: {
|
||||
test: 'Test Connection',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit'
|
||||
},
|
||||
messages: {
|
||||
deleteConfirm: 'Are you sure to delete this configuration?',
|
||||
testSuccess: 'Connection test successful!',
|
||||
testFailed: 'Connection test failed'
|
||||
}
|
||||
},
|
||||
drama: {
|
||||
title: 'My Drama Projects',
|
||||
create: 'Create Project',
|
||||
totalProjects: 'Total {count} projects',
|
||||
createNew: 'Create Project',
|
||||
createDesc: 'Fill in basic information to create your drama project',
|
||||
aiConfig: 'AI Config',
|
||||
aiConfigTip: 'Please configure AI service before creating a project',
|
||||
empty: 'No projects yet',
|
||||
emptyHint: 'Click "Create Project" button above to start your first drama',
|
||||
editProject: 'Edit Project',
|
||||
projectName: 'Project Name',
|
||||
projectNamePlaceholder: 'Enter project name',
|
||||
projectDesc: 'Project Description',
|
||||
projectDescPlaceholder: 'Enter project description (optional)',
|
||||
deleteConfirm: 'Are you sure you want to delete this project?',
|
||||
noCover: 'No cover',
|
||||
noDescription: 'No description',
|
||||
status: {
|
||||
draft: 'Draft',
|
||||
production: 'In Production',
|
||||
completed: 'Completed'
|
||||
},
|
||||
actions: {
|
||||
edit: 'Edit',
|
||||
view: 'View',
|
||||
delete: 'Delete'
|
||||
},
|
||||
management: {
|
||||
overview: 'Project Overview',
|
||||
episodes: 'Episode Management',
|
||||
characters: 'Character Management',
|
||||
scenes: 'Scene Management',
|
||||
projectInfo: 'Project Information',
|
||||
projectName: 'Project Name',
|
||||
projectDesc: 'Project Description',
|
||||
noDescription: 'No description',
|
||||
episodeStats: 'Episode Statistics',
|
||||
characterStats: 'Character Statistics',
|
||||
sceneStats: 'Scene Statistics',
|
||||
episodesCreated: 'Episodes Created',
|
||||
charactersCreated: 'Characters Created',
|
||||
sceneLibraryCount: 'Scene Library Count',
|
||||
startFirstEpisode: 'Start creating your first episode!',
|
||||
noEpisodesYet: 'Your project has no episodes yet. Please create an episode to start production.',
|
||||
createFirstEpisode: 'Create First Episode Now',
|
||||
episodeList: 'Episode List',
|
||||
createNewEpisode: 'Create New Episode',
|
||||
noEpisodes: 'No episodes yet',
|
||||
clickToCreate: 'Click the button above to create your first episode',
|
||||
episodeNumber: 'Episode {number}',
|
||||
goToEdit: 'Go to Edit',
|
||||
characterList: 'Character List',
|
||||
noCharacters: 'No characters yet',
|
||||
charactersTip: 'Characters will be automatically created during script generation',
|
||||
sceneList: 'Scene List',
|
||||
noScenes: 'No scenes yet',
|
||||
scenesTip: 'Scenes will be automatically created during storyboard generation'
|
||||
}
|
||||
},
|
||||
character: {
|
||||
title: 'Character Management',
|
||||
create: 'Create Character',
|
||||
edit: 'Edit Character',
|
||||
add: 'Add Character',
|
||||
list: 'Character List',
|
||||
name: 'Character Name',
|
||||
role: 'Role',
|
||||
personality: 'Personality',
|
||||
appearance: 'Appearance',
|
||||
background: 'Background',
|
||||
description: 'Description',
|
||||
image: 'Character Image',
|
||||
generate: 'Generate Character Image',
|
||||
extracting: 'Extracting...',
|
||||
generateImage: 'Generate Image',
|
||||
batch: 'Batch Operations',
|
||||
empty: 'Characters were created during script generation. You can view and edit them here',
|
||||
backToProject: 'Back to Project',
|
||||
saveChanges: 'Save Changes',
|
||||
nextStep: 'Next Step: Generate Character Images'
|
||||
},
|
||||
scriptGenerationPage: {
|
||||
prevStep: 'Previous',
|
||||
characterList: 'Character List',
|
||||
characterName: 'Character Name',
|
||||
position: 'Position',
|
||||
appearanceDesc: 'Appearance Description',
|
||||
personality: 'Personality',
|
||||
uploadScript: 'Upload Script',
|
||||
uploadContent: 'Upload Content',
|
||||
aiParse: 'AI Parse',
|
||||
confirmSave: 'Confirm & Save',
|
||||
uploadNotice: 'Paste or upload your script file, the system will automatically identify and split into episodes and scenes',
|
||||
uploadMethod: 'Upload Method',
|
||||
dragFilesHere: 'Drag files here or',
|
||||
clickUpload: 'click to upload',
|
||||
supportedFormats: 'Supports .txt, .md, .doc, .docx formats',
|
||||
characterListEditable: 'Character List (Editable)',
|
||||
addCharacter: '+ Add Character',
|
||||
characterType: 'Character Type',
|
||||
mainCharacter: 'Main Character',
|
||||
supportingCharacter: 'Supporting Character',
|
||||
minorCharacter: 'Minor Character',
|
||||
characterDesc: 'Character Description',
|
||||
appearanceFeatures: 'Appearance Features',
|
||||
operations: 'Operations',
|
||||
delete: 'Delete',
|
||||
episodeCount: 'Episode Count',
|
||||
generateFullScript: 'Generate complete episode scripts based on outline',
|
||||
outlineCreatedEpisodes: 'The outline has created {count} episodes, but you can reset the episode count and regenerate',
|
||||
episodePreview: 'Episode Preview (Total {count} episodes)',
|
||||
regenerate: 'Regenerate',
|
||||
episodeNumber: 'Episode',
|
||||
title: 'Title',
|
||||
summary: 'Summary',
|
||||
durationSeconds: 'Duration (seconds)',
|
||||
autoGenerateCharacters: 'Auto-generate character list from outline',
|
||||
charactersCreatedInOutline: 'Characters have been created during outline generation, click "Next" to view and edit'
|
||||
},
|
||||
script: {
|
||||
title: 'Script Generation',
|
||||
backToProject: 'Back to Project',
|
||||
aiGenerate: 'AI Generate Script',
|
||||
uploadScript: 'Upload Script',
|
||||
steps: {
|
||||
outline: 'Generate Outline',
|
||||
characters: 'Generate Characters',
|
||||
episodes: 'Generate Episodes'
|
||||
},
|
||||
form: {
|
||||
theme: 'Creative Theme',
|
||||
themePlaceholder: 'Describe the theme and story concept of the drama you want to create',
|
||||
genre: 'Genre Preference',
|
||||
genrePlaceholder: 'Select a genre',
|
||||
style: 'Style Requirements',
|
||||
stylePlaceholder: 'e.g., Light and humorous, Tense and thrilling, Warm and healing',
|
||||
episodeCount: 'Episode Count',
|
||||
randomGenerate: 'Random Generate',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'Enter script title',
|
||||
summary: 'Summary',
|
||||
summaryPlaceholder: 'Enter script summary',
|
||||
genreExample: 'e.g., Urban, Costume',
|
||||
tags: 'Tags',
|
||||
newTag: 'New Tag'
|
||||
},
|
||||
notice: 'Please enter the creative theme and requirements, AI will generate a script outline for you',
|
||||
generateFailed: 'Generation Failed',
|
||||
generating: 'Generating...',
|
||||
nextStep: 'Next Step',
|
||||
prevStep: 'Previous Step',
|
||||
complete: 'Complete',
|
||||
regenerate: 'Regenerate',
|
||||
regenerateOutline: 'Regenerate Outline',
|
||||
outlinePreview: 'Outline Preview (Editable)'
|
||||
},
|
||||
imageDialog: {
|
||||
title: 'AI Image Generation',
|
||||
selectDrama: 'Select Drama',
|
||||
selectScene: 'Select Scene',
|
||||
selectSceneOptional: 'Select Scene (Optional)',
|
||||
sceneLabel: 'Scene {number}: {title}',
|
||||
prompt: 'Prompt',
|
||||
promptPlaceholder: 'Describe the image you want to generate\nFor example: A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed',
|
||||
negativePrompt: 'Negative Prompt',
|
||||
negativePromptPlaceholder: 'Describe elements you don\'t want (optional)\nFor example: blurry, low quality, watermark',
|
||||
aiService: 'AI Service',
|
||||
selectService: 'Select service',
|
||||
imageSize: 'Image Size',
|
||||
selectSize: 'Select size',
|
||||
square: 'Square',
|
||||
landscape: 'Landscape',
|
||||
portrait: 'Portrait',
|
||||
imageQuality: 'Image Quality',
|
||||
standard: 'Standard',
|
||||
hd: 'HD',
|
||||
style: 'Style',
|
||||
vivid: 'Vivid',
|
||||
natural: 'Natural',
|
||||
advancedSettings: 'Advanced Settings',
|
||||
samplingSteps: 'Sampling Steps',
|
||||
promptRelevance: 'Prompt Relevance',
|
||||
randomSeed: 'Random Seed',
|
||||
leaveBlankRandom: 'Leave blank for random',
|
||||
seedTip: 'Set the same seed to reproduce the image',
|
||||
generate: 'Generate Image',
|
||||
pleaseSelectDrama: 'Please select a drama',
|
||||
pleaseEnterPrompt: 'Please enter a prompt',
|
||||
promptMinLength: 'Prompt must be at least 5 characters',
|
||||
taskSubmitted: 'Image generation task submitted, please check results later',
|
||||
generateFailed: 'Generation failed',
|
||||
weak: 'Weak',
|
||||
moderate: 'Moderate',
|
||||
strong: 'Strong',
|
||||
veryStrong: 'Very Strong'
|
||||
},
|
||||
image: {
|
||||
title: 'AI Image Generation',
|
||||
generate: 'Generate Image',
|
||||
loadFailed: 'Load Failed',
|
||||
generating: 'Generating...',
|
||||
generateFailed: 'Generation Failed'
|
||||
},
|
||||
dramaWorkflow: {
|
||||
returnToList: 'Back',
|
||||
episodeScript: 'Episode {number} Script',
|
||||
storyboardBreakdown: 'Storyboard Breakdown',
|
||||
characterImages: 'Character Images',
|
||||
createChapterPrompt: 'Please create the first chapter to start production',
|
||||
createChapter: 'Create Chapter {number}',
|
||||
nextStepCharacterImages: 'Next: Character Images',
|
||||
nextStep: 'Next',
|
||||
reGenerateShots: 'Re-split',
|
||||
reGenerateShotsConfirm: 'Re-splitting will overwrite existing shots, are you sure?',
|
||||
pleaseWriteScript: 'Please write script content first',
|
||||
splitStoryboardFirst: 'Please split storyboard first',
|
||||
aiSplitting: 'AI Splitting...',
|
||||
aiAutoSplit: 'AI Auto Split',
|
||||
selected: 'Selected',
|
||||
characterCount: 'Characters',
|
||||
generated: 'Generated',
|
||||
batchGenerate: 'Batch Generate'
|
||||
},
|
||||
workflow: {
|
||||
backToProject: 'Back to Project',
|
||||
episodeProduction: 'Episode {number} Production',
|
||||
steps: {
|
||||
content: 'Episode Content',
|
||||
generateImages: 'Generate Images',
|
||||
splitStoryboard: 'Split Storyboard'
|
||||
},
|
||||
scriptPlaceholder: 'Enter episode content...',
|
||||
saveChapter: 'Save Chapter',
|
||||
chapterContent: 'Chapter {number} Content',
|
||||
saved: 'Saved',
|
||||
extractedData: 'Extracted Data',
|
||||
characters: 'Characters',
|
||||
scenes: 'Scenes',
|
||||
extractedCharacters: 'Extracted Characters (This Episode)',
|
||||
extractedScenes: 'Extracted Scenes (This Episode)',
|
||||
extractCharactersAndScenes: 'Extract Characters and Scenes',
|
||||
reExtract: 'Re-extract Characters and Scenes',
|
||||
nextStepGenerateImages: 'Next Step: Generate Images',
|
||||
extractWarning: 'Please click "Extract Characters and Scenes" first, then you can generate images after extraction is complete',
|
||||
characterImages: 'Character Images',
|
||||
sceneImages: 'Scene Images',
|
||||
characterCount: '{count} characters need to generate images',
|
||||
sceneCount: '{count} scenes need to generate images',
|
||||
selectAll: 'Select All',
|
||||
batchGenerate: 'Batch Generate',
|
||||
modelConfig: 'AI Model Configuration',
|
||||
editPrompt: 'Edit Prompt',
|
||||
aiGenerate: 'AI Generate',
|
||||
uploadImage: 'Upload Image',
|
||||
selectFromLibrary: 'Select from Library',
|
||||
shotList: 'Shot List',
|
||||
dragFilesHere: 'Drop files here, or',
|
||||
clickToUpload: 'Click to Upload',
|
||||
prevStep: 'Previous Step',
|
||||
nextStepSplitShots: 'Next Step: Split Shots',
|
||||
reExtractConfirmTitle: 'Re-extract Confirmation',
|
||||
reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?',
|
||||
startReExtracting: 'Starting re-extraction, please wait...',
|
||||
regenerateShots: 'Regenerate Shots',
|
||||
batchGenerateSelected: 'Batch Generate Selected Scenes',
|
||||
generateAllImagesFirst: 'Please generate all character and scene images before splitting shots',
|
||||
sceneImageGenerating: 'Scene image generating, please wait...',
|
||||
sceneImageComplete: 'Scene image generation completed!',
|
||||
sceneImageStarted: 'Scene image generation started',
|
||||
reSplitShots: 'Re-split Shots',
|
||||
enterProfessional: 'Enter Professional Production',
|
||||
editShot: 'Edit Shot',
|
||||
splitSuccess: 'Shot splitting successful! Entering professional production interface...',
|
||||
reSplitConfirm: 'Are you sure you want to re-split the shots?',
|
||||
deleteCharacter: 'Delete Character',
|
||||
splitStoryboardFirst: 'Please split the storyboard first',
|
||||
aiSplitting: 'AI Splitting...',
|
||||
aiAutoSplit: 'AI Auto Split',
|
||||
batchTaskSubmitted: 'Batch generation task submitted!',
|
||||
batchGenerateFailed: 'Batch generation failed',
|
||||
batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes',
|
||||
batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed',
|
||||
addToLibrary: 'Add to Character Library',
|
||||
addToLibraryConfirm: 'Are you sure you want to add character "{name}" to the global character library? Once added, this character can be used in all projects.',
|
||||
addedToLibrary: 'Added to character library!',
|
||||
addFailed: 'Add failed',
|
||||
shotTitle: 'Shot Title',
|
||||
shotTitlePlaceholder: 'Enter shot title',
|
||||
shotType: 'Shot Type',
|
||||
selectShotType: 'Select shot type',
|
||||
longShot: 'Long Shot',
|
||||
fullShot: 'Full Shot',
|
||||
mediumShot: 'Medium Shot',
|
||||
closeUp: 'Close-up',
|
||||
extremeCloseUp: 'Extreme Close-up',
|
||||
cameraAngle: 'Camera Angle',
|
||||
selectAngle: 'Select angle',
|
||||
eyeLevel: 'Eye Level',
|
||||
lowAngle: 'Low Angle',
|
||||
highAngle: 'High Angle',
|
||||
location: 'Location',
|
||||
locationPlaceholder: 'Scene location',
|
||||
shotDescription: 'Shot Description',
|
||||
shotDescriptionPlaceholder: 'Overall shot description',
|
||||
cameraMovement: 'Camera Movement',
|
||||
selectMovement: 'Select movement',
|
||||
staticShot: 'Static Shot',
|
||||
pushIn: 'Push In',
|
||||
pullOut: 'Pull Out',
|
||||
followShot: 'Follow Shot',
|
||||
sideView: 'Side View',
|
||||
time: 'Time',
|
||||
timeSetting: 'Time Setting',
|
||||
actionDescription: 'Action Description',
|
||||
detailedAction: 'Detailed action description',
|
||||
dialogue: 'Dialogue',
|
||||
characterDialogue: 'Character dialogue',
|
||||
generateImageFirst: 'Please generate character images first',
|
||||
saveAndGenerate: 'Save and Generate',
|
||||
saveConfig: 'Save Configuration',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
addAll: 'Add All',
|
||||
addToTimeline: 'Add to Timeline',
|
||||
deleteAsset: 'Delete Asset',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
tip: 'Tip',
|
||||
edit: 'Edit'
|
||||
},
|
||||
tooltip: {
|
||||
editPrompt: 'Edit Prompt',
|
||||
aiGenerate: 'AI Generate',
|
||||
uploadImage: 'Upload Image',
|
||||
selectFromLibrary: 'Select from Library',
|
||||
shotList: 'Shot List',
|
||||
dragFilesHere: 'Drop files here, or',
|
||||
prevStep: 'Previous Step',
|
||||
nextStepSplitShots: 'Next Step: Split Shots',
|
||||
reExtractConfirmTitle: 'Re-extract Confirmation',
|
||||
reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?',
|
||||
startReExtracting: 'Starting re-extraction, please wait...',
|
||||
regenerateShots: 'Regenerate Shots',
|
||||
batchGenerateSelected: 'Batch Generate Selected Scenes',
|
||||
generateAllImagesFirst: 'Please generate all character and scene images before splitting shots',
|
||||
sceneImageGenerating: 'Scene image generating, please wait...',
|
||||
sceneImageComplete: 'Scene image generation completed!',
|
||||
sceneImageStarted: 'Scene image generation started',
|
||||
reSplitShots: 'Re-split Shots',
|
||||
editShot: 'Edit Shot',
|
||||
splitSuccess: 'Shot splitting successful! Entering professional production interface...',
|
||||
reSplitConfirm: 'Are you sure you want to re-split the shots?',
|
||||
deleteCharacter: 'Delete Character',
|
||||
splitStoryboardFirst: 'Please split the storyboard first',
|
||||
aiSplitting: 'AI Splitting...',
|
||||
aiAutoSplit: 'AI Auto Split',
|
||||
batchTaskSubmitted: 'Batch generation task submitted!',
|
||||
batchGenerateFailed: 'Batch generation failed',
|
||||
batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes',
|
||||
batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed',
|
||||
addToLibrary: 'Add to Character Library',
|
||||
addToLibraryConfirm: 'Are you sure you want to add character "{name}" to the global character library? Once added, this character can be used in all projects.',
|
||||
addedToLibrary: 'Added to character library!',
|
||||
addFailed: 'Add failed',
|
||||
shotTitle: 'Shot Title',
|
||||
shotTitlePlaceholder: 'Enter shot title',
|
||||
shotType: 'Shot Type',
|
||||
selectShotType: 'Select shot type',
|
||||
longShot: 'Long Shot',
|
||||
fullShot: 'Full Shot',
|
||||
mediumShot: 'Medium Shot',
|
||||
closeUp: 'Close-up',
|
||||
extremeCloseUp: 'Extreme Close-up',
|
||||
cameraAngle: 'Camera Angle',
|
||||
selectAngle: 'Select angle',
|
||||
eyeLevel: 'Eye Level',
|
||||
lowAngle: 'Low Angle',
|
||||
highAngle: 'High Angle',
|
||||
location: 'Location',
|
||||
locationPlaceholder: 'Scene location',
|
||||
shotDescription: 'Shot Description',
|
||||
shotDescriptionPlaceholder: 'Overall shot description',
|
||||
cameraMovement: 'Camera Movement',
|
||||
selectMovement: 'Select movement',
|
||||
staticShot: 'Static Shot',
|
||||
pushIn: 'Push In',
|
||||
pullOut: 'Pull Out',
|
||||
followShot: 'Follow Shot',
|
||||
sideView: 'Side View',
|
||||
time: 'Time',
|
||||
timeSetting: 'Time setting',
|
||||
actionDescription: 'Action Description',
|
||||
detailedAction: 'Detailed action description',
|
||||
dialogue: 'Dialogue',
|
||||
characterDialogue: 'Character dialogue',
|
||||
generateImageFirst: 'Please generate character image first',
|
||||
result: 'Result',
|
||||
actionResult: 'Action result',
|
||||
atmosphere: 'Atmosphere',
|
||||
atmosphereDescription: 'Atmosphere description',
|
||||
loadLibraryFailed: 'Failed to load character library',
|
||||
imagePrompt: 'Image Prompt',
|
||||
imagePromptPlaceholder: 'Prompt for AI image generation',
|
||||
videoPrompt: 'Video Prompt',
|
||||
videoPromptPlaceholder: 'Prompt for AI video generation',
|
||||
bgmHint: 'BGM Hint',
|
||||
bgmAtmosphere: 'BGM atmosphere description',
|
||||
soundEffect: 'Sound Effect',
|
||||
soundEffectDescription: 'Sound effect description',
|
||||
durationSeconds: 'Duration (seconds)',
|
||||
emptyLibrary: 'Character library is empty, please generate or upload character images first',
|
||||
textModelTip: 'Used to generate episode content, characters, scenes and other text',
|
||||
uploadFormatTip: 'Supports jpg/png formats, file size should not exceed 10MB',
|
||||
aiModelConfig: 'AI Model Configuration',
|
||||
textGenModel: 'Text Generation Model',
|
||||
imageGenModel: 'Image Generation Model',
|
||||
selectTextModel: 'Select text generation model',
|
||||
selectImageModel: 'Select image generation model',
|
||||
modelConfigTip: 'For generating character and scene images',
|
||||
modelConfigSaved: 'Model configuration saved',
|
||||
pleaseSelectModels: 'Please select text and image generation models'
|
||||
},
|
||||
professionalEditor: {
|
||||
duration: 'Duration',
|
||||
seconds: 's',
|
||||
videoDuration: 'Video Duration',
|
||||
downloadVideo: 'Download Video'
|
||||
},
|
||||
storyboard: {
|
||||
title: 'Storyboard',
|
||||
edit: 'Storyboard Edit',
|
||||
create: 'Create Storyboard',
|
||||
script: 'Script',
|
||||
scene: 'Scene',
|
||||
shot: 'Shot',
|
||||
shotNumber: 'Shot {number}',
|
||||
untitled: 'Untitled Shot',
|
||||
scriptStructure: 'Script Structure',
|
||||
add: 'Add',
|
||||
noStoryboard: 'No Storyboards',
|
||||
shotProperties: 'Shot Properties',
|
||||
selectScene: 'Select Scene',
|
||||
inDevelopment: 'Feature under development...',
|
||||
generateScript: 'Generate Script',
|
||||
generateImage: 'Generate Image',
|
||||
generateVideo: 'Generate Video',
|
||||
table: {
|
||||
number: 'No.',
|
||||
title: 'Title',
|
||||
shotType: 'Shot Type',
|
||||
movement: 'Movement',
|
||||
location: 'Location',
|
||||
character: 'Character',
|
||||
dialogue: 'Dialogue',
|
||||
action: 'Action',
|
||||
duration: 'Duration',
|
||||
operations: 'Operations'
|
||||
}
|
||||
},
|
||||
timeline: {
|
||||
title: 'Timeline Editor',
|
||||
backToEditor: 'Back',
|
||||
noScenes: 'No available scenes',
|
||||
loadFailed: 'Failed to load storyboards'
|
||||
},
|
||||
editor: {
|
||||
backToEpisode: 'Back to Episode Edit',
|
||||
episode: 'Episode {number}',
|
||||
settings: 'Settings',
|
||||
basicInfo: 'Basic Info',
|
||||
sceneProduction: 'Scene Production',
|
||||
sceneId: 'Scene ID',
|
||||
sceneGenerating: 'Scene image generating...',
|
||||
noBackground: 'No background linked',
|
||||
cast: 'Cast',
|
||||
addCharacter: 'Add Character',
|
||||
removeCharacter: 'Remove Character',
|
||||
noCharacters: 'No characters specified',
|
||||
visualSettings: 'Visual Settings',
|
||||
shotType: 'Shot Type',
|
||||
shotTypePlaceholder: 'Select shot type',
|
||||
movement: 'Camera Movement',
|
||||
movementPlaceholder: 'Camera movement',
|
||||
angle: 'Camera Angle',
|
||||
anglePlaceholder: 'Camera angle',
|
||||
action: 'Action',
|
||||
actionPlaceholder: 'Describe the action...',
|
||||
result: 'Result',
|
||||
resultPlaceholder: 'Describe the result...',
|
||||
dialogue: 'Dialogue',
|
||||
dialoguePlaceholder: 'Enter dialogue...',
|
||||
soundEffects: 'Sound Effects',
|
||||
soundEffectsPlaceholder: 'Describe sound effects...',
|
||||
transitions: 'Transitions',
|
||||
transitionsPlaceholder: 'Select transition',
|
||||
duration: 'Duration',
|
||||
seconds: 's',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Overall shot description...',
|
||||
bgmPrompt: 'BGM Prompt',
|
||||
bgmPromptPlaceholder: 'Describe BGM atmosphere, e.g., Intense background music',
|
||||
atmosphere: 'Atmosphere',
|
||||
atmospherePlaceholder: 'Describe environment atmosphere, e.g., Dark and oppressive, Bright and warm',
|
||||
lightingEffect: 'Lighting Effect',
|
||||
specialEffects: 'Special Effects',
|
||||
props: 'Props',
|
||||
emotionalTone: 'Emotional Tone',
|
||||
shotImage: 'Shot Image',
|
||||
noShotSelected: 'No shot selected',
|
||||
selectFrameType: 'Select Frame Type',
|
||||
firstFrame: 'First Frame',
|
||||
lastFrame: 'Last Frame',
|
||||
panelFrame: 'Panel',
|
||||
actionSequence: 'Action Sequence',
|
||||
keyFrame: 'Key Frame',
|
||||
panelCount: 'Panel Count',
|
||||
prompt: 'Prompt',
|
||||
extractPrompt: 'Extract Prompt',
|
||||
promptPlaceholder: 'Click Extract Prompt button, the system will generate image prompts based on storyboard content...',
|
||||
generating: 'Generating...',
|
||||
generateImage: 'Generate Image',
|
||||
uploadImage: 'Upload Image',
|
||||
generationResult: 'Generation Result'
|
||||
},
|
||||
video: {
|
||||
title: 'AI Video Generation',
|
||||
generate: 'Generate Video',
|
||||
merge: 'Merge Video',
|
||||
mediaLibrary: 'Video Media Library',
|
||||
videoCount: '{count} videos',
|
||||
dragToTimeline: 'Drag scenes to timeline to start editing',
|
||||
videoTrack: 'Video Track',
|
||||
audioTrack: 'Audio Track',
|
||||
clearTrack: 'Clear Track',
|
||||
soundAndMusic: 'Sound & Music',
|
||||
soundMusicInDev: 'Sound & Music feature in development',
|
||||
noMergeYet: 'No videos merged yet',
|
||||
mergeInstructions: 'Arrange videos in the timeline editor and click "Merge Video" to proceed',
|
||||
selectVideoModel: 'Please select a video model',
|
||||
mergeComplete: 'Video merge completed and downloaded!',
|
||||
mergeTaskSubmitted: 'Video merge task submitted, processing in background...',
|
||||
audio: 'Audio',
|
||||
extractAudio: 'Extract audio from all video clips',
|
||||
model: 'Model',
|
||||
videoGeneration: 'Video Generation',
|
||||
soundAndMusicTab: 'Sound & Music',
|
||||
videoMerge: 'Video Merge',
|
||||
noMergeRecords: 'No merge records',
|
||||
transitionType: 'Transition Type',
|
||||
transitionDuration: 'Transition Duration',
|
||||
selectTransition: 'Select transition',
|
||||
filter: {
|
||||
drama: 'Script',
|
||||
allDramas: 'All Scripts',
|
||||
status: 'Status',
|
||||
allStatus: 'All Status',
|
||||
query: 'Query',
|
||||
reset: 'Reset'
|
||||
},
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed'
|
||||
},
|
||||
prompt: 'Prompt',
|
||||
duration: 'Duration',
|
||||
createdAt: 'Created At',
|
||||
actions: {
|
||||
view: 'View Details',
|
||||
download: 'Download',
|
||||
delete: 'Delete'
|
||||
}
|
||||
},
|
||||
asset: {
|
||||
title: 'Asset Library',
|
||||
type: 'Asset Type',
|
||||
upload: 'Upload',
|
||||
import: 'Import',
|
||||
export: 'Export'
|
||||
},
|
||||
genres: {
|
||||
urban: 'Urban',
|
||||
costume: 'Costume',
|
||||
mystery: 'Mystery',
|
||||
romance: 'Romance',
|
||||
comedy: 'Comedy'
|
||||
},
|
||||
message: {
|
||||
deleteConfirm: 'Are you sure to delete?',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
createSuccess: 'Created successfully',
|
||||
updateSuccess: 'Updated successfully',
|
||||
operationSuccess: 'Operation successful',
|
||||
operationFailed: 'Operation failed',
|
||||
loadingFailed: 'Loading failed',
|
||||
networkError: 'Network error'
|
||||
}
|
||||
}
|
||||
36
web/src/locales/index.ts
Normal file
36
web/src/locales/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './zh-CN'
|
||||
import enUS from './en-US'
|
||||
|
||||
// 从 localStorage 获取保存的语言,默认为中文
|
||||
const getStoredLanguage = (): string => {
|
||||
const stored = localStorage.getItem('language')
|
||||
if (stored) return stored
|
||||
|
||||
// 自动检测浏览器语言
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
if (browserLang.startsWith('zh')) return 'zh-CN'
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // 使用 Composition API 模式
|
||||
locale: getStoredLanguage(),
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n
|
||||
|
||||
// 导出语言切换函数
|
||||
export const setLanguage = (lang: string) => {
|
||||
i18n.global.locale.value = lang as any
|
||||
localStorage.setItem('language', lang)
|
||||
}
|
||||
|
||||
export const getCurrentLanguage = () => {
|
||||
return i18n.global.locale.value
|
||||
}
|
||||
617
web/src/locales/zh-CN.ts
Normal file
617
web/src/locales/zh-CN.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
export default {
|
||||
nav: {
|
||||
home: '首页',
|
||||
characters: '角色管理',
|
||||
storyboard: '分镜制作',
|
||||
videos: '视频管理',
|
||||
assets: '资源库',
|
||||
settings: '设置',
|
||||
dramas: '短剧项目'
|
||||
},
|
||||
dashboard: {
|
||||
title: '🎬 Drama Generator',
|
||||
welcome: '欢迎使用 AI 短剧生成平台',
|
||||
subtitle: '从剧本到视频,一站式短剧创作工具',
|
||||
stats: {
|
||||
projects: '短剧项目',
|
||||
images: '生成图片',
|
||||
videos: '生成视频',
|
||||
tasks: '处理中任务'
|
||||
},
|
||||
quickStart: '快速开始',
|
||||
actions: {
|
||||
newProject: '创建新项目',
|
||||
newProjectDesc: '开始一个全新的短剧项目',
|
||||
myProjects: '我的项目',
|
||||
myProjectsDesc: '查看和管理已有项目'
|
||||
}
|
||||
},
|
||||
common: {
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
confirm: '确定',
|
||||
search: '搜索',
|
||||
filter: '筛选',
|
||||
reset: '重置',
|
||||
submit: '提交',
|
||||
close: '关闭',
|
||||
back: '返回',
|
||||
next: '下一步',
|
||||
previous: '上一步',
|
||||
selectAll: '全选',
|
||||
loading: '加载中...',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
noData: '暂无数据',
|
||||
pleaseSelect: '请选择',
|
||||
add: '添加',
|
||||
view: '查看',
|
||||
upload: '上传',
|
||||
download: '下载',
|
||||
generating: '生成中...',
|
||||
notGenerated: '未生成',
|
||||
generateFailed: '生成失败',
|
||||
clickToRegenerate: '点击重新生成',
|
||||
queuing: '排队中',
|
||||
processing: '处理中',
|
||||
saveAndGenerate: '保存并生成',
|
||||
saveConfig: '保存配置',
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
addAll: '一键添加全部',
|
||||
addToTimeline: '添加到时间线',
|
||||
deleteAsset: '删除素材',
|
||||
confirmDelete: '确认删除',
|
||||
tip: '提示',
|
||||
status: '状态',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间'
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
aiConfig: 'AI配置',
|
||||
general: '通用设置',
|
||||
language: '语言',
|
||||
theme: '主题'
|
||||
},
|
||||
aiConfig: {
|
||||
title: 'AI 服务配置',
|
||||
addConfig: '添加配置',
|
||||
editConfig: '编辑配置',
|
||||
back: '返回',
|
||||
empty: '暂无配置,点击添加配置开始使用',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
endpoint: '端点',
|
||||
queryEndpoint: '查询端点',
|
||||
tabs: {
|
||||
text: '文本生成',
|
||||
image: '图片生成',
|
||||
video: '视频生成'
|
||||
},
|
||||
form: {
|
||||
name: '配置名称',
|
||||
namePlaceholder: '例如:OpenAI GPT-4',
|
||||
provider: '厂商',
|
||||
providerPlaceholder: '请选择厂商',
|
||||
providerTip: '选择AI服务提供商',
|
||||
priority: '优先级',
|
||||
priorityTip: '数值越大优先级越高,相同模型时优先使用高优先级配置',
|
||||
model: '模型',
|
||||
modelPlaceholder: '输入或选择模型名称',
|
||||
modelTip: '可直接输入模型名称或从列表选择,支持多个模型',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlPlaceholder: 'https://api.openai.com',
|
||||
baseUrlTip: 'API 服务的基础地址,如 Chatfire: https://api.chatfire.site/v1,Gemini: https://generativelanguage.googleapis.com(无需 /v1)',
|
||||
fullEndpoint: '完整调用路径',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
apiKeyTip: '您的 API 密钥',
|
||||
isActive: '启用状态'
|
||||
},
|
||||
actions: {
|
||||
test: '测试连接',
|
||||
delete: '删除',
|
||||
edit: '编辑'
|
||||
},
|
||||
messages: {
|
||||
deleteConfirm: '确定要删除此配置吗?',
|
||||
testSuccess: '连接测试成功!',
|
||||
testFailed: '连接测试失败'
|
||||
}
|
||||
},
|
||||
drama: {
|
||||
title: '短剧管理',
|
||||
create: '创建项目',
|
||||
totalProjects: '共 {count} 个项目',
|
||||
createNew: '创建新项目',
|
||||
createDesc: '开始创作您的短剧项目',
|
||||
aiConfig: 'AI配置',
|
||||
aiConfigTip: '请先配置 AI 服务后再创建项目',
|
||||
empty: '暂无项目,点击上方按钮创建新项目',
|
||||
emptyHint: '点击上方"创建新项目"按钮开始您的第一部短剧',
|
||||
editProject: '编辑项目',
|
||||
projectName: '项目名称',
|
||||
projectNamePlaceholder: '请输入项目名称',
|
||||
projectDesc: '项目描述',
|
||||
projectDescPlaceholder: '请输入项目描述(可选)',
|
||||
deleteConfirm: '确定要删除这个项目吗?',
|
||||
noCover: '暂无封面',
|
||||
noDescription: '暂无描述',
|
||||
status: {
|
||||
draft: '草稿',
|
||||
production: '制作中',
|
||||
completed: '已完成'
|
||||
},
|
||||
actions: {
|
||||
edit: '编辑',
|
||||
view: '查看',
|
||||
delete: '删除'
|
||||
},
|
||||
management: {
|
||||
overview: '项目概览',
|
||||
episodes: '章节管理',
|
||||
characters: '角色管理',
|
||||
scenes: '场景管理',
|
||||
projectInfo: '项目信息',
|
||||
projectName: '项目名称',
|
||||
projectDesc: '项目描述',
|
||||
noDescription: '暂无描述',
|
||||
episodeStats: '章节统计',
|
||||
characterStats: '角色统计',
|
||||
sceneStats: '场景统计',
|
||||
episodesCreated: '已创建章节',
|
||||
charactersCreated: '已创建角色',
|
||||
sceneLibraryCount: '场景库数量',
|
||||
startFirstEpisode: '开始创作您的第一个章节!',
|
||||
noEpisodesYet: '您的项目还没有章节。请先创建一个章节开始制作。',
|
||||
createFirstEpisode: '立即创建第一个章节',
|
||||
episodeList: '章节列表',
|
||||
createNewEpisode: '创建新章节',
|
||||
noEpisodes: '还没有章节',
|
||||
clickToCreate: '点击上方按钮创建第一个章节',
|
||||
episodeNumber: '第 {number} 章',
|
||||
goToEdit: '进入编辑',
|
||||
characterList: '角色列表',
|
||||
noCharacters: '还没有角色',
|
||||
charactersTip: '角色将在剧本生成阶段自动创建',
|
||||
sceneList: '场景列表',
|
||||
noScenes: '还没有场景',
|
||||
scenesTip: '场景将在分镜生成阶段自动创建'
|
||||
}
|
||||
},
|
||||
character: {
|
||||
title: '角色管理',
|
||||
create: '创建角色',
|
||||
edit: '编辑角色',
|
||||
add: '添加角色',
|
||||
list: '角色列表',
|
||||
name: '角色名称',
|
||||
role: '角色',
|
||||
personality: '性格',
|
||||
appearance: '外貌',
|
||||
background: '背景',
|
||||
description: '角色描述',
|
||||
image: '角色形象',
|
||||
generate: '生成角色形象',
|
||||
extracting: '提取中...',
|
||||
generateImage: '生成形象',
|
||||
batch: '批量操作',
|
||||
empty: '角色已在剧本生成阶段创建,您可以在此查看和编辑',
|
||||
backToProject: '返回项目',
|
||||
saveChanges: '保存修改',
|
||||
nextStep: '下一步:生成角色图片'
|
||||
},
|
||||
script: {
|
||||
title: '剧本生成',
|
||||
backToProject: '返回项目',
|
||||
aiGenerate: 'AI 生成剧本',
|
||||
uploadScript: '上传剧本',
|
||||
steps: {
|
||||
outline: '生成大纲',
|
||||
characters: '生成角色',
|
||||
episodes: '生成剧集'
|
||||
},
|
||||
form: {
|
||||
theme: '创作主题',
|
||||
themePlaceholder: '描述你想创作的短剧主题和故事概念',
|
||||
genre: '类型偏好',
|
||||
genrePlaceholder: '选择类型',
|
||||
style: '风格要求',
|
||||
stylePlaceholder: '例如:轻松幽默、紧张刺激、温馨治愈',
|
||||
episodeCount: '剧集数量',
|
||||
randomGenerate: '随机生成',
|
||||
title: '标题',
|
||||
titlePlaceholder: '请输入剧本标题',
|
||||
summary: '概要',
|
||||
summaryPlaceholder: '请输入剧本概要',
|
||||
genreExample: '例如:都市、古装',
|
||||
tags: '标签',
|
||||
newTag: '新标签'
|
||||
},
|
||||
notice: '请输入创作主题和相关要求,AI将为您生成剧本大纲',
|
||||
generateFailed: '生成失败',
|
||||
generating: '生成中...',
|
||||
nextStep: '下一步',
|
||||
prevStep: '上一步',
|
||||
complete: '完成',
|
||||
regenerate: '重新生成',
|
||||
regenerateOutline: '重新生成大纲',
|
||||
outlinePreview: '大纲预览(可编辑)'
|
||||
},
|
||||
imageDialog: {
|
||||
title: 'AI 图片生成',
|
||||
selectDrama: '选择剧本',
|
||||
selectScene: '选择场景',
|
||||
selectSceneOptional: '选择场景(可选)',
|
||||
sceneLabel: '场景{number}: {title}',
|
||||
prompt: '提示词',
|
||||
promptPlaceholder: '描述你想生成的图片\n例如:A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed',
|
||||
negativePrompt: '反向提示词',
|
||||
negativePromptPlaceholder: '描述不希望出现的元素(可选)\n例如:blurry, low quality, watermark',
|
||||
aiService: 'AI 服务',
|
||||
selectService: '选择服务',
|
||||
imageSize: '图片尺寸',
|
||||
selectSize: '选择尺寸',
|
||||
square: '正方形',
|
||||
landscape: '横向',
|
||||
portrait: '纵向',
|
||||
imageQuality: '图片质量',
|
||||
standard: '标准',
|
||||
hd: '高清',
|
||||
style: '风格',
|
||||
vivid: '鲜艳',
|
||||
natural: '自然',
|
||||
advancedSettings: '高级设置',
|
||||
samplingSteps: '采样步数',
|
||||
promptRelevance: '提示词相关性',
|
||||
randomSeed: '随机种子',
|
||||
leaveBlankRandom: '留空随机',
|
||||
seedTip: '设置相同种子可复现图片',
|
||||
generate: '生成图片',
|
||||
pleaseSelectDrama: '请选择剧本',
|
||||
pleaseEnterPrompt: '请输入提示词',
|
||||
promptMinLength: '提示词至少5个字符',
|
||||
taskSubmitted: '图片生成任务已提交,请稍后查看结果',
|
||||
generateFailed: '生成失败',
|
||||
weak: '弱',
|
||||
moderate: '适中',
|
||||
strong: '强',
|
||||
veryStrong: '很强'
|
||||
},
|
||||
image: {
|
||||
title: 'AI 图片生成',
|
||||
generate: '生成图片',
|
||||
loadFailed: '加载失败',
|
||||
generating: '生成中...',
|
||||
generateFailed: '生成失败'
|
||||
},
|
||||
dramaWorkflow: {
|
||||
returnToList: '返回',
|
||||
episodeScript: '第{number}集剧本',
|
||||
storyboardBreakdown: '分镜拆解',
|
||||
characterImages: '角色图片',
|
||||
createChapterPrompt: '请创建第一章开始制作',
|
||||
createChapter: '创建第{number}章',
|
||||
nextStepCharacterImages: '下一步:角色图片',
|
||||
nextStep: '下一步',
|
||||
reGenerateShots: '重新拆分',
|
||||
reGenerateShotsConfirm: '重新拂分将覆盖现有镜头,确定继续吗?',
|
||||
pleaseWriteScript: '请先创作剧本内容',
|
||||
splitStoryboardFirst: '请先对剧本进行分镜拆解',
|
||||
aiSplitting: 'AI拆分中...',
|
||||
aiAutoSplit: 'AI自动拆分',
|
||||
selected: '已选',
|
||||
characterCount: '角色数',
|
||||
generated: '已生成',
|
||||
batchGenerate: '批量生成'
|
||||
},
|
||||
workflow: {
|
||||
backToProject: '返回项目',
|
||||
episodeProduction: '第{number}章制作',
|
||||
steps: {
|
||||
content: '章节内容',
|
||||
generateImages: '生成图片',
|
||||
splitStoryboard: '拆分分镜'
|
||||
},
|
||||
scriptPlaceholder: '请输入章节内容...',
|
||||
saveChapter: '保存章节',
|
||||
chapterContent: '第{number}章内容',
|
||||
saved: '已保存',
|
||||
extractedData: '已提取数据',
|
||||
characters: '角色',
|
||||
scenes: '场景',
|
||||
extractedCharacters: '提取的角色(本集)',
|
||||
extractedScenes: '提取的场景(本集)',
|
||||
extractCharactersAndScenes: '提取角色和场景',
|
||||
reExtract: '重新提取角色和场景',
|
||||
nextStepGenerateImages: '下一步:生成图片',
|
||||
extractWarning: '请先点击“提取角色和场景”按钮,完成提取后才能生成图片',
|
||||
characterImages: '角色图片',
|
||||
sceneImages: '场景图片',
|
||||
characterCount: '共 {count} 个角色需要生成图片',
|
||||
sceneCount: '共 {count} 个场景需要生成图片',
|
||||
selectAll: '全选',
|
||||
batchGenerate: '批量生成',
|
||||
modelConfig: 'AI模型配置',
|
||||
editPrompt: '修改提示词',
|
||||
aiGenerate: 'AI生成',
|
||||
uploadImage: '上传图片',
|
||||
selectFromLibrary: '从角色库选择',
|
||||
shotList: '镜头列表',
|
||||
dragFilesHere: '将文件拖到此处,或',
|
||||
clickToUpload: '点击上传',
|
||||
prevStep: '上一步',
|
||||
nextStepSplitShots: '下一步:拆分分镜',
|
||||
reExtractConfirmTitle: '重新提取确认',
|
||||
reExtractConfirmMessage: '重新提取将覆盖已提取的角色和场景(包括已生成的图片),确定继续吗?',
|
||||
startReExtracting: '开始重新提取,请稍候...',
|
||||
regenerateShots: '重新生成分镜',
|
||||
batchGenerateSelected: '批量生成选中场景',
|
||||
generateAllImagesFirst: '请先生成所有角色和场景图片后再进行分镜拆分',
|
||||
sceneImageGenerating: '场景图片生成中,请稍候...',
|
||||
sceneImageComplete: '场景图片生成完成!',
|
||||
sceneImageStarted: '场景图片生成已启动',
|
||||
reSplitShots: '重新拆分',
|
||||
enterProfessional: '进入专业制作',
|
||||
editShot: '编辑镜头',
|
||||
splitSuccess: '分镜拆分成功!正在进入专业制作界面...',
|
||||
reSplitConfirm: '确定要重新拂分分镜吗?',
|
||||
deleteCharacter: '删除角色',
|
||||
splitStoryboardFirst: '请先对章节进行分镜拆解',
|
||||
aiSplitting: 'AI拆分中...',
|
||||
aiAutoSplit: 'AI自动拆分',
|
||||
batchTaskSubmitted: '批量生成任务已提交!',
|
||||
batchGenerateFailed: '批量生成失败',
|
||||
batchCompleteSuccess: '批量生成完成!成功生成 {count} 个场景',
|
||||
batchCompletePartial: '生成完成:成功 {success} 个,失败 {fail} 个',
|
||||
addToLibrary: '添加到角色库',
|
||||
addToLibraryConfirm: '确定要将角色“{name}”添加到全局角色库吗?添加后可以在所有项目中使用该角色形象。',
|
||||
addedToLibrary: '已添加到角色库!',
|
||||
addFailed: '添加失败',
|
||||
shotTitle: '镜头标题',
|
||||
shotTitlePlaceholder: '请输入镜头标题',
|
||||
shotType: '景别',
|
||||
selectShotType: '选择景别',
|
||||
longShot: '远景',
|
||||
fullShot: '全景',
|
||||
mediumShot: '中景',
|
||||
closeUp: '近景',
|
||||
extremeCloseUp: '特写',
|
||||
cameraAngle: '镜头角度',
|
||||
selectAngle: '选择角度',
|
||||
eyeLevel: '平视',
|
||||
lowAngle: '仰视',
|
||||
highAngle: '俯视',
|
||||
location: '地点',
|
||||
locationPlaceholder: '场景地点',
|
||||
shotDescription: '镜头描述',
|
||||
shotDescriptionPlaceholder: '镜头整体描述',
|
||||
cameraMovement: '运镜方式',
|
||||
selectMovement: '选择运镜',
|
||||
staticShot: '固定镜头',
|
||||
pushIn: '推镜',
|
||||
pullOut: '拉镜',
|
||||
followShot: '跟镜',
|
||||
sideView: '侧面',
|
||||
time: '时间',
|
||||
timeSetting: '时间设定',
|
||||
actionDescription: '动作描述',
|
||||
detailedAction: '详细动作描述',
|
||||
dialogue: '对白',
|
||||
characterDialogue: '角色对白',
|
||||
generateImageFirst: '请先生成角色图片',
|
||||
result: '画面结果',
|
||||
actionResult: '动作结果',
|
||||
atmosphere: '环境氛围',
|
||||
atmosphereDescription: '环境氛围描述',
|
||||
loadLibraryFailed: '获取角色库失败',
|
||||
imagePrompt: '图片提示词',
|
||||
imagePromptPlaceholder: '用于AI生成图片的提示词',
|
||||
videoPrompt: '视频提示词',
|
||||
videoPromptPlaceholder: '用于AI生成视频的提示词',
|
||||
bgmHint: '配乐提示',
|
||||
bgmAtmosphere: '配乐氛围描述',
|
||||
soundEffect: '音效',
|
||||
soundEffectDescription: '音效描述',
|
||||
durationSeconds: '时长(秒)',
|
||||
emptyLibrary: '角色库为空,请先生成或上传角色图片',
|
||||
textModelTip: '用于生成章节内容、角色、场景等文本',
|
||||
uploadFormatTip: '支持 jpg/png 格式,文件大小不超过 10MB',
|
||||
aiModelConfig: 'AI模型配置',
|
||||
textGenModel: '文本生成模型',
|
||||
imageGenModel: '图片生成模型',
|
||||
selectTextModel: '选择文本生成模型',
|
||||
selectImageModel: '选择图片生成模型',
|
||||
modelConfigTip: '用于生成角色和场景图片',
|
||||
modelConfigSaved: '模型配置已保存',
|
||||
pleaseSelectModels: '请选择文本和图片生成模型'
|
||||
},
|
||||
professionalEditor: {
|
||||
duration: '时长',
|
||||
seconds: '秒',
|
||||
videoDuration: '视频时长',
|
||||
downloadVideo: '下载视频'
|
||||
},
|
||||
storyboard: {
|
||||
title: '分镜制作',
|
||||
edit: '分镜编辑',
|
||||
create: '创建分镜',
|
||||
script: '剧本',
|
||||
scene: '场景',
|
||||
shot: '镜头',
|
||||
shotNumber: '镜头 {number}',
|
||||
untitled: '未命名镜头',
|
||||
scriptStructure: '剧本结构',
|
||||
add: '添加',
|
||||
noStoryboard: '暂无分镜',
|
||||
shotProperties: '镜头属性',
|
||||
selectScene: '选择场景',
|
||||
inDevelopment: '功能开发中...',
|
||||
generateScript: '生成分镜脚本',
|
||||
generateImage: '生成分镜图片',
|
||||
generateVideo: '生成视频',
|
||||
table: {
|
||||
number: '编号',
|
||||
title: '标题',
|
||||
shotType: '景别',
|
||||
movement: '运镜',
|
||||
location: '地点',
|
||||
character: '角色',
|
||||
dialogue: '对白',
|
||||
action: '动作',
|
||||
duration: '时长',
|
||||
operations: '操作'
|
||||
}
|
||||
},
|
||||
timeline: {
|
||||
title: '时间线编辑器',
|
||||
backToEditor: '返回',
|
||||
noScenes: '暂无可用场景',
|
||||
loadFailed: '加载分镜失败'
|
||||
},
|
||||
editor: {
|
||||
backToEpisode: '返回剧集编辑',
|
||||
episode: '第{number}集',
|
||||
settings: '设置',
|
||||
basicInfo: '基础信息',
|
||||
sceneProduction: '场景制作',
|
||||
sceneId: '场景ID',
|
||||
sceneGenerating: '场景图片生成中...',
|
||||
noBackground: '未关联背景',
|
||||
cast: '登场角色',
|
||||
addCharacter: '添加角色',
|
||||
removeCharacter: '移除角色',
|
||||
noCharacters: '未指定角色',
|
||||
visualSettings: '视效设置',
|
||||
shotType: '景别',
|
||||
shotTypePlaceholder: '选择景别',
|
||||
movement: '运镜方式',
|
||||
movementPlaceholder: '运镜方式',
|
||||
angle: '镜头角度',
|
||||
anglePlaceholder: '镜头角度',
|
||||
action: '动作描述',
|
||||
actionPlaceholder: '描述角色的动作过程...',
|
||||
result: '动作结果',
|
||||
resultPlaceholder: '描述动作的结果...',
|
||||
dialogue: '对白',
|
||||
dialoguePlaceholder: '输入角色对白...',
|
||||
soundEffects: '音效',
|
||||
soundEffectsPlaceholder: '描述音效...',
|
||||
transitions: '转场效果',
|
||||
transitionsPlaceholder: '选择转场',
|
||||
duration: '时长',
|
||||
seconds: '秒',
|
||||
description: '镜头描述',
|
||||
descriptionPlaceholder: '整体镜头描述...',
|
||||
bgmPrompt: '配乐提示',
|
||||
bgmPromptPlaceholder: '描述配乐氛围,如:紧张激烈的背景音乐',
|
||||
atmosphere: '环境氛围',
|
||||
atmospherePlaceholder: '描述环境氛围,如:昱暗压抑、明亮温馨',
|
||||
lightingEffect: '光照效果',
|
||||
specialEffects: '特效',
|
||||
props: '道具',
|
||||
emotionalTone: '情绪色调',
|
||||
shotImage: '镜头图片',
|
||||
noShotSelected: '未选择镜头',
|
||||
selectFrameType: '选择帧类型',
|
||||
firstFrame: '首帧',
|
||||
lastFrame: '尾帧',
|
||||
panelFrame: '分镜板',
|
||||
actionSequence: '动作序列',
|
||||
keyFrame: '关键帧',
|
||||
panelCount: '格数',
|
||||
prompt: '提示词',
|
||||
extractPrompt: '提取提示词',
|
||||
promptPlaceholder: '点击提取提示词按钮,系统将根据分镜内容生成图片提示词...',
|
||||
generating: '生成中...',
|
||||
generateImage: '生成图片',
|
||||
uploadImage: '上传图片',
|
||||
generationResult: '生成结果'
|
||||
},
|
||||
video: {
|
||||
title: 'AI 视频生成',
|
||||
generate: '生成视频',
|
||||
merge: '合成视频',
|
||||
mediaLibrary: '视频素材库',
|
||||
videoCount: '{count} 个视频',
|
||||
dragToTimeline: '将场景拖拽到时间线开始编辑',
|
||||
videoTrack: '视频轨道',
|
||||
audioTrack: '音频轨道',
|
||||
clearTrack: '清空轨道',
|
||||
soundAndMusic: '音效与配乐',
|
||||
soundMusicInDev: '音效与配乐功能开发中',
|
||||
noMergeYet: '还没有合成过视频',
|
||||
mergeInstructions: '在时间线编辑器中排列好视频后点击“合成视频”即可',
|
||||
selectVideoModel: '请选择视频模型',
|
||||
mergeComplete: '视频合成完成并已下载!',
|
||||
mergeTaskSubmitted: '视频合成任务已提交,正在后台处理...',
|
||||
audio: '音频',
|
||||
extractAudio: '从所有视频片段提取音频',
|
||||
model: '模型',
|
||||
videoGeneration: '视频生成',
|
||||
soundAndMusicTab: '音效与配乐',
|
||||
videoMerge: '视频合成',
|
||||
noMergeRecords: '暂无视频合成记录',
|
||||
transitionType: '转场类型',
|
||||
transitionDuration: '转场时长',
|
||||
selectTransition: '选择转场效果',
|
||||
filter: {
|
||||
drama: '剧本',
|
||||
allDramas: '全部剧本',
|
||||
status: '状态',
|
||||
allStatus: '全部状态',
|
||||
query: '查询',
|
||||
reset: '重置'
|
||||
},
|
||||
status: {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
},
|
||||
prompt: '提示词',
|
||||
duration: '时长',
|
||||
createdAt: '创建时间',
|
||||
actions: {
|
||||
view: '查看详情',
|
||||
download: '下载',
|
||||
delete: '删除'
|
||||
}
|
||||
},
|
||||
asset: {
|
||||
title: '资源库',
|
||||
type: '资源类型',
|
||||
upload: '上传',
|
||||
import: '导入',
|
||||
export: '导出'
|
||||
},
|
||||
genres: {
|
||||
urban: '都市',
|
||||
costume: '古装',
|
||||
mystery: '悬疑',
|
||||
romance: '爱情',
|
||||
comedy: '喜剧'
|
||||
},
|
||||
tooltip: {
|
||||
editPrompt: '修改提示词',
|
||||
aiGenerate: 'AI生成',
|
||||
uploadImage: '上传图片',
|
||||
selectFromLibrary: '从角色库选择'
|
||||
},
|
||||
message: {
|
||||
deleteConfirm: '确定要删除吗?',
|
||||
deleteSuccess: '删除成功',
|
||||
createSuccess: '创建成功',
|
||||
updateSuccess: '更新成功',
|
||||
operationSuccess: '操作成功',
|
||||
operationFailed: '操作失败',
|
||||
loadingFailed: '加载失败',
|
||||
networkError: '网络错误'
|
||||
}
|
||||
}
|
||||
32
web/src/main.ts
Normal file
32
web/src/main.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/styles/element/index.scss'
|
||||
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './locales'
|
||||
import './assets/styles/main.css'
|
||||
|
||||
// Apply theme before app mounts to prevent flash
|
||||
// 在应用挂载前应用主题,防止闪烁
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(ElementPlus)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
84
web/src/router/index.ts
Normal file
84
web/src/router/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'DramaList',
|
||||
component: () => import('../views/drama/DramaList.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/create',
|
||||
name: 'DramaCreate',
|
||||
component: () => import('../views/drama/DramaCreate.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:id',
|
||||
name: 'DramaManagement',
|
||||
component: () => import('../views/drama/DramaManagement.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:id/episode/:episodeNumber',
|
||||
name: 'EpisodeWorkflowNew',
|
||||
component: () => import('../views/drama/EpisodeWorkflow.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:id/script',
|
||||
name: 'ScriptGeneration',
|
||||
component: () => import('../views/workflow/ScriptGeneration.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:id/characters',
|
||||
name: 'CharacterExtraction',
|
||||
component: () => import('../views/workflow/CharacterExtraction.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:id/images/characters',
|
||||
name: 'CharacterImages',
|
||||
component: () => import('../views/workflow/CharacterImages.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:id/settings',
|
||||
name: 'DramaSettings',
|
||||
component: () => import('../views/workflow/DramaSettings.vue')
|
||||
},
|
||||
{
|
||||
path: '/episodes/:id/edit',
|
||||
name: 'ScriptEdit',
|
||||
component: () => import('../views/script/ScriptEdit.vue')
|
||||
},
|
||||
{
|
||||
path: '/episodes/:id/storyboard',
|
||||
name: 'StoryboardEdit',
|
||||
component: () => import('../views/storyboard/StoryboardEdit.vue')
|
||||
},
|
||||
{
|
||||
path: '/episodes/:id/generate',
|
||||
name: 'Generation',
|
||||
component: () => import('../views/generation/ImageGeneration.vue')
|
||||
},
|
||||
{
|
||||
path: '/timeline/:id',
|
||||
name: 'TimelineEditor',
|
||||
component: () => import('../views/editor/TimelineEditor.vue')
|
||||
},
|
||||
{
|
||||
path: '/dramas/:dramaId/episode/:episodeNumber/professional',
|
||||
name: 'ProfessionalEditor',
|
||||
component: () => import('../views/drama/ProfessionalEditor.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings/ai-config',
|
||||
name: 'AIConfig',
|
||||
component: () => import('../views/settings/AIConfig.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// 开源版本 - 无需认证
|
||||
|
||||
export default router
|
||||
233
web/src/stores/episode.ts
Normal file
233
web/src/stores/episode.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { Episode, Character, Scene } from '@/types/drama'
|
||||
|
||||
interface EpisodeCache {
|
||||
data: Episode
|
||||
loading: boolean
|
||||
error: string | null
|
||||
lastFetch: number
|
||||
}
|
||||
|
||||
interface EpisodeOperations {
|
||||
refresh: () => Promise<void>
|
||||
set: (params: SetOperationParams) => Promise<void>
|
||||
del: (params: DeleteOperationParams) => Promise<void>
|
||||
saveScript: (content: string) => Promise<void>
|
||||
extractData: () => Promise<void>
|
||||
generateImages: (options?: GenerateImageOptions) => Promise<void>
|
||||
generateStoryboards: () => Promise<void>
|
||||
}
|
||||
|
||||
interface SetOperationParams {
|
||||
type: 'character' | 'scene' | 'storyboard'
|
||||
data: any
|
||||
}
|
||||
|
||||
interface DeleteOperationParams {
|
||||
type: 'character' | 'scene' | 'storyboard'
|
||||
id: string | number
|
||||
}
|
||||
|
||||
interface GenerateImageOptions {
|
||||
characterIds?: number[]
|
||||
sceneIds?: string[]
|
||||
}
|
||||
|
||||
export interface CachedEpisode {
|
||||
value: Episode
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
set: (params: SetOperationParams) => Promise<void>
|
||||
del: (params: DeleteOperationParams) => Promise<void>
|
||||
saveScript: (content: string) => Promise<void>
|
||||
extractData: () => Promise<void>
|
||||
generateImages: (options?: GenerateImageOptions) => Promise<void>
|
||||
generateStoryboards: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useEpisodeStore = defineStore('episode', () => {
|
||||
const caches = reactive<Map<string, EpisodeCache>>(new Map())
|
||||
|
||||
const getCacheByEpisodeId = (episodeId: string): CachedEpisode => {
|
||||
if (!caches.has(episodeId)) {
|
||||
caches.set(episodeId, {
|
||||
data: {} as Episode,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetch: 0
|
||||
})
|
||||
fetchEpisode(episodeId)
|
||||
}
|
||||
|
||||
const cache = caches.get(episodeId)!
|
||||
|
||||
const operations: EpisodeOperations = {
|
||||
async refresh() {
|
||||
await fetchEpisode(episodeId, true)
|
||||
},
|
||||
|
||||
async set(params: SetOperationParams) {
|
||||
const { type, data } = params
|
||||
|
||||
switch (type) {
|
||||
case 'character':
|
||||
await dramaAPI.saveCharacters(cache.data.drama_id, [data], episodeId)
|
||||
await fetchEpisode(episodeId, true)
|
||||
break
|
||||
case 'scene':
|
||||
await dramaAPI.updateScene(data.id, data)
|
||||
await fetchEpisode(episodeId, true)
|
||||
break
|
||||
case 'storyboard':
|
||||
await dramaAPI.updateStoryboard(data.id, data)
|
||||
await fetchEpisode(episodeId, true)
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
async del(params: DeleteOperationParams) {
|
||||
const { type, id } = params
|
||||
|
||||
switch (type) {
|
||||
case 'character':
|
||||
const characters = cache.data.characters?.filter(c => c.id !== id) || []
|
||||
await dramaAPI.saveCharacters(cache.data.drama_id, characters, episodeId)
|
||||
await fetchEpisode(episodeId, true)
|
||||
break
|
||||
case 'scene':
|
||||
break
|
||||
case 'storyboard':
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
async saveScript(content: string) {
|
||||
const parts = episodeId.split('-')
|
||||
const dramaId = parts[0]
|
||||
const episodeNumber = parseInt(parts.length > 1 ? parts[1] : cache.data.episode_number?.toString() || '1')
|
||||
|
||||
await dramaAPI.saveEpisodes(dramaId, [{
|
||||
episode_number: episodeNumber,
|
||||
script_content: content
|
||||
}])
|
||||
|
||||
await fetchEpisode(episodeId, true)
|
||||
},
|
||||
|
||||
async extractData() {
|
||||
await dramaAPI.extractBackgrounds(episodeId)
|
||||
await fetchEpisode(episodeId, true)
|
||||
},
|
||||
|
||||
async generateImages(options?: GenerateImageOptions) {
|
||||
const promises: Promise<any>[] = []
|
||||
|
||||
if (options?.characterIds && options.characterIds.length > 0) {
|
||||
options.characterIds.forEach(id => {
|
||||
const character = cache.data.characters?.find(c => c.id === id)
|
||||
if (character) {
|
||||
promises.push(
|
||||
dramaAPI.generateSceneImage({
|
||||
scene_id: character.id.toString(),
|
||||
prompt: character.appearance || character.description || character.name,
|
||||
model: undefined
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options?.sceneIds && options.sceneIds.length > 0) {
|
||||
options.sceneIds.forEach(sceneId => {
|
||||
promises.push(
|
||||
dramaAPI.generateSceneImage({
|
||||
scene_id: sceneId,
|
||||
model: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (promises.length > 0) {
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
await fetchEpisode(episodeId, true)
|
||||
},
|
||||
|
||||
async generateStoryboards() {
|
||||
await dramaAPI.generateStoryboard(episodeId)
|
||||
await fetchEpisode(episodeId, true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return cache.data
|
||||
},
|
||||
get loading() {
|
||||
return cache.loading
|
||||
},
|
||||
get error() {
|
||||
return cache.error
|
||||
},
|
||||
...operations
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEpisode = async (episodeId: string, force = false) => {
|
||||
const cache = caches.get(episodeId)
|
||||
if (!cache) return
|
||||
|
||||
const now = Date.now()
|
||||
if (!force && cache.lastFetch && (now - cache.lastFetch) < 3000) {
|
||||
return
|
||||
}
|
||||
|
||||
cache.loading = true
|
||||
cache.error = null
|
||||
|
||||
try {
|
||||
const parts = episodeId.split('-')
|
||||
const dramaId = parts[0]
|
||||
const episodeNumber = parts.length > 1 ? parseInt(parts[1]) : null
|
||||
|
||||
const drama = await dramaAPI.get(dramaId)
|
||||
|
||||
let episode: Episode | undefined
|
||||
if (episodeNumber !== null) {
|
||||
episode = drama.episodes?.find(e => e.episode_number === episodeNumber)
|
||||
} else {
|
||||
episode = drama.episodes?.find(e => e.id === episodeId)
|
||||
}
|
||||
|
||||
if (episode) {
|
||||
cache.data = episode
|
||||
cache.lastFetch = now
|
||||
} else {
|
||||
cache.error = '未找到章节数据'
|
||||
}
|
||||
} catch (error: any) {
|
||||
cache.error = error.message || '加载章节数据失败'
|
||||
console.error('Failed to fetch episode:', error)
|
||||
} finally {
|
||||
cache.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearCache = (episodeId?: string) => {
|
||||
if (episodeId) {
|
||||
caches.delete(episodeId)
|
||||
} else {
|
||||
caches.clear()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getCacheByEpisodeId,
|
||||
clearCache
|
||||
}
|
||||
})
|
||||
65
web/src/types/ai.ts
Normal file
65
web/src/types/ai.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface AIServiceConfig {
|
||||
id: number
|
||||
service_type: AIServiceType
|
||||
provider?: string // 厂商标识
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string | string[] // 支持单个或多个模型
|
||||
endpoint: string
|
||||
query_endpoint?: string // 异步查询端点(用于视频等异步任务)
|
||||
priority: number // 优先级,数值越大优先级越高
|
||||
is_active: boolean
|
||||
settings?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AIServiceType = 'text' | 'image' | 'video'
|
||||
|
||||
export interface CreateAIConfigRequest {
|
||||
service_type: AIServiceType
|
||||
provider?: string // 厂商标识
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string | string[] // 支持单个或多个模型
|
||||
endpoint?: string
|
||||
query_endpoint?: string // 异步查询端点(用于视频等异步任务)
|
||||
priority?: number // 优先级,数值越大优先级越高
|
||||
settings?: string
|
||||
}
|
||||
|
||||
export interface UpdateAIConfigRequest {
|
||||
name?: string
|
||||
provider?: string // 厂商标识
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
model?: string | string[] // 支持单个或多个模型
|
||||
endpoint?: string
|
||||
query_endpoint?: string // 异步查询端点(用于视频等异步任务)
|
||||
priority?: number // 优先级,数值越大优先级越高
|
||||
is_active?: boolean
|
||||
settings?: string
|
||||
}
|
||||
|
||||
export interface TestConnectionRequest {
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string | string[] // 支持单个或多个模型
|
||||
provider?: string // 厂商标识
|
||||
endpoint?: string
|
||||
query_endpoint?: string // 异步查询端点(用于视频等异步任务)
|
||||
}
|
||||
|
||||
export interface AIServiceProvider {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
service_type: AIServiceType
|
||||
default_url: string
|
||||
description: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
94
web/src/types/asset.ts
Normal file
94
web/src/types/asset.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export interface Asset {
|
||||
id: number
|
||||
drama_id?: number
|
||||
episode_id?: number
|
||||
storyboard_id?: number
|
||||
storyboard_num?: number
|
||||
name: string
|
||||
description?: string
|
||||
type: AssetType
|
||||
category?: string
|
||||
url: string
|
||||
thumbnail_url?: string
|
||||
local_path?: string
|
||||
file_size?: number
|
||||
mime_type?: string
|
||||
width?: number
|
||||
height?: number
|
||||
duration?: number
|
||||
format?: string
|
||||
image_gen_id?: number
|
||||
video_gen_id?: number
|
||||
tags?: AssetTag[]
|
||||
collections?: AssetCollection[]
|
||||
is_favorite: boolean
|
||||
view_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AssetType = 'image' | 'video' | 'audio'
|
||||
|
||||
export interface AssetTag {
|
||||
id: number
|
||||
name: string
|
||||
color?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AssetCollection {
|
||||
id: number
|
||||
drama_id?: number
|
||||
name: string
|
||||
description?: string
|
||||
assets?: Asset[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateAssetRequest {
|
||||
drama_id?: number
|
||||
name: string
|
||||
description?: string
|
||||
type: AssetType
|
||||
category?: string
|
||||
url: string
|
||||
thumbnail_url?: string
|
||||
local_path?: string
|
||||
file_size?: number
|
||||
mime_type?: string
|
||||
width?: number
|
||||
height?: number
|
||||
duration?: number
|
||||
format?: string
|
||||
image_gen_id?: number
|
||||
video_gen_id?: number
|
||||
tag_ids?: number[]
|
||||
}
|
||||
|
||||
export interface UpdateAssetRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
thumbnail_url?: string
|
||||
tag_ids?: number[]
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface ListAssetsParams {
|
||||
drama_id?: string
|
||||
episode_id?: number
|
||||
storyboard_id?: number
|
||||
type?: 'image' | 'video' | 'audio'
|
||||
category?: string
|
||||
tag_ids?: number[]
|
||||
is_favorite?: boolean
|
||||
search?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const ASSET_CATEGORIES = {
|
||||
image: ['角色', '场景', '道具', '背景', '其他'],
|
||||
video: ['分镜', '特效', '片头', '片尾', '其他'],
|
||||
audio: ['配音', '音效', '背景音乐', '片头曲', '片尾曲', '其他']
|
||||
}
|
||||
143
web/src/types/drama.ts
Normal file
143
web/src/types/drama.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export interface Drama {
|
||||
id: string
|
||||
|
||||
title: string
|
||||
description?: string
|
||||
genre?: string
|
||||
style?: string
|
||||
total_episodes: number
|
||||
total_duration: number
|
||||
total_scenes?: number
|
||||
duration?: number
|
||||
status: DramaStatus
|
||||
thumbnail?: string
|
||||
tags?: any
|
||||
metadata?: any
|
||||
created_at: string
|
||||
updated_at: string
|
||||
characters?: Character[]
|
||||
episodes?: Episode[]
|
||||
scenes?: Scene[]
|
||||
}
|
||||
|
||||
export type DramaStatus = 'draft' | 'planning' | 'production' | 'completed' | 'archived' | 'generating' | 'error'
|
||||
|
||||
export interface Character {
|
||||
id: number
|
||||
drama_id: string
|
||||
name: string
|
||||
role?: string
|
||||
description?: string
|
||||
appearance?: string
|
||||
personality?: string
|
||||
voice_style?: string
|
||||
background?: string
|
||||
reference_images?: any
|
||||
seed_value?: string
|
||||
sort_order?: number
|
||||
image_url?: string
|
||||
image_generation_status?: string
|
||||
image_generation_error?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: string
|
||||
drama_id: string
|
||||
episode_number: number
|
||||
title: string
|
||||
content: string
|
||||
description?: string
|
||||
script_content?: string
|
||||
duration?: number
|
||||
status: string
|
||||
video_url?: string
|
||||
thumbnail?: string
|
||||
storyboard_count?: number
|
||||
scene_count?: number
|
||||
composition_count?: number
|
||||
video_count?: number
|
||||
timeline_status?: string
|
||||
storyboards?: Storyboard[]
|
||||
scenes?: Scene[]
|
||||
characters?: Character[]
|
||||
shots?: any[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Storyboard {
|
||||
id: string
|
||||
episode_id: string
|
||||
storyboard_number: number
|
||||
title?: string
|
||||
description?: string
|
||||
location?: string
|
||||
time?: string
|
||||
duration?: number
|
||||
dialogue?: string
|
||||
action?: string
|
||||
atmosphere?: string
|
||||
image_prompt?: string
|
||||
video_prompt?: string
|
||||
characters?: any
|
||||
image_url?: string
|
||||
video_url?: string
|
||||
composed_image?: string
|
||||
scene_id?: string
|
||||
scene?: Scene
|
||||
created_at: string
|
||||
updated_at: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id: string
|
||||
drama_id: string
|
||||
location: string
|
||||
time: string
|
||||
prompt: string
|
||||
description?: string
|
||||
title?: string
|
||||
storyboard_number?: number
|
||||
storyboard_count?: number
|
||||
image_url?: string
|
||||
video_url?: string
|
||||
status: string
|
||||
image_generation_status?: string
|
||||
image_generation_error?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateDramaRequest {
|
||||
title: string
|
||||
description?: string
|
||||
genre?: string
|
||||
tags?: string
|
||||
}
|
||||
|
||||
export interface UpdateDramaRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
genre?: string
|
||||
tags?: string
|
||||
status?: DramaStatus
|
||||
}
|
||||
|
||||
export interface DramaListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: DramaStatus
|
||||
genre?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface DramaStats {
|
||||
total: number
|
||||
by_status: Array<{
|
||||
status: string
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
79
web/src/types/generation.ts
Normal file
79
web/src/types/generation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface GenerateOutlineRequest {
|
||||
drama_id: string
|
||||
theme: string
|
||||
genre?: string
|
||||
style?: string
|
||||
length?: number
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface GenerateCharactersRequest {
|
||||
drama_id: string
|
||||
outline?: string
|
||||
count?: number
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface GenerateEpisodesRequest {
|
||||
drama_id: string
|
||||
outline?: string
|
||||
episode_count: number
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface OutlineResult {
|
||||
title: string
|
||||
summary: string
|
||||
genre: string
|
||||
tags: string[]
|
||||
characters: CharacterOutline[]
|
||||
episodes: EpisodeOutline[]
|
||||
key_scenes: string[]
|
||||
}
|
||||
|
||||
export interface CharacterOutline {
|
||||
name: string
|
||||
role: string
|
||||
description: string
|
||||
personality: string
|
||||
appearance: string
|
||||
}
|
||||
|
||||
export interface EpisodeOutline {
|
||||
episode_number: number
|
||||
title: string
|
||||
summary: string
|
||||
scenes: string[]
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface ParseScriptRequest {
|
||||
drama_id: string
|
||||
script_content: string
|
||||
auto_split?: boolean
|
||||
}
|
||||
|
||||
export interface ParseScriptResult {
|
||||
episodes: ParsedEpisode[]
|
||||
characters: ParsedCharacter[]
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface ParsedCharacter {
|
||||
name: string
|
||||
role: string
|
||||
description: string
|
||||
personality: string
|
||||
}
|
||||
|
||||
export interface ParsedEpisode {
|
||||
episode_number: number
|
||||
title: string
|
||||
description: string
|
||||
script_content: string
|
||||
duration: number
|
||||
chapter_start?: number
|
||||
chapter_end?: number
|
||||
start_marker?: string
|
||||
end_marker?: string
|
||||
}
|
||||
65
web/src/types/image.ts
Normal file
65
web/src/types/image.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface ImageGeneration {
|
||||
id: number
|
||||
storyboard_id?: number
|
||||
scene_id?: string
|
||||
drama_id: string
|
||||
character_id?: number
|
||||
image_type?: string
|
||||
frame_type?: string
|
||||
provider: string
|
||||
prompt: string
|
||||
negative_prompt?: string
|
||||
model: string
|
||||
size?: string
|
||||
quality?: string
|
||||
style?: string
|
||||
steps?: number
|
||||
cfg_scale?: number
|
||||
seed?: number
|
||||
image_url?: string
|
||||
image_generation?: any
|
||||
local_path?: string
|
||||
status: ImageStatus
|
||||
task_id?: string
|
||||
error_msg?: string
|
||||
width?: number
|
||||
height?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export type ImageStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
export type ImageProvider = 'openai' | 'dalle' | 'midjourney' | 'stable_diffusion' | 'sd'
|
||||
|
||||
export interface GenerateImageRequest {
|
||||
scene_id?: number
|
||||
storyboard_id?: number
|
||||
drama_id: string
|
||||
image_type?: string
|
||||
frame_type?: string
|
||||
prompt: string
|
||||
negative_prompt?: string
|
||||
reference_images?: string[]
|
||||
provider?: string
|
||||
model?: string
|
||||
size?: string
|
||||
quality?: string
|
||||
style?: string
|
||||
steps?: number
|
||||
cfg_scale?: number
|
||||
seed?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export interface ImageGenerationListParams {
|
||||
drama_id?: string
|
||||
scene_id?: string
|
||||
storyboard_id?: number
|
||||
frame_type?: string
|
||||
status?: ImageStatus
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
165
web/src/types/timeline.ts
Normal file
165
web/src/types/timeline.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Asset } from './asset'
|
||||
|
||||
export interface Timeline {
|
||||
id: number
|
||||
drama_id: number
|
||||
episode_id?: number
|
||||
name: string
|
||||
description?: string
|
||||
duration: number
|
||||
fps: number
|
||||
resolution?: string
|
||||
status: TimelineStatus
|
||||
tracks?: TimelineTrack[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type TimelineStatus = 'draft' | 'editing' | 'completed' | 'exporting'
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: number
|
||||
timeline_id: number
|
||||
name: string
|
||||
type: TrackType
|
||||
order: number
|
||||
is_locked: boolean
|
||||
is_muted: boolean
|
||||
volume?: number
|
||||
clips?: TimelineClip[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type TrackType = 'video' | 'audio' | 'text'
|
||||
|
||||
export interface TimelineClip {
|
||||
id: number
|
||||
track_id: number
|
||||
asset_id?: number
|
||||
asset?: Asset
|
||||
scene_id?: number
|
||||
name: string
|
||||
start_time: number
|
||||
end_time: number
|
||||
duration: number
|
||||
trim_start?: number
|
||||
trim_end?: number
|
||||
speed?: number
|
||||
volume?: number
|
||||
is_muted: boolean
|
||||
fade_in?: number
|
||||
fade_out?: number
|
||||
transition_in_id?: number
|
||||
transition_out_id?: number
|
||||
in_transition?: ClipTransition
|
||||
out_transition?: ClipTransition
|
||||
effects?: ClipEffect[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ClipTransition {
|
||||
id: number
|
||||
type: TransitionType
|
||||
duration: number
|
||||
easing?: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export type TransitionType = 'fade' | 'crossfade' | 'slide' | 'wipe' | 'zoom' | 'dissolve'
|
||||
|
||||
export interface ClipEffect {
|
||||
id: number
|
||||
clip_id: number
|
||||
type: EffectType
|
||||
name: string
|
||||
is_enabled: boolean
|
||||
order: number
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export type EffectType = 'filter' | 'color' | 'blur' | 'brightness' | 'contrast' | 'saturation'
|
||||
|
||||
export interface CreateTimelineRequest {
|
||||
drama_id: number
|
||||
episode_id?: number
|
||||
name: string
|
||||
description?: string
|
||||
fps?: number
|
||||
resolution?: string
|
||||
}
|
||||
|
||||
export interface UpdateTimelineRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
fps?: number
|
||||
resolution?: string
|
||||
status?: TimelineStatus
|
||||
}
|
||||
|
||||
export interface CreateTrackRequest {
|
||||
name: string
|
||||
type: TrackType
|
||||
order?: number
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export interface UpdateTrackRequest {
|
||||
name?: string
|
||||
order?: number
|
||||
is_locked?: boolean
|
||||
is_muted?: boolean
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export interface CreateClipRequest {
|
||||
track_id: number
|
||||
asset_id?: number
|
||||
scene_id?: number
|
||||
name?: string
|
||||
start_time: number
|
||||
duration: number
|
||||
trim_start?: number
|
||||
trim_end?: number
|
||||
speed?: number
|
||||
volume?: number
|
||||
fade_in?: number
|
||||
fade_out?: number
|
||||
}
|
||||
|
||||
export interface UpdateClipRequest {
|
||||
name?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
trim_start?: number
|
||||
trim_end?: number
|
||||
speed?: number
|
||||
volume?: number
|
||||
is_muted?: boolean
|
||||
fade_in?: number
|
||||
fade_out?: number
|
||||
}
|
||||
|
||||
export interface CreateTransitionRequest {
|
||||
type: TransitionType
|
||||
duration: number
|
||||
easing?: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export const TRANSITION_TYPES = [
|
||||
{ label: '淡入淡出', value: 'fade' },
|
||||
{ label: '交叉淡化', value: 'crossfade' },
|
||||
{ label: '滑动', value: 'slide' },
|
||||
{ label: '擦除', value: 'wipe' },
|
||||
{ label: '缩放', value: 'zoom' },
|
||||
{ label: '溶解', value: 'dissolve' }
|
||||
]
|
||||
|
||||
export const EFFECT_TYPES = [
|
||||
{ label: '滤镜', value: 'filter' },
|
||||
{ label: '色彩', value: 'color' },
|
||||
{ label: '模糊', value: 'blur' },
|
||||
{ label: '亮度', value: 'brightness' },
|
||||
{ label: '对比度', value: 'contrast' },
|
||||
{ label: '饱和度', value: 'saturation' }
|
||||
]
|
||||
26
web/src/types/user.ts
Normal file
26
web/src/types/user.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
role: string
|
||||
status: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
text_provider: string
|
||||
text_model: string
|
||||
text_api_key_set: boolean
|
||||
image_provider: string
|
||||
image_model: string
|
||||
image_api_key_set: boolean
|
||||
video_provider: string
|
||||
video_model: string
|
||||
video_api_key_set: boolean
|
||||
default_style: string
|
||||
default_resolution: string
|
||||
default_fps: number
|
||||
}
|
||||
83
web/src/types/video.ts
Normal file
83
web/src/types/video.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface VideoGeneration {
|
||||
id: number
|
||||
storyboard_id?: number
|
||||
scene_id?: string // 已废弃,保留用于兼容
|
||||
drama_id: string
|
||||
image_gen_id?: number
|
||||
provider: string
|
||||
prompt: string
|
||||
model?: string
|
||||
image_url?: string
|
||||
first_frame_url?: string
|
||||
duration?: number
|
||||
fps?: number
|
||||
resolution?: string
|
||||
aspect_ratio?: string
|
||||
style?: string
|
||||
motion_level?: number
|
||||
camera_motion?: string
|
||||
seed?: number
|
||||
video_url?: string
|
||||
local_path?: string
|
||||
status: VideoStatus
|
||||
task_id?: string
|
||||
error_msg?: string
|
||||
width?: number
|
||||
height?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export type VideoStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
export type VideoProvider = 'runway' | 'pika' | 'doubao' | 'openai'
|
||||
|
||||
export interface GenerateVideoRequest {
|
||||
storyboard_id?: number
|
||||
scene_id?: string // 已废弃,保留用于兼容
|
||||
drama_id: string
|
||||
image_gen_id?: number
|
||||
image_url?: string
|
||||
prompt: string
|
||||
provider?: string
|
||||
model?: string
|
||||
duration?: number
|
||||
fps?: number
|
||||
aspect_ratio?: string
|
||||
style?: string
|
||||
motion_level?: number
|
||||
camera_motion?: string
|
||||
seed?: number
|
||||
reference_mode?: string // 参考图模式:single, first_last, multiple, none
|
||||
first_frame_url?: string // 首帧图片URL
|
||||
last_frame_url?: string // 尾帧图片URL
|
||||
reference_image_urls?: string[] // 多图参考模式
|
||||
}
|
||||
|
||||
export interface VideoGenerationListParams {
|
||||
drama_id?: string
|
||||
storyboard_id?: string
|
||||
scene_id?: string // 已废弃,保留用于兼容
|
||||
status?: string // 支持单个状态或逗号分隔的多个状态,如 "pending,processing"
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const VIDEO_ASPECT_RATIOS = [
|
||||
{ label: '16:9 (横屏)', value: '16:9' },
|
||||
{ label: '9:16 (竖屏)', value: '9:16' },
|
||||
{ label: '1:1 (正方形)', value: '1:1' },
|
||||
{ label: '4:3 (传统)', value: '4:3' }
|
||||
]
|
||||
|
||||
export const CAMERA_MOTIONS = [
|
||||
{ label: '静止', value: 'static' },
|
||||
{ label: '推进', value: 'zoom_in' },
|
||||
{ label: '拉远', value: 'zoom_out' },
|
||||
{ label: '左移', value: 'pan_left' },
|
||||
{ label: '右移', value: 'pan_right' },
|
||||
{ label: '上移', value: 'tilt_up' },
|
||||
{ label: '下移', value: 'tilt_down' },
|
||||
{ label: '环绕', value: 'orbit' }
|
||||
]
|
||||
218
web/src/utils/ffmpeg.ts
Normal file
218
web/src/utils/ffmpeg.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg'
|
||||
import { fetchFile, toBlobURL } from '@ffmpeg/util'
|
||||
|
||||
let ffmpegInstance: FFmpeg | null = null
|
||||
let loadPromise: Promise<FFmpeg> | null = null
|
||||
|
||||
export interface VideoTrimOptions {
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
|
||||
export interface VideoMergeOptions {
|
||||
clips: Array<{
|
||||
url: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ProgressCallback {
|
||||
(progress: number): void
|
||||
}
|
||||
|
||||
async function getFFmpeg(): Promise<FFmpeg> {
|
||||
if (ffmpegInstance) {
|
||||
return ffmpegInstance
|
||||
}
|
||||
|
||||
if (loadPromise) {
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
const ffmpeg = new FFmpeg()
|
||||
|
||||
ffmpeg.on('log', ({ message }) => {
|
||||
console.log('[FFmpeg]', message)
|
||||
})
|
||||
|
||||
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'
|
||||
await ffmpeg.load({
|
||||
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
|
||||
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
|
||||
})
|
||||
|
||||
ffmpegInstance = ffmpeg
|
||||
return ffmpeg
|
||||
})()
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export async function trimVideo(
|
||||
videoUrl: string,
|
||||
options: VideoTrimOptions,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<Blob> {
|
||||
const ffmpeg = await getFFmpeg()
|
||||
|
||||
if (onProgress) onProgress(10)
|
||||
|
||||
const inputFileName = 'input.mp4'
|
||||
const outputFileName = 'output.mp4'
|
||||
|
||||
await ffmpeg.writeFile(inputFileName, await fetchFile(videoUrl))
|
||||
|
||||
if (onProgress) onProgress(30)
|
||||
|
||||
const args = [
|
||||
'-i', inputFileName,
|
||||
'-ss', options.startTime.toString(),
|
||||
'-to', options.endTime.toString(),
|
||||
'-c', 'copy',
|
||||
'-avoid_negative_ts', '1',
|
||||
outputFileName
|
||||
]
|
||||
|
||||
await ffmpeg.exec(args)
|
||||
|
||||
if (onProgress) onProgress(80)
|
||||
|
||||
const data = await ffmpeg.readFile(outputFileName) as Uint8Array
|
||||
|
||||
await ffmpeg.deleteFile(inputFileName)
|
||||
await ffmpeg.deleteFile(outputFileName)
|
||||
|
||||
if (onProgress) onProgress(100)
|
||||
|
||||
return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
}
|
||||
|
||||
export async function mergeVideos(
|
||||
options: VideoMergeOptions,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<Blob> {
|
||||
const ffmpeg = await getFFmpeg()
|
||||
|
||||
if (onProgress) onProgress(5)
|
||||
|
||||
const tempFiles: string[] = []
|
||||
|
||||
for (let i = 0; i < options.clips.length; i++) {
|
||||
const clip = options.clips[i]
|
||||
const fileName = `clip_${i}.mp4`
|
||||
|
||||
await ffmpeg.writeFile(fileName, await fetchFile(clip.url))
|
||||
tempFiles.push(fileName)
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(5 + (i + 1) / options.clips.length * 40)
|
||||
}
|
||||
}
|
||||
|
||||
const listContent = tempFiles.map(file => `file '${file}'`).join('\n')
|
||||
await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))
|
||||
|
||||
if (onProgress) onProgress(50)
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', 'filelist.txt',
|
||||
'-c', 'copy',
|
||||
'output.mp4'
|
||||
])
|
||||
|
||||
if (onProgress) onProgress(90)
|
||||
|
||||
const data = await ffmpeg.readFile('output.mp4') as Uint8Array
|
||||
|
||||
for (const file of tempFiles) {
|
||||
await ffmpeg.deleteFile(file)
|
||||
}
|
||||
await ffmpeg.deleteFile('filelist.txt')
|
||||
await ffmpeg.deleteFile('output.mp4')
|
||||
|
||||
if (onProgress) onProgress(100)
|
||||
|
||||
return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
}
|
||||
|
||||
export async function trimAndMergeVideos(
|
||||
clips: Array<{
|
||||
url: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}>,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<Blob> {
|
||||
const ffmpeg = await getFFmpeg()
|
||||
|
||||
if (onProgress) onProgress(5)
|
||||
|
||||
const trimmedFiles: string[] = []
|
||||
|
||||
for (let i = 0; i < clips.length; i++) {
|
||||
const clip = clips[i]
|
||||
const inputName = `input_${i}.mp4`
|
||||
const outputName = `trimmed_${i}.mp4`
|
||||
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(clip.url))
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-i', inputName,
|
||||
'-ss', clip.startTime.toString(),
|
||||
'-to', clip.endTime.toString(),
|
||||
'-c', 'copy',
|
||||
'-avoid_negative_ts', '1',
|
||||
outputName
|
||||
])
|
||||
|
||||
await ffmpeg.deleteFile(inputName)
|
||||
trimmedFiles.push(outputName)
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(5 + (i + 1) / clips.length * 60)
|
||||
}
|
||||
}
|
||||
|
||||
const listContent = trimmedFiles.map(file => `file '${file}'`).join('\n')
|
||||
await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))
|
||||
|
||||
if (onProgress) onProgress(70)
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', 'filelist.txt',
|
||||
'-c', 'copy',
|
||||
'final.mp4'
|
||||
])
|
||||
|
||||
if (onProgress) onProgress(95)
|
||||
|
||||
const data = await ffmpeg.readFile('final.mp4') as Uint8Array
|
||||
|
||||
for (const file of trimmedFiles) {
|
||||
await ffmpeg.deleteFile(file)
|
||||
}
|
||||
await ffmpeg.deleteFile('filelist.txt')
|
||||
await ffmpeg.deleteFile('final.mp4')
|
||||
|
||||
if (onProgress) onProgress(100)
|
||||
|
||||
return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
}
|
||||
|
||||
export async function isFFmpegLoaded(): Promise<boolean> {
|
||||
return ffmpegInstance !== null
|
||||
}
|
||||
|
||||
export async function unloadFFmpeg(): Promise<void> {
|
||||
if (ffmpegInstance) {
|
||||
await ffmpegInstance.terminate()
|
||||
ffmpegInstance = null
|
||||
loadPromise = null
|
||||
}
|
||||
}
|
||||
48
web/src/utils/request.ts
Normal file
48
web/src/utils/request.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface CustomAxiosInstance extends Omit<AxiosInstance, 'get' | 'post' | 'put' | 'patch' | 'delete'> {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
|
||||
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 600000, // 10分钟超时,匹配后端AI生成接口
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}) as CustomAxiosInstance
|
||||
|
||||
// 开源版本 - 无需认证token
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data
|
||||
if (res.success) {
|
||||
return res.data
|
||||
} else {
|
||||
// 不在这里显示错误提示,让业务代码自行处理
|
||||
return Promise.reject(new Error(res.error?.message || '请求失败'))
|
||||
}
|
||||
},
|
||||
(error: AxiosError<any>) => {
|
||||
// 不在拦截器中自动显示错误提示,让业务代码根据具体情况处理
|
||||
// 只抛出错误供调用者捕获
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
328
web/src/utils/videoMerger.ts
Normal file
328
web/src/utils/videoMerger.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg'
|
||||
import { fetchFile, toBlobURL } from '@ffmpeg/util'
|
||||
|
||||
export interface VideoClip {
|
||||
url: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
transition?: TransitionEffect
|
||||
}
|
||||
|
||||
export type TransitionType = 'fade' | 'fadeblack' | 'fadewhite' | 'slideleft' | 'slideright' | 'slideup' | 'slidedown' | 'wipeleft' | 'wiperight' | 'circleopen' | 'circleclose' | 'none'
|
||||
|
||||
export interface TransitionEffect {
|
||||
type: TransitionType
|
||||
duration: number // 转场时长(秒)
|
||||
}
|
||||
|
||||
export interface MergeProgress {
|
||||
phase: 'loading' | 'processing' | 'encoding' | 'completed'
|
||||
progress: number
|
||||
message: string
|
||||
}
|
||||
|
||||
class VideoMerger {
|
||||
private ffmpeg: FFmpeg
|
||||
private loaded: boolean = false
|
||||
private onProgress?: (progress: MergeProgress) => void
|
||||
|
||||
constructor() {
|
||||
this.ffmpeg = new FFmpeg()
|
||||
}
|
||||
|
||||
async initialize(onProgress?: (progress: MergeProgress) => void) {
|
||||
if (this.loaded) return
|
||||
|
||||
this.onProgress = onProgress
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'loading',
|
||||
progress: 0,
|
||||
message: '正在加载FFmpeg引擎(首次需要下载约30MB)...'
|
||||
})
|
||||
|
||||
// CDN列表(优先使用国内CDN)
|
||||
const cdnList = [
|
||||
'https://unpkg.zhimg.com/@ffmpeg/core@0.12.6/dist/esm', // 知乎CDN镜像(国内)
|
||||
'https://npm.elemecdn.com/@ffmpeg/core@0.12.6/dist/esm', // 饿了么CDN(国内)
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm', // jsDelivr(全球CDN,国内可用)
|
||||
'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm', // unpkg(国外)
|
||||
]
|
||||
|
||||
this.ffmpeg.on('log', ({ message }) => {
|
||||
console.log('[FFmpeg]', message)
|
||||
})
|
||||
|
||||
this.ffmpeg.on('progress', ({ progress, time }) => {
|
||||
this.onProgress?.({
|
||||
phase: 'encoding',
|
||||
progress: Math.round(progress * 100),
|
||||
message: `正在合并视频... ${Math.round(progress * 100)}%`
|
||||
})
|
||||
})
|
||||
|
||||
// 尝试多个CDN源
|
||||
let lastError: Error | null = null
|
||||
for (let i = 0; i < cdnList.length; i++) {
|
||||
const baseURL = cdnList[i]
|
||||
|
||||
try {
|
||||
this.onProgress?.({
|
||||
phase: 'loading',
|
||||
progress: (i / cdnList.length) * 50,
|
||||
message: `正在从CDN ${i + 1}/${cdnList.length} 加载FFmpeg...`
|
||||
})
|
||||
|
||||
// 添加超时控制
|
||||
const loadPromise = this.ffmpeg.load({
|
||||
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
|
||||
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('加载超时')), 60000) // 60秒超时
|
||||
})
|
||||
|
||||
await Promise.race([loadPromise, timeoutPromise])
|
||||
|
||||
// 加载成功
|
||||
this.loaded = true
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'loading',
|
||||
progress: 100,
|
||||
message: 'FFmpeg加载完成'
|
||||
})
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
console.error(`CDN ${i + 1} 加载失败:`, error)
|
||||
lastError = error as Error
|
||||
|
||||
if (i < cdnList.length - 1) {
|
||||
this.onProgress?.({
|
||||
phase: 'loading',
|
||||
progress: ((i + 1) / cdnList.length) * 50,
|
||||
message: `CDN ${i + 1} 失败,尝试备用源...`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有CDN都失败
|
||||
throw new Error(`FFmpeg加载失败: ${lastError?.message || '未知错误'}。请检查网络连接或稍后重试。`)
|
||||
}
|
||||
|
||||
async mergeVideos(clips: VideoClip[]): Promise<Blob> {
|
||||
if (!this.loaded) {
|
||||
await this.initialize(this.onProgress)
|
||||
}
|
||||
|
||||
if (clips.length === 0) {
|
||||
throw new Error('没有视频片段')
|
||||
}
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'processing',
|
||||
progress: 0,
|
||||
message: '正在下载视频片段...'
|
||||
})
|
||||
|
||||
// 并行下载所有视频文件
|
||||
this.onProgress?.({
|
||||
phase: 'processing',
|
||||
progress: 0,
|
||||
message: `正在下载 ${clips.length} 个视频片段...`
|
||||
})
|
||||
|
||||
const downloadPromises = clips.map((clip, i) =>
|
||||
fetchFile(clip.url).then(data => ({ index: i, data }))
|
||||
)
|
||||
|
||||
const downloads = await Promise.all(downloadPromises)
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'processing',
|
||||
progress: 30,
|
||||
message: '下载完成,正在处理视频...'
|
||||
})
|
||||
|
||||
// 写入文件系统并处理
|
||||
const inputFiles: string[] = []
|
||||
for (let i = 0; i < clips.length; i++) {
|
||||
const clip = clips[i]
|
||||
const download = downloads.find(d => d.index === i)!
|
||||
const inputFileName = `input${i}.mp4`
|
||||
const outputFileName = `clip${i}.mp4`
|
||||
|
||||
// 写入原始视频
|
||||
await this.ffmpeg.writeFile(inputFileName, download.data)
|
||||
|
||||
// 如果需要裁剪,先裁剪视频
|
||||
if (clip.startTime > 0 || clip.endTime < clip.duration) {
|
||||
this.onProgress?.({
|
||||
phase: 'processing',
|
||||
progress: Math.round(30 + (i / clips.length) * 20),
|
||||
message: `正在裁剪视频片段 ${i + 1}/${clips.length}...`
|
||||
})
|
||||
|
||||
await this.ffmpeg.exec([
|
||||
'-i', inputFileName,
|
||||
'-ss', clip.startTime.toString(),
|
||||
'-t', (clip.endTime - clip.startTime).toString(),
|
||||
'-c', 'copy',
|
||||
outputFileName
|
||||
])
|
||||
|
||||
inputFiles.push(outputFileName)
|
||||
await this.ffmpeg.deleteFile(inputFileName)
|
||||
} else {
|
||||
inputFiles.push(inputFileName)
|
||||
}
|
||||
}
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'processing',
|
||||
progress: 50,
|
||||
message: '正在准备合并...'
|
||||
})
|
||||
|
||||
// 检查是否有转场效果
|
||||
const hasTransitions = clips.some(clip => clip.transition && clip.transition.type !== 'none')
|
||||
|
||||
if (!hasTransitions || clips.length === 1) {
|
||||
// 没有转场效果,使用简单的concat方式(更快)
|
||||
const concatContent = inputFiles.map(f => `file '${f}'`).join('\n')
|
||||
await this.ffmpeg.writeFile('concat.txt', concatContent)
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'encoding',
|
||||
progress: 0,
|
||||
message: '正在合并视频...'
|
||||
})
|
||||
|
||||
await this.ffmpeg.exec([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', 'concat.txt',
|
||||
'-c', 'copy',
|
||||
'-movflags', '+faststart',
|
||||
'output.mp4'
|
||||
])
|
||||
} else {
|
||||
// 有转场效果,使用filter_complex(需要重新编码)
|
||||
this.onProgress?.({
|
||||
phase: 'encoding',
|
||||
progress: 0,
|
||||
message: '正在添加转场效果并合并视频(这需要较长时间)...'
|
||||
})
|
||||
|
||||
await this.mergeWithTransitions(inputFiles, clips)
|
||||
}
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'completed',
|
||||
progress: 90,
|
||||
message: '正在生成最终文件...'
|
||||
})
|
||||
|
||||
// 读取输出文件
|
||||
const data = await this.ffmpeg.readFile('output.mp4')
|
||||
const blob = new Blob([data], { type: 'video/mp4' })
|
||||
|
||||
// 清理临时文件
|
||||
for (const file of inputFiles) {
|
||||
await this.ffmpeg.deleteFile(file)
|
||||
}
|
||||
await this.ffmpeg.deleteFile('concat.txt')
|
||||
await this.ffmpeg.deleteFile('output.mp4')
|
||||
|
||||
this.onProgress?.({
|
||||
phase: 'completed',
|
||||
progress: 100,
|
||||
message: '合并完成!'
|
||||
})
|
||||
|
||||
return blob
|
||||
}
|
||||
|
||||
private async mergeWithTransitions(inputFiles: string[], clips: VideoClip[]) {
|
||||
// 构建FFmpeg filter_complex命令
|
||||
const filterParts: string[] = []
|
||||
const inputs: string[] = []
|
||||
|
||||
// 为每个输入添加标签
|
||||
for (let i = 0; i < inputFiles.length; i++) {
|
||||
inputs.push('-i', inputFiles[i])
|
||||
filterParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`)
|
||||
filterParts.push(`[${i}:a]asetpts=PTS-STARTPTS[a${i}]`)
|
||||
}
|
||||
|
||||
// 构建转场链
|
||||
let videoChain = 'v0'
|
||||
let audioChain = 'a0'
|
||||
|
||||
for (let i = 1; i < clips.length; i++) {
|
||||
const transition = clips[i].transition
|
||||
const transType = transition?.type || 'fade'
|
||||
const transDuration = transition?.duration || 1.0
|
||||
|
||||
const offset = clips.slice(0, i).reduce((sum, c) => sum + c.duration, 0) - transDuration
|
||||
|
||||
// 视频转场
|
||||
const xfadeFilter = this.getXfadeFilter(transType, transDuration, offset)
|
||||
filterParts.push(`[${videoChain}][v${i}]${xfadeFilter}[v${i}out]`)
|
||||
videoChain = `v${i}out`
|
||||
|
||||
// 音频交叉淡入淡出
|
||||
filterParts.push(`[${audioChain}][a${i}]acrossfade=d=${transDuration}:c1=tri:c2=tri[a${i}out]`)
|
||||
audioChain = `a${i}out`
|
||||
}
|
||||
|
||||
const filterComplex = filterParts.join(';')
|
||||
|
||||
// 执行FFmpeg命令
|
||||
await this.ffmpeg.exec([
|
||||
...inputs,
|
||||
'-filter_complex', filterComplex,
|
||||
'-map', `[${videoChain}]`,
|
||||
'-map', `[${audioChain}]`,
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
'-crf', '23',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
'output.mp4'
|
||||
])
|
||||
}
|
||||
|
||||
private getXfadeFilter(type: TransitionType, duration: number, offset: number): string {
|
||||
const xfadeTypes: Record<string, string> = {
|
||||
'fade': 'fade',
|
||||
'fadeblack': 'fadeblack',
|
||||
'fadewhite': 'fadewhite',
|
||||
'slideleft': 'slideleft',
|
||||
'slideright': 'slideright',
|
||||
'slideup': 'slideup',
|
||||
'slidedown': 'slidedown',
|
||||
'wipeleft': 'wipeleft',
|
||||
'wiperight': 'wiperight',
|
||||
'circleopen': 'circleopen',
|
||||
'circleclose': 'circleclose'
|
||||
}
|
||||
|
||||
const xfadeType = xfadeTypes[type] || 'fade'
|
||||
return `xfade=transition=${xfadeType}:duration=${duration}:offset=${offset}`
|
||||
}
|
||||
|
||||
async terminate() {
|
||||
if (this.loaded) {
|
||||
this.ffmpeg.terminate()
|
||||
this.loaded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const videoMerger = new VideoMerger()
|
||||
191
web/src/views/dashboard/Dashboard.vue
Normal file
191
web/src/views/dashboard/Dashboard.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<h2>{{ $t('dashboard.title') }}</h2>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<div class="welcome-section">
|
||||
<h1>{{ $t('dashboard.welcome') }}</h1>
|
||||
<p>{{ $t('dashboard.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-item">
|
||||
<el-icon :size="40" color="#409eff"><Document /></el-icon>
|
||||
<h3>0</h3>
|
||||
<p>{{ $t('dashboard.stats.projects') }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-item">
|
||||
<el-icon :size="40" color="#67c23a"><Picture /></el-icon>
|
||||
<h3>0</h3>
|
||||
<p>{{ $t('dashboard.stats.images') }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-item">
|
||||
<el-icon :size="40" color="#e6a23c"><VideoPlay /></el-icon>
|
||||
<h3>0</h3>
|
||||
<p>{{ $t('dashboard.stats.videos') }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-item">
|
||||
<el-icon :size="40" color="#f56c6c"><Clock /></el-icon>
|
||||
<h3>0</h3>
|
||||
<p>{{ $t('dashboard.stats.tasks') }}</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="quick-actions">
|
||||
<h2>{{ $t('dashboard.quickStart') }}</h2>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="action-card" @click="goToDramas">
|
||||
<el-icon :size="50" color="#409eff"><Plus /></el-icon>
|
||||
<h3>{{ $t('dashboard.actions.newProject') }}</h3>
|
||||
<p>{{ $t('dashboard.actions.newProjectDesc') }}</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="action-card" @click="goToDramas">
|
||||
<el-icon :size="50" color="#67c23a"><FolderOpened /></el-icon>
|
||||
<h3>{{ $t('dashboard.actions.myProjects') }}</h3>
|
||||
<p>{{ $t('dashboard.actions.myProjectsDesc') }}</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Document, Picture, VideoPlay, Clock, Plus, FolderOpened, Setting } from '@element-plus/icons-vue'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goToDramas = () => {
|
||||
router.push('/dramas')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/settings/ai-config')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.welcome-section h1 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.welcome-section p {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.stat-item h3 {
|
||||
font-size: 32px;
|
||||
margin: 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-item p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quick-actions h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.action-card h3 {
|
||||
margin: 15px 0 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
175
web/src/views/drama/DramaCreate.vue
Normal file
175
web/src/views/drama/DramaCreate.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<!-- Drama Create Page / 创建短剧页面 -->
|
||||
<div class="page-container">
|
||||
<div class="content-wrapper animate-fade-in">
|
||||
<!-- Header / 头部 -->
|
||||
<PageHeader
|
||||
title="创建新项目"
|
||||
subtitle="填写基本信息来创建你的短剧项目"
|
||||
:show-back="true"
|
||||
back-text="返回"
|
||||
:show-border="false"
|
||||
/>
|
||||
|
||||
<!-- Form Card / 表单卡片 -->
|
||||
<div class="form-card">
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="create-form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="项目标题" prop="title" required>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="给你的短剧起个名字"
|
||||
size="large"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="项目描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="简要描述你的短剧内容、风格或创意(可选)"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="goBack">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<el-icon v-if="!loading"><Plus /></el-icon>
|
||||
创建项目
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { CreateDramaRequest } from '@/types/drama'
|
||||
import { PageHeader } from '@/components/common'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive<CreateDramaRequest>({
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入项目标题', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '标题长度在 1 到 100 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// Submit form / 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
const drama = await dramaAPI.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
router.push(`/dramas/${drama.id}`)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '创建失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Go back / 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========================================
|
||||
Page Layout / 页面布局 - 紧凑边距
|
||||
======================================== */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-container {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Card / 表单卡片
|
||||
======================================== */
|
||||
.form-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Styles / 表单样式 - 紧凑内边距
|
||||
======================================== */
|
||||
.create-form {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.create-form :deep(.el-form-item) {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Actions / 表单操作区
|
||||
======================================== */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.form-actions .el-button {
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
514
web/src/views/drama/DramaList.vue
Normal file
514
web/src/views/drama/DramaList.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<!-- Drama List Page - Refactored with modern minimalist design -->
|
||||
<!-- 短剧列表页面 - 使用现代简约设计重构 -->
|
||||
<div class="page-container">
|
||||
<div class="content-wrapper animate-fade-in">
|
||||
<!-- Page Header / 页面头部 -->
|
||||
<PageHeader
|
||||
:title="$t('drama.title')"
|
||||
:subtitle="$t('drama.totalProjects', { count: total })"
|
||||
>
|
||||
<template #actions>
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<el-button @click="showAIConfig = true" class="header-btn">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span class="btn-text">{{ $t('drama.aiConfig') }}</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleCreate" class="header-btn primary">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span class="btn-text">{{ $t('drama.createNew') }}</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Project Grid / 项目网格 -->
|
||||
<div v-loading="loading" class="projects-grid" :class="{ 'is-empty': !loading && dramas.length === 0 }">
|
||||
<!-- Empty state / 空状态 -->
|
||||
<EmptyState
|
||||
v-if="!loading && dramas.length === 0"
|
||||
:title="$t('drama.empty')"
|
||||
:description="$t('drama.emptyHint')"
|
||||
:icon="Film"
|
||||
>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
{{ $t('drama.createNew') }}
|
||||
</el-button>
|
||||
</EmptyState>
|
||||
|
||||
<!-- Project Cards / 项目卡片列表 -->
|
||||
<ProjectCard
|
||||
v-for="drama in dramas"
|
||||
:key="drama.id"
|
||||
:title="drama.title"
|
||||
:description="drama.description"
|
||||
:updated-at="drama.updated_at"
|
||||
:episode-count="drama.total_episodes || 0"
|
||||
@click="viewDrama(drama.id)"
|
||||
>
|
||||
<template #actions>
|
||||
<ActionButton
|
||||
:icon="Edit"
|
||||
:tooltip="$t('common.edit')"
|
||||
@click="editDrama(drama.id)"
|
||||
/>
|
||||
<el-popconfirm
|
||||
:title="$t('drama.deleteConfirm')"
|
||||
:confirm-button-text="$t('common.confirm')"
|
||||
:cancel-button-text="$t('common.cancel')"
|
||||
@confirm="deleteDrama(drama.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
:icon="Delete"
|
||||
class="action-button danger"
|
||||
link
|
||||
/>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
|
||||
<!-- Edit Dialog / 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
:title="$t('drama.editProject')"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
class="edit-dialog"
|
||||
>
|
||||
<el-form
|
||||
:model="editForm"
|
||||
label-position="top"
|
||||
v-loading="editLoading"
|
||||
class="edit-form"
|
||||
>
|
||||
<el-form-item :label="$t('drama.projectName')" required>
|
||||
<el-input
|
||||
v-model="editForm.title"
|
||||
:placeholder="$t('drama.projectNamePlaceholder')"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('drama.projectDesc')">
|
||||
<el-input
|
||||
v-model="editForm.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('drama.projectDescPlaceholder')"
|
||||
resize="none"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="editDialogVisible = false" size="large">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="saveEdit"
|
||||
:loading="editLoading"
|
||||
size="large"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Create Drama Dialog / 创建短剧弹窗 -->
|
||||
<CreateDramaDialog
|
||||
v-model="createDialogVisible"
|
||||
@created="loadDramas"
|
||||
/>
|
||||
|
||||
<!-- AI Config Dialog / AI配置弹窗 -->
|
||||
<AIConfigDialog v-model="showAIConfig" />
|
||||
</div>
|
||||
|
||||
<!-- Sticky Pagination / 吸底分页器 -->
|
||||
<div v-if="total > 0" class="pagination-sticky">
|
||||
<div class="pagination-inner">
|
||||
<div class="pagination-info">
|
||||
<span class="pagination-total">{{ $t('drama.totalProjects', { count: total }) }}</span>
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
:pager-count="5"
|
||||
layout="prev, pager, next"
|
||||
@size-change="loadDramas"
|
||||
@current-change="loadDramas"
|
||||
/>
|
||||
</div>
|
||||
<div class="pagination-size">
|
||||
<span class="size-label">{{ $t('common.perPage') }}</span>
|
||||
<el-select
|
||||
v-model="queryParams.page_size"
|
||||
size="small"
|
||||
class="size-select"
|
||||
@change="loadDramas"
|
||||
>
|
||||
<el-option :value="12" label="12" />
|
||||
<el-option :value="24" label="24" />
|
||||
<el-option :value="36" label="36" />
|
||||
<el-option :value="48" label="48" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Film,
|
||||
Setting,
|
||||
Edit,
|
||||
View,
|
||||
Delete,
|
||||
InfoFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { Drama, DramaListQuery } from '@/types/drama'
|
||||
import { PageHeader, ProjectCard, ThemeToggle, ActionButton, CreateDramaDialog, EmptyState, AIConfigDialog } from '@/components/common'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const dramas = ref<Drama[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const queryParams = ref<DramaListQuery>({
|
||||
page: 1,
|
||||
page_size: 12
|
||||
})
|
||||
|
||||
// Create dialog state / 创建弹窗状态
|
||||
const createDialogVisible = ref(false)
|
||||
const showAIConfig = ref(false)
|
||||
|
||||
// Load drama list / 加载短剧列表
|
||||
const loadDramas = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await dramaAPI.list(queryParams.value)
|
||||
dramas.value = res.items || []
|
||||
total.value = res.pagination?.total || 0
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation handlers / 导航处理
|
||||
const handleCreate = () => createDialogVisible.value = true
|
||||
const viewDrama = (id: string) => router.push(`/dramas/${id}`)
|
||||
|
||||
// Edit dialog state / 编辑对话框状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editForm = ref({
|
||||
id: '',
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Open edit dialog / 打开编辑对话框
|
||||
const editDrama = async (id: string) => {
|
||||
editLoading.value = true
|
||||
editDialogVisible.value = true
|
||||
try {
|
||||
const drama = await dramaAPI.get(id)
|
||||
editForm.value = {
|
||||
id: drama.id,
|
||||
title: drama.title,
|
||||
description: drama.description || ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
editDialogVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save edit changes / 保存编辑更改
|
||||
const saveEdit = async () => {
|
||||
if (!editForm.value.title) {
|
||||
ElMessage.warning('请输入项目名称')
|
||||
return
|
||||
}
|
||||
|
||||
editLoading.value = true
|
||||
try {
|
||||
await dramaAPI.update(editForm.value.id, {
|
||||
title: editForm.value.title,
|
||||
description: editForm.value.description
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
loadDramas()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete drama / 删除短剧
|
||||
const deleteDrama = async (id: string) => {
|
||||
try {
|
||||
await dramaAPI.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadDramas()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDramas()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========================================
|
||||
Page Layout / 页面布局 - 紧凑边距
|
||||
======================================== */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-container {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.page-container {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Header Buttons / 头部按钮
|
||||
======================================== */
|
||||
.header-btn {
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);
|
||||
}
|
||||
|
||||
.header-btn.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(14, 165, 233, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Projects Grid / 项目网格 - 紧凑间距
|
||||
======================================== */
|
||||
.projects-grid {
|
||||
display: flex;
|
||||
/* grid-template-columns: repeat(2, 1fr); */
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
min-height: 300px;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.projects-grid.is-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Sticky Pagination / 吸底分页器
|
||||
======================================== */
|
||||
.pagination-sticky {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .pagination-sticky {
|
||||
background: rgba(10, 15, 26, 0.9);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.pagination-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pagination-inner {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pagination-info {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-total {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pagination-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.size-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.size-label {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.size-select {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.size-select :deep(.el-input__wrapper) {
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Edit Dialog / 编辑对话框
|
||||
======================================== */
|
||||
.edit-dialog :deep(.el-dialog) {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.edit-dialog :deep(.el-dialog__header) {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.edit-dialog :deep(.el-dialog__title) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.edit-dialog :deep(.el-dialog__body) {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.edit-form :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Delete button style */
|
||||
.action-button.danger {
|
||||
padding: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
</style>
|
||||
798
web/src/views/drama/DramaManagement.vue
Normal file
798
web/src/views/drama/DramaManagement.vue
Normal file
@@ -0,0 +1,798 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="content-wrapper animate-fade-in">
|
||||
<!-- Page Header / 页面头部 -->
|
||||
<PageHeader
|
||||
:title="drama?.title || ''"
|
||||
:subtitle="drama?.description || $t('drama.management.overview')"
|
||||
:show-back="true"
|
||||
:back-text="$t('common.back')"
|
||||
/>
|
||||
|
||||
<!-- Tabs / 标签页 -->
|
||||
<div class="tabs-wrapper">
|
||||
<el-tabs v-model="activeTab" class="management-tabs">
|
||||
<!-- 项目概览 -->
|
||||
<el-tab-pane :label="$t('drama.management.overview')" name="overview">
|
||||
<div class="stats-grid">
|
||||
<StatCard
|
||||
:label="$t('drama.management.episodeStats')"
|
||||
:value="episodesCount"
|
||||
:icon="Document"
|
||||
icon-color="var(--accent)"
|
||||
icon-bg="var(--accent-light)"
|
||||
value-color="var(--accent)"
|
||||
:description="$t('drama.management.episodesCreated')"
|
||||
/>
|
||||
<StatCard
|
||||
:label="$t('drama.management.characterStats')"
|
||||
:value="charactersCount"
|
||||
:icon="User"
|
||||
icon-color="var(--success)"
|
||||
icon-bg="var(--success-light)"
|
||||
value-color="var(--success)"
|
||||
:description="$t('drama.management.charactersCreated')"
|
||||
/>
|
||||
<StatCard
|
||||
:label="$t('drama.management.sceneStats')"
|
||||
:value="scenesCount"
|
||||
:icon="Picture"
|
||||
icon-color="var(--warning)"
|
||||
icon-bg="var(--warning-light)"
|
||||
value-color="var(--warning)"
|
||||
:description="$t('drama.management.sceneLibraryCount')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 引导卡片:无章节时显示 -->
|
||||
<el-alert
|
||||
v-if="episodesCount === 0"
|
||||
:title="$t('drama.management.startFirstEpisode')"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<template #default>
|
||||
<p style="margin: 8px 0;">{{ $t('drama.management.noEpisodesYet') }}</p>
|
||||
<el-button type="primary" :icon="Plus" @click="createNewEpisode" style="margin-top: 8px;">
|
||||
{{ $t('drama.management.createFirstEpisode') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 20px;">
|
||||
<template #header>
|
||||
<h3 class="card-title">{{ $t('drama.management.projectInfo') }}</h3>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="$t('drama.management.projectName')">{{ drama?.title }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('common.createdAt')">{{ formatDate(drama?.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('drama.management.projectDesc')" :span="2">
|
||||
{{ drama?.description || $t('drama.management.noDescription') }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 章节管理 -->
|
||||
<el-tab-pane :label="$t('drama.management.episodes')" name="episodes">
|
||||
<div class="tab-header">
|
||||
<h2>{{ $t('drama.management.episodeList') }}</h2>
|
||||
<el-button type="primary" :icon="Plus" @click="createNewEpisode">{{ $t('drama.management.createNewEpisode') }}</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态引导 -->
|
||||
<el-empty
|
||||
v-if="episodesCount === 0"
|
||||
:description="$t('drama.management.noEpisodes')"
|
||||
style="margin-top: 40px;"
|
||||
>
|
||||
<template #image>
|
||||
<el-icon :size="80" class="empty-icon"><Document /></el-icon>
|
||||
</template>
|
||||
<el-button type="primary" :icon="Plus" @click="createNewEpisode">
|
||||
{{ $t('drama.management.createFirstEpisode') }}
|
||||
</el-button>
|
||||
</el-empty>
|
||||
|
||||
<el-table v-else :data="sortedEpisodes" border stripe style="margin-top: 16px;">
|
||||
<el-table-column type="index" :label="$t('storyboard.table.number')" width="80" />
|
||||
<el-table-column prop="title" :label="$t('drama.management.episodeList')" min-width="200" />
|
||||
<el-table-column :label="$t('common.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getEpisodeStatusType(row)">{{ getEpisodeStatusText(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Shots" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.shots?.length || 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.createdAt')" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('storyboard.table.operations')" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="enterEpisodeWorkflow(row)">
|
||||
{{ $t('drama.management.goToEdit') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteEpisode(row)">
|
||||
{{ $t('common.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 角色管理 -->
|
||||
<el-tab-pane :label="$t('drama.management.characters')" name="characters">
|
||||
<div class="tab-header">
|
||||
<h2>{{ $t('drama.management.characterList') }}</h2>
|
||||
<el-button type="primary" :icon="Plus" @click="openAddCharacterDialog">{{ $t('character.add') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px;">
|
||||
<el-col :span="6" v-for="character in drama?.characters" :key="character.id">
|
||||
<el-card shadow="hover" class="character-card">
|
||||
<div class="character-preview">
|
||||
<img v-if="character.image_url" :src="fixImageUrl(character.image_url)" :alt="character.name" />
|
||||
<el-avatar v-else :size="120">{{ character.name[0] }}</el-avatar>
|
||||
</div>
|
||||
|
||||
<div class="character-info">
|
||||
<h4>{{ character.name }}</h4>
|
||||
<el-tag :type="character.role === 'main' ? 'danger' : 'info'" size="small">
|
||||
{{ character.role === 'main' ? 'Main' : character.role === 'supporting' ? 'Supporting' : 'Minor' }}
|
||||
</el-tag>
|
||||
<p class="desc">{{ character.appearance || character.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="character-actions">
|
||||
<el-button size="small" @click="editCharacter(character)">{{ $t('common.edit') }}</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCharacter(character)">{{ $t('common.delete') }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-empty v-if="!drama?.characters || drama.characters.length === 0" :description="$t('drama.management.noCharacters')" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 场景库管理 -->
|
||||
<el-tab-pane :label="$t('drama.management.sceneList')" name="scenes">
|
||||
<div class="tab-header">
|
||||
<h2>{{ $t('drama.management.sceneList') }}</h2>
|
||||
<el-button type="primary" :icon="Plus" @click="openAddSceneDialog">{{ $t('common.add') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px;">
|
||||
<el-col :span="6" v-for="scene in scenes" :key="scene.id">
|
||||
<el-card shadow="hover" class="scene-card">
|
||||
<div class="scene-preview">
|
||||
<img v-if="scene.image_url" :src="fixImageUrl(scene.image_url)" :alt="scene.name" />
|
||||
<div v-else class="scene-placeholder">
|
||||
<el-icon :size="48"><Picture /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scene-info">
|
||||
<h4>{{ scene.name }}</h4>
|
||||
<p class="desc">{{ scene.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="scene-actions">
|
||||
<el-button size="small" @click="editScene(scene)">{{ $t('common.edit') }}</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteScene(scene)">{{ $t('common.delete') }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-empty v-if="scenes.length === 0" :description="$t('drama.management.noScenes')" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 添加角色对话框 -->
|
||||
<el-dialog v-model="addCharacterDialogVisible" :title="$t('character.add')" width="600px">
|
||||
<el-form :model="newCharacter" label-width="100px">
|
||||
<el-form-item :label="$t('character.name')">
|
||||
<el-input v-model="newCharacter.name" :placeholder="$t('character.name')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('character.role')">
|
||||
<el-select v-model="newCharacter.role" :placeholder="$t('common.pleaseSelect')">
|
||||
<el-option label="Main" value="main" />
|
||||
<el-option label="Supporting" value="supporting" />
|
||||
<el-option label="Minor" value="minor" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('character.appearance')">
|
||||
<el-input v-model="newCharacter.appearance" type="textarea" :rows="3" :placeholder="$t('character.appearance')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('character.personality')">
|
||||
<el-input v-model="newCharacter.personality" type="textarea" :rows="3" :placeholder="$t('character.personality')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('character.description')">
|
||||
<el-input v-model="newCharacter.description" type="textarea" :rows="3" :placeholder="$t('common.description')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addCharacterDialogVisible = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="addCharacter">{{ $t('common.confirm') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加场景对话框 -->
|
||||
<el-dialog v-model="addSceneDialogVisible" :title="$t('common.add')" width="600px">
|
||||
<el-form :model="newScene" label-width="100px">
|
||||
<el-form-item :label="$t('common.name')">
|
||||
<el-input v-model="newScene.name" :placeholder="$t('common.name')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.description')">
|
||||
<el-input v-model="newScene.description" type="textarea" :rows="4" :placeholder="$t('common.description')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addSceneDialogVisible = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="addScene">{{ $t('common.confirm') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Document, User, Picture, Plus } from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { Drama } from '@/types/drama'
|
||||
import { PageHeader, StatCard, EmptyState } from '@/components/common'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const drama = ref<Drama>()
|
||||
const activeTab = ref(route.query.tab as string || 'overview')
|
||||
const scenes = ref<any[]>([])
|
||||
|
||||
const addCharacterDialogVisible = ref(false)
|
||||
const addSceneDialogVisible = ref(false)
|
||||
|
||||
const newCharacter = ref({
|
||||
name: '',
|
||||
role: 'supporting',
|
||||
appearance: '',
|
||||
personality: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const newScene = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const episodesCount = computed(() => drama.value?.episodes?.length || 0)
|
||||
const charactersCount = computed(() => drama.value?.characters?.length || 0)
|
||||
const scenesCount = computed(() => scenes.value.length)
|
||||
|
||||
const sortedEpisodes = computed(() => {
|
||||
if (!drama.value?.episodes) return []
|
||||
return [...drama.value.episodes].sort((a, b) => a.episode_number - b.episode_number)
|
||||
})
|
||||
|
||||
const loadDramaData = async () => {
|
||||
try {
|
||||
const data = await dramaAPI.get(route.params.id as string)
|
||||
drama.value = data
|
||||
loadScenes()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载项目数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadScenes = async () => {
|
||||
// 场景数据已经在drama中加载了(后端Preload了Scenes)
|
||||
if (drama.value?.scenes) {
|
||||
scenes.value = drama.value.scenes
|
||||
} else {
|
||||
scenes.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status?: string) => {
|
||||
const map: Record<string, any> = {
|
||||
draft: 'info',
|
||||
in_progress: 'warning',
|
||||
completed: 'success'
|
||||
}
|
||||
return map[status || 'draft'] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
in_progress: '制作中',
|
||||
completed: '已完成'
|
||||
}
|
||||
return map[status || 'draft'] || '草稿'
|
||||
}
|
||||
|
||||
const getEpisodeStatusType = (episode: any) => {
|
||||
if (episode.shots && episode.shots.length > 0) return 'success'
|
||||
if (episode.script_content) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getEpisodeStatusText = (episode: any) => {
|
||||
if (episode.shots && episode.shots.length > 0) return '已拆分'
|
||||
if (episode.script_content) return '已创建'
|
||||
return '草稿'
|
||||
}
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const fixImageUrl = (url: string) => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
return `${import.meta.env.VITE_API_BASE_URL}${url}`
|
||||
}
|
||||
|
||||
const createNewEpisode = () => {
|
||||
const nextEpisodeNumber = episodesCount.value + 1
|
||||
router.push({
|
||||
name: 'EpisodeWorkflowNew',
|
||||
params: {
|
||||
id: route.params.id,
|
||||
episodeNumber: nextEpisodeNumber
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const enterEpisodeWorkflow = (episode: any) => {
|
||||
router.push({
|
||||
name: 'EpisodeWorkflowNew',
|
||||
params: {
|
||||
id: route.params.id,
|
||||
episodeNumber: episode.episode_number
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEpisode = async (episode: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除第${episode.episode_number}章吗?此操作将同时删除该章节的所有相关数据(角色、场景、分镜等)。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 过滤掉要删除的章节
|
||||
const existingEpisodes = drama.value?.episodes || []
|
||||
const updatedEpisodes = existingEpisodes
|
||||
.filter(ep => ep.episode_number !== episode.episode_number)
|
||||
.map(ep => ({
|
||||
episode_number: ep.episode_number,
|
||||
title: ep.title,
|
||||
script_content: ep.script_content,
|
||||
description: ep.description,
|
||||
duration: ep.duration,
|
||||
status: ep.status
|
||||
}))
|
||||
|
||||
// 保存更新后的章节列表
|
||||
await dramaAPI.saveEpisodes(drama.value!.id, updatedEpisodes)
|
||||
|
||||
ElMessage.success(`第${episode.episode_number}章删除成功`)
|
||||
await loadDramaData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openAddCharacterDialog = () => {
|
||||
newCharacter.value = {
|
||||
name: '',
|
||||
role: 'supporting',
|
||||
appearance: '',
|
||||
personality: '',
|
||||
description: ''
|
||||
}
|
||||
addCharacterDialogVisible.value = true
|
||||
}
|
||||
|
||||
const addCharacter = async () => {
|
||||
if (!newCharacter.value.name.trim()) {
|
||||
ElMessage.warning('请输入角色名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const existingCharacters = drama.value?.characters || []
|
||||
const allCharacters = [
|
||||
...existingCharacters.map(c => ({
|
||||
name: c.name,
|
||||
role: c.role,
|
||||
appearance: c.appearance,
|
||||
personality: c.personality,
|
||||
description: c.description
|
||||
})),
|
||||
newCharacter.value
|
||||
]
|
||||
|
||||
await dramaAPI.saveCharacters(drama.value!.id, allCharacters)
|
||||
ElMessage.success('角色添加成功')
|
||||
addCharacterDialogVisible.value = false
|
||||
await loadDramaData()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const editCharacter = (character: any) => {
|
||||
ElMessage.info('编辑功能开发中')
|
||||
}
|
||||
|
||||
const deleteCharacter = async (character: any) => {
|
||||
if (character.library_id) {
|
||||
ElMessage.warning('该角色来自角色库,请前往角色库进行删除')
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除角色"${character.name}"吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const updatedCharacters = drama.value!.characters!.filter(c => c.id !== character.id)
|
||||
await dramaAPI.saveCharacters(drama.value!.id, updatedCharacters.map(c => ({
|
||||
name: c.name,
|
||||
role: c.role,
|
||||
appearance: c.appearance,
|
||||
personality: c.personality,
|
||||
description: c.description
|
||||
})))
|
||||
ElMessage.success('删除成功')
|
||||
await loadDramaData()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openAddSceneDialog = () => {
|
||||
newScene.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
addSceneDialogVisible.value = true
|
||||
}
|
||||
|
||||
const addScene = async () => {
|
||||
if (!newScene.value.name.trim()) {
|
||||
ElMessage.warning('请输入场景名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: 调用场景库API
|
||||
ElMessage.success('场景添加成功')
|
||||
addSceneDialogVisible.value = false
|
||||
await loadScenes()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const editScene = (scene: any) => {
|
||||
ElMessage.info('编辑功能开发中')
|
||||
}
|
||||
|
||||
const deleteScene = async (scene: any) => {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除场景"${scene.name}"吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
// TODO: 调用删除API
|
||||
ElMessage.success('删除成功')
|
||||
await loadScenes()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDramaData()
|
||||
loadScenes()
|
||||
|
||||
// 如果有query参数指定tab,切换到对应tab
|
||||
if (route.query.tab) {
|
||||
activeTab.value = route.query.tab as string
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========================================
|
||||
Page Layout / 页面布局 - 紧凑边距
|
||||
======================================== */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-container {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.page-container {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stats Grid / 统计网格 - 紧凑间距
|
||||
======================================== */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Tabs Wrapper / 标签页容器 - 紧凑内边距
|
||||
======================================== */
|
||||
.tabs-wrapper {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.tabs-wrapper {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Tab Header / 标签页头部
|
||||
======================================== */
|
||||
.tab-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tab-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Character & Scene Cards / 角色场景卡片
|
||||
======================================== */
|
||||
.character-card, .scene-card {
|
||||
margin-bottom: var(--space-4);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.character-card:hover, .scene-card:hover {
|
||||
border-color: var(--border-secondary);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.character-card :deep(.el-card__body),
|
||||
.scene-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.character-preview, .scene-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-preview img, .scene-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.character-card:hover .character-preview img,
|
||||
.scene-card:hover .scene-preview img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.scene-placeholder {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.character-info, .scene-info {
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.character-info h4, .scene-info h4 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin: var(--space-2) 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.character-actions, .scene-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
justify-content: center;
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Dark Mode / 深色模式
|
||||
======================================== */
|
||||
.dark .tabs-wrapper {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-card) {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-card__header) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-table) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-table th),
|
||||
.dark :deep(.el-table tr) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-table td),
|
||||
.dark :deep(.el-table th) {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-descriptions) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-descriptions__label) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-descriptions__content) {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-descriptions__cell) {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-dialog) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-dialog__header) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-form-item__label) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__wrapper) {
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 1px var(--border-primary) inset;
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__inner) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-textarea__inner) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 1px var(--border-primary) inset;
|
||||
}
|
||||
</style>
|
||||
2066
web/src/views/drama/DramaWorkflow.vue
Normal file
2066
web/src/views/drama/DramaWorkflow.vue
Normal file
File diff suppressed because it is too large
Load Diff
2329
web/src/views/drama/EpisodeWorkflow.vue
Normal file
2329
web/src/views/drama/EpisodeWorkflow.vue
Normal file
File diff suppressed because it is too large
Load Diff
4271
web/src/views/drama/ProfessionalEditor.vue
Normal file
4271
web/src/views/drama/ProfessionalEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
425
web/src/views/drama/components/GenerateDialog.vue
Normal file
425
web/src/views/drama/components/GenerateDialog.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-steps :active="currentStep" finish-status="success" align-center>
|
||||
<el-step title="生成大纲" />
|
||||
<el-step title="生成角色" />
|
||||
<el-step title="生成剧集" />
|
||||
</el-steps>
|
||||
|
||||
<div class="step-content">
|
||||
<!-- 步骤1: 生成大纲 -->
|
||||
<div v-if="currentStep === 0" class="step-panel">
|
||||
<el-form :model="outlineForm" label-width="100px">
|
||||
<el-form-item label="创作主题" required>
|
||||
<el-input
|
||||
v-model="outlineForm.theme"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述你想创作的短剧主题和故事概念 例如:一个都市白领意外穿越到古代,凭借现代知识改变命运的故事"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="类型偏好">
|
||||
<el-select v-model="outlineForm.genre" placeholder="选择类型" clearable>
|
||||
<el-option label="都市" value="都市" />
|
||||
<el-option label="古装" value="古装" />
|
||||
<el-option label="悬疑" value="悬疑" />
|
||||
<el-option label="爱情" value="爱情" />
|
||||
<el-option label="喜剧" value="喜剧" />
|
||||
<el-option label="奇幻" value="奇幻" />
|
||||
<el-option label="科幻" value="科幻" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="风格要求">
|
||||
<el-input
|
||||
v-model="outlineForm.style"
|
||||
placeholder="例如:轻松幽默、紧张刺激、温馨治愈"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="剧集数量">
|
||||
<el-input-number v-model="outlineForm.length" :min="3" :max="20" />
|
||||
<span class="form-tip">建议3-10集</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创意度">
|
||||
<el-slider v-model="temperatureValue" :min="0" :max="100" :marks="temperatureMarks" />
|
||||
<div class="form-tip">数值越高,生成内容越有创意但可能不稳定</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 生成角色 -->
|
||||
<div v-if="currentStep === 1" class="step-panel">
|
||||
<div v-if="outlineResult" class="outline-preview">
|
||||
<h3>{{ outlineResult.title }}</h3>
|
||||
<p class="summary">{{ outlineResult.summary }}</p>
|
||||
<div class="tags">
|
||||
<el-tag v-for="tag in outlineResult.tags" :key="tag" size="small">{{ tag }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :model="charactersForm" label-width="100px">
|
||||
<el-form-item label="角色数量">
|
||||
<el-input-number v-model="charactersForm.count" :min="2" :max="10" />
|
||||
<span class="form-tip">建议3-5个主要角色</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创意度">
|
||||
<el-slider v-model="charactersTemperature" :min="0" :max="100" :marks="temperatureMarks" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 生成剧集 -->
|
||||
<div v-if="currentStep === 2" class="step-panel">
|
||||
<div v-if="characters.length > 0" class="characters-preview">
|
||||
<h4>已创建角色:</h4>
|
||||
<div class="character-list">
|
||||
<el-tag
|
||||
v-for="char in characters"
|
||||
:key="char.id"
|
||||
size="large"
|
||||
effect="plain"
|
||||
>
|
||||
{{ char.name }} ({{ char.role }})
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :model="episodesForm" label-width="100px">
|
||||
<el-form-item label="剧集数量" required>
|
||||
<el-input-number v-model="episodesForm.episode_count" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创意度">
|
||||
<el-slider v-model="episodesTemperature" :min="0" :max="100" :marks="temperatureMarks" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果展示 -->
|
||||
<div v-if="currentStep === 3" class="step-panel">
|
||||
<el-result
|
||||
icon="success"
|
||||
title="生成完成!"
|
||||
sub-title="已成功生成剧本大纲、角色设定和分集剧本"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="viewDrama">查看剧本详情</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<el-descriptions title="生成内容" :column="2" border class="result-info">
|
||||
<el-descriptions-item label="剧本标题">
|
||||
{{ outlineResult?.title }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ outlineResult?.genre }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色数量">
|
||||
{{ characters.length }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="剧集数量">
|
||||
{{ episodes.length }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button v-if="currentStep > 0 && currentStep < 3" @click="prevStep">
|
||||
上一步
|
||||
</el-button>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
v-if="currentStep < 3"
|
||||
type="primary"
|
||||
:loading="generating"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ currentStep === 2 ? '完成生成' : '下一步' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { generationAPI } from '@/api/generation'
|
||||
import type { OutlineResult } from '@/types/generation'
|
||||
import type { Character, Episode } from '@/types/drama'
|
||||
|
||||
interface Props {
|
||||
dramaId: string
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const currentStep = ref(0)
|
||||
const generating = ref(false)
|
||||
|
||||
const outlineForm = reactive({
|
||||
theme: '',
|
||||
genre: '',
|
||||
style: '',
|
||||
length: 5
|
||||
})
|
||||
|
||||
const charactersForm = reactive({
|
||||
count: 5
|
||||
})
|
||||
|
||||
const episodesForm = reactive({
|
||||
episode_count: 5
|
||||
})
|
||||
|
||||
const temperatureValue = ref(70)
|
||||
const charactersTemperature = ref(60)
|
||||
const episodesTemperature = ref(60)
|
||||
|
||||
const temperatureMarks = {
|
||||
0: '保守',
|
||||
50: '平衡',
|
||||
100: '创新'
|
||||
}
|
||||
|
||||
const outlineResult = ref<OutlineResult>()
|
||||
const characters = ref<Character[]>([])
|
||||
const episodes = ref<Episode[]>([])
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const titles = ['AI 剧本生成 - 大纲', 'AI 剧本生成 - 角色', 'AI 剧本生成 - 剧集', '生成完成']
|
||||
return titles[currentStep.value]
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => outlineResult.value, (result) => {
|
||||
if (result) {
|
||||
episodesForm.episode_count = result.episodes?.length || 5
|
||||
}
|
||||
})
|
||||
|
||||
const nextStep = async () => {
|
||||
if (currentStep.value === 0) {
|
||||
await generateOutline()
|
||||
} else if (currentStep.value === 1) {
|
||||
await generateCharacters()
|
||||
} else if (currentStep.value === 2) {
|
||||
await generateEpisodes()
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
const generateOutline = async () => {
|
||||
if (!outlineForm.theme.trim()) {
|
||||
ElMessage.warning('请输入创作主题')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const result = await generationAPI.generateOutline({
|
||||
drama_id: props.dramaId,
|
||||
theme: outlineForm.theme,
|
||||
genre: outlineForm.genre,
|
||||
style: outlineForm.style,
|
||||
length: outlineForm.length,
|
||||
temperature: temperatureValue.value / 100
|
||||
})
|
||||
|
||||
outlineResult.value = result
|
||||
ElMessage.success('大纲生成成功!')
|
||||
currentStep.value++
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '大纲生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateCharacters = async () => {
|
||||
generating.value = true
|
||||
try {
|
||||
const outline = outlineResult.value
|
||||
? JSON.stringify(outlineResult.value)
|
||||
: ''
|
||||
|
||||
const result = await generationAPI.generateCharacters({
|
||||
drama_id: props.dramaId,
|
||||
outline,
|
||||
count: charactersForm.count,
|
||||
temperature: charactersTemperature.value / 100
|
||||
})
|
||||
|
||||
characters.value = result
|
||||
ElMessage.success('角色生成成功!')
|
||||
currentStep.value++
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '角色生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateEpisodes = async () => {
|
||||
generating.value = true
|
||||
try {
|
||||
const outline = outlineResult.value
|
||||
? JSON.stringify(outlineResult.value)
|
||||
: ''
|
||||
|
||||
const result = await generationAPI.generateEpisodes({
|
||||
drama_id: props.dramaId,
|
||||
outline,
|
||||
episode_count: episodesForm.episode_count,
|
||||
temperature: episodesTemperature.value / 100
|
||||
})
|
||||
|
||||
episodes.value = result
|
||||
ElMessage.success('剧集生成成功!')
|
||||
currentStep.value++
|
||||
emit('success')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '剧集生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const viewDrama = () => {
|
||||
handleClose()
|
||||
router.push(`/dramas/${props.dramaId}`)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
currentStep.value = 0
|
||||
outlineForm.theme = ''
|
||||
outlineForm.genre = ''
|
||||
outlineForm.style = ''
|
||||
outlineForm.length = 5
|
||||
charactersForm.count = 5
|
||||
episodesForm.episode_count = 5
|
||||
temperatureValue.value = 70
|
||||
charactersTemperature.value = 60
|
||||
episodesTemperature.value = 60
|
||||
outlineResult.value = undefined
|
||||
characters.value = []
|
||||
episodes.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-content {
|
||||
margin: 30px 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.step-panel {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.outline-preview {
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.outline-preview h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.outline-preview .summary {
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.outline-preview .tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.characters-preview {
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.characters-preview h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.character-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-info {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
225
web/src/views/drama/components/UploadScriptDialog.vue
Normal file
225
web/src/views/drama/components/UploadScriptDialog.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="上传剧本"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="剧本内容" required>
|
||||
<el-input
|
||||
v-model="form.script_content"
|
||||
type="textarea"
|
||||
:rows="15"
|
||||
placeholder="粘贴您的剧本内容 系统将自动识别并拆分为剧集和场景"
|
||||
maxlength="50000"
|
||||
show-word-limit
|
||||
/>
|
||||
<div class="form-tip">
|
||||
支持多种剧本格式,系统会智能识别剧集、场景、对话等内容
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="拆分选项">
|
||||
<el-checkbox v-model="form.auto_split">自动拆分剧集</el-checkbox>
|
||||
<div class="form-tip">
|
||||
启用后将自动识别剧集分界点,否则作为单集处理
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template v-if="parseResult">
|
||||
<el-divider>解析结果</el-divider>
|
||||
|
||||
<div class="parse-result">
|
||||
<el-alert
|
||||
title="解析完成"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
共识别 {{ parseResult.episodes.length }} 个剧集,
|
||||
{{ totalScenes }} 个场景
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="summary-box" v-if="parseResult.summary">
|
||||
<h4>剧本概要</h4>
|
||||
<p>{{ parseResult.summary }}</p>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="activeEpisode" accordion>
|
||||
<el-collapse-item
|
||||
v-for="episode in parseResult.episodes"
|
||||
:key="episode.episode_number"
|
||||
:title="`第${episode.episode_number}集: ${episode.title}`"
|
||||
:name="episode.episode_number"
|
||||
>
|
||||
<div class="episode-info">
|
||||
<p><strong>场景数:</strong>{{ episode.scenes.length }}</p>
|
||||
|
||||
<el-table :data="episode.scenes" size="small" border>
|
||||
<el-table-column prop="storyboard_number" label="场景号" width="80" />
|
||||
<el-table-column prop="title" label="标题" width="150" />
|
||||
<el-table-column prop="location" label="地点" width="120" />
|
||||
<el-table-column prop="time" label="时间" width="100" />
|
||||
<el-table-column prop="characters" label="角色" width="150" />
|
||||
<el-table-column label="对话">
|
||||
<template #default="{ row }">
|
||||
<div class="dialogue-preview">{{ row.dialogue }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button v-if="!parseResult" type="primary" @click="handleParse" :loading="parsing">
|
||||
解析剧本
|
||||
</el-button>
|
||||
<el-button v-else type="success" @click="handleSave" :loading="saving">
|
||||
保存到项目
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { generationAPI } from '@/api/generation'
|
||||
import type { ParseScriptResult } from '@/types/generation'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
dramaId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
script_content: '',
|
||||
auto_split: true
|
||||
})
|
||||
|
||||
const parsing = ref(false)
|
||||
const saving = ref(false)
|
||||
const parseResult = ref<ParseScriptResult>()
|
||||
const activeEpisode = ref<number>()
|
||||
|
||||
const totalScenes = computed(() => {
|
||||
if (!parseResult.value) return 0
|
||||
return parseResult.value.episodes.reduce((sum, ep) => sum + ep.scenes.length, 0)
|
||||
})
|
||||
|
||||
const handleParse = async () => {
|
||||
if (!form.script_content.trim()) {
|
||||
ElMessage.warning('请输入剧本内容')
|
||||
return
|
||||
}
|
||||
|
||||
parsing.value = true
|
||||
try {
|
||||
parseResult.value = await generationAPI.parseScript({
|
||||
drama_id: props.dramaId,
|
||||
script_content: form.script_content,
|
||||
auto_split: form.auto_split
|
||||
})
|
||||
ElMessage.success('剧本解析成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '解析失败')
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!parseResult.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// TODO: 调用保存接口将解析结果保存到数据库
|
||||
ElMessage.success('保存成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
form.script_content = ''
|
||||
form.auto_split = true
|
||||
parseResult.value = undefined
|
||||
activeEpisode.value = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.parse-result {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-box h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.summary-box p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.dialogue-preview {
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
93
web/src/views/editor/TimelineEditor.vue
Normal file
93
web/src/views/editor/TimelineEditor.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="timeline-editor-page">
|
||||
<div class="editor-header">
|
||||
<el-button link @click="goBack" class="back-button">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
{{ $t('timeline.backToEditor') }}
|
||||
</el-button>
|
||||
<h2>{{ $t('timeline.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<VideoTimelineEditor
|
||||
v-if="scenes.length > 0"
|
||||
:scenes="scenes"
|
||||
:episode-id="episodeId"
|
||||
/>
|
||||
<el-empty v-else :description="$t('timeline.noScenes')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import VideoTimelineEditor from '@/components/editor/VideoTimelineEditor.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const episodeId = route.params.id as string
|
||||
const scenes = ref<any[]>([])
|
||||
|
||||
const loadScenes = async () => {
|
||||
try {
|
||||
const res = await dramaAPI.getStoryboards(episodeId)
|
||||
scenes.value = res.storyboards || []
|
||||
} catch (error: any) {
|
||||
ElMessage.error($t('timeline.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadScenes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.timeline-editor-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
431
web/src/views/generation/ImageGeneration.vue
Normal file
431
web/src/views/generation/ImageGeneration.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div class="image-generation-container">
|
||||
<el-page-header @back="goBack" class="page-header">
|
||||
<template #content>
|
||||
<div class="header-content">
|
||||
<h2>{{ $t('image.title') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="showGenerateDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
{{ $t('image.generate') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline>
|
||||
<el-form-item :label="$t('video.filter.drama')">
|
||||
<el-select v-model="filters.drama_id" :placeholder="$t('video.filter.allDramas')" clearable>
|
||||
<el-option
|
||||
v-for="drama in dramas"
|
||||
:key="drama.id"
|
||||
:label="drama.title"
|
||||
:value="drama.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('video.filter.status')">
|
||||
<el-select v-model="filters.status" :placeholder="$t('video.filter.allStatus')" clearable>
|
||||
<el-option :label="$t('video.status.processing')" value="processing" />
|
||||
<el-option :label="$t('video.status.completed')" value="completed" />
|
||||
<el-option :label="$t('video.status.failed')" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadImages">{{ $t('video.filter.query') }}</el-button>
|
||||
<el-button @click="resetFilters">{{ $t('video.filter.reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<el-col
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<el-card class="image-card" shadow="hover">
|
||||
<div class="image-wrapper">
|
||||
<el-image
|
||||
v-if="image.status === 'completed' && image.image_url"
|
||||
:src="image.image_url"
|
||||
fit="cover"
|
||||
class="image"
|
||||
:preview-src-list="[image.image_url]"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon><PictureFilled /></el-icon>
|
||||
<span>{{ $t('image.loadFailed') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
|
||||
<div v-else-if="image.status === 'processing'" class="image-placeholder processing">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>{{ $t('image.generating') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="image.status === 'failed'" class="image-placeholder failed">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
<span>{{ $t('image.generateFailed') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="image-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>等待生成</span>
|
||||
</div>
|
||||
|
||||
<div class="image-overlay">
|
||||
<el-tag :type="getStatusType(image.status)" size="small">
|
||||
{{ getStatusText(image.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<div class="prompt-text">{{ truncateText(image.prompt, 60) }}</div>
|
||||
<div class="meta-info">
|
||||
<span class="provider-tag">
|
||||
<el-tag size="small" effect="plain">{{ image.provider }}</el-tag>
|
||||
</span>
|
||||
<span class="time-text">{{ formatTime(image.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="card-actions">
|
||||
<el-button text size="small" @click="viewDetails(image)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="image.status === 'completed'"
|
||||
text
|
||||
size="small"
|
||||
@click="downloadImage(image)"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定删除该图片吗?"
|
||||
@confirm="deleteImage(image.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button text size="small" type="danger">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-empty v-if="!loading && images.length === 0" description="暂无图片,开始生成吧!" />
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadImages"
|
||||
@size-change="loadImages"
|
||||
class="pagination"
|
||||
/>
|
||||
|
||||
<GenerateImageDialog
|
||||
v-model="showGenerateDialog"
|
||||
:drama-id="filters.drama_id"
|
||||
@success="loadImages"
|
||||
/>
|
||||
|
||||
<ImageDetailDialog
|
||||
v-model="showDetailDialog"
|
||||
:image="selectedImage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Plus, Picture, PictureFilled, Loading, CircleClose,
|
||||
View, Download, Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import { imageAPI } from '@/api/image'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { ImageGeneration, ImageStatus } from '@/types/image'
|
||||
import type { Drama } from '@/types/drama'
|
||||
import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
import ImageDetailDialog from './components/ImageDetailDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const images = ref<ImageGeneration[]>([])
|
||||
const dramas = ref<Drama[]>([])
|
||||
const total = ref(0)
|
||||
const showGenerateDialog = ref(false)
|
||||
const showDetailDialog = ref(false)
|
||||
const selectedImage = ref<ImageGeneration>()
|
||||
|
||||
const filters = reactive({
|
||||
drama_id: undefined as string | undefined,
|
||||
status: undefined as ImageStatus | undefined
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 12
|
||||
})
|
||||
|
||||
const loadImages = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await imageAPI.listImages({
|
||||
drama_id: filters.drama_id,
|
||||
status: filters.status,
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
})
|
||||
images.value = result.items
|
||||
total.value = result.pagination.total
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDramas = async () => {
|
||||
try {
|
||||
const result = await dramaAPI.list({ page: 1, page_size: 100 })
|
||||
dramas.value = result.items
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dramas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.drama_id = undefined
|
||||
filters.status = undefined
|
||||
pagination.page = 1
|
||||
loadImages()
|
||||
}
|
||||
|
||||
const viewDetails = (image: ImageGeneration) => {
|
||||
selectedImage.value = image
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const downloadImage = (image: ImageGeneration) => {
|
||||
if (!image.image_url) return
|
||||
window.open(image.image_url, '_blank')
|
||||
}
|
||||
|
||||
const deleteImage = async (id: number) => {
|
||||
try {
|
||||
await imageAPI.deleteImage(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadImages()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status: ImageStatus) => {
|
||||
const types: Record<ImageStatus, any> = {
|
||||
pending: 'info',
|
||||
processing: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return types[status]
|
||||
}
|
||||
|
||||
const getStatusText = (status: ImageStatus) => {
|
||||
const texts: Record<ImageStatus, string> = {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
const truncateText = (text: string, length: number) => {
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const dramaId = route.query.drama_id as string
|
||||
if (dramaId) {
|
||||
filters.drama_id = dramaId
|
||||
}
|
||||
|
||||
loadDramas()
|
||||
loadImages()
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const hasProcessing = images.value.some(img => img.status === 'processing')
|
||||
if (hasProcessing) {
|
||||
loadImages()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-generation-container {
|
||||
padding: 24px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.image-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-placeholder .el-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-placeholder.processing {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.image-placeholder.failed {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
477
web/src/views/generation/VideoGeneration.vue
Normal file
477
web/src/views/generation/VideoGeneration.vue
Normal file
@@ -0,0 +1,477 @@
|
||||
<template>
|
||||
<div class="video-generation-container">
|
||||
<el-page-header @back="goBack" class="page-header">
|
||||
<template #content>
|
||||
<div class="header-content">
|
||||
<h2>{{ $t('video.title') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="showGenerateDialog = true">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
{{ $t('video.generate') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline>
|
||||
<el-form-item :label="$t('video.filter.drama')">
|
||||
<el-select v-model="filters.drama_id" :placeholder="$t('video.filter.allDramas')" clearable>
|
||||
<el-option
|
||||
v-for="drama in dramas"
|
||||
:key="drama.id"
|
||||
:label="drama.title"
|
||||
:value="drama.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('video.filter.status')">
|
||||
<el-select v-model="filters.status" :placeholder="$t('video.filter.allStatus')" clearable>
|
||||
<el-option :label="$t('video.status.processing')" value="processing" />
|
||||
<el-option :label="$t('video.status.completed')" value="completed" />
|
||||
<el-option :label="$t('video.status.failed')" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadVideos">{{ $t('video.filter.query') }}</el-button>
|
||||
<el-button @click="resetFilters">{{ $t('video.filter.reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<el-col
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<el-card class="video-card" shadow="hover">
|
||||
<div class="video-wrapper">
|
||||
<video
|
||||
v-if="video.status === 'completed' && video.video_url"
|
||||
:src="video.video_url"
|
||||
class="video-player"
|
||||
controls
|
||||
:poster="video.first_frame_url"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
|
||||
<div v-else-if="video.status === 'processing'" class="video-placeholder processing">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>生成中...</span>
|
||||
<div class="progress-text">预计需要 1-3 分钟</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="video.status === 'failed'" class="video-placeholder failed">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
<span>生成失败</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="video-placeholder">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>等待生成</span>
|
||||
</div>
|
||||
|
||||
<div class="video-overlay">
|
||||
<el-tag :type="getStatusType(video.status)" size="small">
|
||||
{{ getStatusText(video.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="video.duration" size="small" class="duration-tag">
|
||||
{{ video.duration }}s
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-info">
|
||||
<div class="prompt-text">{{ truncateText(video.prompt, 60) }}</div>
|
||||
<div class="meta-info">
|
||||
<span class="provider-tag">
|
||||
<el-tag size="small" effect="plain">{{ video.provider }}</el-tag>
|
||||
</span>
|
||||
<span class="time-text">{{ formatTime(video.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="video.aspect_ratio || video.resolution" class="specs-info">
|
||||
<span v-if="video.aspect_ratio" class="spec-item">{{ video.aspect_ratio }}</span>
|
||||
<span v-if="video.resolution" class="spec-item">{{ video.resolution }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="card-actions">
|
||||
<el-button text size="small" @click="viewDetails(video)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="video.status === 'completed'"
|
||||
text
|
||||
size="small"
|
||||
@click="downloadVideo(video)"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定删除该视频吗?"
|
||||
@confirm="deleteVideo(video.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button text size="small" type="danger">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-empty v-if="!loading && videos.length === 0" description="暂无视频,开始生成吧!" />
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadVideos"
|
||||
@size-change="loadVideos"
|
||||
class="pagination"
|
||||
/>
|
||||
|
||||
<GenerateVideoDialog
|
||||
v-model="showGenerateDialog"
|
||||
:drama-id="filters.drama_id"
|
||||
@success="loadVideos"
|
||||
/>
|
||||
|
||||
<VideoDetailDialog
|
||||
v-model="showDetailDialog"
|
||||
:video="selectedVideo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
VideoPlay, VideoCamera, Loading, CircleClose,
|
||||
View, Download, Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import { videoAPI } from '@/api/video'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { VideoGeneration, VideoStatus } from '@/types/video'
|
||||
import type { Drama } from '@/types/drama'
|
||||
import GenerateVideoDialog from './components/GenerateVideoDialog.vue'
|
||||
import VideoDetailDialog from './components/VideoDetailDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const videos = ref<VideoGeneration[]>([])
|
||||
const dramas = ref<Drama[]>([])
|
||||
const total = ref(0)
|
||||
const showGenerateDialog = ref(false)
|
||||
const showDetailDialog = ref(false)
|
||||
const selectedVideo = ref<VideoGeneration>()
|
||||
let pollInterval: number | null = null
|
||||
|
||||
const filters = reactive({
|
||||
drama_id: undefined as string | undefined,
|
||||
status: undefined as VideoStatus | undefined
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 12
|
||||
})
|
||||
|
||||
const loadVideos = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await videoAPI.listVideos({
|
||||
drama_id: filters.drama_id,
|
||||
status: filters.status,
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
})
|
||||
videos.value = result.items
|
||||
total.value = result.pagination.total
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDramas = async () => {
|
||||
try {
|
||||
const result = await dramaAPI.list({ page: 1, page_size: 100 })
|
||||
dramas.value = result.items
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dramas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.drama_id = undefined
|
||||
filters.status = undefined
|
||||
pagination.page = 1
|
||||
loadVideos()
|
||||
}
|
||||
|
||||
const viewDetails = (video: VideoGeneration) => {
|
||||
selectedVideo.value = video
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const downloadVideo = (video: VideoGeneration) => {
|
||||
if (!video.video_url) return
|
||||
window.open(video.video_url, '_blank')
|
||||
}
|
||||
|
||||
const deleteVideo = async (id: number) => {
|
||||
try {
|
||||
await videoAPI.deleteVideo(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadVideos()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status: VideoStatus) => {
|
||||
const types: Record<VideoStatus, any> = {
|
||||
pending: 'info',
|
||||
processing: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return types[status]
|
||||
}
|
||||
|
||||
const getStatusText = (status: VideoStatus) => {
|
||||
const texts: Record<VideoStatus, string> = {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
const truncateText = (text: string, length: number) => {
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollInterval = setInterval(() => {
|
||||
const hasProcessing = videos.value.some(v => v.status === 'processing')
|
||||
if (hasProcessing) {
|
||||
loadVideos()
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const dramaId = route.query.drama_id as string
|
||||
if (dramaId) {
|
||||
filters.drama_id = dramaId
|
||||
}
|
||||
|
||||
loadDramas()
|
||||
loadVideos()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-generation-container {
|
||||
padding: 24px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.video-placeholder .el-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.video-placeholder.processing {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.video-placeholder.failed {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.duration-tag {
|
||||
background: rgba(0, 0, 0, 0.6) !important;
|
||||
color: #fff !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.specs-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
padding: 2px 6px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
303
web/src/views/generation/components/GenerateImageDialog.vue
Normal file
303
web/src/views/generation/components/GenerateImageDialog.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="$t('imageDialog.title')"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
|
||||
<el-form-item :label="$t('imageDialog.selectDrama')" prop="drama_id">
|
||||
<el-select v-model="form.drama_id" :placeholder="$t('imageDialog.selectDrama')" @change="onDramaChange">
|
||||
<el-option
|
||||
v-for="drama in dramas"
|
||||
:key="drama.id"
|
||||
:label="drama.title"
|
||||
:value="drama.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.selectScene')" prop="scene_id">
|
||||
<el-select
|
||||
v-model="form.scene_id"
|
||||
:placeholder="$t('imageDialog.selectSceneOptional')"
|
||||
clearable
|
||||
@change="onSceneChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="scene in scenes"
|
||||
:key="scene.id"
|
||||
:label="$t('imageDialog.sceneLabel', { number: scene.storyboard_number, title: scene.title })"
|
||||
:value="scene.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.prompt')" prop="prompt">
|
||||
<el-input
|
||||
v-model="form.prompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:placeholder="$t('imageDialog.promptPlaceholder')"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.negativePrompt')">
|
||||
<el-input
|
||||
v-model="form.negative_prompt"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('imageDialog.negativePromptPlaceholder')"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.aiService')">
|
||||
<el-select v-model="form.provider" :placeholder="$t('imageDialog.selectService')">
|
||||
<el-option label="OpenAI/DALL-E" value="openai" />
|
||||
<el-option label="Stable Diffusion" value="stable_diffusion" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.imageSize')">
|
||||
<el-select v-model="form.size" :placeholder="$t('imageDialog.selectSize')">
|
||||
<el-option :label="`1024x1024 (${$t('imageDialog.square')})`" value="1024x1024" />
|
||||
<el-option :label="`1792x1024 (${$t('imageDialog.landscape')})`" value="1792x1024" />
|
||||
<el-option :label="`1024x1792 (${$t('imageDialog.portrait')})`" value="1024x1792" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.imageQuality')" v-if="form.provider === 'openai'">
|
||||
<el-radio-group v-model="form.quality">
|
||||
<el-radio label="standard">{{ $t('imageDialog.standard') }}</el-radio>
|
||||
<el-radio label="hd">{{ $t('imageDialog.hd') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.style')" v-if="form.provider === 'openai'">
|
||||
<el-radio-group v-model="form.style">
|
||||
<el-radio label="vivid">{{ $t('imageDialog.vivid') }}</el-radio>
|
||||
<el-radio label="natural">{{ $t('imageDialog.natural') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-collapse v-if="form.provider === 'stable_diffusion'">
|
||||
<el-collapse-item :title="$t('imageDialog.advancedSettings')" name="advanced">
|
||||
<el-form-item :label="$t('imageDialog.samplingSteps')">
|
||||
<el-slider v-model="form.steps" :min="10" :max="50" :marks="stepsMarks" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.promptRelevance')">
|
||||
<el-slider v-model="form.cfg_scale" :min="1" :max="20" :step="0.5" :marks="cfgMarks" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('imageDialog.randomSeed')">
|
||||
<el-input-number v-model="form.seed" :min="-1" :placeholder="$t('imageDialog.leaveBlankRandom')" />
|
||||
<span class="form-tip">{{ $t('imageDialog.seedTip') }}</span>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate">
|
||||
{{ $t('imageDialog.generate') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { imageAPI } from '@/api/image'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { Drama, Scene } from '@/types/drama'
|
||||
import type { GenerateImageRequest } from '@/types/image'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
dramaId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const generating = ref(false)
|
||||
const dramas = ref<Drama[]>([])
|
||||
const scenes = ref<Scene[]>([])
|
||||
|
||||
const form = reactive<GenerateImageRequest>({
|
||||
drama_id: props.dramaId || '',
|
||||
scene_id: undefined,
|
||||
prompt: '',
|
||||
negative_prompt: '',
|
||||
provider: 'openai',
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
style: 'vivid',
|
||||
steps: 30,
|
||||
cfg_scale: 7.5,
|
||||
seed: undefined
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
drama_id: [
|
||||
{ required: true, message: t('imageDialog.pleaseSelectDrama'), trigger: 'change' }
|
||||
],
|
||||
prompt: [
|
||||
{ required: true, message: t('imageDialog.pleaseEnterPrompt'), trigger: 'blur' },
|
||||
{ min: 5, message: t('imageDialog.promptMinLength'), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const stepsMarks = {
|
||||
10: '10',
|
||||
20: '20',
|
||||
30: '30',
|
||||
40: '40',
|
||||
50: '50'
|
||||
}
|
||||
|
||||
const cfgMarks = {
|
||||
1: t('imageDialog.weak'),
|
||||
7.5: t('imageDialog.moderate'),
|
||||
15: t('imageDialog.strong'),
|
||||
20: t('imageDialog.veryStrong')
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
loadDramas()
|
||||
if (props.dramaId) {
|
||||
form.drama_id = props.dramaId
|
||||
loadScenes(props.dramaId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const loadDramas = async () => {
|
||||
try {
|
||||
const result = await dramaAPI.list({ page: 1, page_size: 100 })
|
||||
dramas.value = result.items || []
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dramas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadScenes = async (dramaId: string) => {
|
||||
try {
|
||||
const drama = await dramaAPI.get(dramaId)
|
||||
const allScenes: Scene[] = []
|
||||
|
||||
if (drama.episodes) {
|
||||
for (const episode of drama.episodes) {
|
||||
if (episode.scenes) {
|
||||
allScenes.push(...episode.scenes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scenes.value = allScenes
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load scenes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onDramaChange = (dramaId: string) => {
|
||||
form.scene_id = undefined
|
||||
scenes.value = []
|
||||
if (dramaId) {
|
||||
loadScenes(dramaId)
|
||||
}
|
||||
}
|
||||
|
||||
const onSceneChange = (sceneId: number | undefined) => {
|
||||
if (!sceneId) return
|
||||
|
||||
const scene = scenes.value.find(s => s.id === sceneId)
|
||||
if (scene && scene.prompt) {
|
||||
form.prompt = scene.prompt
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const params: GenerateImageRequest = {
|
||||
drama_id: form.drama_id,
|
||||
prompt: form.prompt,
|
||||
provider: form.provider
|
||||
}
|
||||
|
||||
if (form.scene_id) {
|
||||
params.scene_id = form.scene_id
|
||||
}
|
||||
|
||||
if (form.negative_prompt) {
|
||||
params.negative_prompt = form.negative_prompt
|
||||
}
|
||||
|
||||
if (form.size) {
|
||||
params.size = form.size
|
||||
}
|
||||
|
||||
if (form.provider === 'openai') {
|
||||
if (form.quality) params.quality = form.quality
|
||||
if (form.style) params.style = form.style
|
||||
}
|
||||
|
||||
if (form.provider === 'stable_diffusion') {
|
||||
if (form.steps) params.steps = form.steps
|
||||
if (form.cfg_scale) params.cfg_scale = form.cfg_scale
|
||||
if (form.seed && form.seed > 0) params.seed = form.seed
|
||||
}
|
||||
|
||||
await imageAPI.generateImage(params)
|
||||
|
||||
ElMessage.success(t('imageDialog.taskSubmitted'))
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || t('imageDialog.generateFailed'))
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-tip {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
362
web/src/views/generation/components/GenerateVideoDialog.vue
Normal file
362
web/src/views/generation/components/GenerateVideoDialog.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="AI 视频生成"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
|
||||
<el-form-item label="选择剧本" prop="drama_id">
|
||||
<el-select v-model="form.drama_id" placeholder="选择剧本" @change="onDramaChange">
|
||||
<el-option
|
||||
v-for="drama in dramas"
|
||||
:key="drama.id"
|
||||
:label="drama.title"
|
||||
:value="drama.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择图片" prop="image_gen_id">
|
||||
<el-select
|
||||
v-model="form.image_gen_id"
|
||||
placeholder="选择已生成的图片"
|
||||
clearable
|
||||
@change="onImageChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
:label="truncateText(image.prompt, 50)"
|
||||
:value="image.id"
|
||||
>
|
||||
<div class="image-option">
|
||||
<img v-if="image.image_url" :src="image.image_url" class="image-thumb" />
|
||||
<span>{{ truncateText(image.prompt, 40) }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="form-tip">或直接输入图片 URL</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="图片 URL" prop="image_url">
|
||||
<el-input
|
||||
v-model="form.image_url"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
:disabled="!!form.image_gen_id"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="视频提示词" prop="prompt">
|
||||
<el-input
|
||||
v-model="form.prompt"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="描述视频中的动作和运镜 例如:Camera slowly zooms in, wind blowing through hair, cinematic lighting"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="AI 服务">
|
||||
<el-select v-model="form.provider" placeholder="选择服务">
|
||||
<el-option label="豆包视频" value="doubao" />
|
||||
<el-option label="Runway" value="runway" />
|
||||
<el-option label="Pika" value="pika" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="视频时长">
|
||||
<el-slider
|
||||
v-model="form.duration"
|
||||
:min="3"
|
||||
:max="10"
|
||||
:marks="durationMarks"
|
||||
show-stops
|
||||
/>
|
||||
<span class="slider-value">{{ form.duration }} 秒</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="宽高比">
|
||||
<el-radio-group v-model="form.aspect_ratio">
|
||||
<el-radio label="16:9">16:9 (横屏)</el-radio>
|
||||
<el-radio label="9:16">9:16 (竖屏)</el-radio>
|
||||
<el-radio label="1:1">1:1 (方形)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-collapse>
|
||||
<el-collapse-item title="高级设置" name="advanced">
|
||||
<el-form-item label="运动强度">
|
||||
<el-slider
|
||||
v-model="form.motion_level"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:marks="motionMarks"
|
||||
/>
|
||||
<span class="slider-value">{{ form.motion_level }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="镜头运动">
|
||||
<el-select v-model="form.camera_motion" placeholder="选择镜头运动" clearable>
|
||||
<el-option label="静止" value="static" />
|
||||
<el-option label="推进 (Zoom In)" value="zoom_in" />
|
||||
<el-option label="拉远 (Zoom Out)" value="zoom_out" />
|
||||
<el-option label="左移 (Pan Left)" value="pan_left" />
|
||||
<el-option label="右移 (Pan Right)" value="pan_right" />
|
||||
<el-option label="上移 (Tilt Up)" value="tilt_up" />
|
||||
<el-option label="下移 (Tilt Down)" value="tilt_down" />
|
||||
<el-option label="环绕 (Orbit)" value="orbit" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="风格" v-if="form.provider === 'doubao'">
|
||||
<el-input v-model="form.style" placeholder="例如:电影级、动画风格" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="随机种子">
|
||||
<el-input-number v-model="form.seed" :min="-1" placeholder="留空随机" />
|
||||
<span class="form-tip">设置相同种子可复现视频</span>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate">
|
||||
生成视频
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { videoAPI } from '@/api/video'
|
||||
import { imageAPI } from '@/api/image'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import type { Drama } from '@/types/drama'
|
||||
import type { ImageGeneration } from '@/types/image'
|
||||
import type { GenerateVideoRequest } from '@/types/video'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
dramaId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const generating = ref(false)
|
||||
const dramas = ref<Drama[]>([])
|
||||
const images = ref<ImageGeneration[]>([])
|
||||
|
||||
const form = reactive<GenerateVideoRequest & { image_gen_id?: number }>({
|
||||
drama_id: props.dramaId || '',
|
||||
image_gen_id: undefined,
|
||||
image_url: '',
|
||||
prompt: '',
|
||||
provider: 'doubao',
|
||||
duration: 5,
|
||||
aspect_ratio: '16:9',
|
||||
motion_level: 50,
|
||||
camera_motion: undefined,
|
||||
style: undefined,
|
||||
seed: undefined
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
drama_id: [
|
||||
{ required: true, message: '请选择剧本', trigger: 'change' }
|
||||
],
|
||||
prompt: [
|
||||
{ required: true, message: '请输入视频提示词', trigger: 'blur' },
|
||||
{ min: 5, message: '提示词至少5个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const durationMarks = {
|
||||
3: '3s',
|
||||
5: '5s',
|
||||
7: '7s',
|
||||
10: '10s'
|
||||
}
|
||||
|
||||
const motionMarks = {
|
||||
0: '静态',
|
||||
50: '适中',
|
||||
100: '剧烈'
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
loadDramas()
|
||||
if (props.dramaId) {
|
||||
form.drama_id = props.dramaId
|
||||
loadImages(props.dramaId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const loadDramas = async () => {
|
||||
try {
|
||||
const result = await dramaAPI.list({ page: 1, page_size: 100 })
|
||||
dramas.value = result.items
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dramas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadImages = async (dramaId: string) => {
|
||||
try {
|
||||
const result = await imageAPI.listImages({
|
||||
drama_id: dramaId,
|
||||
status: 'completed',
|
||||
page: 1,
|
||||
page_size: 100
|
||||
})
|
||||
images.value = result.items
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load images:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onDramaChange = (dramaId: string) => {
|
||||
form.image_gen_id = undefined
|
||||
form.image_url = ''
|
||||
images.value = []
|
||||
if (dramaId) {
|
||||
loadImages(dramaId)
|
||||
}
|
||||
}
|
||||
|
||||
const onImageChange = (imageGenId: number | undefined) => {
|
||||
if (!imageGenId) {
|
||||
form.image_url = ''
|
||||
return
|
||||
}
|
||||
|
||||
const image = images.value.find(img => img.id === imageGenId)
|
||||
if (image && image.image_url) {
|
||||
form.image_url = image.image_url
|
||||
form.prompt = image.prompt
|
||||
}
|
||||
}
|
||||
|
||||
const truncateText = (text: string, length: number) => {
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
console.log('handleGenerate called')
|
||||
|
||||
if (!formRef.value) {
|
||||
console.error('formRef is null')
|
||||
ElMessage.error('表单初始化失败,请刷新页面重试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate()
|
||||
console.log('Form validation result:', valid)
|
||||
|
||||
if (!valid) {
|
||||
console.log('Form validation failed')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
console.log('Starting video generation...', form)
|
||||
|
||||
try {
|
||||
if (form.image_gen_id) {
|
||||
console.log('Generating from image:', form.image_gen_id)
|
||||
await videoAPI.generateFromImage(form.image_gen_id)
|
||||
} else {
|
||||
const params: GenerateVideoRequest = {
|
||||
drama_id: form.drama_id,
|
||||
prompt: form.prompt,
|
||||
provider: form.provider
|
||||
}
|
||||
|
||||
// 判断参考图模式
|
||||
if (form.image_url && form.image_url.trim()) {
|
||||
params.image_url = form.image_url
|
||||
params.reference_mode = 'single'
|
||||
} else {
|
||||
// 纯文本生成,无参考图
|
||||
params.reference_mode = 'none'
|
||||
}
|
||||
|
||||
if (form.duration) params.duration = form.duration
|
||||
if (form.aspect_ratio) params.aspect_ratio = form.aspect_ratio
|
||||
if (form.motion_level !== undefined) params.motion_level = form.motion_level
|
||||
if (form.camera_motion) params.camera_motion = form.camera_motion
|
||||
if (form.style) params.style = form.style
|
||||
if (form.seed && form.seed > 0) params.seed = form.seed
|
||||
|
||||
console.log('Generating video with params:', params)
|
||||
await videoAPI.generateVideo(params)
|
||||
}
|
||||
|
||||
ElMessage.success('视频生成任务已提交,请稍后查看结果')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
console.error('Video generation failed:', error)
|
||||
ElMessage.error(error.response?.data?.message || error.message || '生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Form validation error:', error)
|
||||
ElMessage.warning('请检查表单填写是否完整')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.image-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.image-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
294
web/src/views/generation/components/ImageDetailDialog.vue
Normal file
294
web/src/views/generation/components/ImageDetailDialog.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="图片详情"
|
||||
width="900px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="image" class="image-detail">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="14">
|
||||
<div class="image-preview">
|
||||
<el-image
|
||||
v-if="image.status === 'completed' && image.image_url"
|
||||
:src="image.image_url"
|
||||
fit="contain"
|
||||
class="preview-image"
|
||||
:preview-src-list="[image.image_url]"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-error">
|
||||
<el-icon><PictureFilled /></el-icon>
|
||||
<span>加载失败</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
|
||||
<div v-else-if="image.status === 'processing'" class="image-status">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>生成中,请稍候...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="image.status === 'failed'" class="image-status error">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
<span>生成失败</span>
|
||||
<div class="error-message">{{ image.error_msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="10">
|
||||
<div class="image-info">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusType(image.status)">
|
||||
{{ getStatusText(image.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="AI 服务">
|
||||
{{ image.provider }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="模型" v-if="image.model">
|
||||
{{ image.model }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="尺寸" v-if="image.size">
|
||||
{{ image.size }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="分辨率" v-if="image.width && image.height">
|
||||
{{ image.width }} × {{ image.height }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="质量" v-if="image.quality">
|
||||
{{ image.quality }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="风格" v-if="image.style">
|
||||
{{ image.style }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="采样步数" v-if="image.steps">
|
||||
{{ image.steps }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="CFG Scale" v-if="image.cfg_scale">
|
||||
{{ image.cfg_scale }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="随机种子" v-if="image.seed">
|
||||
{{ image.seed }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDateTime(image.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="完成时间" v-if="image.completed_at">
|
||||
{{ formatDateTime(image.completed_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="prompt-section">
|
||||
<h4>提示词</h4>
|
||||
<div class="prompt-text">{{ image.prompt }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="image.negative_prompt" class="prompt-section">
|
||||
<h4>反向提示词</h4>
|
||||
<div class="prompt-text">{{ image.negative_prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<el-button
|
||||
v-if="image?.status === 'completed' && image?.image_url"
|
||||
type="primary"
|
||||
@click="downloadImage"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
下载图片
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="image?.status === 'completed'"
|
||||
type="success"
|
||||
@click="regenerate"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
PictureFilled, Loading, CircleClose,
|
||||
Download, Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import { imageAPI } from '@/api/image'
|
||||
import type { ImageGeneration, ImageStatus } from '@/types/image'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
image?: ImageGeneration
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
regenerate: [image: ImageGeneration]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const getStatusType = (status: ImageStatus) => {
|
||||
const types: Record<ImageStatus, any> = {
|
||||
pending: 'info',
|
||||
processing: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return types[status]
|
||||
}
|
||||
|
||||
const getStatusText = (status: ImageStatus) => {
|
||||
const texts: Record<ImageStatus, string> = {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!props.image?.image_url) return
|
||||
window.open(props.image.image_url, '_blank')
|
||||
}
|
||||
|
||||
const regenerate = () => {
|
||||
if (!props.image) return
|
||||
emit('regenerate', props.image)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-detail {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.image-status .el-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.image-status.error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: #fef0f0;
|
||||
border: 1px solid #fde2e2;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #f56c6c;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.image-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.image-error .el-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prompt-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
312
web/src/views/generation/components/VideoDetailDialog.vue
Normal file
312
web/src/views/generation/components/VideoDetailDialog.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="视频详情"
|
||||
width="1000px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="video" class="video-detail">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<div class="video-preview">
|
||||
<video
|
||||
v-if="video.status === 'completed' && video.video_url"
|
||||
:src="video.video_url"
|
||||
class="preview-video"
|
||||
controls
|
||||
autoplay
|
||||
loop
|
||||
:poster="video.first_frame_url"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
|
||||
<div v-else-if="video.status === 'processing'" class="video-status">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>生成中,请稍候...</span>
|
||||
<div class="status-message">预计需要 1-3 分钟</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="video.status === 'failed'" class="video-status error">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
<span>生成失败</span>
|
||||
<div class="error-message">{{ video.error_msg }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="video-status">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>等待生成</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<div class="video-info">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusType(video.status)">
|
||||
{{ getStatusText(video.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="AI 服务">
|
||||
{{ video.provider }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="模型" v-if="video.model">
|
||||
{{ video.model }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="时长" v-if="video.duration">
|
||||
{{ video.duration }} 秒
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="宽高比" v-if="video.aspect_ratio">
|
||||
{{ video.aspect_ratio }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="分辨率" v-if="video.width && video.height">
|
||||
{{ video.width }} × {{ video.height }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="FPS" v-if="video.fps">
|
||||
{{ video.fps }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="运动强度" v-if="video.motion_level !== undefined">
|
||||
{{ video.motion_level }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="镜头运动" v-if="video.camera_motion">
|
||||
{{ getCameraMotionText(video.camera_motion) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="风格" v-if="video.style">
|
||||
{{ video.style }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="随机种子" v-if="video.seed">
|
||||
{{ video.seed }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDateTime(video.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="完成时间" v-if="video.completed_at">
|
||||
{{ formatDateTime(video.completed_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="prompt-section">
|
||||
<h4>视频提示词</h4>
|
||||
<div class="prompt-text">{{ video.prompt }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="video.image_url" class="image-section">
|
||||
<h4>源图片</h4>
|
||||
<el-image
|
||||
:src="video.image_url"
|
||||
fit="contain"
|
||||
class="source-image"
|
||||
:preview-src-list="[video.image_url]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<el-button
|
||||
v-if="video?.status === 'completed' && video?.video_url"
|
||||
type="primary"
|
||||
@click="downloadVideo"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
下载视频
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="video?.status === 'completed'"
|
||||
type="success"
|
||||
@click="regenerate"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
VideoCamera, Loading, CircleClose,
|
||||
Download, Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { VideoGeneration, VideoStatus } from '@/types/video'
|
||||
import { CAMERA_MOTIONS } from '@/types/video'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
video?: VideoGeneration
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
regenerate: [video: VideoGeneration]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const getStatusType = (status: VideoStatus) => {
|
||||
const types: Record<VideoStatus, any> = {
|
||||
pending: 'info',
|
||||
processing: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return types[status]
|
||||
}
|
||||
|
||||
const getStatusText = (status: VideoStatus) => {
|
||||
const texts: Record<VideoStatus, string> = {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
const getCameraMotionText = (motion: string) => {
|
||||
const item = CAMERA_MOTIONS.find(m => m.value === motion)
|
||||
return item ? item.label : motion
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const downloadVideo = () => {
|
||||
if (!props.video?.video_url) return
|
||||
window.open(props.video.video_url, '_blank')
|
||||
}
|
||||
|
||||
const regenerate = () => {
|
||||
if (!props.video) return
|
||||
emit('regenerate', props.video)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-detail {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.video-status .el-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.video-status.error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: #fef0f0;
|
||||
border: 1px solid #fde2e2;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #f56c6c;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prompt-section,
|
||||
.image-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-section h4,
|
||||
.image-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.source-image {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
26
web/src/views/script/ScriptEdit.vue
Normal file
26
web/src/views/script/ScriptEdit.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="script-edit-container">
|
||||
<el-page-header @back="goBack" title="返回">
|
||||
<template #content>
|
||||
<h2>剧本编辑</h2>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p>功能开发中...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-edit-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
740
web/src/views/settings/AIConfig.vue
Normal file
740
web/src/views/settings/AIConfig.vue
Normal file
@@ -0,0 +1,740 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="content-wrapper animate-fade-in">
|
||||
<!-- Page Header / 页面头部 -->
|
||||
<PageHeader
|
||||
:title="$t('aiConfig.title')"
|
||||
:subtitle="$t('aiConfig.subtitle') || '管理 AI 服务配置'"
|
||||
:show-back="true"
|
||||
:back-text="$t('common.back')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>{{ $t('aiConfig.addConfig') }}</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tabs / 标签页 -->
|
||||
<div class="tabs-wrapper">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="config-tabs">
|
||||
<el-tab-pane :label="$t('aiConfig.tabs.text')" name="text">
|
||||
<ConfigList
|
||||
:configs="configs"
|
||||
:loading="loading"
|
||||
:show-test-button="true"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggleActive"
|
||||
@test="handleTest"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('aiConfig.tabs.image')" name="image">
|
||||
<ConfigList
|
||||
:configs="configs"
|
||||
:loading="loading"
|
||||
:show-test-button="false"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggleActive"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('aiConfig.tabs.video')" name="video">
|
||||
<ConfigList
|
||||
:configs="configs"
|
||||
:loading="loading"
|
||||
:show-test-button="false"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggleActive"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- Edit/Create Dialog / 编辑创建弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? $t('aiConfig.editConfig') : $t('aiConfig.addConfig')"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item :label="$t('aiConfig.form.name')" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="$t('aiConfig.form.namePlaceholder')" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.provider')" prop="provider">
|
||||
<el-select
|
||||
v-model="form.provider"
|
||||
:placeholder="$t('aiConfig.form.providerPlaceholder')"
|
||||
@change="handleProviderChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.id"
|
||||
:label="provider.name"
|
||||
:value="provider.id"
|
||||
:disabled="provider.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.providerTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.priority')" prop="priority">
|
||||
<el-input-number
|
||||
v-model="form.priority"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.priorityTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.model')" prop="model">
|
||||
<el-select
|
||||
v-model="form.model"
|
||||
:placeholder="$t('aiConfig.form.modelPlaceholder')"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in availableModels"
|
||||
:key="model"
|
||||
:label="model"
|
||||
:value="model"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.modelTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.baseUrl')" prop="base_url">
|
||||
<el-input v-model="form.base_url" :placeholder="$t('aiConfig.form.baseUrlPlaceholder')" />
|
||||
<div class="form-tip">
|
||||
{{ $t('aiConfig.form.baseUrlTip') }}
|
||||
<br>
|
||||
{{ $t('aiConfig.form.fullEndpoint') }}: {{ fullEndpointExample }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('aiConfig.form.apiKey')" prop="api_key">
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="$t('aiConfig.form.apiKeyPlaceholder')"
|
||||
/>
|
||||
<div class="form-tip">{{ $t('aiConfig.form.apiKeyTip') }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isEdit" :label="$t('aiConfig.form.isActive')">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ $t('common.cancel') }}</el-button>
|
||||
<el-button v-if="form.service_type === 'text'" @click="testConnection" :loading="testing">{{ $t('aiConfig.actions.test') }}</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
{{ isEdit ? $t('common.save') : $t('common.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { aiAPI } from '@/api/ai'
|
||||
import { PageHeader } from '@/components/common'
|
||||
import type { AIServiceConfig, AIServiceType, CreateAIConfigRequest, UpdateAIConfigRequest } from '@/types/ai'
|
||||
import ConfigList from './components/ConfigList.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref<AIServiceType>('text')
|
||||
const loading = ref(false)
|
||||
const configs = ref<AIServiceConfig[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref<number>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const testing = ref(false)
|
||||
|
||||
const form = reactive<CreateAIConfigRequest & { is_active?: boolean, provider?: string }>({
|
||||
service_type: 'text',
|
||||
provider: '',
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: [], // 改为数组支持多选
|
||||
priority: 0, // 默认优先级为0
|
||||
is_active: true
|
||||
})
|
||||
|
||||
// 厂商和模型配置
|
||||
interface ProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
models: string[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
|
||||
text: [
|
||||
{ id: 'openai', name: 'OpenAI', models: ['gpt-5.2', 'gemini-3-pro-preview'] },
|
||||
{
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'doubao-seed-1-8-251228',
|
||||
'kimi-k2-thinking',
|
||||
'gemini-3-pro',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
models: [
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
}
|
||||
],
|
||||
image: [
|
||||
{
|
||||
id: 'volcengine',
|
||||
name: '火山引擎',
|
||||
models: [
|
||||
'doubao-seedream-4-5-251128',
|
||||
'doubao-seedream-4-0-250828',
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'doubao-seedream-4-5-251128',
|
||||
'nano-banana-pro',
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
models: [
|
||||
'gemini-3-pro-image-preview',
|
||||
]
|
||||
},
|
||||
{ id: 'openai', name: 'OpenAI', models: ['dall-e-3', 'dall-e-2'] }
|
||||
],
|
||||
video: [
|
||||
{
|
||||
id: 'volces',
|
||||
name: '火山引擎',
|
||||
models: [
|
||||
'doubao-seedance-1-5-pro-251215',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'doubao-seedance-1-0-pro-250528',
|
||||
'doubao-seedance-1-0-pro-fast-251015'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'doubao-seedance-1-5-pro-251215',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'doubao-seedance-1-0-pro-250528',
|
||||
'doubao-seedance-1-0-pro-fast-251015',
|
||||
'sora',
|
||||
'sora-pro'
|
||||
]
|
||||
},
|
||||
{ id: 'openai', name: 'OpenAI', models: ['sora-2', 'sora-2-pro'] },
|
||||
// { id: 'minimax', name: 'MiniMax', models: ['MiniMax-Hailuo-2.3', 'MiniMax-Hailuo-2.3-Fast', 'MiniMax-Hailuo-02'] }
|
||||
]
|
||||
}
|
||||
|
||||
// 当前可用的厂商列表
|
||||
const availableProviders = computed(() => {
|
||||
return providerConfigs[form.service_type] || []
|
||||
})
|
||||
|
||||
// 当前可用的模型列表
|
||||
const availableModels = computed(() => {
|
||||
if (!form.provider) return []
|
||||
const provider = availableProviders.value.find(p => p.id === form.provider)
|
||||
return provider?.models || []
|
||||
})
|
||||
|
||||
// 完整端点示例
|
||||
const fullEndpointExample = computed(() => {
|
||||
const baseUrl = form.base_url || 'https://api.example.com'
|
||||
const provider = form.provider
|
||||
const serviceType = form.service_type
|
||||
|
||||
let endpoint = ''
|
||||
|
||||
if (serviceType === 'text') {
|
||||
if (provider === 'gemini' || provider === 'google') {
|
||||
endpoint = '/v1beta/models/{model}:generateContent'
|
||||
} else {
|
||||
endpoint = '/chat/completions'
|
||||
}
|
||||
} else if (serviceType === 'image') {
|
||||
if (provider === 'gemini' || provider === 'google') {
|
||||
endpoint = '/v1beta/models/{model}:generateContent'
|
||||
} else {
|
||||
endpoint = '/images/generations'
|
||||
}
|
||||
} else if (serviceType === 'video') {
|
||||
if (provider === 'chatfire') {
|
||||
endpoint = '/video/generations'
|
||||
} else if (provider === 'doubao' || provider === 'volcengine' || provider === 'volces') {
|
||||
endpoint = '/contents/generations/tasks'
|
||||
} else if (provider === 'openai') {
|
||||
endpoint = '/videos'
|
||||
} else {
|
||||
endpoint = '/video/generations'
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl + endpoint
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入配置名称', trigger: 'blur' }
|
||||
],
|
||||
provider: [
|
||||
{ required: true, message: '请选择厂商', trigger: 'change' }
|
||||
],
|
||||
base_url: [
|
||||
{ required: true, message: '请输入 Base URL', trigger: 'blur' },
|
||||
{ type: 'url', message: '请输入正确的 URL 格式', trigger: 'blur' }
|
||||
],
|
||||
api_key: [
|
||||
{ required: true, message: '请输入 API Key', trigger: 'blur' }
|
||||
],
|
||||
model: [
|
||||
{
|
||||
required: true,
|
||||
message: '请至少选择一个模型',
|
||||
trigger: 'change',
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
callback()
|
||||
} else if (typeof value === 'string' && value.length > 0) {
|
||||
callback()
|
||||
} else {
|
||||
callback(new Error('请至少选择一个模型'))
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const loadConfigs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = await aiAPI.list(activeTab.value)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机配置名称
|
||||
const generateConfigName = (provider: string, serviceType: AIServiceType): string => {
|
||||
const providerNames: Record<string, string> = {
|
||||
'chatfire': 'ChatFire',
|
||||
'openai': 'OpenAI',
|
||||
'gemini': 'Gemini',
|
||||
'google': 'Google'
|
||||
}
|
||||
|
||||
const serviceNames: Record<AIServiceType, string> = {
|
||||
'text': '文本',
|
||||
'image': '图片',
|
||||
'video': '视频'
|
||||
}
|
||||
|
||||
const randomNum = Math.floor(Math.random() * 10000).toString().padStart(4, '0')
|
||||
const providerName = providerNames[provider] || provider
|
||||
const serviceName = serviceNames[serviceType] || serviceType
|
||||
|
||||
return `${providerName}-${serviceName}-${randomNum}`
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
editingId.value = undefined
|
||||
resetForm()
|
||||
form.service_type = activeTab.value
|
||||
// 默认选择 chatfire
|
||||
form.provider = 'chatfire'
|
||||
// 设置默认 base_url
|
||||
form.base_url = 'https://api.chatfire.site/v1'
|
||||
// 自动生成随机配置名称
|
||||
form.name = generateConfigName('chatfire', activeTab.value)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (config: AIServiceConfig) => {
|
||||
isEdit.value = true
|
||||
editingId.value = config.id
|
||||
|
||||
Object.assign(form, {
|
||||
service_type: config.service_type,
|
||||
provider: config.provider || 'chatfire', // 直接使用配置中的 provider,默认为 chatfire
|
||||
name: config.name,
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: Array.isArray(config.model) ? config.model : [config.model], // 统一转换为数组
|
||||
priority: config.priority || 0,
|
||||
is_active: config.is_active
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (config: AIServiceConfig) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该配置吗?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await aiAPI.delete(config.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadConfigs()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (config: AIServiceConfig) => {
|
||||
try {
|
||||
const newActiveState = !config.is_active
|
||||
await aiAPI.update(config.id, { is_active: newActiveState })
|
||||
ElMessage.success(newActiveState ? '已启用配置' : '已禁用配置')
|
||||
await loadConfigs()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
await aiAPI.testConnection({
|
||||
base_url: form.base_url,
|
||||
api_key: form.api_key,
|
||||
model: form.model,
|
||||
provider: form.provider
|
||||
})
|
||||
ElMessage.success('连接测试成功!')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '连接测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (config: AIServiceConfig) => {
|
||||
testing.value = true
|
||||
try {
|
||||
await aiAPI.testConnection({
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: config.model,
|
||||
provider: config.provider
|
||||
})
|
||||
ElMessage.success('连接测试成功!')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '连接测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value && editingId.value) {
|
||||
const updateData: UpdateAIConfigRequest = {
|
||||
name: form.name,
|
||||
provider: form.provider,
|
||||
base_url: form.base_url,
|
||||
api_key: form.api_key,
|
||||
model: form.model,
|
||||
priority: form.priority,
|
||||
is_active: form.is_active
|
||||
}
|
||||
await aiAPI.update(editingId.value, updateData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await aiAPI.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
loadConfigs()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabChange = (tabName: string | number) => {
|
||||
// 标签页切换时重新加载对应服务类型的配置
|
||||
activeTab.value = tabName as AIServiceType
|
||||
loadConfigs()
|
||||
}
|
||||
|
||||
const handleProviderChange = () => {
|
||||
// 切换厂商时清空已选模型
|
||||
form.model = []
|
||||
|
||||
// 根据厂商自动设置默认 base_url
|
||||
if (form.provider === 'gemini' || form.provider === 'google') {
|
||||
form.base_url = 'https://api.chatfire.site'
|
||||
} else {
|
||||
// openai, chatfire 等其他厂商
|
||||
form.base_url = 'https://api.chatfire.site/v1'
|
||||
}
|
||||
|
||||
// 仅在新建配置时自动更新名称
|
||||
if (!isEdit.value) {
|
||||
form.name = generateConfigName(form.provider, form.service_type)
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultEndpoint 已移除,端点由后端根据 provider 自动设置
|
||||
// 保留该函数定义以避免编译错误
|
||||
const getDefaultEndpoint = (serviceType: AIServiceType): string => {
|
||||
switch (serviceType) {
|
||||
case 'text':
|
||||
return ''
|
||||
case 'image':
|
||||
return '/v1/images/generations'
|
||||
case 'video':
|
||||
return '/v1/video/generations'
|
||||
default:
|
||||
return '/v1/chat/completions'
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
const serviceType = form.service_type || 'text'
|
||||
Object.assign(form, {
|
||||
service_type: serviceType,
|
||||
provider: '',
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: [], // 改为空数组
|
||||
priority: 0,
|
||||
is_active: true
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========================================
|
||||
Page Layout / 页面布局 - 紧凑边距
|
||||
======================================== */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-container {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.page-container {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Tabs / 标签页 - 紧凑内边距
|
||||
======================================== */
|
||||
.tabs-wrapper {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.tabs-wrapper {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Tips / 表单提示
|
||||
======================================== */
|
||||
.form-tip {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Dialog / 弹窗
|
||||
======================================== */
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__title) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__footer) {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Dark Mode / 深色模式
|
||||
======================================== */
|
||||
.dark .tabs-wrapper {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-dialog) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-dialog__header) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-dialog__body) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark :deep(.el-form-item__label) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__wrapper) {
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 1px var(--border-primary) inset;
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__inner) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-input__inner::placeholder) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dark :deep(.el-select .el-input__wrapper) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-textarea__inner) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 1px var(--border-primary) inset;
|
||||
}
|
||||
|
||||
.dark :deep(.el-input-number) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-switch__core) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-button--default) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-button--default:hover) {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
</style>
|
||||
218
web/src/views/settings/components/ConfigList.vue
Normal file
218
web/src/views/settings/components/ConfigList.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="config-list">
|
||||
<el-empty v-if="!loading && configs.length === 0" :description="$t('aiConfig.empty')" />
|
||||
|
||||
<el-card
|
||||
v-for="config in configs"
|
||||
:key="config.id"
|
||||
class="config-card"
|
||||
shadow="hover"
|
||||
>
|
||||
<div class="config-header">
|
||||
<div class="config-title">
|
||||
<h3>{{ config.name }}</h3>
|
||||
<el-tag v-if="config.is_active" type="success" size="small">{{ $t('aiConfig.enabled') }}</el-tag>
|
||||
<el-tag v-else type="info" size="small">{{ $t('aiConfig.disabled') }}</el-tag>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<el-button v-if="showTestButton" text @click="$emit('test', config)" :icon="Connection">
|
||||
{{ $t('aiConfig.actions.test') }}
|
||||
</el-button>
|
||||
<el-button text @click="$emit('edit', config)" :icon="Edit">
|
||||
{{ $t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
:type="config.is_active ? 'warning' : 'success'"
|
||||
@click="$emit('toggle-active', config)"
|
||||
>
|
||||
{{ config.is_active ? $t('aiConfig.disable') : $t('aiConfig.enable') }}
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
:title="$t('aiConfig.messages.deleteConfirm')"
|
||||
@confirm="$emit('delete', config)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button text type="danger" :icon="Delete">
|
||||
{{ $t('common.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-info">
|
||||
<div class="info-item">
|
||||
<label>Base URL:</label>
|
||||
<span class="url-text">{{ config.base_url }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>{{ $t('aiConfig.endpoint') }}:</label>
|
||||
<span>{{ config.endpoint || '/v1/chat/completions' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="config.service_type === 'video' && config.query_endpoint" class="info-item">
|
||||
<label>{{ $t('aiConfig.queryEndpoint') }}:</label>
|
||||
<span>{{ config.query_endpoint }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>优先级:</label>
|
||||
<el-tag size="small" :type="(config.priority || 0) >= 50 ? 'danger' : (config.priority || 0) >= 20 ? 'warning' : 'info'">
|
||||
{{ config.priority || 0 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>模型:</label>
|
||||
<template v-if="Array.isArray(config.model)">
|
||||
<el-tag
|
||||
v-for="(model, index) in config.model"
|
||||
:key="index"
|
||||
size="small"
|
||||
effect="plain"
|
||||
style="margin-right: 4px"
|
||||
>
|
||||
{{ model }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<el-tag v-else size="small" effect="plain">{{ config.model }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>API Key:</label>
|
||||
<span class="api-key">{{ maskApiKey(config.api_key) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>创建时间:</label>
|
||||
<span class="time-text">{{ formatDate(config.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Connection, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import type { AIServiceConfig } from '@/types/ai'
|
||||
|
||||
defineProps<{
|
||||
configs: AIServiceConfig[]
|
||||
loading: boolean
|
||||
showTestButton?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [config: AIServiceConfig]
|
||||
delete: [config: AIServiceConfig]
|
||||
toggleActive: [config: AIServiceConfig]
|
||||
test: [config: AIServiceConfig]
|
||||
}>()
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
if (!key) return ''
|
||||
if (key.length <= 8) return '***'
|
||||
return key.substring(0, 4) + '***' + key.substring(key.length - 4)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.config-card :deep(.el-card__body) {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.config-card:hover {
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.config-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.config-title h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.config-info {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
min-width: 5.5rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.url-text {
|
||||
color: #0ea5e9 !important;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.api-key {
|
||||
font-family: monospace;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .config-card {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.dark .config-card :deep(.el-card__body) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
26
web/src/views/storyboard/StoryboardEdit.vue
Normal file
26
web/src/views/storyboard/StoryboardEdit.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="storyboard-edit-container">
|
||||
<el-page-header @back="goBack" :title="$t('common.back')">
|
||||
<template #content>
|
||||
<h2>{{ $t('storyboard.edit') }}</h2>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p>{{ $t('storyboard.inDevelopment') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.storyboard-edit-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
210
web/src/views/workflow/CharacterExtraction.vue
Normal file
210
web/src/views/workflow/CharacterExtraction.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="character-extraction-container">
|
||||
<el-page-header @back="goBack" :title="$t('character.backToProject')">
|
||||
<template #content>
|
||||
<h2>{{ $t('character.title') }}</h2>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="main-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>{{ $t('character.list') }}</h3>
|
||||
<div class="header-actions">
|
||||
<el-button @click="addCharacter">
|
||||
<el-icon><Plus /></el-icon>
|
||||
{{ $t('character.add') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="saveCharacters" :loading="saving">
|
||||
{{ $t('character.saveChanges') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="characters.length === 0" :description="$t('character.empty')" />
|
||||
|
||||
<el-row :gutter="20" v-else>
|
||||
<el-col :span="8" v-for="character in characters" :key="character.id">
|
||||
<el-card shadow="hover" class="character-card">
|
||||
<template #header>
|
||||
<div class="character-header">
|
||||
<el-avatar :size="60">{{ character.name[0] }}</el-avatar>
|
||||
<div class="character-info">
|
||||
<h4>{{ character.name }}</h4>
|
||||
<el-tag size="small">{{ character.role }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="character-details">
|
||||
<p><strong>{{ $t('character.personality') }}:</strong>{{ character.personality }}</p>
|
||||
<p><strong>{{ $t('character.appearance') }}:</strong>{{ character.appearance }}</p>
|
||||
<p><strong>{{ $t('character.background') }}:</strong>{{ character.background }}</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button-group style="width: 100%">
|
||||
<el-button size="small" @click="editCharacter(character)">{{ $t('common.edit') }}</el-button>
|
||||
<el-button size="small" type="primary" @click="generateCharacterImage(character)">
|
||||
{{ $t('character.generateImage') }}
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="actions" v-if="characters.length > 0">
|
||||
<el-button type="success" size="large" @click="goToNextStep">
|
||||
{{ $t('character.nextStep') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑角色" width="600px">
|
||||
<el-form :model="editForm" label-width="80px">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="editForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-input v-model="editForm.role" />
|
||||
</el-form-item>
|
||||
<el-form-item label="性格">
|
||||
<el-input v-model="editForm.personality" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外貌">
|
||||
<el-input v-model="editForm.appearance" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCharacter">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { MagicStick } from '@element-plus/icons-vue'
|
||||
import { generationAPI } from '@/api/generation'
|
||||
import type { Character } from '@/types/drama'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const dramaId = route.params.id as string
|
||||
|
||||
const characters = ref<Character[]>([])
|
||||
const saving = ref(false)
|
||||
const editDialogVisible = ref(false)
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
role: '',
|
||||
personality: '',
|
||||
appearance: '',
|
||||
background: ''
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
|
||||
const addCharacter = () => {
|
||||
Object.assign(editForm, {
|
||||
name: '',
|
||||
role: '',
|
||||
personality: '',
|
||||
appearance: '',
|
||||
background: ''
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveCharacters = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// TODO: 调用保存角色API
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
ElMessage.success('保存成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editCharacter = (character: Character) => {
|
||||
Object.assign(editForm, character)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveCharacter = () => {
|
||||
// TODO: 保存角色信息
|
||||
editDialogVisible.value = false
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
|
||||
const generateCharacterImage = (character: Character) => {
|
||||
router.push(`/dramas/${dramaId}/images/characters?character=${character.id}`)
|
||||
}
|
||||
|
||||
const goToNextStep = () => {
|
||||
router.push(`/dramas/${dramaId}/images/characters`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载已有角色
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-extraction-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.character-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.character-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.character-details p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
346
web/src/views/workflow/CharacterImages.vue
Normal file
346
web/src/views/workflow/CharacterImages.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div class="character-images-container">
|
||||
<el-page-header @back="goBack" title="返回项目">
|
||||
<template #content>
|
||||
<h2>角色形象生成</h2>
|
||||
</template>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="batchGenerate" :loading="batchGenerating" :disabled="selectedCharacters.length === 0">
|
||||
<el-icon><Picture /></el-icon>
|
||||
批量生成 ({{ selectedCharacters.length }})
|
||||
</el-button>
|
||||
<el-button @click="goToCharacterManagement">
|
||||
<el-icon><Edit /></el-icon>
|
||||
管理角色
|
||||
</el-button>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="main-card">
|
||||
<div class="toolbar">
|
||||
<el-checkbox v-model="selectAll" @change="handleSelectAll" :indeterminate="isIndeterminate">
|
||||
全选
|
||||
</el-checkbox>
|
||||
<span class="selection-info">已选择 {{ selectedCharacters.length }} / {{ characters.length }} 个角色</span>
|
||||
</div>
|
||||
|
||||
<div class="character-list">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="character in characters" :key="character.id">
|
||||
<el-card shadow="hover" class="character-card" :class="{ 'has-image': character.image_url, 'selected': isSelected(character.id) }">
|
||||
<el-checkbox
|
||||
class="card-checkbox"
|
||||
:model-value="isSelected(character.id)"
|
||||
@change="toggleSelection(character.id)"
|
||||
/>
|
||||
<div class="character-preview">
|
||||
<img v-if="character.image_url" :src="character.image_url" :alt="character.name" />
|
||||
<el-avatar v-else :size="120">{{ character.name[0] }}</el-avatar>
|
||||
</div>
|
||||
|
||||
<div class="character-info">
|
||||
<h4>{{ character.name }}</h4>
|
||||
<p class="role">{{ character.role }}</p>
|
||||
<p class="desc">{{ character.appearance }}</p>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="generateImage(character)"
|
||||
:loading="generatingIds.includes(character.id)"
|
||||
:disabled="batchGenerating || (generatingIds.length > 0 && !generatingIds.includes(character.id))"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span v-if="generatingIds.includes(character.id)">生成中...</span>
|
||||
<span v-else>{{ character.image_url ? '重新生成' : '生成形象' }}</span>
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="success" size="large" @click="goToNextStep" :disabled="!allImagesGenerated">
|
||||
完成并返回项目
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Edit, Picture } from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import { characterLibraryAPI } from '@/api/character-library'
|
||||
import type { Character } from '@/types/drama'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const dramaId = route.params.id as string
|
||||
|
||||
const characters = ref<Character[]>([])
|
||||
const generatingIds = ref<(number | string)[]>([])
|
||||
const batchGenerating = ref(false)
|
||||
const selectedCharacters = ref<(number | string)[]>([])
|
||||
const selectAll = ref(false)
|
||||
|
||||
const allImagesGenerated = computed(() => {
|
||||
return characters.value.length > 0 && characters.value.every(c => c.image_url)
|
||||
})
|
||||
|
||||
const isIndeterminate = computed(() => {
|
||||
const selectedCount = selectedCharacters.value.length
|
||||
return selectedCount > 0 && selectedCount < characters.value.length
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
|
||||
const goToCharacterManagement = () => {
|
||||
router.push(`/dramas/${dramaId}/characters`)
|
||||
}
|
||||
|
||||
const isSelected = (id: number | string) => {
|
||||
return selectedCharacters.value.includes(id)
|
||||
}
|
||||
|
||||
const toggleSelection = (id: number | string) => {
|
||||
const index = selectedCharacters.value.indexOf(id)
|
||||
if (index > -1) {
|
||||
selectedCharacters.value.splice(index, 1)
|
||||
} else {
|
||||
selectedCharacters.value.push(id)
|
||||
}
|
||||
updateSelectAllState()
|
||||
}
|
||||
|
||||
const handleSelectAll = (val: boolean) => {
|
||||
if (val) {
|
||||
selectedCharacters.value = characters.value.map(c => c.id)
|
||||
} else {
|
||||
selectedCharacters.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelectAllState = () => {
|
||||
selectAll.value = selectedCharacters.value.length === characters.value.length
|
||||
}
|
||||
|
||||
const generateImage = async (character: Character) => {
|
||||
if (generatingIds.value.includes(character.id)) return
|
||||
|
||||
generatingIds.value.push(character.id)
|
||||
try {
|
||||
const result = await characterLibraryAPI.generateCharacterImage(character.id as string)
|
||||
|
||||
// 更新角色图片
|
||||
const index = characters.value.findIndex(c => c.id === character.id)
|
||||
if (index !== -1) {
|
||||
characters.value[index].image_url = result.image_url
|
||||
}
|
||||
|
||||
ElMessage.success(`${character.name}的形象生成成功`)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || `${character.name}生成失败`)
|
||||
} finally {
|
||||
const index = generatingIds.value.indexOf(character.id)
|
||||
if (index > -1) {
|
||||
generatingIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchGenerate = async () => {
|
||||
if (selectedCharacters.value.length === 0) {
|
||||
ElMessage.warning('请选择要生成的角色')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCharacters.value.length > 10) {
|
||||
ElMessage.warning('单次最多生成10个角色')
|
||||
return
|
||||
}
|
||||
|
||||
batchGenerating.value = true
|
||||
generatingIds.value = [...selectedCharacters.value]
|
||||
|
||||
try {
|
||||
await characterLibraryAPI.batchGenerateCharacterImages(
|
||||
selectedCharacters.value.map(id => String(id))
|
||||
)
|
||||
|
||||
ElMessage.success(`批量生成任务已提交,正在后台生成 ${selectedCharacters.value.length} 个角色形象`)
|
||||
|
||||
// 轮询检查生成状态
|
||||
startPolling()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '批量生成失败')
|
||||
batchGenerating.value = false
|
||||
generatingIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
let pollingTimer: number | null = null
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollingTimer) return
|
||||
|
||||
pollingTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const drama = await dramaAPI.get(dramaId)
|
||||
if (drama.characters) {
|
||||
// 更新角色列表
|
||||
characters.value = drama.characters
|
||||
|
||||
// 检查是否所有选中的角色都生成完成
|
||||
const allGenerated = selectedCharacters.value.every(id => {
|
||||
const char = characters.value.find(c => c.id === id)
|
||||
return char?.image_url
|
||||
})
|
||||
|
||||
if (allGenerated) {
|
||||
stopPolling()
|
||||
ElMessage.success('批量生成完成')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询错误:', error)
|
||||
}
|
||||
}, 5000) // 每5秒检查一次
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer)
|
||||
pollingTimer = null
|
||||
}
|
||||
batchGenerating.value = false
|
||||
generatingIds.value = []
|
||||
selectedCharacters.value = []
|
||||
selectAll.value = false
|
||||
}
|
||||
|
||||
const goToNextStep = () => {
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const drama = await dramaAPI.get(dramaId)
|
||||
if (drama.characters && drama.characters.length > 0) {
|
||||
characters.value = drama.characters
|
||||
} else {
|
||||
ElMessage.warning('未找到角色信息,请先完成剧本生成')
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载角色失败')
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件销毁时清理轮询
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-images-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.character-card.has-image {
|
||||
border-color: #67c23a;
|
||||
}
|
||||
|
||||
.character-preview {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.character-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.character-info h4 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.character-info .role {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.character-info .desc {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.character-card.selected {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
139
web/src/views/workflow/DramaSettings.vue
Normal file
139
web/src/views/workflow/DramaSettings.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="drama-settings-container">
|
||||
<el-page-header @back="goBack" title="返回项目">
|
||||
<template #content>
|
||||
<h2>项目设置</h2>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="main-card">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-form :model="form" label-width="100px" style="max-width: 600px">
|
||||
<el-form-item label="项目标题">
|
||||
<el-input v-model="form.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.genre">
|
||||
<el-option label="都市" value="都市" />
|
||||
<el-option label="古装" value="古装" />
|
||||
<el-option label="悬疑" value="悬疑" />
|
||||
<el-option label="爱情" value="爱情" />
|
||||
<el-option label="喜剧" value="喜剧" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="策划中" value="planning" />
|
||||
<el-option label="制作中" value="production" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已归档" value="archived" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSettings">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="危险操作" name="danger">
|
||||
<el-alert
|
||||
title="警告"
|
||||
type="warning"
|
||||
description="以下操作不可恢复,请谨慎操作"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="danger-zone">
|
||||
<el-button type="danger" @click="deleteProject">删除项目</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const dramaId = route.params.id as string
|
||||
|
||||
const activeTab = ref('basic')
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
genre: '',
|
||||
status: 'draft' as any
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await dramaAPI.update(dramaId, form)
|
||||
ElMessage.success('设置保存成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除此项目吗?此操作不可恢复!',
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
|
||||
await dramaAPI.delete(dramaId)
|
||||
ElMessage.success('项目已删除')
|
||||
router.push('/dramas')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const drama = await dramaAPI.get(dramaId)
|
||||
Object.assign(form, drama)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drama-settings-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
199
web/src/views/workflow/SceneImages.vue
Normal file
199
web/src/views/workflow/SceneImages.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="scene-images-container">
|
||||
<el-page-header @back="goBack" title="返回项目">
|
||||
<template #content>
|
||||
<h2>场景图片生成</h2>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="main-card">
|
||||
<el-tabs v-model="activeEpisode">
|
||||
<el-tab-pane
|
||||
v-for="episode in episodes"
|
||||
:key="episode.id"
|
||||
:label="`第${episode.episode_number}集`"
|
||||
:name="episode.id"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8" v-for="scene in episode.scenes" :key="scene.id">
|
||||
<el-card shadow="hover" class="scene-card" :class="{ 'has-image': scene.image_url }">
|
||||
<template #header>
|
||||
<div class="scene-header">
|
||||
<span class="scene-number">场景 {{ scene.storyboard_number }}</span>
|
||||
<el-tag size="small">{{ scene.location }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="scene-preview">
|
||||
<img v-if="scene.image_url" :src="scene.image_url" :alt="scene.title" />
|
||||
<div v-else class="placeholder">
|
||||
<el-icon :size="48"><Picture /></el-icon>
|
||||
<p>未生成</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scene-info">
|
||||
<h4>{{ scene.title }}</h4>
|
||||
<p class="description">{{ scene.description }}</p>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="generateImage(scene)"
|
||||
:loading="generatingId === scene.id"
|
||||
:disabled="!!generatingId && generatingId !== scene.id"
|
||||
style="width: 100%"
|
||||
>
|
||||
{{ scene.image_url ? '重新生成' : '生成图片' }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="success" size="large" @click="goToNextStep" :disabled="!allImagesGenerated">
|
||||
下一步:视频生成
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import type { Episode, Scene } from '@/types/drama'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const dramaId = route.params.id as string
|
||||
|
||||
const episodes = ref<Episode[]>([])
|
||||
const activeEpisode = ref<string>()
|
||||
const generatingId = ref<string>()
|
||||
|
||||
const allImagesGenerated = computed(() => {
|
||||
return episodes.value.every(ep =>
|
||||
ep.scenes?.every(s => s.image_url)
|
||||
)
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.push(`/dramas/${dramaId}`)
|
||||
}
|
||||
|
||||
const generateImage = async (scene: Scene) => {
|
||||
generatingId.value = scene.id
|
||||
try {
|
||||
const { imageAPI } = await import('@/api/image')
|
||||
|
||||
// 构建场景提示词
|
||||
let prompt = `${scene.location}, ${scene.time}`
|
||||
if (scene.description) {
|
||||
prompt += `, ${scene.description}`
|
||||
}
|
||||
prompt += ', detailed background scene, anime style, high quality, no characters'
|
||||
|
||||
const result = await imageAPI.generateImage({
|
||||
drama_id: dramaId,
|
||||
scene_id: scene.id as number,
|
||||
image_type: 'scene',
|
||||
prompt: prompt
|
||||
})
|
||||
|
||||
ElMessage.success('场景图片生成任务已提交')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '生成失败')
|
||||
} finally {
|
||||
generatingId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const goToNextStep = () => {
|
||||
router.push(`/dramas/${dramaId}/videos`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载剧集和场景列表
|
||||
episodes.value = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scene-images-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.scene-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scene-card.has-image {
|
||||
border-color: #67c23a;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-number {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scene-preview {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scene-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.placeholder p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.scene-info h4 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.scene-info .description {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
margin: 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
1221
web/src/views/workflow/ScriptGeneration.vue
Normal file
1221
web/src/views/workflow/ScriptGeneration.vue
Normal file
File diff suppressed because it is too large
Load Diff
22
web/src/views/workflow/StoryboardGeneration.vue
Normal file
22
web/src/views/workflow/StoryboardGeneration.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="storyboard-generation">
|
||||
<el-empty description="分镜拆解功能开发中" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
dramaId: string
|
||||
episodeId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
storyboardGenerated: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.storyboard-generation {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
67
web/tailwind.config.js
Normal file
67
web/tailwind.config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Primary brand colors / 主品牌色
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
// Neutral colors for backgrounds / 中性色背景
|
||||
surface: {
|
||||
light: '#ffffff',
|
||||
DEFAULT: '#f8fafc',
|
||||
dark: '#0f172a',
|
||||
},
|
||||
// Card backgrounds / 卡片背景
|
||||
card: {
|
||||
light: '#ffffff',
|
||||
dark: '#1e293b',
|
||||
},
|
||||
// Border colors / 边框色
|
||||
border: {
|
||||
light: '#e2e8f0',
|
||||
dark: '#334155',
|
||||
},
|
||||
// Text colors / 文字色
|
||||
content: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#64748b',
|
||||
muted: '#94a3b8',
|
||||
'primary-dark': '#f1f5f9',
|
||||
'secondary-dark': '#94a3b8',
|
||||
'muted-dark': '#64748b',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05)',
|
||||
'card-hover': '0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05)',
|
||||
'card-dark': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
|
||||
'card-hover-dark': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)',
|
||||
},
|
||||
borderRadius: {
|
||||
'xl': '0.875rem',
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
40
web/tsconfig.json
Normal file
40
web/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
web/tsconfig.node.json
Normal file
11
web/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
web/vite.config.ts
Normal file
22
web/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3012,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5678',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user