create project

This commit is contained in:
2026-01-16 17:30:40 +08:00
commit effac6b017
157 changed files with 45997 additions and 0 deletions

29
web/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

17
web/src/App.vue Normal file
View 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
View 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
View 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}`)
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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}`)
}
}

View 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,
)
);

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

711
web/src/locales/en-US.ts Normal file
View 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
View 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
View 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/v1Gemini: 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
View 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
View 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
View 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
View 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
View 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
View 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
}>
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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()

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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="描述你想创作的短剧主题和故事概念&#10;例如:一个都市白领意外穿越到古代,凭借现代知识改变命运的故事"
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>

View 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="粘贴您的剧本内容&#10;系统将自动识别并拆分为剧集和场景"
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>

View 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>

View 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>

View 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>

View 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>

View 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="描述视频中的动作和运镜&#10;例如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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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
}
}
}
})