1、添加中英文版本
2、修复已知BUG 3、完善功能 4、添加minimax视频渠道
This commit is contained in:
45
web/src/api/audio.ts
Normal file
45
web/src/api/audio.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = '/api/v1'
|
||||
|
||||
export interface ExtractAudioRequest {
|
||||
video_url: string
|
||||
}
|
||||
|
||||
export interface ExtractAudioResponse {
|
||||
audio_url: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface BatchExtractAudioRequest {
|
||||
video_urls: string[]
|
||||
}
|
||||
|
||||
export interface BatchExtractAudioResponse {
|
||||
results: ExtractAudioResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const audioAPI = {
|
||||
/**
|
||||
* 从视频URL提取音频
|
||||
*/
|
||||
extractAudio: async (videoUrl: string): Promise<ExtractAudioResponse> => {
|
||||
const response = await axios.post<ExtractAudioResponse>(
|
||||
`${API_BASE_URL}/audio/extract`,
|
||||
{ video_url: videoUrl }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量从视频URL提取音频
|
||||
*/
|
||||
batchExtractAudio: async (videoUrls: string[]): Promise<BatchExtractAudioResponse> => {
|
||||
const response = await axios.post<BatchExtractAudioResponse>(
|
||||
`${API_BASE_URL}/audio/extract/batch`,
|
||||
{ video_urls: videoUrls }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,8 @@ export const dramaAPI = {
|
||||
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`)
|
||||
extractBackgrounds(episodeId: string, model?: string) {
|
||||
return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`, { model })
|
||||
},
|
||||
|
||||
batchGenerateBackgrounds(episodeId: string) {
|
||||
@@ -112,6 +112,10 @@ export const dramaAPI = {
|
||||
return request.post('/scenes/generate-image', data)
|
||||
},
|
||||
|
||||
deleteScene(sceneId: string) {
|
||||
return request.delete(`/scenes/${sceneId}`)
|
||||
},
|
||||
|
||||
// 完成集数制作(触发视频合成)
|
||||
finalizeEpisode(episodeId: string, timelineData?: any) {
|
||||
return request.post(`/episodes/${episodeId}/finalize`, timelineData || {})
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import type { Character, Episode } from '../types/drama'
|
||||
import type {
|
||||
GenerateCharactersRequest,
|
||||
GenerateEpisodesRequest,
|
||||
GenerateOutlineRequest,
|
||||
OutlineResult
|
||||
GenerateCharactersRequest
|
||||
} 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`)
|
||||
generateStoryboard(episodeId: string, model?: string) {
|
||||
return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`, { model })
|
||||
},
|
||||
|
||||
getTaskStatus(taskId: string) {
|
||||
|
||||
13
web/src/api/settings.ts
Normal file
13
web/src/api/settings.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const settingsAPI = {
|
||||
// 获取系统语言
|
||||
getLanguage() {
|
||||
return request.get<{ language: string }>('/settings/language')
|
||||
},
|
||||
|
||||
// 更新系统语言
|
||||
updateLanguage(language: 'zh' | 'en') {
|
||||
return request.put<{ message: string; language: string }>('/settings/language', { language })
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,7 @@ body {
|
||||
--el-table-header-bg-color: var(--bg-secondary);
|
||||
--el-table-tr-bg-color: var(--bg-card);
|
||||
--el-table-row-hover-bg-color: var(--bg-card-hover);
|
||||
--el-fill-color-lighter: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dark .el-table th.el-table__cell,
|
||||
@@ -563,6 +564,10 @@ body {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Pagination overrides / 分页样式覆盖 */
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: transparent;
|
||||
|
||||
@@ -21,24 +21,90 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setLanguage } from '@/locales'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { settingsAPI } from '@/api/settings'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const currentLang = ref(locale.value)
|
||||
const loading = ref(false)
|
||||
|
||||
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'
|
||||
)
|
||||
const handleCommand = async (lang: string) => {
|
||||
if (loading.value) return
|
||||
|
||||
// 将 zh-CN/en-US 转换为 zh/en (后端格式)
|
||||
const backendLang = lang === 'zh-CN' ? 'zh' : 'en'
|
||||
const currentBackendLang = currentLang.value === 'zh-CN' ? 'zh' : 'en'
|
||||
|
||||
// 双语确认消息
|
||||
const confirmMessage = backendLang === 'zh'
|
||||
? `切换为中文后,后端生成的所有提示词、角色描述、场景描述等都将使用中文。是否继续?
|
||||
|
||||
|
||||
After switching to Chinese, all prompts, character descriptions, scene descriptions generated by the backend will use Chinese. Continue?`
|
||||
: `After switching to English, all prompts, character descriptions, scene descriptions generated by the backend will use English. Continue?
|
||||
|
||||
|
||||
切换为英文后,后端生成的所有提示词、角色描述、场景描述等都将使用英文。是否继续?`
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
confirmMessage,
|
||||
'切换语言 / Switch Language',
|
||||
{
|
||||
confirmButtonText: '确定 / Confirm',
|
||||
cancelButtonText: '取消 / Cancel',
|
||||
type: 'warning',
|
||||
dangerouslyUseHTMLString: false
|
||||
}
|
||||
)
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 调用后端API更新语言设置
|
||||
const res = await settingsAPI.updateLanguage(backendLang)
|
||||
console.log('Backend language updated:', res)
|
||||
|
||||
// 更新前端语言
|
||||
setLanguage(lang)
|
||||
currentLang.value = lang
|
||||
|
||||
// 使用后端返回的双语消息(request拦截器已经返回了data)
|
||||
ElMessage.success({
|
||||
message: res?.message || (backendLang === 'zh' ? '语言已切换为中文' : 'Language switched to English'),
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('Failed to switch language:', error)
|
||||
|
||||
// 安全获取错误消息
|
||||
let errorMessage = '未知错误'
|
||||
if (error?.message) {
|
||||
errorMessage = error.message
|
||||
} else if (error?.response?.data?.error?.message) {
|
||||
errorMessage = error.response.data.error.message
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error
|
||||
}
|
||||
|
||||
// 双语错误提示
|
||||
const errorMsg = currentBackendLang === 'zh'
|
||||
? `切换语言失败: ${errorMessage}`
|
||||
: `Failed to switch language: ${errorMessage}`
|
||||
|
||||
ElMessage.error({
|
||||
message: errorMsg,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -283,13 +283,9 @@ const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'gpt-4o',
|
||||
'gemini-3-pro-preview',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'doubao-seed-1-8-251228',
|
||||
'kimi-k2-thinking',
|
||||
'gemini-3-pro',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-pro-preview'
|
||||
'doubao-seed-1-8-251228'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -341,18 +337,43 @@ const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
|
||||
'sora-pro'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: 'MiniMax 海螺',
|
||||
models: [
|
||||
'MiniMax-Hailuo-2.3',
|
||||
'MiniMax-Hailuo-2.3-Fast',
|
||||
'MiniMax-Hailuo-02'
|
||||
]
|
||||
},
|
||||
{ id: 'openai', name: 'OpenAI', models: ['sora-2', 'sora-2-pro'] }
|
||||
]
|
||||
}
|
||||
|
||||
// 当前可用的厂商列表(显示所有配置的厂商)
|
||||
const availableProviders = computed(() => {
|
||||
// 返回当前service_type下的所有厂商
|
||||
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 || []
|
||||
|
||||
// 从已激活的配置中提取该 provider 的所有模型
|
||||
const activeConfigsForProvider = configs.value.filter(
|
||||
c => c.provider === form.provider &&
|
||||
c.service_type === form.service_type &&
|
||||
c.is_active
|
||||
)
|
||||
|
||||
// 提取所有模型,去重
|
||||
const models = new Set<string>()
|
||||
activeConfigsForProvider.forEach(config => {
|
||||
config.model.forEach(m => models.add(m))
|
||||
})
|
||||
|
||||
return Array.from(models)
|
||||
})
|
||||
|
||||
const fullEndpointExample = computed(() => {
|
||||
@@ -379,6 +400,8 @@ const fullEndpointExample = computed(() => {
|
||||
endpoint = '/video/generations'
|
||||
} else if (provider === 'doubao' || provider === 'volcengine' || provider === 'volces') {
|
||||
endpoint = '/contents/generations/tasks'
|
||||
} else if (provider === 'minimax') {
|
||||
endpoint = '/video_generation'
|
||||
} else if (provider === 'openai') {
|
||||
endpoint = '/videos'
|
||||
} else {
|
||||
@@ -585,9 +608,17 @@ const handleTabChange = (tabName: string | number) => {
|
||||
const handleProviderChange = () => {
|
||||
form.model = []
|
||||
|
||||
// 根据厂商自动设置 Base URL
|
||||
if (form.provider === 'gemini' || form.provider === 'google') {
|
||||
form.base_url = 'https://api.chatfire.site'
|
||||
form.base_url = 'https://generativelanguage.googleapis.com'
|
||||
} else if (form.provider === 'minimax') {
|
||||
form.base_url = 'https://api.minimaxi.com/v1'
|
||||
} else if (form.provider === 'volces' || form.provider === 'volcengine') {
|
||||
form.base_url = 'https://ark.cn-beijing.volces.com/api/v3'
|
||||
} else if (form.provider === 'openai') {
|
||||
form.base_url = 'https://api.openai.com/v1'
|
||||
} else {
|
||||
// chatfire 和其他厂商
|
||||
form.base_url = 'https://api.chatfire.site/v1'
|
||||
}
|
||||
|
||||
@@ -812,6 +843,9 @@ watch(visible, (val) => {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- Left section: Logo + Left slot -->
|
||||
<div class="header-left">
|
||||
<router-link v-if="showLogo" to="/" class="logo">
|
||||
<span class="logo-text">🎬 AI Drama</span>
|
||||
<span class="logo-text">🎬 HuoBao Drama</span>
|
||||
</router-link>
|
||||
<!-- Left slot for business content | 左侧插槽用于业务内容 -->
|
||||
<slot name="left" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<router-link to="/" class="logo">
|
||||
<span class="logo-text">🎬 AI Drama</span>
|
||||
<span class="logo-text">🎬 HuoBao Drama</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
:class="{ active: currentShotIndex === index }"
|
||||
@click="selectShot(index)"
|
||||
>
|
||||
<div class="scene-number">{{ shot.shot_number }}</div>
|
||||
<div class="scene-number">{{ shot.storyboard_number }}</div>
|
||||
<div class="scene-content">
|
||||
<div class="scene-title">
|
||||
<el-tag size="small" type="info">{{ shot.shot_type }}</el-tag>
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="center-panel">
|
||||
<div class="preview-header">
|
||||
<div class="header-info">
|
||||
<el-tag type="info">镜头 {{ currentShot?.shot_number || '-' }}</el-tag>
|
||||
<el-tag type="info">镜头 {{ currentShot?.storyboard_number || '-' }}</el-tag>
|
||||
<span class="shot-type">{{ currentShot?.shot_type }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -101,7 +101,7 @@
|
||||
@click="selectShot(index)"
|
||||
>
|
||||
<div class="clip-content">
|
||||
<span class="clip-number">{{ shot.shot_number }}</span>
|
||||
<span class="clip-number">{{ shot.storyboard_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,11 +116,11 @@
|
||||
<div class="param-section" v-if="currentShot">
|
||||
<div class="param-group">
|
||||
<label>镜号</label>
|
||||
<el-input v-model="currentShot.shot_number" disabled size="small" />
|
||||
<el-input :model-value="currentShot.storyboard_number" disabled size="small" />
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>景别 (Scene)</label>
|
||||
<el-select v-model="currentShot.shot_type" size="small" @change="handleShotUpdate">
|
||||
<label>景别</label>
|
||||
<el-select v-model="currentShot.shot_type" size="small" @change="handleShotUpdateImmediate">
|
||||
<el-option label="特写" value="特写" />
|
||||
<el-option label="近景" value="近景" />
|
||||
<el-option label="中景" value="中景" />
|
||||
@@ -128,14 +128,35 @@
|
||||
<el-option label="远景" value="远景" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>镜头角度</label>
|
||||
<el-select v-model="currentShot.angle" size="small" @change="handleShotUpdateImmediate">
|
||||
<el-option label="平视" value="平视" />
|
||||
<el-option label="仰视" value="仰视" />
|
||||
<el-option label="俯视" value="俯视" />
|
||||
<el-option label="侧面" value="侧面" />
|
||||
<el-option label="背面" value="背面" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>运镜方式</label>
|
||||
<el-select v-model="currentShot.movement" size="small" @change="handleShotUpdateImmediate">
|
||||
<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>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-group">
|
||||
<label>时间</label>
|
||||
<el-input v-model="currentShot.time" size="small" @change="handleShotUpdate" />
|
||||
<el-input v-model="currentShot.time" size="small" @blur="handleShotUpdateImmediate" />
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>地点</label>
|
||||
<el-input v-model="currentShot.location" size="small" @change="handleShotUpdate" />
|
||||
<el-input v-model="currentShot.location" size="small" @blur="handleShotUpdateImmediate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +170,7 @@
|
||||
:rows="2"
|
||||
size="small"
|
||||
placeholder="角色对话或旁白"
|
||||
@change="handleShotUpdate"
|
||||
@blur="handleShotUpdateImmediate"
|
||||
/>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
@@ -159,7 +180,7 @@
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
size="small"
|
||||
@change="handleShotUpdate"
|
||||
@blur="handleShotUpdateImmediate"
|
||||
/>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
@@ -169,24 +190,32 @@
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
size="small"
|
||||
@change="handleShotUpdate"
|
||||
@blur="handleShotUpdateImmediate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<el-divider />
|
||||
|
||||
<div class="param-group">
|
||||
<label>情绪与强度</label>
|
||||
<div class="param-row">
|
||||
<el-input v-model="currentShot.emotion" size="small" placeholder="情绪" @change="handleShotUpdate" />
|
||||
<el-select v-model="currentShot.emotion_intensity" size="small" @change="handleShotUpdate">
|
||||
<el-option label="极强↑↑↑" value="3" />
|
||||
<el-option label="强↑↑" value="2" />
|
||||
<el-option label="中↑" value="1" />
|
||||
<el-option label="平稳→" value="0" />
|
||||
<el-option label="弱↓" value="-1" />
|
||||
</el-select>
|
||||
</div>
|
||||
<label>环境氛围</label>
|
||||
<el-input
|
||||
v-model="currentShot.atmosphere"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
size="small"
|
||||
placeholder="描述光线、色调、声音环境等"
|
||||
@blur="handleShotUpdateImmediate"
|
||||
/>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>时长(秒)</label>
|
||||
<el-input-number
|
||||
v-model="currentShot.duration"
|
||||
:min="4"
|
||||
:max="12"
|
||||
size="small"
|
||||
@change="handleShotUpdateImmediate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
@@ -382,8 +411,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { debounce } from 'lodash-es'
|
||||
import {
|
||||
VideoPlay,
|
||||
VideoPause,
|
||||
@@ -402,21 +432,24 @@ import { videoAPI } from '@/api/video'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Storyboard {
|
||||
id?: string
|
||||
shot_number: string
|
||||
shot_type: string
|
||||
time: string
|
||||
location: string
|
||||
action: string
|
||||
result: string
|
||||
emotion: string
|
||||
emotion_intensity: string
|
||||
id?: string | number
|
||||
storyboard_number: number
|
||||
shot_type?: string
|
||||
angle?: string
|
||||
movement?: string
|
||||
time?: string
|
||||
location?: string
|
||||
action?: string
|
||||
result?: string
|
||||
atmosphere?: string
|
||||
dialogue?: string
|
||||
duration?: number
|
||||
background_url?: string
|
||||
video_url?: string
|
||||
camera_angle?: string
|
||||
motion?: string
|
||||
background_id?: string | number
|
||||
scene_id?: string | number
|
||||
title?: string
|
||||
bgm_prompt?: string
|
||||
sound_effect?: string
|
||||
}
|
||||
|
||||
interface Background {
|
||||
@@ -533,10 +566,101 @@ const autoSelectCharacters = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleShotUpdate = () => {
|
||||
if (currentShot.value) {
|
||||
emit('update:storyboard', currentShot.value)
|
||||
// 测试函数:不使用防抖,立即触发
|
||||
const handleShotUpdateImmediate = async () => {
|
||||
console.log('=== handleShotUpdate 被触发 ===')
|
||||
|
||||
if (!currentShot.value) {
|
||||
console.warn('handleShotUpdate: currentShot.value is null')
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentShot.value.id) {
|
||||
console.warn('handleShotUpdate: currentShot.value.id is null or undefined', currentShot.value)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建更新数据,只发送有值的字段(包括空字符串)
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
if (currentShot.value.shot_type !== undefined) updateData.shot_type = currentShot.value.shot_type
|
||||
if (currentShot.value.angle !== undefined) updateData.angle = currentShot.value.angle
|
||||
if (currentShot.value.movement !== undefined) updateData.movement = currentShot.value.movement
|
||||
if (currentShot.value.time !== undefined) updateData.time = currentShot.value.time
|
||||
if (currentShot.value.location !== undefined) updateData.location = currentShot.value.location
|
||||
if (currentShot.value.action !== undefined) updateData.action = currentShot.value.action
|
||||
if (currentShot.value.dialogue !== undefined) updateData.dialogue = currentShot.value.dialogue
|
||||
if (currentShot.value.result !== undefined) updateData.result = currentShot.value.result
|
||||
if (currentShot.value.atmosphere !== undefined) updateData.atmosphere = currentShot.value.atmosphere
|
||||
if (currentShot.value.duration !== undefined) updateData.duration = currentShot.value.duration
|
||||
if (currentShot.value.title !== undefined) updateData.title = currentShot.value.title
|
||||
if (currentShot.value.bgm_prompt !== undefined) updateData.bgm_prompt = currentShot.value.bgm_prompt
|
||||
if (currentShot.value.sound_effect !== undefined) updateData.sound_effect = currentShot.value.sound_effect
|
||||
|
||||
console.log('调用更新接口:', {
|
||||
storyboard_id: currentShot.value.id,
|
||||
updateData
|
||||
})
|
||||
|
||||
await dramaAPI.updateStoryboard(currentShot.value.id.toString(), updateData)
|
||||
|
||||
emit('update:storyboard', currentShot.value)
|
||||
ElMessage.success('分镜更新成功')
|
||||
} catch (error: any) {
|
||||
console.error('更新分镜失败:', error)
|
||||
ElMessage.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleShotUpdate = debounce(async () => {
|
||||
if (!currentShot.value) {
|
||||
console.warn('handleShotUpdate: currentShot.value is null')
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentShot.value.id) {
|
||||
console.warn('handleShotUpdate: currentShot.value.id is null or undefined', currentShot.value)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建更新数据,只发送有值的字段(包括空字符串)
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
if (currentShot.value.shot_type !== undefined) updateData.shot_type = currentShot.value.shot_type
|
||||
if (currentShot.value.angle !== undefined) updateData.angle = currentShot.value.angle
|
||||
if (currentShot.value.movement !== undefined) updateData.movement = currentShot.value.movement
|
||||
if (currentShot.value.time !== undefined) updateData.time = currentShot.value.time
|
||||
if (currentShot.value.location !== undefined) updateData.location = currentShot.value.location
|
||||
if (currentShot.value.action !== undefined) updateData.action = currentShot.value.action
|
||||
if (currentShot.value.dialogue !== undefined) updateData.dialogue = currentShot.value.dialogue
|
||||
if (currentShot.value.result !== undefined) updateData.result = currentShot.value.result
|
||||
if (currentShot.value.atmosphere !== undefined) updateData.atmosphere = currentShot.value.atmosphere
|
||||
if (currentShot.value.duration !== undefined) updateData.duration = currentShot.value.duration
|
||||
if (currentShot.value.title !== undefined) updateData.title = currentShot.value.title
|
||||
if (currentShot.value.bgm_prompt !== undefined) updateData.bgm_prompt = currentShot.value.bgm_prompt
|
||||
if (currentShot.value.sound_effect !== undefined) updateData.sound_effect = currentShot.value.sound_effect
|
||||
|
||||
console.log('调用更新接口:', {
|
||||
storyboard_id: currentShot.value.id,
|
||||
updateData
|
||||
})
|
||||
|
||||
await dramaAPI.updateStoryboard(currentShot.value.id.toString(), updateData)
|
||||
|
||||
emit('update:storyboard', currentShot.value)
|
||||
ElMessage.success('分镜更新成功')
|
||||
} catch (error: any) {
|
||||
console.error('更新分镜失败:', error)
|
||||
ElMessage.error(error.message || '更新失败')
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 使用立即触发版本进行测试
|
||||
const testUpdate = () => {
|
||||
console.log('testUpdate 被调用')
|
||||
handleShotUpdateImmediate()
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
@@ -737,7 +861,6 @@ const loadBackgrounds = async () => {
|
||||
}
|
||||
|
||||
// 加载可用角色列表
|
||||
import { onMounted, watch } from 'vue'
|
||||
onMounted(async () => {
|
||||
// 加载背景数据
|
||||
await loadBackgrounds()
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
@timeupdate="handlePreviewTimeUpdate"
|
||||
@ended="handlePreviewEnded"
|
||||
/>
|
||||
<!-- 音频播放器(隐藏) -->
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
:src="currentAudioUrl"
|
||||
@loadedmetadata="handleAudioLoaded"
|
||||
@ended="handleAudioEnded"
|
||||
style="display: none;"
|
||||
/>
|
||||
<!-- 转场效果层 -->
|
||||
<div
|
||||
v-if="transitionState.active"
|
||||
@@ -86,7 +94,7 @@
|
||||
>
|
||||
<div class="media-thumbnail" @click="previewScene(scene)">
|
||||
<video :src="scene.video_url" />
|
||||
<div class="media-duration">{{ scene.duration || '5.0' }}s</div>
|
||||
<div class="media-duration">{{ scene.duration > 0 ? scene.duration.toFixed(1) : '?' }}s</div>
|
||||
<el-button
|
||||
class="delete-btn"
|
||||
type="danger"
|
||||
@@ -379,7 +387,8 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
VideoPlay, VideoPause, Plus, FolderAdd, ArrowLeft, ArrowRight,
|
||||
Scissor, Connection, Setting, ZoomIn, ZoomOut, Refresh, Download, Delete
|
||||
Scissor, Connection, Setting, ZoomIn, ZoomOut, Refresh, Download, Delete,
|
||||
Close, VideoCamera, Check, Loading, Headset, Microphone
|
||||
} from '@element-plus/icons-vue'
|
||||
import { videoMerger, type MergeProgress } from '@/utils/videoMerger'
|
||||
import { trimAndMergeVideos } from '@/utils/ffmpeg'
|
||||
@@ -450,7 +459,7 @@ const availableStoryboards = computed(() => {
|
||||
storyboard_num: a.storyboard_num,
|
||||
storyboard_id: a.storyboard_id,
|
||||
video_url: a.url,
|
||||
duration: a.duration || 5,
|
||||
duration: a.duration || 0,
|
||||
name: a.name,
|
||||
isAsset: true,
|
||||
assetId: a.id
|
||||
@@ -468,6 +477,7 @@ const audioClips = ref<AudioClip[]>([])
|
||||
const selectedClipId = ref<string | null>(null)
|
||||
const selectedAudioClipId = ref<string | null>(null)
|
||||
const previewPlayer = ref<HTMLVideoElement | null>(null)
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
const timelineContainer = ref<HTMLElement | null>(null)
|
||||
const showAudioTrack = ref(true) // 是否显示音频轨道
|
||||
|
||||
@@ -537,6 +547,16 @@ const currentPreviewUrl = computed(() => {
|
||||
return clip?.video_url || timelineClips.value[0]?.video_url || ''
|
||||
})
|
||||
|
||||
// 当前音频URL
|
||||
const currentAudioUrl = computed(() => {
|
||||
if (audioClips.value.length === 0) return ''
|
||||
// 根据当前时间找到应该播放的音频片段
|
||||
const audioClip = audioClips.value.find(a =>
|
||||
currentTime.value >= a.position && currentTime.value < a.position + a.duration
|
||||
)
|
||||
return audioClip?.audio_url || ''
|
||||
})
|
||||
|
||||
const previewScene = (scene: Scene) => {
|
||||
if (previewPlayer.value) {
|
||||
previewPlayer.value.src = scene.video_url
|
||||
@@ -557,6 +577,36 @@ const handlePreviewLoaded = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAudioLoaded = () => {
|
||||
// 音频加载完成后跳转到正确的时间点
|
||||
if (audioPlayer.value && audioClips.value.length > 0) {
|
||||
const audioClip = audioClips.value.find(a =>
|
||||
currentTime.value >= a.position && currentTime.value < a.position + a.duration
|
||||
)
|
||||
if (audioClip) {
|
||||
const offsetInClip = currentTime.value - audioClip.position
|
||||
audioPlayer.value.currentTime = audioClip.start_time + offsetInClip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAudioEnded = () => {
|
||||
// 音频自然结束,尝试播放下一个音频片段
|
||||
const currentAudio = audioClips.value.find(a =>
|
||||
currentTime.value >= a.position && currentTime.value < a.position + a.duration
|
||||
)
|
||||
|
||||
if (currentAudio) {
|
||||
const currentIndex = audioClips.value.findIndex(a => a.id === currentAudio.id)
|
||||
const nextAudio = audioClips.value[currentIndex + 1]
|
||||
|
||||
if (nextAudio && isPlaying.value) {
|
||||
// 有下一个音频片段且正在播放,继续
|
||||
// 时间线会自动更新到下一个片段
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewTimeUpdate = () => {
|
||||
if (!isPlaying.value || !previewPlayer.value) return
|
||||
|
||||
@@ -615,11 +665,24 @@ const switchToClip = async (clip: TimelineClip) => {
|
||||
|
||||
// 暂停当前播放,避免冲突
|
||||
previewPlayer.value.pause()
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.pause()
|
||||
}
|
||||
|
||||
// 切换视频源
|
||||
currentTime.value = clip.position
|
||||
previewPlayer.value.src = clip.video_url
|
||||
|
||||
// 同步切换音频源
|
||||
if (audioClips.value.length > 0 && audioPlayer.value) {
|
||||
const audioClip = audioClips.value.find(a =>
|
||||
clip.position >= a.position && clip.position < a.position + a.duration
|
||||
)
|
||||
if (audioClip) {
|
||||
audioPlayer.value.src = audioClip.audio_url
|
||||
}
|
||||
}
|
||||
|
||||
// 等待视频加载
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -656,6 +719,19 @@ const switchToClip = async (clip: TimelineClip) => {
|
||||
|
||||
if (isPlaying.value) {
|
||||
await previewPlayer.value.play()
|
||||
|
||||
// 同步播放音频
|
||||
if (audioClips.value.length > 0 && audioPlayer.value) {
|
||||
const audioClip = audioClips.value.find(a =>
|
||||
clip.position >= a.position && clip.position < a.position + a.duration
|
||||
)
|
||||
if (audioClip && audioPlayer.value.src) {
|
||||
audioPlayer.value.currentTime = audioClip.start_time
|
||||
audioPlayer.value.play().catch(err => {
|
||||
console.warn('音频播放失败:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换视频片段失败:', error)
|
||||
@@ -737,13 +813,43 @@ const handleTrackDrop = (event: DragEvent) => {
|
||||
addClipToTimeline(scene)
|
||||
}
|
||||
|
||||
const addClipToTimeline = (scene: Scene, insertAtPosition?: number) => {
|
||||
const getVideoDuration = (videoUrl: string): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.src = videoUrl
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = video.duration
|
||||
video.remove()
|
||||
resolve(duration)
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
video.remove()
|
||||
reject(new Error('Failed to load video'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addClipToTimeline = async (scene: Scene, insertAtPosition?: number) => {
|
||||
// 获取视频真实时长
|
||||
let videoDuration = scene.duration || 5
|
||||
if (scene.video_url) {
|
||||
try {
|
||||
videoDuration = await getVideoDuration(scene.video_url)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get video duration, using default or scene duration:', error)
|
||||
videoDuration = scene.duration || 5
|
||||
}
|
||||
}
|
||||
|
||||
// 计算新片段的位置
|
||||
let clipPosition: number
|
||||
let insertAfterIndex: number | null = null
|
||||
|
||||
if (insertAtPosition !== undefined && timelineClips.value.length > 0) {
|
||||
// 如果指定了插入位置,找到应该插入的位置
|
||||
// 如果指定了插入位置,找到应该插入的位置
|
||||
clipPosition = insertAtPosition
|
||||
} else if (selectedClipId.value && timelineClips.value.length > 0) {
|
||||
// 如果有选中的片段,插入到选中片段之后
|
||||
@@ -774,8 +880,8 @@ const addClipToTimeline = (scene: Scene, insertAtPosition?: number) => {
|
||||
storyboard_number: scene.storyboard_number,
|
||||
video_url: scene.video_url,
|
||||
start_time: 0,
|
||||
end_time: scene.duration || 5,
|
||||
duration: scene.duration || 5,
|
||||
end_time: videoDuration,
|
||||
duration: videoDuration,
|
||||
position: clipPosition,
|
||||
order: timelineClips.value.length,
|
||||
transition: {
|
||||
@@ -805,7 +911,7 @@ const addClipToTimeline = (scene: Scene, insertAtPosition?: number) => {
|
||||
}
|
||||
|
||||
// 一键添加全部场景
|
||||
const addAllScenesInOrder = () => {
|
||||
const addAllScenesInOrder = async () => {
|
||||
if (availableStoryboards.value.length === 0) {
|
||||
ElMessage.warning('没有可用的场景')
|
||||
return
|
||||
@@ -819,10 +925,10 @@ const addAllScenesInOrder = () => {
|
||||
// 清空当前选中,让所有场景都添加到末尾
|
||||
selectedClipId.value = null
|
||||
|
||||
// 批量添加
|
||||
sortedScenes.forEach(scene => {
|
||||
addClipToTimeline(scene)
|
||||
})
|
||||
// 批量添加(顺序添加以确保正确的时长)
|
||||
for (const scene of sortedScenes) {
|
||||
await addClipToTimeline(scene)
|
||||
}
|
||||
|
||||
ElMessage.success(`已批量添加 ${sortedScenes.length} 个场景到时间线`)
|
||||
}
|
||||
@@ -965,29 +1071,73 @@ const extractAllAudio = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info('正在提取音频...')
|
||||
|
||||
// 清空现有音频
|
||||
audioClips.value = []
|
||||
|
||||
// 为每个视频片段创建对应的音频片段
|
||||
timelineClips.value.forEach((clip, index) => {
|
||||
const audioClip: AudioClip = {
|
||||
id: `audio_${Date.now()}_${index}`,
|
||||
source_clip_id: clip.id,
|
||||
audio_url: clip.video_url, // 实际应用中应该提取音频,这里暂用视频URL
|
||||
start_time: clip.start_time,
|
||||
end_time: clip.end_time,
|
||||
duration: clip.duration,
|
||||
position: clip.position,
|
||||
order: index,
|
||||
volume: 1.0
|
||||
}
|
||||
audioClips.value.push(audioClip)
|
||||
const loadingMessage = ElMessage.info({
|
||||
message: '正在从视频中提取音频轨道,请稍候...',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
updateAudioClipOrders()
|
||||
ElMessage.success(`已提取 ${audioClips.value.length} 个音频片段`)
|
||||
try {
|
||||
// 清空现有音频
|
||||
audioClips.value = []
|
||||
|
||||
// 收集所有视频URL
|
||||
const videoUrls = timelineClips.value.map(clip => clip.video_url)
|
||||
|
||||
// 调用后端API批量提取音频
|
||||
const { audioAPI } = await import('@/api/audio')
|
||||
const response = await audioAPI.batchExtractAudio(videoUrls)
|
||||
|
||||
if (!response.results || response.results.length === 0) {
|
||||
throw new Error('音频提取失败,未返回结果')
|
||||
}
|
||||
|
||||
// 为每个视频片段创建对应的音频片段
|
||||
timelineClips.value.forEach((clip, index) => {
|
||||
const extractedAudio = response.results[index]
|
||||
if (!extractedAudio) {
|
||||
console.warn(`视频片段 ${index} 未能提取音频`)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证音频时长
|
||||
const audioDuration = extractedAudio.duration
|
||||
if (!audioDuration || audioDuration <= 0) {
|
||||
console.error(`音频片段 ${index} 时长无效:`, audioDuration)
|
||||
throw new Error(`音频片段 ${index + 1} 时长无效`)
|
||||
}
|
||||
|
||||
console.log(`音频片段 ${index}:`, {
|
||||
video_duration: clip.duration,
|
||||
audio_duration: audioDuration,
|
||||
video_position: clip.position,
|
||||
video_url: clip.video_url,
|
||||
audio_url: extractedAudio.audio_url
|
||||
})
|
||||
|
||||
const audioClip: AudioClip = {
|
||||
id: `audio_${Date.now()}_${index}`,
|
||||
source_clip_id: clip.id,
|
||||
audio_url: extractedAudio.audio_url,
|
||||
start_time: 0, // 音频从头开始播放
|
||||
end_time: audioDuration, // 使用实际音频时长
|
||||
duration: audioDuration, // 使用提取的音频时长
|
||||
position: clip.position, // 和视频片段在时间轴上相同位置
|
||||
order: index,
|
||||
volume: 1.0
|
||||
}
|
||||
audioClips.value.push(audioClip)
|
||||
})
|
||||
|
||||
updateAudioClipOrders()
|
||||
loadingMessage.close()
|
||||
ElMessage.success(`已成功提取 ${audioClips.value.length} 个音频片段`)
|
||||
} catch (error: any) {
|
||||
console.error('提取音频失败:', error)
|
||||
loadingMessage.close()
|
||||
ElMessage.error(error.message || '音频提取失败,请重试')
|
||||
// 清空部分提取的音频
|
||||
audioClips.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const selectAudioClip = (audio: AudioClip) => {
|
||||
@@ -1325,7 +1475,7 @@ const clickTimeline = (event: MouseEvent) => {
|
||||
const seekToTime = (time: number) => {
|
||||
currentTime.value = time
|
||||
|
||||
// 找到对应时间的片段并播放
|
||||
// 找到对应时间的视频片段并播放
|
||||
const clip = timelineClips.value.find(c =>
|
||||
time >= c.position && time < c.position + c.duration
|
||||
)
|
||||
@@ -1344,6 +1494,33 @@ const seekToTime = (time: number) => {
|
||||
previewPlayer.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
// 同步音频播放器
|
||||
if (audioClips.value.length > 0 && audioPlayer.value) {
|
||||
const audioClip = audioClips.value.find(a =>
|
||||
time >= a.position && time < a.position + a.duration
|
||||
)
|
||||
|
||||
if (audioClip) {
|
||||
// 切换音频源(如果需要)
|
||||
if (audioPlayer.value.src !== audioClip.audio_url) {
|
||||
audioPlayer.value.src = audioClip.audio_url
|
||||
}
|
||||
|
||||
// 跳转到音频片段内的对应时间
|
||||
const offsetInAudioClip = time - audioClip.position
|
||||
audioPlayer.value.currentTime = audioClip.start_time + offsetInAudioClip
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioPlayer.value.play().catch(err => {
|
||||
console.warn('音频播放失败:', err)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 当前位置没有音频,暂停音频播放器
|
||||
audioPlayer.value.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 播放控制
|
||||
@@ -1355,7 +1532,7 @@ const playTimeline = () => {
|
||||
|
||||
isPlaying.value = true
|
||||
|
||||
// 找到当前时间对应的片段
|
||||
// 找到当前时间对应的视频片段
|
||||
const clip = timelineClips.value.find(c =>
|
||||
currentTime.value >= c.position && currentTime.value < c.position + c.duration
|
||||
)
|
||||
@@ -1373,6 +1550,24 @@ const playTimeline = () => {
|
||||
seekToTime(0)
|
||||
previewPlayer.value?.play()
|
||||
}
|
||||
|
||||
// 同时播放音频(如果有)
|
||||
if (audioClips.value.length > 0 && audioPlayer.value) {
|
||||
const audioClip = audioClips.value.find(a =>
|
||||
currentTime.value >= a.position && currentTime.value < a.position + a.duration
|
||||
)
|
||||
|
||||
if (audioClip) {
|
||||
if (audioPlayer.value.src !== audioClip.audio_url) {
|
||||
audioPlayer.value.src = audioClip.audio_url
|
||||
}
|
||||
const offsetInAudioClip = currentTime.value - audioClip.position
|
||||
audioPlayer.value.currentTime = audioClip.start_time + offsetInAudioClip
|
||||
audioPlayer.value.play().catch(err => {
|
||||
console.warn('音频播放失败:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pauseTimeline = () => {
|
||||
@@ -1380,6 +1575,10 @@ const pauseTimeline = () => {
|
||||
if (previewPlayer.value) {
|
||||
previewPlayer.value.pause()
|
||||
}
|
||||
// 同时暂停音频
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
|
||||
@@ -60,6 +60,13 @@ export default {
|
||||
aiConfig: 'AI Configuration',
|
||||
general: 'General',
|
||||
language: 'Language',
|
||||
systemLanguage: 'System Language',
|
||||
currentLanguage: 'Current Language',
|
||||
languageSwitchNotice: 'Language Switch Notice',
|
||||
languageSwitchDesc: 'After switching system language, the following will be affected:',
|
||||
languageSwitchItem1: 'All prompts generated by backend (storyboard descriptions, character descriptions, scene descriptions, etc.) will use the selected language',
|
||||
languageSwitchItem2: 'Conversations with AI models will use the selected language',
|
||||
languageSwitchItem3: 'Already generated content will not be automatically updated and needs to be regenerated',
|
||||
theme: 'Theme'
|
||||
},
|
||||
aiConfig: {
|
||||
|
||||
@@ -74,6 +74,13 @@ export default {
|
||||
title: '设置',
|
||||
aiConfig: 'AI配置',
|
||||
general: '通用设置',
|
||||
systemLanguage: '系统语言',
|
||||
currentLanguage: '当前语言',
|
||||
languageSwitchNotice: '语言切换提醒',
|
||||
languageSwitchDesc: '切换系统语言后,以下内容将受到影响:',
|
||||
languageSwitchItem1: '后端生成的所有提示词(分镜描述、角色描述、场景描述等)将使用所选语言',
|
||||
languageSwitchItem2: '与AI模型的对话将使用所选语言',
|
||||
languageSwitchItem3: '已生成的内容不会自动更新,需要重新生成',
|
||||
language: '语言',
|
||||
theme: '主题'
|
||||
},
|
||||
|
||||
@@ -22,11 +22,6 @@ const routes: RouteRecordRaw[] = [
|
||||
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',
|
||||
|
||||
@@ -1,50 +1,10 @@
|
||||
export interface GenerateOutlineRequest {
|
||||
drama_id: string
|
||||
theme: string
|
||||
genre?: string
|
||||
style?: string
|
||||
length?: number
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface GenerateCharactersRequest {
|
||||
drama_id: string
|
||||
episode_id?: number
|
||||
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
|
||||
model?: string // 指定使用的文本模型
|
||||
}
|
||||
|
||||
export interface ParseScriptRequest {
|
||||
|
||||
@@ -262,6 +262,7 @@ 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 { characterLibraryAPI } from '@/api/character-library'
|
||||
import type { Drama } from '@/types/drama'
|
||||
import { AppHeader, StatCard, EmptyState } from '@/components/common'
|
||||
|
||||
@@ -464,29 +465,30 @@ const deleteCharacter = async (character: any) => {
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除角色"${character.name}"吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
if (!character.id) {
|
||||
ElMessage.error('角色ID不存在,无法删除')
|
||||
return
|
||||
}
|
||||
|
||||
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 ElMessageBox.confirm(
|
||||
`确定要删除角色"${character.name}"吗?此操作不可恢复。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await characterLibraryAPI.deleteCharacter(character.id)
|
||||
ElMessage.success('角色已删除')
|
||||
await loadDramaData()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除角色失败:', error)
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,22 +521,30 @@ const editScene = (scene: any) => {
|
||||
}
|
||||
|
||||
const deleteScene = async (scene: any) => {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除场景"${scene.name}"吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
if (!scene.id) {
|
||||
ElMessage.error('场景ID不存在,无法删除')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: 调用删除API
|
||||
ElMessage.success('删除成功')
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除场景"${scene.name || scene.location}"吗?此操作不可恢复。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await dramaAPI.deleteScene(scene.id.toString())
|
||||
ElMessage.success('场景已删除')
|
||||
await loadScenes()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除场景失败:', error)
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +753,10 @@ onMounted(() => {
|
||||
|
||||
.dark :deep(.el-table) {
|
||||
background: var(--bg-card);
|
||||
--el-table-bg-color: var(--bg-card);
|
||||
--el-table-tr-bg-color: var(--bg-card);
|
||||
--el-table-header-bg-color: var(--bg-secondary);
|
||||
--el-fill-color-lighter: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-table th),
|
||||
@@ -755,6 +769,14 @@ onMounted(() => {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dark :deep(.el-table__body tr:hover > td) {
|
||||
background: var(--bg-card-hover) !important;
|
||||
}
|
||||
|
||||
.dark :deep(.el-descriptions) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
@@ -936,8 +936,12 @@ const loadAIConfigs = async () => {
|
||||
aiAPI.list('image')
|
||||
])
|
||||
|
||||
// 只使用激活的配置
|
||||
const activeTextList = textList.filter(c => c.is_active)
|
||||
const activeImageList = imageList.filter(c => c.is_active)
|
||||
|
||||
// 展开模型列表并去重(保留优先级最高的)
|
||||
const allTextModels = textList.flatMap(config => {
|
||||
const allTextModels = activeTextList.flatMap(config => {
|
||||
const models = Array.isArray(config.model) ? config.model : [config.model]
|
||||
return models.map(modelName => ({
|
||||
modelName,
|
||||
@@ -956,7 +960,7 @@ const loadAIConfigs = async () => {
|
||||
})
|
||||
textModels.value = Array.from(textModelMap.values())
|
||||
|
||||
const allImageModels = imageList.flatMap(config => {
|
||||
const allImageModels = activeImageList.flatMap(config => {
|
||||
const models = Array.isArray(config.model) ? config.model : [config.model]
|
||||
return models.map(modelName => ({
|
||||
modelName,
|
||||
@@ -982,6 +986,28 @@ const loadAIConfigs = async () => {
|
||||
if (imageModels.value.length > 0 && !selectedImageModel.value) {
|
||||
selectedImageModel.value = imageModels.value[0].modelName
|
||||
}
|
||||
|
||||
// 验证已选择的模型是否还在可用列表中,如果不在则重置为默认值
|
||||
const availableTextModelNames = textModels.value.map(m => m.modelName)
|
||||
const availableImageModelNames = imageModels.value.map(m => m.modelName)
|
||||
|
||||
if (selectedTextModel.value && !availableTextModelNames.includes(selectedTextModel.value)) {
|
||||
console.warn(`已选择的文本模型 ${selectedTextModel.value} 不在可用列表中,重置为默认值`)
|
||||
selectedTextModel.value = textModels.value.length > 0 ? textModels.value[0].modelName : ''
|
||||
// 更新 localStorage
|
||||
if (selectedTextModel.value) {
|
||||
localStorage.setItem(`ai_text_model_${dramaId}`, selectedTextModel.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedImageModel.value && !availableImageModelNames.includes(selectedImageModel.value)) {
|
||||
console.warn(`已选择的图片模型 ${selectedImageModel.value} 不在可用列表中,重置为默认值`)
|
||||
selectedImageModel.value = imageModels.value.length > 0 ? imageModels.value[0].modelName : ''
|
||||
// 更新 localStorage
|
||||
if (selectedImageModel.value) {
|
||||
localStorage.setItem(`ai_image_model_${dramaId}`, selectedImageModel.value)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载AI配置失败:', error)
|
||||
}
|
||||
@@ -1231,10 +1257,12 @@ const extractCharactersAndBackgrounds = async () => {
|
||||
const [characterTask, backgroundTask] = await Promise.all([
|
||||
generationAPI.generateCharacters({
|
||||
drama_id: dramaId.toString(),
|
||||
episode_id: episodeId,
|
||||
outline: currentEpisode.value.script_content || '',
|
||||
count: 0
|
||||
count: 0,
|
||||
model: selectedTextModel.value // 传递用户选择的文本模型
|
||||
}),
|
||||
dramaAPI.extractBackgrounds(episodeId)
|
||||
dramaAPI.extractBackgrounds(episodeId.toString(), selectedTextModel.value) // 传递用户选择的文本模型
|
||||
])
|
||||
|
||||
ElMessage.success('任务已创建,正在后台处理...')
|
||||
@@ -1449,8 +1477,20 @@ const generateShots = async () => {
|
||||
|
||||
try {
|
||||
const episodeId = currentEpisode.value.id.toString()
|
||||
|
||||
// 【调试日志】输出当前操作的集数信息
|
||||
console.log('=== 开始生成分镜 ===')
|
||||
console.log('当前 episodeNumber (路由参数):', episodeNumber)
|
||||
console.log('当前 episodeId (从 currentEpisode 获取):', episodeId)
|
||||
console.log('currentEpisode 完整信息:', {
|
||||
id: currentEpisode.value?.id,
|
||||
episode_number: currentEpisode.value?.episode_number,
|
||||
title: currentEpisode.value?.title
|
||||
})
|
||||
console.log('所有剧集列表:', drama.value?.episodes?.map(ep => ({ id: ep.id, episode_number: ep.episode_number, title: ep.title })))
|
||||
|
||||
// 创建异步任务
|
||||
const response = await generationAPI.generateStoryboard(episodeId)
|
||||
const response = await generationAPI.generateStoryboard(episodeId, selectedTextModel.value)
|
||||
|
||||
taskMessage.value = response.message || '任务已创建'
|
||||
|
||||
|
||||
@@ -168,25 +168,25 @@
|
||||
<div class="narrative-section">
|
||||
<div class="section-label">{{ $t('editor.action') }} (Action)</div>
|
||||
<el-input v-model="currentStoryboard.action" type="textarea" :rows="3"
|
||||
:placeholder="$t('editor.actionPlaceholder')" />
|
||||
:placeholder="$t('editor.actionPlaceholder')" @blur="saveStoryboardField('action')" />
|
||||
</div>
|
||||
|
||||
<div class="narrative-section">
|
||||
<div class="section-label">{{ $t('editor.result') }} (Result)</div>
|
||||
<el-input v-model="currentStoryboard.result" type="textarea" :rows="2"
|
||||
:placeholder="$t('editor.resultPlaceholder')" />
|
||||
:placeholder="$t('editor.resultPlaceholder')" @blur="saveStoryboardField('result')" />
|
||||
</div>
|
||||
|
||||
<div class="dialogue-section">
|
||||
<div class="section-label">{{ $t('editor.dialogue') }} (Dialogue)</div>
|
||||
<el-input v-model="currentStoryboard.dialogue" type="textarea" :rows="3"
|
||||
:placeholder="$t('editor.dialoguePlaceholder')" />
|
||||
:placeholder="$t('editor.dialoguePlaceholder')" @blur="saveStoryboardField('dialogue')" />
|
||||
</div>
|
||||
|
||||
<div class="narrative-section">
|
||||
<div class="section-label">{{ $t('editor.description') }} (Description)</div>
|
||||
<el-input v-model="currentStoryboard.description" type="textarea" :rows="3"
|
||||
:placeholder="$t('editor.descriptionPlaceholder')" />
|
||||
:placeholder="$t('editor.descriptionPlaceholder')" @blur="saveStoryboardField('description')" />
|
||||
</div>
|
||||
|
||||
<!-- 音效设置 -->
|
||||
@@ -194,7 +194,7 @@
|
||||
<div class="section-label">{{ $t('editor.soundEffects') }}</div>
|
||||
<div class="audio-controls">
|
||||
<el-input v-model="currentStoryboard.sound_effect" :placeholder="$t('editor.soundEffectsPlaceholder')"
|
||||
size="small" type="textarea" :rows="2" />
|
||||
size="small" type="textarea" :rows="2" @blur="saveStoryboardField('sound_effect')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
<div class="section-label">{{ $t('editor.bgmPrompt') }}</div>
|
||||
<div class="audio-controls">
|
||||
<el-input v-model="currentStoryboard.bgm_prompt" :placeholder="$t('editor.bgmPromptPlaceholder')"
|
||||
size="small" type="textarea" :rows="2" />
|
||||
size="small" type="textarea" :rows="2" @blur="saveStoryboardField('bgm_prompt')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
<div class="section-label">{{ $t('editor.atmosphere') }}</div>
|
||||
<div class="audio-controls">
|
||||
<el-input v-model="currentStoryboard.atmosphere" :placeholder="$t('editor.atmospherePlaceholder')"
|
||||
size="small" type="textarea" :rows="2" />
|
||||
size="small" type="textarea" :rows="2" @blur="saveStoryboardField('atmosphere')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-steps :active="currentStep" finish-status="success" align-center>
|
||||
<el-step title="生成大纲" />
|
||||
<el-step title="生成角色" />
|
||||
<el-step title="生成剧集" />
|
||||
</el-steps>
|
||||
|
||||
<div class="step-content">
|
||||
<!-- 步骤1: 生成大纲 -->
|
||||
<div v-if="currentStep === 0" class="step-panel">
|
||||
<el-form :model="outlineForm" label-width="100px">
|
||||
<el-form-item label="创作主题" required>
|
||||
<el-input
|
||||
v-model="outlineForm.theme"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述你想创作的短剧主题和故事概念 例如:一个都市白领意外穿越到古代,凭借现代知识改变命运的故事"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="类型偏好">
|
||||
<el-select v-model="outlineForm.genre" placeholder="选择类型" clearable>
|
||||
<el-option label="都市" value="都市" />
|
||||
<el-option label="古装" value="古装" />
|
||||
<el-option label="悬疑" value="悬疑" />
|
||||
<el-option label="爱情" value="爱情" />
|
||||
<el-option label="喜剧" value="喜剧" />
|
||||
<el-option label="奇幻" value="奇幻" />
|
||||
<el-option label="科幻" value="科幻" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="风格要求">
|
||||
<el-input
|
||||
v-model="outlineForm.style"
|
||||
placeholder="例如:轻松幽默、紧张刺激、温馨治愈"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="剧集数量">
|
||||
<el-input-number v-model="outlineForm.length" :min="3" :max="20" />
|
||||
<span class="form-tip">建议3-10集</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创意度">
|
||||
<el-slider v-model="temperatureValue" :min="0" :max="100" :marks="temperatureMarks" />
|
||||
<div class="form-tip">数值越高,生成内容越有创意但可能不稳定</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 生成角色 -->
|
||||
<div v-if="currentStep === 1" class="step-panel">
|
||||
<div v-if="outlineResult" class="outline-preview">
|
||||
<h3>{{ outlineResult.title }}</h3>
|
||||
<p class="summary">{{ outlineResult.summary }}</p>
|
||||
<div class="tags">
|
||||
<el-tag v-for="tag in outlineResult.tags" :key="tag" size="small">{{ tag }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :model="charactersForm" label-width="100px">
|
||||
<el-form-item label="角色数量">
|
||||
<el-input-number v-model="charactersForm.count" :min="2" :max="10" />
|
||||
<span class="form-tip">建议3-5个主要角色</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创意度">
|
||||
<el-slider v-model="charactersTemperature" :min="0" :max="100" :marks="temperatureMarks" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 生成剧集 -->
|
||||
<div v-if="currentStep === 2" class="step-panel">
|
||||
<div v-if="characters.length > 0" class="characters-preview">
|
||||
<h4>已创建角色:</h4>
|
||||
<div class="character-list">
|
||||
<el-tag
|
||||
v-for="char in characters"
|
||||
:key="char.id"
|
||||
size="large"
|
||||
effect="plain"
|
||||
>
|
||||
{{ char.name }} ({{ char.role }})
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :model="episodesForm" label-width="100px">
|
||||
<el-form-item label="剧集数量" required>
|
||||
<el-input-number v-model="episodesForm.episode_count" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创意度">
|
||||
<el-slider v-model="episodesTemperature" :min="0" :max="100" :marks="temperatureMarks" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果展示 -->
|
||||
<div v-if="currentStep === 3" class="step-panel">
|
||||
<el-result
|
||||
icon="success"
|
||||
title="生成完成!"
|
||||
sub-title="已成功生成剧本大纲、角色设定和分集剧本"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="viewDrama">查看剧本详情</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<el-descriptions title="生成内容" :column="2" border class="result-info">
|
||||
<el-descriptions-item label="剧本标题">
|
||||
{{ outlineResult?.title }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ outlineResult?.genre }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色数量">
|
||||
{{ characters.length }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="剧集数量">
|
||||
{{ episodes.length }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button v-if="currentStep > 0 && currentStep < 3" @click="prevStep">
|
||||
上一步
|
||||
</el-button>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
v-if="currentStep < 3"
|
||||
type="primary"
|
||||
:loading="generating"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ currentStep === 2 ? '完成生成' : '下一步' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { generationAPI } from '@/api/generation'
|
||||
import type { OutlineResult } from '@/types/generation'
|
||||
import type { Character, Episode } from '@/types/drama'
|
||||
|
||||
interface Props {
|
||||
dramaId: string
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const currentStep = ref(0)
|
||||
const generating = ref(false)
|
||||
|
||||
const outlineForm = reactive({
|
||||
theme: '',
|
||||
genre: '',
|
||||
style: '',
|
||||
length: 5
|
||||
})
|
||||
|
||||
const charactersForm = reactive({
|
||||
count: 5
|
||||
})
|
||||
|
||||
const episodesForm = reactive({
|
||||
episode_count: 5
|
||||
})
|
||||
|
||||
const temperatureValue = ref(70)
|
||||
const charactersTemperature = ref(60)
|
||||
const episodesTemperature = ref(60)
|
||||
|
||||
const temperatureMarks = {
|
||||
0: '保守',
|
||||
50: '平衡',
|
||||
100: '创新'
|
||||
}
|
||||
|
||||
const outlineResult = ref<OutlineResult>()
|
||||
const characters = ref<Character[]>([])
|
||||
const episodes = ref<Episode[]>([])
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const titles = ['AI 剧本生成 - 大纲', 'AI 剧本生成 - 角色', 'AI 剧本生成 - 剧集', '生成完成']
|
||||
return titles[currentStep.value]
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => outlineResult.value, (result) => {
|
||||
if (result) {
|
||||
episodesForm.episode_count = result.episodes?.length || 5
|
||||
}
|
||||
})
|
||||
|
||||
const nextStep = async () => {
|
||||
if (currentStep.value === 0) {
|
||||
await generateOutline()
|
||||
} else if (currentStep.value === 1) {
|
||||
await generateCharacters()
|
||||
} else if (currentStep.value === 2) {
|
||||
await generateEpisodes()
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
const generateOutline = async () => {
|
||||
if (!outlineForm.theme.trim()) {
|
||||
ElMessage.warning('请输入创作主题')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const result = await generationAPI.generateOutline({
|
||||
drama_id: props.dramaId,
|
||||
theme: outlineForm.theme,
|
||||
genre: outlineForm.genre,
|
||||
style: outlineForm.style,
|
||||
length: outlineForm.length,
|
||||
temperature: temperatureValue.value / 100
|
||||
})
|
||||
|
||||
outlineResult.value = result
|
||||
ElMessage.success('大纲生成成功!')
|
||||
currentStep.value++
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '大纲生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateCharacters = async () => {
|
||||
generating.value = true
|
||||
try {
|
||||
const outline = outlineResult.value
|
||||
? JSON.stringify(outlineResult.value)
|
||||
: ''
|
||||
|
||||
const result = await generationAPI.generateCharacters({
|
||||
drama_id: props.dramaId,
|
||||
outline,
|
||||
count: charactersForm.count,
|
||||
temperature: charactersTemperature.value / 100
|
||||
})
|
||||
|
||||
characters.value = result
|
||||
ElMessage.success('角色生成成功!')
|
||||
currentStep.value++
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '角色生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateEpisodes = async () => {
|
||||
generating.value = true
|
||||
try {
|
||||
const outline = outlineResult.value
|
||||
? JSON.stringify(outlineResult.value)
|
||||
: ''
|
||||
|
||||
const result = await generationAPI.generateEpisodes({
|
||||
drama_id: props.dramaId,
|
||||
outline,
|
||||
episode_count: episodesForm.episode_count,
|
||||
temperature: episodesTemperature.value / 100
|
||||
})
|
||||
|
||||
episodes.value = result
|
||||
ElMessage.success('剧集生成成功!')
|
||||
currentStep.value++
|
||||
emit('success')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '剧集生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const viewDrama = () => {
|
||||
handleClose()
|
||||
router.push(`/dramas/${props.dramaId}`)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
currentStep.value = 0
|
||||
outlineForm.theme = ''
|
||||
outlineForm.genre = ''
|
||||
outlineForm.style = ''
|
||||
outlineForm.length = 5
|
||||
charactersForm.count = 5
|
||||
episodesForm.episode_count = 5
|
||||
temperatureValue.value = 70
|
||||
charactersTemperature.value = 60
|
||||
episodesTemperature.value = 60
|
||||
outlineResult.value = undefined
|
||||
characters.value = []
|
||||
episodes.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-content {
|
||||
margin: 30px 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.step-panel {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.outline-preview {
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.outline-preview h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.outline-preview .summary {
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.outline-preview .tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.characters-preview {
|
||||
padding: 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.characters-preview h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.character-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-info {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -207,13 +207,9 @@ const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
|
||||
id: 'chatfire',
|
||||
name: 'Chatfire',
|
||||
models: [
|
||||
'gpt-4o',
|
||||
'gemini-3-pro-preview',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'doubao-seed-1-8-251228',
|
||||
'kimi-k2-thinking',
|
||||
'gemini-3-pro',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -281,16 +277,39 @@ const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
|
||||
]
|
||||
}
|
||||
|
||||
// 当前可用的厂商列表
|
||||
// 当前可用的厂商列表(只显示有激活配置的)
|
||||
const availableProviders = computed(() => {
|
||||
return providerConfigs[form.service_type] || []
|
||||
// 获取当前service_type下所有激活的配置
|
||||
const activeConfigs = configs.value.filter(
|
||||
c => c.service_type === form.service_type && c.is_active
|
||||
)
|
||||
|
||||
// 提取所有激活配置的provider,去重
|
||||
const activeProviderIds = new Set(activeConfigs.map(c => c.provider))
|
||||
|
||||
// 从providerConfigs中筛选出有激活配置的provider
|
||||
const allProviders = providerConfigs[form.service_type] || []
|
||||
return allProviders.filter(p => activeProviderIds.has(p.id))
|
||||
})
|
||||
|
||||
// 当前可用的模型列表
|
||||
// 当前可用的模型列表(从已激活的配置中获取)
|
||||
const availableModels = computed(() => {
|
||||
if (!form.provider) return []
|
||||
const provider = availableProviders.value.find(p => p.id === form.provider)
|
||||
return provider?.models || []
|
||||
|
||||
// 从已激活的配置中提取该 provider 的所有模型
|
||||
const activeConfigsForProvider = configs.value.filter(
|
||||
c => c.provider === form.provider &&
|
||||
c.service_type === form.service_type &&
|
||||
c.is_active
|
||||
)
|
||||
|
||||
// 提取所有模型,去重
|
||||
const models = new Set<string>()
|
||||
activeConfigsForProvider.forEach(config => {
|
||||
config.model.forEach(m => models.add(m))
|
||||
})
|
||||
|
||||
return Array.from(models)
|
||||
})
|
||||
|
||||
// 完整端点示例
|
||||
|
||||
169
web/src/views/settings/SystemSettings.vue
Normal file
169
web/src/views/settings/SystemSettings.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="system-settings">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ $t('settings.systemLanguage') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="$t('settings.currentLanguage')">
|
||||
<el-radio-group
|
||||
v-model="currentLanguage"
|
||||
@change="handleLanguageChange"
|
||||
:disabled="loading"
|
||||
>
|
||||
<el-radio label="zh">简体中文</el-radio>
|
||||
<el-radio label="en">English</el-radio>
|
||||
</el-radio-group>
|
||||
<div v-if="loading" style="margin-top: 8px; color: var(--el-color-primary);">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
{{ currentLanguage === 'zh' ? '正在切换语言...' : 'Switching language...' }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-alert
|
||||
:title="$t('settings.languageSwitchNotice')"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<p>{{ $t('settings.languageSwitchDesc') }}</p>
|
||||
<ul>
|
||||
<li>{{ $t('settings.languageSwitchItem1') }}</li>
|
||||
<li>{{ $t('settings.languageSwitchItem2') }}</li>
|
||||
<li>{{ $t('settings.languageSwitchItem3') }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { settingsAPI } from '@/api/settings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const currentLanguage = ref<'zh' | 'en'>('zh')
|
||||
const loading = ref(false)
|
||||
|
||||
const loadCurrentLanguage = async () => {
|
||||
try {
|
||||
const res = await settingsAPI.getLanguage()
|
||||
currentLanguage.value = res?.language as 'zh' | 'en'
|
||||
// 同步前端语言
|
||||
locale.value = res?.language as 'zh' | 'en'
|
||||
console.log('Current language loaded:', res?.language)
|
||||
} catch (error) {
|
||||
console.error('Failed to load language:', error)
|
||||
const errorMsg = currentLanguage.value === 'zh'
|
||||
? '获取语言设置失败'
|
||||
: 'Failed to load language settings'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLanguageChange = async (value: 'zh' | 'en') => {
|
||||
// 双语确认消息
|
||||
const confirmMessage = value === 'zh'
|
||||
? `切换为中文后,后端生成的所有提示词、角色描述、场景描述等都将使用中文。是否继续?
|
||||
|
||||
|
||||
After switching to Chinese, all prompts, character descriptions, scene descriptions generated by the backend will use Chinese. Continue?`
|
||||
: `After switching to English, all prompts, character descriptions, scene descriptions generated by the backend will use English. Continue?
|
||||
|
||||
|
||||
切换为英文后,后端生成的所有提示词、角色描述、场景描述等都将使用英文。是否继续?`
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
confirmMessage,
|
||||
'切换语言 / Switch Language',
|
||||
{
|
||||
confirmButtonText: '确定 / Confirm',
|
||||
cancelButtonText: '取消 / Cancel',
|
||||
type: 'warning',
|
||||
dangerouslyUseHTMLString: false
|
||||
}
|
||||
)
|
||||
|
||||
loading.value = true
|
||||
console.log('Updating language to:', value)
|
||||
|
||||
const res = await settingsAPI.updateLanguage(value)
|
||||
console.log('Language update response:', res)
|
||||
|
||||
// 同时更新前端语言
|
||||
locale.value = value
|
||||
|
||||
// 使用后端返回的双语消息(request拦截器已经返回了data)
|
||||
ElMessage.success({
|
||||
message: res?.message || (value === 'zh' ? '语言已切换为中文' : 'Language switched to English'),
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Language update error:', error)
|
||||
|
||||
if (error !== 'cancel') {
|
||||
// 安全获取错误消息
|
||||
let errorMessage = '未知错误'
|
||||
if (error?.message) {
|
||||
errorMessage = error.message
|
||||
} else if (error?.response?.data?.error?.message) {
|
||||
errorMessage = error.response.data.error.message
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error
|
||||
}
|
||||
|
||||
// 双语错误提示
|
||||
const errorMsg = currentLanguage.value === 'zh'
|
||||
? `切换语言失败: ${errorMessage}`
|
||||
: `Failed to switch language: ${errorMessage}`
|
||||
|
||||
ElMessage.error({
|
||||
message: errorMsg,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复原来的选择
|
||||
await loadCurrentLanguage()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCurrentLanguage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-settings {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-alert ul) {
|
||||
margin-top: 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-alert li) {
|
||||
margin: 5px 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user