修复部分BUG 前端页面添加英文

This commit is contained in:
Connor
2026-01-14 12:40:45 +08:00
parent d628283ef6
commit 0a5b39249e
28 changed files with 2204 additions and 599 deletions

View File

@@ -14,12 +14,14 @@ import (
type ImageGenerationHandler struct {
imageService *services.ImageGenerationService
taskService *services.TaskService
log *logger.Logger
}
func NewImageGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage) *ImageGenerationHandler {
return &ImageGenerationHandler{
imageService: services.NewImageGenerationService(db, transferService, localStorage, log),
taskService: services.NewTaskService(db, log),
log: log,
}
}
@@ -71,18 +73,57 @@ func (h *ImageGenerationHandler) GetBackgroundsForEpisode(c *gin.Context) {
}
func (h *ImageGenerationHandler) ExtractBackgroundsForEpisode(c *gin.Context) {
episodeID := c.Param("episode_id")
// 同步执行场景提取
backgrounds, err := h.imageService.ExtractBackgroundsForEpisode(episodeID)
// 创建异步任务
task, err := h.taskService.CreateTask("background_extraction", episodeID)
if err != nil {
h.log.Errorw("Failed to extract backgrounds", "error", err)
h.log.Errorw("Failed to create task", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, backgrounds)
// 启动后台goroutine处理
go h.processBackgroundExtraction(task.ID, episodeID)
// 立即返回任务ID
response.Success(c, gin.H{
"task_id": task.ID,
"status": "pending",
"message": "场景提取任务已创建,正在后台处理...",
})
}
// processBackgroundExtraction 后台处理场景提取
func (h *ImageGenerationHandler) processBackgroundExtraction(taskID, episodeID string) {
h.log.Infow("Starting background extraction", "task_id", taskID, "episode_id", episodeID)
// 更新任务状态为处理中
if err := h.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始提取场景..."); err != nil {
h.log.Errorw("Failed to update task status", "error", err)
}
// 调用实际的提取逻辑
backgrounds, err := h.imageService.ExtractBackgroundsForEpisode(episodeID)
if err != nil {
h.log.Errorw("Failed to extract backgrounds", "error", err, "task_id", taskID)
if updateErr := h.taskService.UpdateTaskError(taskID, err); updateErr != nil {
h.log.Errorw("Failed to update task error", "error", updateErr)
}
return
}
// 更新任务结果
result := gin.H{
"backgrounds": backgrounds,
"total": len(backgrounds),
}
if err := h.taskService.UpdateTaskResult(taskID, result); err != nil {
h.log.Errorw("Failed to update task result", "error", err)
return
}
h.log.Infow("Background extraction completed", "task_id", taskID, "total", len(backgrounds))
}
func (h *ImageGenerationHandler) BatchGenerateForEpisode(c *gin.Context) {

View File

@@ -11,12 +11,14 @@ import (
type ScriptGenerationHandler struct {
scriptService *services.ScriptGenerationService
taskService *services.TaskService
log *logger.Logger
}
func NewScriptGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationHandler {
return &ScriptGenerationHandler{
scriptService: services.NewScriptGenerationService(db, log),
taskService: services.NewTaskService(db, log),
log: log,
}
}
@@ -46,15 +48,55 @@ func (h *ScriptGenerationHandler) GenerateCharacters(c *gin.Context) {
return
}
// 同步执行角色生成
characters, err := h.scriptService.GenerateCharacters(&req)
// 创建异步任务
task, err := h.taskService.CreateTask("character_generation", req.DramaID)
if err != nil {
h.log.Errorw("Failed to generate characters", "error", err)
response.InternalError(c, "生成角色失败")
h.log.Errorw("Failed to create task", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, characters)
// 启动后台goroutine处理
go h.processCharacterGeneration(task.ID, &req)
// 立即返回任务ID
response.Success(c, gin.H{
"task_id": task.ID,
"status": "pending",
"message": "角色生成任务已创建,正在后台处理...",
})
}
// processCharacterGeneration 后台处理角色生成
func (h *ScriptGenerationHandler) processCharacterGeneration(taskID string, req *services.GenerateCharactersRequest) {
h.log.Infow("Starting character generation", "task_id", taskID, "drama_id", req.DramaID)
// 更新任务状态为处理中
if err := h.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始生成角色..."); err != nil {
h.log.Errorw("Failed to update task status", "error", err)
}
// 调用实际的生成逻辑
characters, err := h.scriptService.GenerateCharacters(req)
if err != nil {
h.log.Errorw("Failed to generate characters", "error", err, "task_id", taskID)
if updateErr := h.taskService.UpdateTaskError(taskID, err); updateErr != nil {
h.log.Errorw("Failed to update task error", "error", updateErr)
}
return
}
// 更新任务结果
result := gin.H{
"characters": characters,
"total": len(characters),
}
if err := h.taskService.UpdateTaskResult(taskID, result); err != nil {
h.log.Errorw("Failed to update task result", "error", err)
return
}
h.log.Infow("Character generation completed", "task_id", taskID, "total", len(characters))
}
func (h *ScriptGenerationHandler) GenerateEpisodes(c *gin.Context) {

View File

@@ -317,14 +317,14 @@ func (s *AIService) TestConnection(req *TestConnectionRequest) error {
func (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfig, error) {
var config models.AIServiceConfig
// 按优先级降序获取第一个启用的配置
err := s.db.Where("service_type = ? AND is_active = ?", serviceType, true).
// 按优先级降序获取第一个配置
err := s.db.Where("service_type = ?", serviceType).
Order("priority DESC, created_at DESC").
First(&config).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("no active config found")
return nil, errors.New("no config found")
}
return nil, err
}
@@ -332,10 +332,10 @@ func (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfi
return &config, nil
}
// GetConfigForModel 根据服务类型和模型名称获取优先级最高的启用配置
// GetConfigForModel 根据服务类型和模型名称获取优先级最高的配置
func (s *AIService) GetConfigForModel(serviceType string, modelName string) (*models.AIServiceConfig, error) {
var configs []models.AIServiceConfig
err := s.db.Where("service_type = ? AND is_active = ?", serviceType, true).
err := s.db.Where("service_type = ?", serviceType).
Order("priority DESC, created_at DESC").
Find(&configs).Error

View File

@@ -41,9 +41,10 @@ type ChatfireSoraRequest struct {
type ChatfireDoubaoRequest struct {
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
} `json:"content"`
}
@@ -70,6 +71,9 @@ type ChatfireTaskResponse struct {
Status string `json:"status,omitempty"`
VideoURL string `json:"video_url,omitempty"`
} `json:"data,omitempty"`
Content struct {
VideoURL string `json:"video_url,omitempty"`
} `json:"content,omitempty"`
}
// getErrorMessage 从 error 字段提取错误信息(支持字符串或对象)
@@ -144,18 +148,83 @@ func (c *ChatfireClient) GenerateVideo(imageURL, prompt string, opts ...VideoOpt
}
// 添加文本内容
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{Type: "text", Text: prompt})
// 如果有图片URL添加图片内容
if imageURL != "" {
// 处理不同的图片模式
// 1. 组图模式多个reference_image
if len(options.ReferenceImageURLs) > 0 {
for _, refURL := range options.ReferenceImageURLs {
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": refURL,
},
Role: "reference_image",
})
}
} else if options.FirstFrameURL != "" && options.LastFrameURL != "" {
// 2. 首尾帧模式
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
}{Type: "image_url", URL: imageURL})
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.FirstFrameURL,
},
Role: "first_frame",
})
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.LastFrameURL,
},
Role: "last_frame",
})
} else if imageURL != "" {
// 3. 单图模式(默认)
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": imageURL,
},
// 单图模式不需要role
})
} else if options.FirstFrameURL != "" {
// 4. 只有首帧
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.FirstFrameURL,
},
Role: "first_frame",
})
}
jsonData, err = json.Marshal(reqBody)
@@ -286,6 +355,9 @@ func (c *ChatfireClient) GetTaskStatus(taskID string) (*VideoResult, error) {
return nil, fmt.Errorf("read response: %w", err)
}
// 调试日志:打印响应内容
fmt.Printf("[Chatfire] GetTaskStatus Response body: %s\n", string(body))
var result ChatfireTaskResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
@@ -307,10 +379,16 @@ func (c *ChatfireClient) GetTaskStatus(taskID string) (*VideoResult, error) {
status = result.Data.Status
}
// 按优先级获取 video_urlVideoURL -> Data.VideoURL -> Content.VideoURL
videoURL := result.VideoURL
if videoURL == "" && result.Data.VideoURL != "" {
videoURL = result.Data.VideoURL
}
if videoURL == "" && result.Content.VideoURL != "" {
videoURL = result.Content.VideoURL
}
fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s, VideoURL: %s\n", responseTaskID, status, videoURL)
videoResult := &VideoResult{
TaskID: responseTaskID,

View File

@@ -20,6 +20,7 @@
"element-plus": "^2.5.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.0"
},
"devDependencies": {

View File

@@ -72,7 +72,7 @@ export const dramaAPI = {
},
extractBackgrounds(episodeId: string) {
return request.post(`/images/episode/${episodeId}/backgrounds/extract`)
return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`)
},
batchGenerateBackgrounds(episodeId: string) {

View File

@@ -13,7 +13,7 @@ export const generationAPI = {
},
generateCharacters(data: GenerateCharactersRequest) {
return request.post<Character[]>('/generation/characters', data)
return request.post<{ task_id: string; status: string; message: string }>('/generation/characters', data)
},
generateEpisodes(data: GenerateEpisodesRequest) {

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

@@ -4,8 +4,8 @@
<div class="editor-toolbar">
<div class="toolbar-left">
<el-button-group>
<el-button :icon="VideoPlay" @click="playTimeline" :disabled="timelineClips.length === 0">播放</el-button>
<el-button :icon="VideoPause" @click="pauseTimeline">暂停</el-button>
<el-button :icon="VideoPlay" @click="playTimeline" :disabled="timelineClips.length === 0">{{ $t('common.play') }}</el-button>
<el-button :icon="VideoPause" @click="pauseTimeline">{{ $t('common.pause') }}</el-button>
</el-button-group>
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(totalDuration) }}</span>
</div>
@@ -17,7 +17,7 @@
:disabled="timelineClips.length === 0"
:loading="serverMerging"
>
合成视频
{{ $t('video.merge') }}
</el-button>
</div>
</div>
@@ -42,7 +42,7 @@
:style="{ animationDuration: transitionState.duration + 's' }"
></div>
<div class="preview-overlay" v-if="!currentPreviewUrl">
<el-empty description="将场景拖拽到时间线开始编辑" />
<el-empty :description="$t('video.dragToTimeline')" />
</div>
</div>
<div class="preview-controls">
@@ -59,8 +59,8 @@
<div class="media-library">
<div class="library-header">
<div class="header-left">
<h4>视频素材库</h4>
<span>{{ availableStoryboards.length }} 个视频</span>
<h4>{{ $t('video.mediaLibrary') }}</h4>
<span>{{ $t('video.videoCount', { count: availableStoryboards.length }) }}</span>
</div>
<el-button
type="primary"
@@ -69,7 +69,7 @@
@click="addAllScenesInOrder"
:disabled="availableStoryboards.length === 0"
>
一键添加全部
{{ $t('common.addAll') }}
</el-button>
</div>
<div class="media-grid">
@@ -98,12 +98,12 @@
:icon="Plus"
@click.stop="addClipToTimeline(scene)"
>
添加到时间线
{{ $t('common.addToTimeline') }}
</el-button>
</div>
</div>
<div class="media-info">
<div class="media-title">镜头 #{{ scene.storyboard_num || scene.assetId }}</div>
<div class="media-title">{{ $t('storyboard.shot') }} #{{ scene.storyboard_num || scene.assetId }}</div>
</div>
</div>
</div>
@@ -116,7 +116,7 @@
<div class="zoom-controls">
<el-button-group size="small">
<el-button @click="zoomOut">-</el-button>
<el-button @click="zoomReset">重置</el-button>
<el-button @click="zoomReset">{{ $t('common.reset') }}</el-button>
<el-button @click="zoomIn">+</el-button>
</el-button-group>
<span class="zoom-level">{{ Math.round(zoom * 100) }}%</span>
@@ -154,7 +154,7 @@
@dragover.prevent
@click="clickTimeline($event)"
>
<div class="track-label">视频轨道</div>
<div class="track-label">{{ $t('video.videoTrack') }}</div>
<div class="track-clips">
<!-- 视频片段 -->
<div
@@ -171,7 +171,7 @@
<video :src="clip.video_url" />
</div>
<div class="clip-info">
<div class="clip-title">场景{{ clip.storyboard_number }}</div>
<div class="clip-title">{{ $t('storyboard.scene') }} {{ clip.storyboard_number }}</div>
<div class="clip-duration">{{ clip.duration.toFixed(1) }}s</div>
</div>
</div>
@@ -204,13 +204,13 @@
@click="clickTimeline($event)"
>
<div class="track-label">
<span>音频轨道</span>
<span>{{ $t('video.audioTrack') }}</span>
<el-button
type="text"
size="small"
@click.stop="extractAllAudio"
:disabled="timelineClips.length === 0"
title="从所有视频片段提取音频"
:title="$t('video.extractAudio')"
>
<el-icon><Headset /></el-icon>
</el-button>
@@ -231,7 +231,7 @@
<el-icon><Microphone /></el-icon>
</div>
<div class="clip-info">
<div class="clip-title">音频 {{ audio.order + 1 }}</div>
<div class="clip-title">{{ $t('video.audio') }} {{ audio.order + 1 }}</div>
<div class="clip-duration">{{ audio.duration.toFixed(1) }}s</div>
</div>
</div>
@@ -253,8 +253,8 @@
width="500px"
>
<el-form label-width="100px">
<el-form-item label="转场类型">
<el-select v-model="editingTransition.type" placeholder="选择转场效果">
<el-form-item :label="$t('video.transitionType')">
<el-select v-model="editingTransition.type" :placeholder="$t('video.selectTransition')">
<el-option label="无转场" value="none" />
<!-- 淡入淡出类 -->
<el-option label="淡入淡出" value="fade" />
@@ -283,7 +283,7 @@
<el-option label="垂直关闭" value="vertclose" />
</el-select>
</el-form-item>
<el-form-item label="转场时长" v-if="editingTransition.type !== 'none'">
<el-form-item :label="$t('video.transitionDuration')" v-if="editingTransition.type !== 'none'">
<el-slider
v-model="editingTransition.duration"
:min="0.3"

699
web/src/locales/en-US.ts Normal file
View File

@@ -0,0 +1,699 @@
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'
},
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: 'Drama Management',
create: 'Create Project',
totalProjects: 'Total {count} projects',
createNew: 'Create New Project',
aiConfig: 'AI Configuration',
aiConfigTip: 'Please configure AI services before creating a project',
empty: 'No projects yet, click the button above to create a new project',
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',
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
}

607
web/src/locales/zh-CN.ts Normal file
View File

@@ -0,0 +1,607 @@
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: '提示',
edit: '编辑',
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: '创建新项目',
aiConfig: 'AI配置',
aiConfigTip: '请先配置 AI 服务后再创建项目',
empty: '暂无项目,点击上方按钮创建新项目',
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: '音频轨道',
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: '网络错误'
}
}

View File

@@ -6,12 +6,14 @@ 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'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {

View File

@@ -3,14 +3,15 @@
<el-container>
<el-header class="header">
<div class="header-content">
<h2>🎬 Drama Generator</h2>
<h2>{{ $t('dashboard.title') }}</h2>
<LanguageSwitcher />
</div>
</el-header>
<el-main>
<div class="welcome-section">
<h1>欢迎使用 AI 短剧生成平台</h1>
<p>从剧本到视频一站式短剧创作工具</p>
<h1>{{ $t('dashboard.welcome') }}</h1>
<p>{{ $t('dashboard.subtitle') }}</p>
</div>
<el-row :gutter="20" class="stats-row">
@@ -19,7 +20,7 @@
<div class="stat-item">
<el-icon :size="40" color="#409eff"><Document /></el-icon>
<h3>0</h3>
<p>短剧项目</p>
<p>{{ $t('dashboard.stats.projects') }}</p>
</div>
</el-card>
</el-col>
@@ -29,7 +30,7 @@
<div class="stat-item">
<el-icon :size="40" color="#67c23a"><Picture /></el-icon>
<h3>0</h3>
<p>生成图片</p>
<p>{{ $t('dashboard.stats.images') }}</p>
</div>
</el-card>
</el-col>
@@ -39,7 +40,7 @@
<div class="stat-item">
<el-icon :size="40" color="#e6a23c"><VideoPlay /></el-icon>
<h3>0</h3>
<p>生成视频</p>
<p>{{ $t('dashboard.stats.videos') }}</p>
</div>
</el-card>
</el-col>
@@ -49,28 +50,28 @@
<div class="stat-item">
<el-icon :size="40" color="#f56c6c"><Clock /></el-icon>
<h3>0</h3>
<p>处理中任务</p>
<p>{{ $t('dashboard.stats.tasks') }}</p>
</div>
</el-card>
</el-col>
</el-row>
<div class="quick-actions">
<h2>快速开始</h2>
<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>创建新项目</h3>
<p>开始一个全新的短剧项目</p>
<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>我的项目</h3>
<p>查看和管理已有项目</p>
<h3>{{ $t('dashboard.actions.myProjects') }}</h3>
<p>{{ $t('dashboard.actions.myProjectsDesc') }}</p>
</el-card>
</el-col>
@@ -84,6 +85,7 @@
<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()

View File

@@ -3,10 +3,11 @@
<div class="page-header-wrapper">
<div class="page-header">
<div>
<h2>我的短剧项目</h2>
<p class="subtitle"> {{ total }} 个项目</p>
<h2>{{ $t('drama.title') }}</h2>
<p class="subtitle">{{ $t('drama.totalProjects', { count: total }) }}</p>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<LanguageSwitcher />
<el-alert
type="info"
:closable="false"
@@ -14,17 +15,17 @@
style="padding: 8px 12px;"
>
<template #title>
<span style="font-size: 13px;">使用前请先配置 AI 服务</span>
<span style="font-size: 13px;">{{ $t('drama.aiConfigTip') }}</span>
</template>
</el-alert>
<el-button type="warning" @click="goToAIConfig" :icon="Setting">AI 配置</el-button>
<el-button type="primary" @click="handleCreate" :icon="Plus">创建新项目</el-button>
<el-button type="warning" @click="goToAIConfig" :icon="Setting">{{ $t('drama.aiConfig') }}</el-button>
<el-button type="primary" @click="handleCreate" :icon="Plus">{{ $t('drama.createNew') }}</el-button>
</div>
</div>
</div>
<div v-loading="loading" class="dramas-grid">
<el-empty v-if="!loading && dramas.length === 0" description="暂无项目,点击创建新项目开始吧" />
<el-empty v-if="!loading && dramas.length === 0" :description="$t('drama.empty')" />
<el-card
v-for="drama in dramas"
@@ -38,7 +39,7 @@
<img v-if="drama.thumbnail" :src="drama.thumbnail" :alt="drama.title" />
<div v-else class="cover-placeholder">
<el-icon :size="64"><Film /></el-icon>
<p>暂无封面</p>
<p>{{ $t('drama.noCover') }}</p>
</div>
</div>
@@ -48,7 +49,7 @@
<el-tag v-if="drama.genre" class="genre-tag" size="small">{{ drama.genre }}</el-tag>
</div>
<p class="drama-description">{{ drama.description || '暂无描述' }}</p>
<p class="drama-description">{{ drama.description || $t('drama.noDescription') }}</p>
<div class="drama-footer">
<div class="meta-info">
@@ -66,7 +67,7 @@
<el-icon><View /></el-icon>
</el-button>
<el-popconfirm
title="确定删除该项目吗?"
:title="$t('drama.deleteConfirm')"
@confirm="deleteDrama(drama.id)"
>
<template #reference>
@@ -127,6 +128,7 @@ import { ElMessage } from 'element-plus'
import { Plus, Clock, Film, Setting } from '@element-plus/icons-vue'
import { dramaAPI } from '@/api/drama'
import type { Drama, DramaListQuery, DramaStatus } from '@/types/drama'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const loading = ref(false)

View File

@@ -2,7 +2,7 @@
<div class="drama-management">
<div class="page-header">
<div class="header-left">
<el-button :icon="ArrowLeft" @click="router.back()">返回</el-button>
<el-button :icon="ArrowLeft" @click="router.back()">{{ $t('common.back') }}</el-button>
<div class="drama-info">
<h1>{{ drama?.title }}</h1>
</div>
@@ -11,19 +11,19 @@
<el-tabs v-model="activeTab" class="management-tabs">
<!-- 项目概览 -->
<el-tab-pane label="项目概览" name="overview">
<el-tab-pane :label="$t('drama.management.overview')" name="overview">
<el-row :gutter="20">
<el-col :span="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<el-icon :size="24" color="#409eff"><Document /></el-icon>
<span>章节统计</span>
<span>{{ $t('drama.management.episodeStats') }}</span>
</div>
</template>
<div class="stat-content">
<div class="stat-number">{{ episodesCount }}</div>
<div class="stat-label">已创建章节</div>
<div class="stat-label">{{ $t('drama.management.episodesCreated') }}</div>
</div>
</el-card>
</el-col>
@@ -32,12 +32,12 @@
<template #header>
<div class="card-header">
<el-icon :size="24" color="#67c23a"><User /></el-icon>
<span>角色统计</span>
<span>{{ $t('drama.management.characterStats') }}</span>
</div>
</template>
<div class="stat-content">
<div class="stat-number">{{ charactersCount }}</div>
<div class="stat-label">已创建角色</div>
<div class="stat-label">{{ $t('drama.management.charactersCreated') }}</div>
</div>
</el-card>
</el-col>
@@ -46,12 +46,12 @@
<template #header>
<div class="card-header">
<el-icon :size="24" color="#e6a23c"><Picture /></el-icon>
<span>场景统计</span>
<span>{{ $t('drama.management.sceneStats') }}</span>
</div>
</template>
<div class="stat-content">
<div class="stat-number">{{ scenesCount }}</div>
<div class="stat-label">场景库数量</div>
<div class="stat-label">{{ $t('drama.management.sceneLibraryCount') }}</div>
</div>
</el-card>
</el-col>
@@ -60,79 +60,79 @@
<!-- 引导卡片无章节时显示 -->
<el-alert
v-if="episodesCount === 0"
title="开始创作您的第一个章节!"
:title="$t('drama.management.startFirstEpisode')"
type="info"
:closable="false"
style="margin-top: 20px;"
>
<template #default>
<p style="margin: 8px 0;">您的项目还没有章节请先创建一个章节开始制作</p>
<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>项目信息</h3>
<h3>{{ $t('drama.management.projectInfo') }}</h3>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="项目名称">{{ drama?.title }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(drama?.created_at) }}</el-descriptions-item>
<el-descriptions-item label="项目描述" :span="2">
{{ drama?.description || '暂无描述' }}
<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="章节管理" name="episodes">
<el-tab-pane :label="$t('drama.management.episodes')" name="episodes">
<div class="tab-header">
<h2>章节列表</h2>
<el-button type="primary" :icon="Plus" @click="createNewEpisode">创建新章节</el-button>
<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="还没有章节"
:description="$t('drama.management.noEpisodes')"
style="margin-top: 40px;"
>
<template #image>
<el-icon :size="80" color="#409eff"><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="序号" width="80" />
<el-table-column prop="title" label="章节标题" min-width="200" />
<el-table-column label="状态" width="120">
<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="分镜数" width="100">
<el-table-column label="Shots" width="100">
<template #default="{ row }">
{{ row.shots?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<el-table-column :label="$t('common.createdAt')" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<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>
@@ -140,10 +140,10 @@
</el-tab-pane>
<!-- 角色管理 -->
<el-tab-pane label="角色管理" name="characters">
<el-tab-pane :label="$t('drama.management.characters')" name="characters">
<div class="tab-header">
<h2>角色列表</h2>
<el-button type="primary" :icon="Plus" @click="openAddCharacterDialog">添加角色</el-button>
<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;">
@@ -157,27 +157,27 @@
<div class="character-info">
<h4>{{ character.name }}</h4>
<el-tag :type="character.role === 'main' ? 'danger' : 'info'" size="small">
{{ character.role === 'main' ? '主角' : character.role === 'supporting' ? '配角' : '次要' }}
{{ 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)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCharacter(character)">删除</el-button>
<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="暂无角色" />
<el-empty v-if="!drama?.characters || drama.characters.length === 0" :description="$t('drama.management.noCharacters')" />
</el-tab-pane>
<!-- 场景库管理 -->
<el-tab-pane label="场景库" name="scenes">
<el-tab-pane :label="$t('drama.management.sceneList')" name="scenes">
<div class="tab-header">
<h2>场景库</h2>
<el-button type="primary" :icon="Plus" @click="openAddSceneDialog">添加场景</el-button>
<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;">
@@ -196,59 +196,59 @@
</div>
<div class="scene-actions">
<el-button size="small" @click="editScene(scene)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteScene(scene)">删除</el-button>
<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="暂无场景" />
<el-empty v-if="scenes.length === 0" :description="$t('drama.management.noScenes')" />
</el-tab-pane>
</el-tabs>
<!-- 添加角色对话框 -->
<el-dialog v-model="addCharacterDialogVisible" title="添加角色" width="600px">
<el-dialog v-model="addCharacterDialogVisible" :title="$t('character.add')" width="600px">
<el-form :model="newCharacter" label-width="100px">
<el-form-item label="角色名称">
<el-input v-model="newCharacter.name" placeholder="请输入角色名称" />
<el-form-item :label="$t('character.name')">
<el-input v-model="newCharacter.name" :placeholder="$t('character.name')" />
</el-form-item>
<el-form-item label="角色类型">
<el-select v-model="newCharacter.role" placeholder="请选择角色类型">
<el-option label="主角" value="main" />
<el-option label="配角" value="supporting" />
<el-option label="次要角色" value="minor" />
<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="外貌特征">
<el-input v-model="newCharacter.appearance" type="textarea" :rows="3" placeholder="描述角色的外貌特征" />
<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="性格特点">
<el-input v-model="newCharacter.personality" type="textarea" :rows="3" placeholder="描述角色的性格特点" />
<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="角色描述">
<el-input v-model="newCharacter.description" type="textarea" :rows="3" placeholder="其他描述信息" />
<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">取消</el-button>
<el-button type="primary" @click="addCharacter">确定</el-button>
<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="添加场景" width="600px">
<el-dialog v-model="addSceneDialogVisible" :title="$t('common.add')" width="600px">
<el-form :model="newScene" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="newScene.name" placeholder="请输入场景名称" />
<el-form-item :label="$t('common.name')">
<el-input v-model="newScene.name" :placeholder="$t('common.name')" />
</el-form-item>
<el-form-item label="场景描述">
<el-input v-model="newScene.description" type="textarea" :rows="4" placeholder="描述场景的特征" />
<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">取消</el-button>
<el-button type="primary" @click="addScene">确定</el-button>
<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>

View File

@@ -5,7 +5,7 @@
<div class="header-left-section">
<el-button text @click="goBack" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
<span>返回</span>
<span>{{ $t('dramaWorkflow.returnToList') }}</span>
</el-button>
<h2 class="drama-title">{{ drama?.title }}</h2>
<el-tag :type="getStatusType(drama?.status)" size="small">{{ getStatusText(drama?.status) }}</el-tag>
@@ -16,17 +16,17 @@
<div class="custom-steps">
<div class="step-item" :class="{ active: currentStep >= 0, current: currentStep === 0 }">
<div class="step-circle">1</div>
<span class="step-text">{{ currentEpisodeNumber }}集剧本</span>
<span class="step-text">{{ $t('dramaWorkflow.episodeScript', { number: currentEpisodeNumber }) }}</span>
</div>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<div class="step-item" :class="{ active: currentStep >= 1, current: currentStep === 1 }">
<div class="step-circle">2</div>
<span class="step-text">分镜拆解</span>
<span class="step-text">{{ $t('dramaWorkflow.storyboardBreakdown') }}</span>
</div>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<div class="step-item" :class="{ active: currentStep >= 2, current: currentStep === 2 }">
<div class="step-circle">3</div>
<span class="step-text">角色图片</span>
<span class="step-text">{{ $t('dramaWorkflow.characterImages') }}</span>
</div>
</div>
</div>
@@ -40,14 +40,14 @@
<div class="stage-body stage-body-fullscreen">
<!-- 初始状态显示创建第一章按钮 -->
<div v-if="!hasScript && !showScriptInput" class="create-chapter-prompt">
<el-empty description="请创建第一章开始制作">
<el-empty :description="$t('dramaWorkflow.createChapterPrompt')">
<el-button
type="primary"
size="large"
@click="startCreateChapter"
:icon="Document"
>
创建第{{ currentEpisodeNumber }}
{{ $t('dramaWorkflow.createChapter', { number: currentEpisodeNumber }) }}
</el-button>
</el-empty>
</div>
@@ -179,14 +179,14 @@
@click="regenerateShots"
:icon="MagicStick"
>
重新拆分
{{ $t('dramaWorkflow.reGenerateShots') }}
</el-button>
<el-button
type="success"
@click="nextStep"
:disabled="!hasCharacters"
>
下一步角色图片
{{ $t('dramaWorkflow.nextStepCharacterImages') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
@@ -194,14 +194,14 @@
<!-- 未拆分时显示 -->
<div v-else class="empty-shots">
<el-empty description="请先对剧本进行分镜拆解">
<el-empty :description="$t('dramaWorkflow.splitStoryboardFirst')">
<el-button
type="primary"
@click="generateShots"
:loading="generatingShots"
:icon="MagicStick"
>
{{ generatingShots ? 'AI拆分中...' : 'AI自动拆分' }}
{{ generatingShots ? $t('dramaWorkflow.aiSplitting') : $t('dramaWorkflow.aiAutoSplit') }}
</el-button>
</el-empty>
</div>
@@ -215,12 +215,12 @@
<div class="batch-toolbar-compact">
<div class="toolbar-left">
<el-checkbox v-model="selectAllCharacters" @change="handleSelectAllCharacters" :indeterminate="isCharacterIndeterminate">
全选
{{ $t('common.selectAll') }}
</el-checkbox>
<span class="selection-info">已选 {{ selectedCharacterIds.length }}/{{ drama?.characters?.length || 0 }}</span>
<span class="selection-info">{{ $t('dramaWorkflow.selected') }} {{ selectedCharacterIds.length }}/{{ drama?.characters?.length || 0 }}</span>
</div>
<div class="toolbar-right">
<span class="stats-compact">角色数: {{ drama?.characters?.length || 0 }} | 已生成: {{ characterImagesCount || 0 }}</span>
<span class="stats-compact">{{ $t('dramaWorkflow.characterCount') }}: {{ drama?.characters?.length || 0 }} | {{ $t('dramaWorkflow.generated') }}: {{ characterImagesCount || 0 }}</span>
<el-button
type="primary"
size="small"
@@ -229,7 +229,7 @@
@click="batchGenerateCharacterImages"
:icon="MagicStick"
>
批量生成({{ selectedCharacterIds.length }})
{{ $t('dramaWorkflow.batchGenerate') }}({{ selectedCharacterIds.length }})
</el-button>
<el-button
type="success"
@@ -237,7 +237,7 @@
@click="nextStep"
:disabled="!allCharactersHaveImages"
>
下一步
{{ $t('dramaWorkflow.nextStep') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
@@ -539,8 +539,9 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
MagicStick,
@@ -569,6 +570,7 @@ import type { Drama, DramaStatus } from '@/types/drama'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const drama = ref<Drama>()
const currentStep = ref(0)
const currentEpisodeNumber = ref(1) // 当前正在创作的集数
@@ -920,7 +922,7 @@ const editCurrentEpisodeScript = () => {
// AI自动拆分分镜
const generateShots = async () => {
if (!currentEpisode.value?.script_content) {
ElMessage.warning('请先创作剧本内容')
ElMessage.warning(t('dramaWorkflow.pleaseWriteScript'))
return
}
@@ -946,11 +948,11 @@ const generateShots = async () => {
// 重新拆分分镜
const regenerateShots = async () => {
await ElMessageBox.confirm(
'重新拆分将覆盖现有镜头,确定继续吗?',
'重新拆分',
t('dramaWorkflow.reGenerateShotsConfirm'),
t('dramaWorkflow.reGenerateShots'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)

View File

@@ -2,29 +2,29 @@
<div class="episode-workflow-container">
<div class="workflow-header">
<div class="header-left">
<el-button :icon="ArrowLeft" @click="goBack">返回项目</el-button>
<h1>{{ episodeNumber }}章制作</h1>
<el-button :icon="ArrowLeft" @click="goBack">{{ $t('workflow.backToProject') }}</el-button>
<h1>{{ $t('workflow.episodeProduction', { number: episodeNumber }) }}</h1>
</div>
<div class="steps-inline">
<div class="custom-steps">
<div class="step-item" :class="{ active: currentStep >= 0, current: currentStep === 0 }">
<div class="step-circle">1</div>
<span class="step-text">章节内容</span>
<span class="step-text">{{ $t('workflow.steps.content') }}</span>
</div>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<div class="step-item" :class="{ active: currentStep >= 1, current: currentStep === 1 }">
<div class="step-circle">2</div>
<span class="step-text">生成图片</span>
<span class="step-text">{{ $t('workflow.steps.generateImages') }}</span>
</div>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<div class="step-item" :class="{ active: currentStep >= 2, current: currentStep === 2 }">
<div class="step-circle">3</div>
<span class="step-text">拆分分镜</span>
<span class="step-text">{{ $t('workflow.steps.splitStoryboard') }}</span>
</div>
</div>
</div>
<div class="header-right">
<el-button :icon="Setting" circle @click="showModelConfigDialog" title="AI模型配置" />
<el-button :icon="Setting" circle @click="showModelConfigDialog" :title="$t('workflow.modelConfig')" />
</div>
</div>
@@ -36,7 +36,7 @@
<el-input
v-model="scriptContent"
type="textarea"
placeholder="请输入章节内容..."
:placeholder="$t('workflow.scriptPlaceholder')"
class="script-textarea script-textarea-fullscreen"
/>
@@ -48,7 +48,7 @@
:disabled="!scriptContent.trim() || generatingScript"
>
<el-icon><Check /></el-icon>
<span>保存章节</span>
<span>{{ $t('workflow.saveChapter') }}</span>
</el-button>
</div>
</div>
@@ -56,8 +56,8 @@
<!-- 已保存时显示内容 -->
<div v-if="hasScript" class="overview-section">
<div class="episode-info">
<h3>{{ episodeNumber }}章内容</h3>
<el-tag type="success" size="large">已保存</el-tag>
<h3>{{ $t('workflow.chapterContent', { number: episodeNumber }) }}</h3>
<el-tag type="success" size="large">{{ $t('workflow.saved') }}</el-tag>
</div>
<div class="overview-content">
<el-input
@@ -80,16 +80,16 @@
>
<template #title>
<div style="display: flex; align-items: center; gap: 16px;">
<span> 已提取数据</span>
<el-tag v-if="hasCharacters" type="success">角色: {{ charactersCount }}</el-tag>
<el-tag v-if="currentEpisode?.scenes" type="success">场景: {{ currentEpisode.scenes.length }}</el-tag>
<span> {{ $t('workflow.extractedData') }}</span>
<el-tag v-if="hasCharacters" type="success">{{ $t('workflow.characters') }}: {{ charactersCount }}</el-tag>
<el-tag v-if="currentEpisode?.scenes" type="success">{{ $t('workflow.scenes') }}: {{ currentEpisode.scenes.length }}</el-tag>
</div>
</template>
</el-alert>
<!-- 角色列表 -->
<div v-if="hasCharacters" style="margin-bottom: 16px;">
<h4 style="margin-bottom: 8px; color: #606266;">提取的角色本集</h4>
<h4 style="margin-bottom: 8px; color: #606266;">{{ $t('workflow.extractedCharacters') }}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<el-tag
v-for="char in currentEpisode?.characters"
@@ -103,7 +103,7 @@
<!-- 场景列表 -->
<div v-if="currentEpisode?.scenes && currentEpisode.scenes.length > 0">
<h4 style="margin-bottom: 8px; color: #606266;">提取的场景本集</h4>
<h4 style="margin-bottom: 8px; color: #606266;">{{ $t('workflow.extractedScenes') }}</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<el-tag
v-for="scene in currentEpisode.scenes"
@@ -127,7 +127,7 @@
:disabled="!hasScript"
>
<el-icon><MagicStick /></el-icon>
{{ hasExtractedData ? '重新提取角色和场景' : '提取角色和场景' }}
{{ hasExtractedData ? $t('workflow.reExtract') : $t('workflow.extractCharactersAndScenes') }}
</el-button>
<el-button
type="success"
@@ -135,14 +135,14 @@
@click="nextStep"
:disabled="!hasExtractedData"
>
下一步生成图片
{{ $t('workflow.nextStepGenerateImages') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
<div v-if="!hasExtractedData" style="margin-top: 8px;">
<el-alert type="warning" :closable="false" style="display: inline-block;">
<template #title>
<span style="font-size: 12px;">
请先点击"提取角色和场景"按钮完成提取后才能生成图片
{{ $t('workflow.extractWarning') }}
</span>
</template>
</el-alert>
@@ -161,14 +161,14 @@
<div class="section-title">
<h3>
<el-icon><User /></el-icon>
角色图片
{{ $t('workflow.characterImages') }}
</h3>
<el-alert
type="info"
:closable="false"
style="margin: 0;"
>
{{ charactersCount }} 个角色需要生成图片
{{ $t('workflow.characterCount', { count: charactersCount }) }}
</el-alert>
</div>
<div class="section-actions">
@@ -177,7 +177,7 @@
@change="toggleSelectAllCharacters"
style="margin-right: 12px;"
>
全选
{{ $t('workflow.selectAll') }}
</el-checkbox>
<el-button
type="primary"
@@ -186,7 +186,7 @@
:disabled="selectedCharacterIds.length === 0"
size="default"
>
批量生成选中角色 ({{ selectedCharacterIds.length }})
{{ $t('workflow.batchGenerate') }} ({{ selectedCharacterIds.length }})
</el-button>
</div>
</div>
@@ -210,7 +210,7 @@
:icon="Delete"
circle
@click="deleteCharacter(char.id)"
title="删除角色"
:title="$t('workflow.deleteCharacter')"
/>
</div>
@@ -220,22 +220,22 @@
</div>
<div v-else-if="char.image_generation_status === 'pending' || char.image_generation_status === 'processing' || generatingCharacterImages[char.id]" class="char-placeholder generating">
<el-icon :size="64" class="rotating"><Loading /></el-icon>
<span>生成中...</span>
<el-tag type="warning" size="small" style="margin-top: 8px;">{{ char.image_generation_status === 'pending' ? '排队中' : '处理中' }}</el-tag>
<span>{{ $t('common.generating') }}</span>
<el-tag type="warning" size="small" style="margin-top: 8px;">{{ char.image_generation_status === 'pending' ? $t('common.queuing') : $t('common.processing') }}</el-tag>
</div>
<div v-else-if="char.image_generation_status === 'failed'" class="char-placeholder failed">
<el-icon :size="64"><WarningFilled /></el-icon>
<span>生成失败</span>
<el-tag type="danger" size="small" style="margin-top: 8px;">点击重新生成</el-tag>
<span>{{ $t('common.generateFailed') }}</span>
<el-tag type="danger" size="small" style="margin-top: 8px;">{{ $t('common.clickToRegenerate') }}</el-tag>
</div>
<div v-else class="char-placeholder">
<el-icon :size="64"><User /></el-icon>
<span>未生成</span>
<span>{{ $t('common.notGenerated') }}</span>
</div>
</div>
<div class="card-actions">
<el-tooltip content="修改提示词" placement="top">
<el-tooltip :content="$t('tooltip.editPrompt')" placement="top">
<el-button
size="small"
@click="openPromptDialog(char, 'character')"
@@ -243,7 +243,7 @@
circle
/>
</el-tooltip>
<el-tooltip content="AI生成" placement="top">
<el-tooltip :content="$t('tooltip.aiGenerate')" placement="top">
<el-button
type="primary"
size="small"
@@ -253,7 +253,7 @@
circle
/>
</el-tooltip>
<el-tooltip content="上传图片" placement="top">
<el-tooltip :content="$t('tooltip.uploadImage')" placement="top">
<el-button
size="small"
@click="uploadCharacterImage(char.id)"
@@ -261,7 +261,7 @@
circle
/>
</el-tooltip>
<el-tooltip content="从角色库选择" placement="top">
<el-tooltip :content="$t('tooltip.selectFromLibrary')" placement="top">
<el-button
size="small"
@click="selectFromLibrary(char.id)"
@@ -269,7 +269,7 @@
circle
/>
</el-tooltip>
<el-tooltip content="添加到角色库" placement="top">
<el-tooltip :content="$t('workflow.addToLibrary')" placement="top">
<el-button
size="small"
@click="addToCharacterLibrary(char)"
@@ -292,14 +292,14 @@
<div class="section-title">
<h3>
<el-icon><Place /></el-icon>
场景图片
{{ $t('workflow.sceneImages') }}
</h3>
<el-alert
type="info"
:closable="false"
style="margin: 0;"
>
{{ drama?.scenes?.length || 0 }} 个场景需要生成图片
{{ $t('workflow.sceneCount', { count: drama?.scenes?.length || 0 }) }}
</el-alert>
</div>
<div class="section-actions">
@@ -308,7 +308,7 @@
@change="toggleSelectAllScenes"
style="margin-right: 12px;"
>
全选
{{ $t('workflow.selectAll') }}
</el-checkbox>
<el-button
type="primary"
@@ -317,7 +317,7 @@
:disabled="selectedSceneIds.length === 0"
size="default"
>
批量生成选中场景 ({{ selectedSceneIds.length }})
{{ $t('workflow.batchGenerateSelected') }} ({{ selectedSceneIds.length }})
</el-button>
</div>
</div>
@@ -343,22 +343,22 @@
</div>
<div v-else-if="scene.image_generation_status === 'pending' || scene.image_generation_status === 'processing' || generatingSceneImages[scene.id]" class="scene-placeholder generating">
<el-icon :size="64" class="rotating"><Loading /></el-icon>
<span>生成中...</span>
<el-tag type="warning" size="small" style="margin-top: 8px;">{{ scene.image_generation_status === 'pending' ? '排队中' : '处理中' }}</el-tag>
<span>{{ $t('common.generating') }}</span>
<el-tag type="warning" size="small" style="margin-top: 8px;">{{ scene.image_generation_status === 'pending' ? $t('common.queuing') : $t('common.processing') }}</el-tag>
</div>
<div v-else-if="scene.image_generation_status === 'failed'" class="scene-placeholder failed" @click="generateSceneImage(scene.id)" style="cursor: pointer;">
<el-icon :size="64"><WarningFilled /></el-icon>
<span>生成失败</span>
<el-tag type="danger" size="small" style="margin-top: 8px;">点击重新生成</el-tag>
<span>{{ $t('common.generateFailed') }}</span>
<el-tag type="danger" size="small" style="margin-top: 8px;">{{ $t('common.clickToRegenerate') }}</el-tag>
</div>
<div v-else class="scene-placeholder">
<el-icon :size="64"><Place /></el-icon>
<span>未生成</span>
<span>{{ $t('common.notGenerated') }}</span>
</div>
</div>
<div class="card-actions">
<el-tooltip content="修改提示词" placement="top">
<el-tooltip :content="$t('tooltip.editPrompt')" placement="top">
<el-button
size="small"
@click="openPromptDialog(scene, 'scene')"
@@ -366,7 +366,7 @@
circle
/>
</el-tooltip>
<el-tooltip content="AI生成" placement="top">
<el-tooltip :content="$t('tooltip.aiGenerate')" placement="top">
<el-button
type="primary"
size="small"
@@ -376,7 +376,7 @@
circle
/>
</el-tooltip>
<el-tooltip content="上传图片" placement="top">
<el-tooltip :content="$t('tooltip.uploadImage')" placement="top">
<el-button
size="small"
@click="uploadSceneImage(scene.id)"
@@ -395,7 +395,7 @@
<div class="action-buttons">
<el-button size="large" @click="prevStep">
<el-icon><ArrowLeft /></el-icon>
上一步
{{ $t('workflow.prevStep') }}
</el-button>
<el-button
type="success"
@@ -403,14 +403,14 @@
@click="nextStep"
:disabled="!allImagesGenerated"
>
下一步拆分分镜
{{ $t('workflow.nextStepSplitShots') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
<div v-if="!allImagesGenerated" style="margin-top: 8px;">
<el-alert type="warning" :closable="false" style="display: inline-block;">
<template #title>
<span style="font-size: 12px;">
请先生成所有角色和场景图片后再进行分镜拆分
{{ $t('workflow.generateAllImagesFirst') }}
</span>
</template>
</el-alert>
@@ -425,32 +425,32 @@
<!-- 分镜列表 -->
<div v-if="currentEpisode?.storyboards && currentEpisode.storyboards.length > 0" class="shots-list">
<div class="shots-header">
<h3>镜头列表</h3>
<h3>{{ $t('workflow.shotList') }}</h3>
</div>
<el-table :data="currentEpisode.storyboards" border stripe style="margin-top: 16px;">
<el-table-column type="index" label="编号" width="60" />
<el-table-column label="标题" width="120" show-overflow-tooltip>
<el-table-column type="index" :label="$t('storyboard.table.number')" width="60" />
<el-table-column :label="$t('storyboard.table.title')" width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.title || '-' }}
</template>
</el-table-column>
<el-table-column label="景别" width="80">
<el-table-column :label="$t('storyboard.table.shotType')" width="80">
<template #default="{ row }">
{{ row.shot_type || '-' }}
</template>
</el-table-column>
<el-table-column label="运镜" width="80">
<el-table-column :label="$t('storyboard.table.movement')" width="80">
<template #default="{ row }">
{{ row.movement || '-' }}
</template>
</el-table-column>
<el-table-column label="地点" width="150" show-overflow-tooltip>
<el-table-column :label="$t('storyboard.table.location')" width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.location || '-' }}
</template>
</el-table-column>
<el-table-column label="角色" width="100">
<el-table-column :label="$t('storyboard.table.character')" width="100">
<template #default="{ row }">
<span v-if="row.characters && row.characters.length > 0">
{{ row.characters.map(c => c.name || c).join(', ') }}
@@ -458,24 +458,24 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="动作" show-overflow-tooltip>
<el-table-column :label="$t('storyboard.table.action')" show-overflow-tooltip>
<template #default="{ row }">
{{ row.action || '-' }}
</template>
</el-table-column>
<el-table-column label="时长" width="80">
<el-table-column :label="$t('storyboard.table.duration')" width="80">
<template #default="{ row }">
{{ row.duration || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column :label="$t('storyboard.table.operations')" width="100" fixed="right">
<template #default="{ row, $index }">
<el-button
type="primary"
size="small"
@click="editShot(row, $index)"
>
编辑
{{ $t('common.edit') }}
</el-button>
</template>
</el-table-column>
@@ -484,20 +484,20 @@
<div class="action-buttons" style="margin-top: 24px;">
<el-button size="large" @click="prevStep">
<el-icon><ArrowLeft /></el-icon>
上一步
{{ $t('workflow.prevStep') }}
</el-button>
<el-button
@click="regenerateShots"
:icon="MagicStick"
>
重新拆分
{{ $t('workflow.reSplitShots') }}
</el-button>
<el-button
type="success"
size="large"
@click="goToProfessionalUI"
>
进入专业制作
{{ $t('workflow.enterProfessional') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
@@ -505,14 +505,14 @@
<!-- 未拆分时显示 -->
<div v-else class="empty-shots">
<el-empty description="请先对章节进行分镜拆解">
<el-empty :description="$t('workflow.splitStoryboardFirst')">
<el-button
type="primary"
@click="generateShots"
:loading="generatingShots"
:icon="MagicStick"
>
{{ generatingShots ? 'AI拆分中...' : 'AI自动拆分' }}
{{ generatingShots ? $t('workflow.aiSplitting') : $t('workflow.aiAutoSplit') }}
</el-button>
<!-- 任务进度显示 -->
@@ -536,45 +536,44 @@
<!-- 镜头编辑对话框 -->
<el-dialog
v-model="shotEditDialogVisible"
title="编辑镜头"
:title="$t('workflow.editShot')"
width="800px"
:close-on-click-modal="false"
>
<el-form v-if="editingShot" label-width="100px" size="default">
<el-form-item label="镜头标题">
<el-input v-model="editingShot.title" placeholder="请输入镜头标题" />
<el-form-item :label="$t('workflow.shotTitle')">
<el-input v-model="editingShot.title" :placeholder="$t('workflow.shotTitlePlaceholder')" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="景别">
<el-select v-model="editingShot.shot_type" placeholder="选择景别">
<el-option label="远景" value="远景" />
<el-option label="全景" value="全景" />
<el-option label="中景" value="中景" />
<el-option label="近景" value="近景" />
<el-option label="特写" value="特写" />
<el-form-item :label="$t('workflow.shotType')">
<el-select v-model="editingShot.shot_type" :placeholder="$t('workflow.selectShotType')">
<el-option :label="$t('workflow.longShot')" value="远景" />
<el-option :label="$t('workflow.fullShot')" value="全景" />
<el-option :label="$t('workflow.mediumShot')" value="中景" />
<el-option :label="$t('workflow.closeUp')" value="近景" />
<el-option :label="$t('workflow.extremeCloseUp')" value="特写" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="镜头角度">
<el-select v-model="editingShot.angle" placeholder="选择角度">
<el-option label="平视" value="平视" />
<el-option label="仰视" value="仰视" />
<el-option label="俯视" value="俯视" />
<el-option label="侧面" value="侧面" />
<el-form-item :label="$t('workflow.cameraAngle')">
<el-select v-model="editingShot.angle" :placeholder="$t('workflow.selectAngle')">
<el-option :label="$t('workflow.eyeLevel')" value="平视" />
<el-option :label="$t('workflow.lowAngle')" value="仰视" />
<el-option :label="$t('workflow.highAngle')" value="俯视" />
<el-option :label="$t('workflow.sideView')" value="侧面" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="运镜方式">
<el-select v-model="editingShot.movement" placeholder="选择运镜">
<el-option label="固定镜头" value="固定镜头" />
<el-option label="推镜" value="推镜" />
<el-option label="拉镜" value="拉镜" />
<el-option label="摇镜" value="镜" />
<el-option label="跟镜" value="跟镜" />
<el-form-item :label="$t('workflow.cameraMovement')">
<el-select v-model="editingShot.movement" :placeholder="$t('workflow.selectMovement')">
<el-option :label="$t('workflow.staticShot')" value="固定镜头" />
<el-option :label="$t('workflow.pushIn')" value="推镜" />
<el-option :label="$t('workflow.pullOut')" value="拉镜" />
<el-option :label="$t('workflow.followShot')" value="镜" />
</el-select>
</el-form-item>
</el-col>
@@ -582,98 +581,98 @@
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="地点">
<el-input v-model="editingShot.location" placeholder="场景地点" />
<el-form-item :label="$t('workflow.location')">
<el-input v-model="editingShot.location" :placeholder="$t('workflow.locationPlaceholder')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="时间">
<el-input v-model="editingShot.time" placeholder="时间设定" />
<el-form-item :label="$t('workflow.time')">
<el-input v-model="editingShot.time" :placeholder="$t('workflow.timeSetting')" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="镜头描述">
<el-input v-model="editingShot.description" type="textarea" :rows="2" placeholder="镜头整体描述" />
<el-form-item :label="$t('workflow.shotDescription')">
<el-input v-model="editingShot.description" type="textarea" :rows="2" :placeholder="$t('workflow.shotDescriptionPlaceholder')" />
</el-form-item>
<el-form-item label="动作描述">
<el-input v-model="editingShot.action" type="textarea" :rows="3" placeholder="详细动作描述" />
<el-form-item :label="$t('workflow.actionDescription')">
<el-input v-model="editingShot.action" type="textarea" :rows="3" :placeholder="$t('workflow.detailedAction')" />
</el-form-item>
<el-form-item label="对白">
<el-input v-model="editingShot.dialogue" type="textarea" :rows="2" placeholder="角色对白" />
<el-form-item :label="$t('workflow.dialogue')">
<el-input v-model="editingShot.dialogue" type="textarea" :rows="2" :placeholder="$t('workflow.characterDialogue')" />
</el-form-item>
<el-form-item label="画面结果">
<el-input v-model="editingShot.result" type="textarea" :rows="2" placeholder="动作结果" />
<el-form-item :label="$t('workflow.result')">
<el-input v-model="editingShot.result" type="textarea" :rows="2" :placeholder="$t('workflow.actionResult')" />
</el-form-item>
<el-form-item label="环境氛围">
<el-input v-model="editingShot.atmosphere" type="textarea" :rows="2" placeholder="环境氛围描述" />
<el-form-item :label="$t('workflow.atmosphere')">
<el-input v-model="editingShot.atmosphere" type="textarea" :rows="2" :placeholder="$t('workflow.atmosphereDescription')" />
</el-form-item>
<el-form-item label="图片提示词">
<el-input v-model="editingShot.image_prompt" type="textarea" :rows="3" placeholder="用于AI生成图片的提示词" />
<el-form-item :label="$t('workflow.imagePrompt')">
<el-input v-model="editingShot.image_prompt" type="textarea" :rows="3" :placeholder="$t('workflow.imagePromptPlaceholder')" />
</el-form-item>
<el-form-item label="视频提示词">
<el-input v-model="editingShot.video_prompt" type="textarea" :rows="3" placeholder="用于AI生成视频的提示词" />
<el-form-item :label="$t('workflow.videoPrompt')">
<el-input v-model="editingShot.video_prompt" type="textarea" :rows="3" :placeholder="$t('workflow.videoPromptPlaceholder')" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="配乐提示">
<el-input v-model="editingShot.bgm_prompt" placeholder="配乐氛围描述" />
<el-form-item :label="$t('workflow.bgmHint')">
<el-input v-model="editingShot.bgm_prompt" :placeholder="$t('workflow.bgmAtmosphere')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="音效">
<el-input v-model="editingShot.sound_effect" placeholder="音效描述" />
<el-form-item :label="$t('workflow.soundEffect')">
<el-input v-model="editingShot.sound_effect" :placeholder="$t('workflow.soundEffectDescription')" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="时长(秒)">
<el-form-item :label="$t('workflow.durationSeconds')">
<el-input-number v-model="editingShot.duration" :min="1" :max="60" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="shotEditDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveShotEdit" :loading="savingShot">保存</el-button>
<el-button @click="shotEditDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveShotEdit" :loading="savingShot">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
<!-- 提示词编辑对话框 -->
<el-dialog
v-model="promptDialogVisible"
title="修改提示词"
:title="$t('workflow.editPrompt')"
width="600px"
>
<el-form label-width="80px">
<el-form-item label="名称">
<el-form-item :label="$t('common.name')">
<el-input v-model="currentEditItem.name" disabled />
</el-form-item>
<el-form-item label="提示词">
<el-form-item label="Prompt">
<el-input
v-model="editPrompt"
type="textarea"
:rows="6"
placeholder="请输入AI生成图片的提示词..."
placeholder="Enter AI image generation prompt..."
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="promptDialogVisible = false">取消</el-button>
<el-button type="primary" @click="savePrompt">保存并生成</el-button>
<el-button @click="promptDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="savePrompt">{{ $t('common.saveAndGenerate') }}</el-button>
</template>
</el-dialog>
<!-- 角色库选择对话框 -->
<el-dialog
v-model="libraryDialogVisible"
title="从角色库选择"
:title="$t('workflow.selectFromLibrary')"
width="800px"
>
<div class="library-grid">
@@ -688,20 +687,20 @@
</div>
</div>
<div v-if="libraryItems.length === 0" class="empty-library">
<el-empty description="角色库为空,请先生成或上传角色图片" />
<el-empty :description="$t('workflow.emptyLibrary')" />
</div>
</el-dialog>
<!-- AI模型配置对话框 -->
<el-dialog
v-model="modelConfigDialogVisible"
title="AI模型配置"
:title="$t('workflow.aiModelConfig')"
width="600px"
:close-on-click-modal="false"
>
<el-form label-width="120px">
<el-form-item label="文本生成模型">
<el-select v-model="selectedTextModel" placeholder="选择文本生成模型" style="width: 100%">
<el-form-item :label="$t('workflow.textGenModel')">
<el-select v-model="selectedTextModel" :placeholder="$t('workflow.selectTextModel')" style="width: 100%">
<el-option
v-for="model in textModels"
:key="model.modelName"
@@ -710,12 +709,12 @@
/>
</el-select>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">
用于生成章节内容角色场景等文本
{{ $t('workflow.textModelTip') }}
</div>
</el-form-item>
<el-form-item label="图片生成模型">
<el-select v-model="selectedImageModel" placeholder="选择图片生成模型" style="width: 100%">
<el-form-item :label="$t('workflow.imageGenModel')">
<el-select v-model="selectedImageModel" :placeholder="$t('workflow.selectImageModel')" style="width: 100%">
<el-option
v-for="model in imageModels"
:key="model.modelName"
@@ -724,21 +723,21 @@
/>
</el-select>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">
用于生成角色和场景图片
{{ $t('workflow.modelConfigTip') }}
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modelConfigDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveModelConfig">保存配置</el-button>
<el-button @click="modelConfigDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveModelConfig">{{ $t('common.saveConfig') }}</el-button>
</template>
</el-dialog>
<!-- 图片上传对话框 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传图片"
:title="$t('tooltip.uploadImage')"
width="500px"
>
<el-upload
@@ -753,11 +752,11 @@
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
{{ $t('workflow.dragFilesHere') }}<em>{{ $t('workflow.clickToUpload') }}</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持 jpg/png 格式文件大小不超过 10MB
{{ $t('workflow.uploadFormatTip') }}
</div>
</template>
</el-upload>
@@ -768,6 +767,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User,
@@ -797,6 +797,7 @@ import type { Drama } from '@/types/drama'
const route = useRoute()
const router = useRouter()
const { t: $t } = useI18n()
const dramaId = route.params.id as string
const episodeNumber = parseInt(route.params.episodeNumber as string)
@@ -907,12 +908,8 @@ const loadAIConfigs = async () => {
aiAPI.list('image')
])
// 将配置展开为模型列表,只显示启用的配置
const activeTextConfigs = textList.filter(c => c.is_active)
const activeImageConfigs = imageList.filter(c => c.is_active)
// 展开模型列表并去重(保留优先级最高的)
const allTextModels = activeTextConfigs.flatMap(config => {
const allTextModels = textList.flatMap(config => {
const models = Array.isArray(config.model) ? config.model : [config.model]
return models.map(modelName => ({
modelName,
@@ -931,7 +928,7 @@ const loadAIConfigs = async () => {
})
textModels.value = Array.from(textModelMap.values())
const allImageModels = activeImageConfigs.flatMap(config => {
const allImageModels = imageList.flatMap(config => {
const models = Array.isArray(config.model) ? config.model : [config.model]
return models.map(modelName => ({
modelName,
@@ -971,7 +968,7 @@ const showModelConfigDialog = () => {
// 保存模型配置
const saveModelConfig = () => {
if (!selectedTextModel.value || !selectedImageModel.value) {
ElMessage.warning('请选择文本和图片生成模型')
ElMessage.warning($t('workflow.pleaseSelectModels'))
return
}
@@ -979,7 +976,7 @@ const saveModelConfig = () => {
localStorage.setItem(`ai_text_model_${dramaId}`, selectedTextModel.value)
localStorage.setItem(`ai_image_model_${dramaId}`, selectedImageModel.value)
ElMessage.success('模型配置已保存')
ElMessage.success($t('workflow.modelConfigSaved'))
modelConfigDialogVisible.value = false
}
@@ -1135,11 +1132,11 @@ const handleExtractCharactersAndBackgrounds = async () => {
if (hasExtractedData.value) {
try {
await ElMessageBox.confirm(
'重新提取将覆盖已提取的角色和场景(包括已生成的图片),确定继续吗?',
'重新提取确认',
$t('workflow.reExtractConfirmMessage'),
$t('workflow.reExtractConfirmTitle'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
distinguishCancelAndClose: true
}
@@ -1152,7 +1149,7 @@ const handleExtractCharactersAndBackgrounds = async () => {
// 显示即将开始的提示
if (hasExtractedData.value) {
ElMessage.info('开始重新提取,请稍候...')
ElMessage.info($t('workflow.startReExtracting'))
}
await extractCharactersAndBackgrounds()
@@ -1160,8 +1157,8 @@ const handleExtractCharactersAndBackgrounds = async () => {
// 轮询检查图片生成状态
const pollImageStatus = async (imageGenId: number, onComplete: () => Promise<void>) => {
const maxAttempts = 60 // 最多轮询60次
const pollInterval = 3000 // 每3秒轮询一次
const maxAttempts = 100 // 最多轮询100次
const pollInterval = 6000 // 每6秒轮询一次
for (let i = 0; i < maxAttempts; i++) {
try {
@@ -1190,7 +1187,6 @@ const pollImageStatus = async (imageGenId: number, onComplete: () => Promise<voi
}
const extractCharactersAndBackgrounds = async () => {
if (!currentEpisode.value?.id) {
ElMessage.error('章节信息不存在')
return
@@ -1201,47 +1197,38 @@ const extractCharactersAndBackgrounds = async () => {
try {
const episodeId = currentEpisode.value.id
// 并行执行角色生成和场景提取
const [characters] = await Promise.all([
// 并行创建异步任务
const [characterTask, backgroundTask] = await Promise.all([
generationAPI.generateCharacters({
drama_id: dramaId.toString(),
outline: currentEpisode.value.script_content || '',
count: 0
}).then(result => {
return result
}).catch(err => {
console.error('[提取] 角色生成API失败:', err)
throw err
}),
dramaAPI.extractBackgrounds(episodeId).then(result => {
return result
}).catch(err => {
console.error('[提取] 场景提取API失败:', err)
throw err
})
dramaAPI.extractBackgrounds(episodeId)
])
// 保存生成的角色到数据库
if (characters && characters.length > 0) {
await dramaAPI.saveCharacters(dramaId, characters, currentEpisode.value?.id)
}
ElMessage.success('任务已创建,正在后台处理...')
// 并行轮询两个任务
await Promise.all([
pollExtractTask(characterTask.task_id, 'character'),
pollExtractTask(backgroundTask.task_id, 'background')
])
ElMessage.success('角色和场景提取成功!')
await loadDramaData()
} catch (error: any) {
console.error('角色和场景提取失败:', error)
// 从接口返回的数据结构中提取错误信息
const errorData = error.response?.data?.error
const errorMsg = errorData?.message || error.message || '提取失败'
// 检查是否是AI配置缺失的错误
if (errorMsg.includes('no active config found') ||
if (errorMsg.includes('no config found') ||
errorMsg.includes('AI client') ||
errorMsg.includes('failed to get AI client')) {
ElMessage({
type: 'warning',
message: '未配置AI服务或当前配置未激活,请前往"设置 > AI服务配置"添加并激活文本生成服务',
message: '未配置AI服务请前往"设置 > AI服务配置"添加文本生成服务',
duration: 5000,
showClose: true
})
@@ -1253,6 +1240,41 @@ const extractCharactersAndBackgrounds = async () => {
}
}
// 轮询提取任务状态
const pollExtractTask = async (taskId: string, type: 'character' | 'background') => {
const maxAttempts = 60 // 最多轮询60次2分钟
const interval = 2000 // 每2秒查询一次
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, interval))
try {
const task = await generationAPI.getTaskStatus(taskId)
if (task.status === 'completed') {
// 任务完成
if (type === 'character' && task.result) {
// 解析角色数据并保存
const result = typeof task.result === 'string' ? JSON.parse(task.result) : task.result
if (result.characters && result.characters.length > 0) {
await dramaAPI.saveCharacters(dramaId, result.characters, currentEpisode.value?.id)
}
}
return
} else if (task.status === 'failed') {
// 任务失败
throw new Error(task.error || `${type === 'character' ? '角色生成' : '场景提取'}失败`)
}
// 否则继续轮询
} catch (error: any) {
console.error(`轮询${type}任务状态失败:`, error)
throw error
}
}
throw new Error(`${type === 'character' ? '角色生成' : '场景提取'}超时`)
}
const generateCharacterImage = async (characterId: number) => {
generatingCharacterImages.value[characterId] = true
@@ -1314,10 +1336,10 @@ const batchGenerateCharacterImages = async () => {
model
)
ElMessage.success('批量生成任务已提交!')
ElMessage.success($t('workflow.batchTaskSubmitted'))
await loadDramaData()
} catch (error: any) {
ElMessage.error(error.message || '批量生成失败')
ElMessage.error(error.message || $t('workflow.batchGenerateFailed'))
} finally {
batchGeneratingCharacters.value = false
}
@@ -1336,14 +1358,14 @@ const generateSceneImage = async (sceneId: string) => {
const imageGenId = response.image_generation?.id
if (imageGenId) {
ElMessage.info('场景图片生成中,请稍候...')
ElMessage.info($t('workflow.sceneImageGenerating'))
// 轮询检查生成状态
await pollImageStatus(imageGenId, async () => {
await loadDramaData()
ElMessage.success('场景图片生成完成!')
ElMessage.success($t('workflow.sceneImageComplete'))
})
} else {
ElMessage.success('场景图片生成已启动')
ElMessage.success($t('workflow.sceneImageStarted'))
await loadDramaData()
}
} catch (error: any) {
@@ -1370,12 +1392,12 @@ const batchGenerateSceneImages = async () => {
const failCount = results.filter(r => r.status === 'rejected').length
if (failCount === 0) {
ElMessage.success(`批量生成完成!成功生成 ${successCount} 个场景`)
ElMessage.success($t('workflow.batchCompleteSuccess', { count: successCount }))
} else {
ElMessage.warning(`生成完成:成功 ${successCount} 个,失败 ${failCount}`)
ElMessage.warning($t('workflow.batchCompletePartial', { success: successCount, fail: failCount }))
}
} catch (error: any) {
ElMessage.error(error.message || '批量生成失败')
ElMessage.error(error.message || $t('workflow.batchGenerateFailed'))
} finally {
batchGeneratingScenes.value = false
}
@@ -1427,7 +1449,7 @@ const pollTaskStatus = async (taskId: string) => {
}
generatingShots.value = false
ElMessage.success('分镜拆分成功!正在进入专业制作界面...')
ElMessage.success($t('workflow.splitSuccess'))
// 跳转到专业编辑器页面
router.push({
@@ -1465,7 +1487,7 @@ const pollTaskStatus = async (taskId: string) => {
}
const regenerateShots = async () => {
await ElMessageBox.confirm('确定要重新拆分分镜吗?', '提示', {
await ElMessageBox.confirm($t('workflow.reSplitConfirm'), $t('common.tip'), {
type: 'warning'
})
@@ -1550,32 +1572,32 @@ const selectFromLibrary = async (characterId: number) => {
currentUploadTarget.value = characterId
libraryDialogVisible.value = true
} catch (error: any) {
ElMessage.error(error.message || '获取角色库失败')
ElMessage.error(error.message || $t('workflow.loadLibraryFailed'))
}
}
const addToCharacterLibrary = async (character: any) => {
if (!character.image_url) {
ElMessage.warning('请先生成角色图片')
ElMessage.warning($t('workflow.generateImageFirst'))
return
}
try {
await ElMessageBox.confirm(
`确定要将角色"${character.name}"添加到全局角色库吗?添加后可以在所有项目中使用该角色形象。`,
'添加到角色库',
$t('workflow.addToLibraryConfirm', { name: character.name }),
$t('workflow.addToLibrary'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'info'
}
)
await characterLibraryAPI.addCharacterToLibrary(character.id.toString())
ElMessage.success('已添加到角色库!')
ElMessage.success($t('workflow.addedToLibrary'))
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '添加失败')
ElMessage.error(error.message || $t('workflow.addFailed'))
}
}
}

View File

@@ -5,10 +5,10 @@
<div class="toolbar-left">
<el-button link @click="goBack" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
返回剧集编辑
{{ $t('editor.backToEpisode') }}
</el-button>
<el-divider direction="vertical" />
<span class="episode-title">{{ drama?.title }} - {{ episodeNumber }}</span>
<span class="episode-title">{{ drama?.title }} - {{ $t('editor.episode', { number: episodeNumber }) }}</span>
</div>
<div class="toolbar-right">
@@ -21,8 +21,8 @@
<!-- 左侧分镜列表 -->
<div class="storyboard-panel">
<div class="panel-header">
<h3>剧本结构</h3>
<el-button text :icon="Plus" @click="handleAddStoryboard">添加</el-button>
<h3>{{ $t('storyboard.scriptStructure') }}</h3>
<el-button text :icon="Plus" @click="handleAddStoryboard">{{ $t('storyboard.add') }}</el-button>
</div>
<div class="storyboard-list">
@@ -36,8 +36,8 @@
<div class="shot-content">
<div class="shot-header">
<div class="shot-title-row">
<span class="shot-number">镜头 {{ shot.storyboard_number }}</span>
<span class="shot-title">{{ shot.title || '未命名镜头' }}</span>
<span class="shot-number">{{ $t('storyboard.shotNumber', { number: shot.storyboard_number }) }}</span>
<span class="shot-title">{{ shot.title || $t('storyboard.untitled') }}</span>
</div>
<div class="shot-duration">{{ shot.duration }}s</div>
</div>
@@ -60,39 +60,39 @@
@asset-deleted="loadVideoAssets"
@merge-completed="handleMergeCompleted"
/>
<el-empty v-else description="暂无分镜" class="empty-timeline" />
<el-empty v-else :description="$t('storyboard.noStoryboard')" class="empty-timeline" />
</div>
<!-- 右侧编辑面板 -->
<div class="edit-panel">
<el-tabs v-model="activeTab" class="edit-tabs">
<!-- 镜头属性标签 -->
<el-tab-pane label="镜头属性" name="shot" v-if="currentStoryboard">
<el-tab-pane :label="$t('storyboard.shotProperties')" name="shot" v-if="currentStoryboard">
<div v-if="currentStoryboard" class="shot-editor-new">
<!-- 场景(Scene) -->
<div class="scene-section">
<div class="section-label">
场景 (Scene)
<el-button size="small" text @click="showSceneSelector = true">选择场景</el-button>
{{ $t('storyboard.scene') }} (Scene)
<el-button size="small" text @click="showSceneSelector = true">{{ $t('storyboard.selectScene') }}</el-button>
</div>
<div class="scene-preview" v-if="currentStoryboard.background?.image_url" @click="showSceneImage">
<img :src="currentStoryboard.background.image_url" alt="场景" style="cursor: pointer;" />
<div class="scene-info">
<div>{{ currentStoryboard.background.location }} · {{ currentStoryboard.background.time }}</div>
<div class="scene-id">场景ID: {{ currentStoryboard.scene_id || 'N/A' }}</div>
<div class="scene-id">{{ $t('editor.sceneId') }}: {{ currentStoryboard.scene_id || 'N/A' }}</div>
</div>
</div>
<div class="scene-preview-empty" v-else>
<el-icon :size="48" color="#666"><Picture /></el-icon>
<div>{{ currentStoryboard.background ? '场景图片生成中...' : '未关联背景' }}</div>
<div>{{ currentStoryboard.background ? $t('editor.sceneGenerating') : $t('editor.noBackground') }}</div>
</div>
</div>
<!-- 登场角色(Cast) -->
<div class="cast-section">
<div class="section-label">
登场角色 (Cast)
<el-button size="small" text :icon="Plus" @click="showCharacterSelector = true">添加角色</el-button>
{{ $t('editor.cast') }} (Cast)
<el-button size="small" text :icon="Plus" @click="showCharacterSelector = true">{{ $t('editor.addCharacter') }}</el-button>
</div>
<div class="cast-list">
<div
@@ -105,23 +105,23 @@
<span v-else>{{ char.name?.[0] || '?' }}</span>
</div>
<div class="cast-name">{{ char.name }}</div>
<div class="cast-remove" @click.stop="toggleCharacterInShot(char.id)" title="移除角色">
<div class="cast-remove" @click.stop="toggleCharacterInShot(char.id)" :title="$t('editor.removeCharacter')">
<el-icon :size="14"><Close /></el-icon>
</div>
</div>
<div v-if="!currentStoryboard?.characters || currentStoryboard.characters.length === 0" class="cast-empty">
未指定角色
{{ $t('editor.noCharacters') }}
</div>
</div>
</div>
<!-- 视效设置 -->
<div class="settings-section">
<div class="section-label">视效设置</div>
<div class="section-label">{{ $t('editor.visualSettings') }}</div>
<div class="settings-grid">
<div class="setting-item">
<label>景别</label>
<el-select v-model="currentStoryboard.shot_type" size="small" placeholder="选择景别" @change="saveStoryboardField('shot_type')">
<label>{{ $t('editor.shotType') }}</label>
<el-select v-model="currentStoryboard.shot_type" size="small" :placeholder="$t('editor.shotTypePlaceholder')" @change="saveStoryboardField('shot_type')">
<el-option label="大远景" value="大远景" />
<el-option label="远景" value="远景" />
<el-option label="全景" value="全景" />
@@ -135,8 +135,8 @@
</div>
<div class="setting-item">
<label>运镜方式</label>
<el-select v-model="currentStoryboard.movement" size="small" placeholder="运镜方式" @change="saveStoryboardField('movement')">
<label>{{ $t('editor.movement') }}</label>
<el-select v-model="currentStoryboard.movement" size="small" :placeholder="$t('editor.movementPlaceholder')" @change="saveStoryboardField('movement')">
<el-option label="固定镜头" value="固定镜头" />
<el-option label="推镜" value="推镜" />
<el-option label="拉镜" value="拉镜" />
@@ -155,8 +155,8 @@
</div>
<div class="setting-item">
<label>镜头角度</label>
<el-select v-model="currentStoryboard.angle" size="small" placeholder="镜头角度" @change="saveStoryboardField('angle')">
<label>{{ $t('editor.angle') }}</label>
<el-select v-model="currentStoryboard.angle" size="small" :placeholder="$t('editor.anglePlaceholder')" @change="saveStoryboardField('angle')">
<el-option label="平视" value="平视" />
<el-option label="俯视" value="俯视" />
<el-option label="仰视" value="仰视" />
@@ -175,52 +175,52 @@
<!-- 叙事内容 -->
<div class="narrative-section">
<div class="section-label">动作描述 (Action)</div>
<div class="section-label">{{ $t('editor.action') }} (Action)</div>
<el-input
v-model="currentStoryboard.action"
type="textarea"
:rows="3"
placeholder="描述角色的动作过程..."
:placeholder="$t('editor.actionPlaceholder')"
/>
</div>
<div class="narrative-section">
<div class="section-label">动作结果 (Result)</div>
<div class="section-label">{{ $t('editor.result') }} (Result)</div>
<el-input
v-model="currentStoryboard.result"
type="textarea"
:rows="2"
placeholder="描述动作完成后的状态..."
:placeholder="$t('editor.resultPlaceholder')"
/>
</div>
<div class="dialogue-section">
<div class="section-label">对白 (Dialogue)</div>
<div class="section-label">{{ $t('editor.dialogue') }} (Dialogue)</div>
<el-input
v-model="currentStoryboard.dialogue"
type="textarea"
:rows="3"
placeholder="角色对白内容..."
:placeholder="$t('editor.dialoguePlaceholder')"
/>
</div>
<div class="narrative-section">
<div class="section-label">镜头描述 (Description)</div>
<div class="section-label">{{ $t('editor.description') }} (Description)</div>
<el-input
v-model="currentStoryboard.description"
type="textarea"
:rows="3"
placeholder="整体镜头描述..."
:placeholder="$t('editor.descriptionPlaceholder')"
/>
</div>
<!-- 音效设置 -->
<div class="settings-section">
<div class="section-label">音效</div>
<div class="section-label">{{ $t('editor.soundEffects') }}</div>
<div class="audio-controls">
<el-input
v-model="currentStoryboard.sound_effect"
placeholder="描述音效,如:脚步声、关门声等"
:placeholder="$t('editor.soundEffectsPlaceholder')"
size="small"
type="textarea"
:rows="2"
@@ -230,11 +230,11 @@
<!-- 配乐设置 -->
<div class="settings-section">
<div class="section-label">配乐提示</div>
<div class="section-label">{{ $t('editor.bgmPrompt') }}</div>
<div class="audio-controls">
<el-input
v-model="currentStoryboard.bgm_prompt"
placeholder="描述配乐氛围,如:紧张激烈的背景音乐"
:placeholder="$t('editor.bgmPromptPlaceholder')"
size="small"
type="textarea"
:rows="2"
@@ -244,11 +244,11 @@
<!-- 氛围设置 -->
<div class="settings-section">
<div class="section-label">环境氛围</div>
<div class="section-label">{{ $t('editor.atmosphere') }}</div>
<div class="audio-controls">
<el-input
v-model="currentStoryboard.atmosphere"
placeholder="描述环境氛围,如:昏暗压抑、明亮温馨"
:placeholder="$t('editor.atmospherePlaceholder')"
size="small"
type="textarea"
:rows="2"
@@ -256,22 +256,22 @@
</div>
</div>
</div>
<el-empty v-else description="未选择镜头" />
<el-empty v-else :description="$t('editor.noShotSelected')" />
</el-tab-pane>
<!-- 图片生成标签 -->
<el-tab-pane label="镜头图片" name="image">
<el-tab-pane :label="$t('editor.shotImage')" name="image">
<div class="tab-content" v-if="currentStoryboard">
<div class="image-generation-section">
<!-- 帧类型选择 -->
<div class="frame-type-selector">
<div class="section-label">选择帧类型</div>
<div class="section-label">{{ $t('editor.selectFrameType') }}</div>
<el-radio-group v-model="selectedFrameType" size="small">
<el-radio-button label="first">首帧</el-radio-button>
<el-radio-button label="last">尾帧</el-radio-button>
<el-radio-button label="panel">分镜板</el-radio-button>
<el-radio-button label="action">动作序列</el-radio-button>
<el-radio-button label="key">关键帧</el-radio-button>
<el-radio-button label="first">{{ $t('editor.firstFrame') }}</el-radio-button>
<el-radio-button label="last">{{ $t('editor.lastFrame') }}</el-radio-button>
<el-radio-button label="panel">{{ $t('editor.panelFrame') }}</el-radio-button>
<el-radio-button label="action">{{ $t('editor.actionSequence') }}</el-radio-button>
<el-radio-button label="key">{{ $t('editor.keyFrame') }}</el-radio-button>
</el-radio-group>
<el-input-number
v-if="selectedFrameType === 'panel'"
@@ -282,13 +282,13 @@
class="panel-count-input"
style="margin-left: 10px; margin-top: 12px;"
/>
<span v-if="selectedFrameType === 'panel'" style="margin-left: 5px; font-size: 12px; color: #999;">格数</span>
<span v-if="selectedFrameType === 'panel'" style="margin-left: 5px; font-size: 12px; color: #999;">{{ $t('editor.panelCount') }}</span>
</div>
<!-- 提示词区域 -->
<div class="prompt-section">
<div class="section-label">
提示词
{{ $t('editor.prompt') }}
<el-button
size="small"
type="primary"
@@ -297,14 +297,14 @@
@click="extractFramePrompt"
style="margin-left: 10px;"
>
提取提示词
{{ $t('editor.extractPrompt') }}
</el-button>
</div>
<el-input
v-model="currentFramePrompt"
type="textarea"
:rows="8"
placeholder="点击提取提示词按钮,系统将根据分镜内容生成图片提示词..."
:placeholder="$t('editor.promptPlaceholder')"
/>
</div>
@@ -317,14 +317,14 @@
:disabled="!currentFramePrompt"
@click="generateFrameImage"
>
{{ generatingImage ? '生成中...' : '生成图片' }}
{{ generatingImage ? $t('editor.generating') : $t('editor.generateImage') }}
</el-button>
<el-button :icon="Upload" @click="uploadImage">上传图片</el-button>
<el-button :icon="Upload" @click="uploadImage">{{ $t('editor.uploadImage') }}</el-button>
</div>
<!-- 生成结果 -->
<div class="generation-result" v-if="generatedImages.length > 0">
<div class="section-label">生成结果 ({{ generatedImages.length }})</div>
<div class="section-label">{{ $t('editor.generationResult') }} ({{ generatedImages.length }})</div>
<div class="image-grid">
<div
v-for="img in generatedImages"
@@ -356,7 +356,7 @@
</el-tab-pane>
<!-- 视频生成标签 -->
<el-tab-pane label="视频生成" name="video">
<el-tab-pane :label="$t('video.videoGeneration')" name="video">
<div class="tab-content" v-if="currentStoryboard">
<div class="video-generation-section">
<!-- 生成提示词展示 -->
@@ -367,8 +367,8 @@
<!-- 视频参数设置 -->
<div class="video-params-section">
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px;">
<span style="min-width: 60px; font-size: 14px; color: #606266;">模型</span>
<el-select v-model="selectedVideoModel" placeholder="请选择视频生成模型" size="default" style="flex: 1;">
<span style="min-width: 60px; font-size: 14px; color: #606266;">{{ $t('video.model') }}</span>
<el-select v-model="selectedVideoModel" :placeholder="$t('video.selectVideoModel')" size="default" style="flex: 1;">
<el-option
v-for="model in videoModelCapabilities"
:key="model.id"
@@ -406,10 +406,10 @@
</div>
<div style="margin-bottom: 8px; display: flex; align-items: center; gap: 12px;">
<span style="min-width: 60px; font-size: 14px; color: #606266;">时长</span>
<span style="min-width: 60px; font-size: 14px; color: #606266;">{{ $t('professionalEditor.duration') }}</span>
<div style="flex: 1; display: flex; align-items: center;">
<el-slider v-model="videoDuration" :min="4" :max="10" :step="1" show-stops style="flex: 1;" />
<span style="margin-left: 10px; min-width: 40px;">{{ videoDuration }}</span>
<span style="margin-left: 10px; min-width: 40px;">{{ videoDuration }}{{ $t('professionalEditor.seconds') }}</span>
</div>
</div>
</div>
@@ -685,21 +685,21 @@
</el-tab-pane>
<!-- 音效与配乐标签 -->
<el-tab-pane label="音效与配乐" name="audio">
<el-tab-pane :label="$t('video.soundAndMusicTab')" name="audio">
<div class="tab-content">
<el-empty description="音效与配乐功能开发中" />
<el-empty :description="$t('video.soundMusicInDev')" />
</div>
</el-tab-pane>
<!-- 视频合成列表标签 -->
<el-tab-pane label="视频合成" name="merges">
<el-tab-pane :label="$t('video.videoMerge')" name="merges">
<div class="tab-content">
<div class="merges-list" v-loading="loadingMerges">
<el-empty v-if="videoMerges.length === 0" description="暂无视频合成记录" :image-size="120">
<el-empty v-if="videoMerges.length === 0" :description="$t('video.noMergeRecords')" :image-size="120">
<template #description>
<div style="color: #909399; font-size: 14px; margin-top: 12px;">
<p style="margin: 0;">还没有合成过视频</p>
<p style="margin: 8px 0 0 0; font-size: 12px;">在时间线编辑器中排列好视频后点击"合成视频"即可</p>
<p style="margin: 0;">{{ $t('video.noMergeYet') }}</p>
<p style="margin: 8px 0 0 0; font-size: 12px;">{{ $t('video.mergeInstructions') }}</p>
</div>
</template>
</el-empty>
@@ -738,8 +738,8 @@
<el-icon :size="16"><Timer /></el-icon>
</div>
<div class="detail-content">
<div class="detail-label">视频时长</div>
<div class="detail-value">{{ merge.duration ? `${merge.duration} ` : '-' }}</div>
<div class="detail-label">{{ $t('professionalEditor.videoDuration') }}</div>
<div class="detail-value">{{ merge.duration ? `${merge.duration} ${$t('professionalEditor.seconds')}` : '-' }}</div>
</div>
</div>
<div class="detail-item">
@@ -906,10 +906,10 @@
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<el-tag :type="getStatusType(previewVideo.status)" size="small">{{ getStatusText(previewVideo.status) }}</el-tag>
<span v-if="previewVideo.duration" style="margin-left: 12px; color: #606266; font-size: 14px;">时长: {{ previewVideo.duration }}秒</span>
<span v-if="previewVideo.duration" style="margin-left: 12px; color: #606266; font-size: 14px;">{{ $t('professionalEditor.duration') }}: {{ previewVideo.duration }}{{ $t('professionalEditor.seconds') }}</span>
</div>
<el-button v-if="previewVideo.video_url" size="small" @click="window.open(previewVideo.video_url, '_blank')">
下载视频
{{ $t('professionalEditor.downloadVideo') }}
</el-button>
</div>
<div v-if="previewVideo.prompt" style="margin-top: 12px; font-size: 12px; color: #606266; line-height: 1.6;">
@@ -922,8 +922,9 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft, Plus, Picture, VideoPlay, VideoPause, View, Setting,
@@ -947,6 +948,7 @@ import type { Drama, Episode, Storyboard } from '@/types/drama'
const route = useRoute()
const router = useRouter()
const { t: $t } = useI18n()
const dramaId = Number(route.params.dramaId)
const episodeNumber = Number(route.params.episodeNumber)

View File

@@ -3,9 +3,9 @@
<div class="editor-header">
<el-button link @click="goBack" class="back-button">
<el-icon><ArrowLeft /></el-icon>
返回
{{ $t('timeline.backToEditor') }}
</el-button>
<h2>时间线编辑器</h2>
<h2>{{ $t('timeline.title') }}</h2>
</div>
<div class="editor-content">
@@ -14,7 +14,7 @@
:scenes="scenes"
:episode-id="episodeId"
/>
<el-empty v-else description="暂无可用场景" />
<el-empty v-else :description="$t('timeline.noScenes')" />
</div>
</div>
</template>
@@ -38,7 +38,7 @@ const loadScenes = async () => {
const res = await dramaAPI.getStoryboards(episodeId)
scenes.value = res.storyboards || []
} catch (error: any) {
ElMessage.error('加载分镜失败')
ElMessage.error($t('timeline.loadFailed'))
}
}

View File

@@ -3,21 +3,21 @@
<el-page-header @back="goBack" class="page-header">
<template #content>
<div class="header-content">
<h2>AI 图片生成</h2>
<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="剧本">
<el-select v-model="filters.drama_id" placeholder="全部剧本" clearable>
<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"
@@ -27,17 +27,17 @@
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable>
<el-option label="生成中" value="processing" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
<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">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<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>
@@ -63,19 +63,19 @@
<template #error>
<div class="image-placeholder">
<el-icon><PictureFilled /></el-icon>
<span>加载失败</span>
<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>生成中...</span>
<span>{{ $t('image.generating') }}</span>
</div>
<div v-else-if="image.status === 'failed'" class="image-placeholder failed">
<el-icon><CircleClose /></el-icon>
<span>生成失败</span>
<span>{{ $t('image.generateFailed') }}</span>
</div>
<div v-else class="image-placeholder">

View File

@@ -3,21 +3,21 @@
<el-page-header @back="goBack" class="page-header">
<template #content>
<div class="header-content">
<h2>AI 视频生成</h2>
<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="剧本">
<el-select v-model="filters.drama_id" placeholder="全部剧本" clearable>
<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"
@@ -27,17 +27,17 @@
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable>
<el-option label="生成中" value="processing" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
<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">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<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>

View File

@@ -1,14 +1,14 @@
<template>
<el-dialog
v-model="visible"
title="AI 图片生成"
: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="选择剧本" prop="drama_id">
<el-select v-model="form.drama_id" placeholder="选择剧本" @change="onDramaChange">
<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"
@@ -18,95 +18,95 @@
</el-select>
</el-form-item>
<el-form-item label="选择场景" prop="scene_id">
<el-form-item :label="$t('imageDialog.selectScene')" prop="scene_id">
<el-select
v-model="form.scene_id"
placeholder="选择场景(可选)"
:placeholder="$t('imageDialog.selectSceneOptional')"
clearable
@change="onSceneChange"
>
<el-option
v-for="scene in scenes"
:key="scene.id"
:label="`场景${scene.storyboard_number}: ${scene.title}`"
:label="$t('imageDialog.sceneLabel', { number: scene.storyboard_number, title: scene.title })"
:value="scene.id"
/>
</el-select>
</el-form-item>
<el-form-item label="提示词" prop="prompt">
<el-form-item :label="$t('imageDialog.prompt')" prop="prompt">
<el-input
v-model="form.prompt"
type="textarea"
:rows="6"
placeholder="描述你想生成的图片&#10;例如A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed"
:placeholder="$t('imageDialog.promptPlaceholder')"
maxlength="2000"
show-word-limit
/>
</el-form-item>
<el-form-item label="反向提示词">
<el-form-item :label="$t('imageDialog.negativePrompt')">
<el-input
v-model="form.negative_prompt"
type="textarea"
:rows="3"
placeholder="描述不希望出现的元素(可选)&#10;例如blurry, low quality, watermark"
:placeholder="$t('imageDialog.negativePromptPlaceholder')"
maxlength="1000"
show-word-limit
/>
</el-form-item>
<el-form-item label="AI 服务">
<el-select v-model="form.provider" placeholder="选择服务">
<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="图片尺寸">
<el-select v-model="form.size" placeholder="选择尺寸">
<el-option label="1024x1024 (正方形)" value="1024x1024" />
<el-option label="1792x1024 (横向)" value="1792x1024" />
<el-option label="1024x1792 (纵向)" value="1024x1792" />
<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="图片质量" v-if="form.provider === 'openai'">
<el-form-item :label="$t('imageDialog.imageQuality')" v-if="form.provider === 'openai'">
<el-radio-group v-model="form.quality">
<el-radio label="standard">标准</el-radio>
<el-radio label="hd">高清</el-radio>
<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="风格" v-if="form.provider === 'openai'">
<el-form-item :label="$t('imageDialog.style')" v-if="form.provider === 'openai'">
<el-radio-group v-model="form.style">
<el-radio label="vivid">鲜艳</el-radio>
<el-radio label="natural">自然</el-radio>
<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="高级设置" name="advanced">
<el-form-item label="采样步数">
<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="提示词相关性">
<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="随机种子">
<el-input-number v-model="form.seed" :min="-1" placeholder="留空随机" />
<span class="form-tip">设置相同种子可复现图片</span>
<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">取消</el-button>
<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>
@@ -115,6 +115,7 @@
<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'
@@ -131,6 +132,8 @@ const emit = defineEmits<{
success: []
}>()
const { t } = useI18n()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
@@ -157,11 +160,11 @@ const form = reactive<GenerateImageRequest>({
const rules: FormRules = {
drama_id: [
{ required: true, message: '请选择剧本', trigger: 'change' }
{ required: true, message: t('imageDialog.pleaseSelectDrama'), trigger: 'change' }
],
prompt: [
{ required: true, message: '请输入提示词', trigger: 'blur' },
{ min: 5, message: '提示词至少5个字符', trigger: 'blur' }
{ required: true, message: t('imageDialog.pleaseEnterPrompt'), trigger: 'blur' },
{ min: 5, message: t('imageDialog.promptMinLength'), trigger: 'blur' }
]
}
@@ -174,10 +177,10 @@ const stepsMarks = {
}
const cfgMarks = {
1: '弱',
7.5: '适中',
15: '强',
20: '很强'
1: t('imageDialog.weak'),
7.5: t('imageDialog.moderate'),
15: t('imageDialog.strong'),
20: t('imageDialog.veryStrong')
}
watch(() => props.modelValue, (val) => {
@@ -274,11 +277,11 @@ const handleGenerate = async () => {
await imageAPI.generateImage(params)
ElMessage.success('图片生成任务已提交,请稍后查看结果')
ElMessage.success(t('imageDialog.taskSubmitted'))
emit('success')
handleClose()
} catch (error: any) {
ElMessage.error(error.message || '生成失败')
ElMessage.error(error.message || t('imageDialog.generateFailed'))
} finally {
generating.value = false
}

View File

@@ -1,16 +1,16 @@
<template>
<div class="ai-config-container">
<el-page-header @back="goBack" title="返回">
<el-page-header @back="goBack" :title="$t('aiConfig.back')">
<template #content>
<div class="page-header">
<h2>AI 服务配置</h2>
<el-button type="primary" @click="showCreateDialog" :icon="Plus">添加配置</el-button>
<h2>{{ $t('aiConfig.title') }}</h2>
<el-button type="primary" @click="showCreateDialog" :icon="Plus">{{ $t('aiConfig.addConfig') }}</el-button>
</div>
</template>
</el-page-header>
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="文本生成" name="text">
<el-tab-pane :label="$t('aiConfig.tabs.text')" name="text">
<ConfigList
:configs="configs"
:loading="loading"
@@ -22,7 +22,7 @@
/>
</el-tab-pane>
<el-tab-pane label="图片生成" name="image">
<el-tab-pane :label="$t('aiConfig.tabs.image')" name="image">
<ConfigList
:configs="configs"
:loading="loading"
@@ -33,7 +33,7 @@
/>
</el-tab-pane>
<el-tab-pane label="视频生成" name="video">
<el-tab-pane :label="$t('aiConfig.tabs.video')" name="video">
<ConfigList
:configs="configs"
:loading="loading"
@@ -47,7 +47,7 @@
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑配置' : '添加配置'"
:title="isEdit ? $t('aiConfig.editConfig') : $t('aiConfig.addConfig')"
width="600px"
:close-on-click-modal="false"
>
@@ -57,14 +57,14 @@
:rules="rules"
label-width="100px"
>
<el-form-item label="配置名称" prop="name">
<el-input v-model="form.name" placeholder="例如OpenAI GPT-4" />
<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="厂商" prop="provider">
<el-form-item :label="$t('aiConfig.form.provider')" prop="provider">
<el-select
v-model="form.provider"
placeholder="请选择厂商"
:placeholder="$t('aiConfig.form.providerPlaceholder')"
@change="handleProviderChange"
style="width: 100%"
>
@@ -76,10 +76,10 @@
:disabled="provider.disabled"
/>
</el-select>
<div class="form-tip">选择AI服务提供商</div>
<div class="form-tip">{{ $t('aiConfig.form.providerTip') }}</div>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-form-item :label="$t('aiConfig.form.priority')" prop="priority">
<el-input-number
v-model="form.priority"
:min="0"
@@ -87,13 +87,13 @@
:step="1"
style="width: 100%"
/>
<div class="form-tip">数值越大优先级越高相同模型时优先使用高优先级配置</div>
<div class="form-tip">{{ $t('aiConfig.form.priorityTip') }}</div>
</el-form-item>
<el-form-item label="模型" prop="model">
<el-form-item :label="$t('aiConfig.form.model')" prop="model">
<el-select
v-model="form.model"
placeholder="输入或选择模型名称"
:placeholder="$t('aiConfig.form.modelPlaceholder')"
multiple
filterable
allow-create
@@ -109,38 +109,38 @@
:value="model"
/>
</el-select>
<div class="form-tip">可直接输入模型名称或从列表选择支持多个模型</div>
<div class="form-tip">{{ $t('aiConfig.form.modelTip') }}</div>
</el-form-item>
<el-form-item label="Base URL" prop="base_url">
<el-input v-model="form.base_url" placeholder="https://api.openai.com" />
<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">
API 服务的基础地址 Chatfire: https://api.chatfire.site/v1Gemini: https://generativelanguage.googleapis.com无需 /v1
{{ $t('aiConfig.form.baseUrlTip') }}
<br>
完整调用路径: {{ fullEndpointExample }}
{{ $t('aiConfig.form.fullEndpoint') }}: {{ fullEndpointExample }}
</div>
</el-form-item>
<el-form-item label="API Key" prop="api_key">
<el-form-item :label="$t('aiConfig.form.apiKey')" prop="api_key">
<el-input
v-model="form.api_key"
type="password"
show-password
placeholder="sk-..."
:placeholder="$t('aiConfig.form.apiKeyPlaceholder')"
/>
<div class="form-tip">您的 API 密钥</div>
<div class="form-tip">{{ $t('aiConfig.form.apiKeyTip') }}</div>
</el-form-item>
<el-form-item v-if="isEdit" label="启用状态">
<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">取消</el-button>
<el-button v-if="form.service_type === 'text'" @click="testConnection" :loading="testing">测试连接</el-button>
<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 ? '保存' : '创建' }}
{{ isEdit ? $t('common.save') : $t('common.create') }}
</el-button>
</template>
</el-dialog>

View File

@@ -1,6 +1,6 @@
<template>
<div v-loading="loading" class="config-list">
<el-empty v-if="!loading && configs.length === 0" description="暂无配置,点击添加配置开始使用" />
<el-empty v-if="!loading && configs.length === 0" :description="$t('aiConfig.empty')" />
<el-card
v-for="config in configs"
@@ -11,30 +11,30 @@
<div class="config-header">
<div class="config-title">
<h3>{{ config.name }}</h3>
<el-tag v-if="config.is_active" type="success" size="small">已启用</el-tag>
<el-tag v-else type="info" size="small">已禁用</el-tag>
<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 ? '禁用' : '启用' }}
{{ config.is_active ? $t('aiConfig.disable') : $t('aiConfig.enable') }}
</el-button>
<el-popconfirm
title="确定删除该配置吗?"
: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>
@@ -48,12 +48,12 @@
</div>
<div class="info-item">
<label>端点</label>
<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>查询端点</label>
<label>{{ $t('aiConfig.queryEndpoint') }}</label>
<span>{{ config.query_endpoint }}</span>
</div>

View File

@@ -1,11 +1,11 @@
<template>
<div class="storyboard-edit-container">
<el-page-header @back="goBack" title="返回">
<el-page-header @back="goBack" :title="$t('common.back')">
<template #content>
<h2>分镜编辑</h2>
<h2>{{ $t('storyboard.edit') }}</h2>
</template>
</el-page-header>
<p>功能开发中...</p>
<p>{{ $t('storyboard.inDevelopment') }}</p>
</div>
</template>

View File

@@ -1,28 +1,28 @@
<template>
<div class="character-extraction-container">
<el-page-header @back="goBack" title="返回项目">
<el-page-header @back="goBack" :title="$t('character.backToProject')">
<template #content>
<h2>角色管理</h2>
<h2>{{ $t('character.title') }}</h2>
</template>
</el-page-header>
<el-card shadow="never" class="main-card">
<template #header>
<div class="card-header">
<h3>角色列表</h3>
<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="角色已在剧本生成阶段创建,您可以在此查看和编辑" />
<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">
@@ -38,16 +38,16 @@
</template>
<div class="character-details">
<p><strong>性格</strong>{{ character.personality }}</p>
<p><strong>外貌</strong>{{ character.appearance }}</p>
<p><strong>背景</strong>{{ character.background }}</p>
<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)">编辑</el-button>
<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>
@@ -57,7 +57,7 @@
<div class="actions" v-if="characters.length > 0">
<el-button type="success" size="large" @click="goToNextStep">
下一步生成角色图片
{{ $t('character.nextStep') }}
</el-button>
</div>
</el-card>

View File

@@ -1,21 +1,21 @@
<template>
<div class="script-generation-container">
<el-page-header @back="goBack" title="返回项目">
<el-page-header @back="goBack" :title="$t('script.backToProject')">
<template #content>
<h2>剧本生成</h2>
<h2>{{ $t('script.title') }}</h2>
</template>
</el-page-header>
<el-card shadow="never" class="main-card">
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="custom-tabs">
<!-- AI 生成剧本 -->
<el-tab-pane label="AI 生成剧本" name="ai">
<el-tab-pane :label="$t('script.aiGenerate')" name="ai">
<!-- 步骤进度条 -->
<div class="steps-wrapper">
<el-steps :active="aiCurrentStep" finish-status="success" align-center process-status="process">
<el-step title="生成大纲" description="" />
<el-step title="生成角色" description="" />
<el-step title="生成剧集" description="" />
<el-step :title="$t('script.steps.outline')" description="" />
<el-step :title="$t('script.steps.characters')" description="" />
<el-step :title="$t('script.steps.episodes')" description="" />
</el-steps>
</div>
@@ -23,13 +23,13 @@
<!-- 步骤 0: 生成大纲 -->
<div v-show="aiCurrentStep === 0" class="step-panel">
<el-form :model="outlineForm" label-width="100px">
<el-form-item label="创作主题" required>
<el-form-item :label="$t('script.form.theme')" required>
<div class="theme-input-wrapper">
<el-input
v-model="outlineForm.theme"
type="textarea"
:rows="4"
placeholder="描述你想创作的短剧主题和故事概念"
:placeholder="$t('script.form.themePlaceholder')"
maxlength="500"
show-word-limit
/>
@@ -39,35 +39,35 @@
@click="generateRandomTheme"
class="random-btn"
>
随机生成
{{ $t('script.form.randomGenerate') }}
</el-button>
</div>
</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-form-item :label="$t('script.form.genre')">
<el-select v-model="outlineForm.genre" :placeholder="$t('script.form.genrePlaceholder')" clearable>
<el-option :label="$t('genres.urban')" value="都市" />
<el-option :label="$t('genres.costume')" value="古装" />
<el-option :label="$t('genres.mystery')" value="悬疑" />
<el-option :label="$t('genres.romance')" value="爱情" />
<el-option :label="$t('genres.comedy')" value="喜剧" />
</el-select>
</el-form-item>
<el-form-item label="风格要求">
<el-form-item :label="$t('script.form.style')">
<el-input
v-model="outlineForm.style"
placeholder="例如:轻松幽默、紧张刺激、温馨治愈"
:placeholder="$t('script.form.stylePlaceholder')"
/>
</el-form-item>
<el-form-item label="剧集数量">
<el-form-item :label="$t('script.form.episodeCount')">
<el-input-number v-model="outlineForm.length" :min="3" :max="20" />
</el-form-item>
</el-form>
<div class="stage-notice">
<p>请输入创作主题和相关要求AI将为您生成剧本大纲</p>
<p>{{ $t('script.notice') }}</p>
</div>
<el-alert
@@ -77,17 +77,17 @@
show-icon
class="error-alert"
>
<template #title>生成失败</template>
<template #title>{{ $t('script.generateFailed') }}</template>
<div>{{ generationError }}</div>
<el-button type="primary" size="small" @click="retryGeneration" style="margin-top: 12px;">
<el-icon><RefreshRight /></el-icon>
重新生成
{{ $t('script.regenerate') }}
</el-button>
</el-alert>
<div v-if="outlineResult" class="result-preview">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4 style="margin: 0;">大纲预览可编辑</h4>
<h4 style="margin: 0;">{{ $t('script.outlinePreview') }}</h4>
<el-button
type="warning"
size="small"
@@ -95,25 +95,25 @@
:loading="generating"
>
<el-icon><RefreshRight /></el-icon>
重新生成大纲
{{ $t('script.regenerateOutline') }}
</el-button>
</div>
<el-form label-width="80px" class="outline-edit-form">
<el-form-item label="标题">
<el-input v-model="outlineResult.title" placeholder="请输入剧本标题" />
<el-form-item :label="$t('script.form.title')">
<el-input v-model="outlineResult.title" :placeholder="$t('script.form.titlePlaceholder')" />
</el-form-item>
<el-form-item label="概要">
<el-form-item :label="$t('script.form.summary')">
<el-input
v-model="outlineResult.summary"
type="textarea"
:rows="8"
placeholder="请输入剧本概要"
:placeholder="$t('script.form.summaryPlaceholder')"
/>
</el-form-item>
<el-form-item label="类型">
<el-input v-model="outlineResult.genre" placeholder="例如:都市、古装" />
<el-form-item :label="$t('script.form.genre')">
<el-input v-model="outlineResult.genre" :placeholder="$t('script.form.genreExample')" />
</el-form-item>
<el-form-item label="标签">
<el-form-item :label="$t('script.form.tags')">
<el-tag
v-for="(tag, index) in outlineResult.tags"
:key="index"
@@ -132,7 +132,7 @@
@keyup.enter="addTag"
@blur="addTag"
/>
<el-button v-else size="small" @click="showTagInput">+ 新标签</el-button>
<el-button v-else size="small" @click="showTagInput">+ {{ $t('script.form.newTag') }}</el-button>
</el-form-item>
</el-form>
</div>
@@ -141,48 +141,48 @@
<!-- 步骤 1: 生成角色 -->
<div v-show="aiCurrentStep === 1" class="step-panel">
<div v-if="charactersResult.length === 0" class="stage-notice">
<p>基于大纲自动生成剧本中的角色列表</p>
<p>{{ $t('scriptGenerationPage.autoGenerateCharacters') }}</p>
<el-alert
title="提示"
type="info"
:closable="false"
style="margin-top: 16px;"
>
角色已在大纲生成时创建点击"下一步"查看并编辑
{{ $t('scriptGenerationPage.charactersCreatedInOutline') }}
</el-alert>
</div>
<div v-if="charactersResult.length > 0" class="result-preview">
<h4>角色列表可编辑</h4>
<el-button type="primary" size="small" @click="addCharacter" class="add-btn">+ 添加角色</el-button>
<h4>{{ $t('scriptGenerationPage.characterListEditable') }}</h4>
<el-button type="primary" size="small" @click="addCharacter" class="add-btn">{{ $t('scriptGenerationPage.addCharacter') }}</el-button>
<el-table :data="charactersResult" border max-height="400" class="editable-table">
<el-table-column prop="name" label="角色名" width="120">
<el-table-column prop="name" :label="$t('scriptGenerationPage.characterName')" width="120">
<template #default="{ row }">
<el-input v-model="row.name" size="small" />
</template>
</el-table-column>
<el-table-column prop="role" label="角色类型" width="120">
<el-table-column prop="role" :label="$t('scriptGenerationPage.characterType')" width="120">
<template #default="{ row }">
<el-select v-model="row.role" size="small">
<el-option label="主角" value="main" />
<el-option label="配角" value="supporting" />
<el-option label="次要角色" value="minor" />
<el-option :label="$t('scriptGenerationPage.mainCharacter')" value="main" />
<el-option :label="$t('scriptGenerationPage.supportingCharacter')" value="supporting" />
<el-option :label="$t('scriptGenerationPage.minorCharacter')" value="minor" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="description" label="角色描述">
<el-table-column prop="description" :label="$t('scriptGenerationPage.characterDesc')">
<template #default="{ row }">
<el-input v-model="row.description" size="small" type="textarea" :rows="2" />
</template>
</el-table-column>
<el-table-column prop="appearance" label="外貌特征" width="200">
<el-table-column prop="appearance" :label="$t('scriptGenerationPage.appearanceFeatures')" width="200">
<template #default="{ row }">
<el-input v-model="row.appearance" size="small" type="textarea" :rows="2" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<el-table-column :label="$t('scriptGenerationPage.operations')" width="80" fixed="right">
<template #default="{ $index }">
<el-button type="danger" size="small" text @click="deleteCharacter($index)">删除</el-button>
<el-button type="danger" size="small" text @click="deleteCharacter($index)">{{ $t('scriptGenerationPage.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -193,13 +193,13 @@
<div v-show="aiCurrentStep === 2" class="step-panel">
<div v-if="episodesResult.length === 0">
<el-form :model="episodesForm" label-width="100px">
<el-form-item label="剧集数量" required>
<el-form-item :label="$t('scriptGenerationPage.episodeCount')" required>
<el-input-number v-model="episodesForm.episode_count" :min="1" :max="10" />
</el-form-item>
</el-form>
<div class="stage-notice">
<p>根据大纲生成完整的分集剧本</p>
<p>{{ $t('scriptGenerationPage.generateFullScript') }}</p>
<el-alert
v-if="outlineResult && outlineResult.episodes && outlineResult.episodes.length > 0"
title="提示"
@@ -207,21 +207,21 @@
:closable="false"
style="margin-top: 16px;"
>
大纲生成时已创建 {{ outlineResult.episodes.length }} 集剧情但您可以重新设置剧集数量并生成
{{ $t('scriptGenerationPage.outlineCreatedEpisodes', { count: outlineResult.episodes.length }) }}
</el-alert>
</div>
</div>
<div v-if="episodesResult.length > 0" class="result-preview">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4>剧集预览{{ episodesResult.length }}</h4>
<el-button size="small" @click="regenerateEpisodes">重新生成</el-button>
<h4>{{ $t('scriptGenerationPage.episodePreview', { count: episodesResult.length }) }}</h4>
<el-button size="small" @click="regenerateEpisodes">{{ $t('scriptGenerationPage.regenerate') }}</el-button>
</div>
<el-table :data="episodesResult" border max-height="400">
<el-table-column prop="episode_number" label="集数" width="80" />
<el-table-column prop="title" label="标题" width="200" />
<el-table-column prop="summary" label="简介" />
<el-table-column prop="duration" label="时长(秒)" width="100" />
<el-table-column prop="episode_number" :label="$t('scriptGenerationPage.episodeNumber')" width="80" />
<el-table-column prop="title" :label="$t('scriptGenerationPage.title')" width="200" />
<el-table-column prop="summary" :label="$t('scriptGenerationPage.summary')" />
<el-table-column prop="duration" :label="$t('scriptGenerationPage.durationSeconds')" width="100" />
</el-table>
</div>
</div>
@@ -229,13 +229,13 @@
</el-tab-pane>
<!-- 方式2: 上传剧本 -->
<el-tab-pane label="上传剧本" name="upload">
<el-tab-pane :label="$t('scriptGenerationPage.uploadScript')" name="upload">
<!-- 上传流程步骤 -->
<div class="steps-wrapper">
<el-steps :active="uploadCurrentStep" finish-status="success" align-center process-status="process">
<el-step title="上传内容" description="" />
<el-step title="AI解析" description="" />
<el-step title="确认保存" description="" />
<el-step :title="$t('scriptGenerationPage.uploadContent')" description="" />
<el-step :title="$t('scriptGenerationPage.aiParse')" description="" />
<el-step :title="$t('scriptGenerationPage.confirmSave')" description="" />
</el-steps>
</div>
@@ -243,11 +243,11 @@
<!-- 步骤 0: 上传内容 -->
<div v-show="uploadCurrentStep === 0" class="step-panel">
<div class="stage-notice">
<p>粘贴或上传您的剧本文件系统将自动识别并拆分为剧集和场景</p>
<p>{{ $t('scriptGenerationPage.uploadNotice') }}</p>
</div>
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="上传方式">
<el-form-item :label="$t('scriptGenerationPage.uploadMethod')">
<div class="upload-options">
<el-upload
ref="uploadRef"
@@ -260,10 +260,10 @@
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处 <em>点击上传</em>
{{ $t('scriptGenerationPage.dragFilesHere') }} <em>{{ $t('scriptGenerationPage.clickUpload') }}</em>
</div>
<div class="el-upload__tip">
支持 .txt, .md, .doc, .docx 格式
{{ $t('scriptGenerationPage.supportedFormats') }}
</div>
</el-upload>
</div>
@@ -331,12 +331,12 @@
</div>
<div v-if="parseResult.characters && parseResult.characters.length > 0" class="characters-list" style="margin-top: 20px;">
<h4>角色列表</h4>
<h4>{{ $t('scriptGenerationPage.characterList') }}</h4>
<el-table :data="parseResult.characters" border max-height="300">
<el-table-column prop="name" label="角色名" width="120" />
<el-table-column prop="role" label="定位" width="100" />
<el-table-column prop="description" label="外貌描述" show-overflow-tooltip />
<el-table-column prop="personality" label="性格" width="200" show-overflow-tooltip />
<el-table-column prop="name" :label="$t('scriptGenerationPage.characterName')" width="120" />
<el-table-column prop="role" :label="$t('scriptGenerationPage.position')" width="100" />
<el-table-column prop="description" :label="$t('scriptGenerationPage.appearanceDesc')" show-overflow-tooltip />
<el-table-column prop="personality" :label="$t('scriptGenerationPage.personality')" width="200" show-overflow-tooltip />
</el-table>
</div>
</div>
@@ -348,7 +348,7 @@
<!-- 底部导航按钮 -->
<div class="navigation-buttons">
<el-button size="large" @click="handlePrevStep" :disabled="!canGoPrev">
上一步
{{ $t('scriptGenerationPage.prevStep') }}
</el-button>
<el-button
size="large"
@@ -367,6 +367,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ElMessage } from 'element-plus';
import { Loading, RefreshRight, MagicStick, UploadFilled } from '@element-plus/icons-vue';
import { generationAPI } from '@/api/generation';
@@ -375,6 +376,7 @@ import type { OutlineResult, ParseScriptResult } from '@/types/generation';
const route = useRoute();
const router = useRouter();
const { t: $t } = useI18n();
const dramaId = route.params.id as string;
// 从URL query参数恢复状态如果没有则使用默认值