Files
huobao-drama/web/src/views/drama/DramaManagement.vue
Connor d39759926e 1、添加中英文版本
2、修复已知BUG
3、完善功能
4、添加minimax视频渠道
2026-01-18 05:21:34 +08:00

872 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-container">
<div class="content-wrapper animate-fade-in">
<!-- Page Header / 页面头部 -->
<AppHeader :fixed="false" :show-logo="false">
<template #left>
<el-button text @click="$router.back()" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
<span>{{ $t('common.back') }}</span>
</el-button>
<div class="page-title">
<h1>{{ drama?.title || '' }}</h1>
<span class="subtitle">{{ drama?.description || $t('drama.management.overview') }}</span>
</div>
</template>
</AppHeader>
<!-- Tabs / 标签页 -->
<div class="tabs-wrapper">
<el-tabs v-model="activeTab" class="management-tabs">
<!-- 项目概览 -->
<el-tab-pane :label="$t('drama.management.overview')" name="overview">
<div class="stats-grid">
<StatCard
:label="$t('drama.management.episodeStats')"
:value="episodesCount"
:icon="Document"
icon-color="var(--accent)"
icon-bg="var(--accent-light)"
value-color="var(--accent)"
:description="$t('drama.management.episodesCreated')"
/>
<StatCard
:label="$t('drama.management.characterStats')"
:value="charactersCount"
:icon="User"
icon-color="var(--success)"
icon-bg="var(--success-light)"
value-color="var(--success)"
:description="$t('drama.management.charactersCreated')"
/>
<StatCard
:label="$t('drama.management.sceneStats')"
:value="scenesCount"
:icon="Picture"
icon-color="var(--warning)"
icon-bg="var(--warning-light)"
value-color="var(--warning)"
:description="$t('drama.management.sceneLibraryCount')"
/>
</div>
<!-- 引导卡片无章节时显示 -->
<el-alert
v-if="episodesCount === 0"
:title="$t('drama.management.startFirstEpisode')"
type="info"
:closable="false"
style="margin-top: 20px;"
>
<template #default>
<p style="margin: 8px 0;">{{ $t('drama.management.noEpisodesYet') }}</p>
<el-button type="primary" :icon="Plus" @click="createNewEpisode" style="margin-top: 8px;">
{{ $t('drama.management.createFirstEpisode') }}
</el-button>
</template>
</el-alert>
<el-card shadow="never" class="project-info-card">
<template #header>
<div class="card-header">
<h3 class="card-title">{{ $t('drama.management.projectInfo') }}</h3>
<el-tag :type="getStatusType(drama?.status)" size="small">{{ getStatusText(drama?.status) }}</el-tag>
</div>
</template>
<el-descriptions :column="2" border class="project-descriptions">
<el-descriptions-item :label="$t('drama.management.projectName')">
<span class="info-value">{{ drama?.title }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('common.createdAt')">
<span class="info-value">{{ formatDate(drama?.created_at) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('drama.management.projectDesc')" :span="2">
<span class="info-desc">{{ drama?.description || $t('drama.management.noDescription') }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
<!-- 章节管理 -->
<el-tab-pane :label="$t('drama.management.episodes')" name="episodes">
<div class="tab-header">
<h2>{{ $t('drama.management.episodeList') }}</h2>
<el-button type="primary" :icon="Plus" @click="createNewEpisode">{{ $t('drama.management.createNewEpisode') }}</el-button>
</div>
<!-- 空状态引导 -->
<el-empty
v-if="episodesCount === 0"
:description="$t('drama.management.noEpisodes')"
style="margin-top: 40px;"
>
<template #image>
<el-icon :size="80" class="empty-icon"><Document /></el-icon>
</template>
<el-button type="primary" :icon="Plus" @click="createNewEpisode">
{{ $t('drama.management.createFirstEpisode') }}
</el-button>
</el-empty>
<el-table v-else :data="sortedEpisodes" border stripe style="margin-top: 16px;">
<el-table-column type="index" :label="$t('storyboard.table.number')" width="80" />
<el-table-column prop="title" :label="$t('drama.management.episodeList')" min-width="200" />
<el-table-column :label="$t('common.status')" width="120">
<template #default="{ row }">
<el-tag :type="getEpisodeStatusType(row)">{{ getEpisodeStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Shots" width="100">
<template #default="{ row }">
{{ row.shots?.length || 0 }}
</template>
</el-table-column>
<el-table-column :label="$t('common.createdAt')" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column :label="$t('storyboard.table.operations')" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="enterEpisodeWorkflow(row)">
{{ $t('drama.management.goToEdit') }}
</el-button>
<el-button size="small" type="danger" @click="deleteEpisode(row)">
{{ $t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 角色管理 -->
<el-tab-pane :label="$t('drama.management.characters')" name="characters">
<div class="tab-header">
<h2>{{ $t('drama.management.characterList') }}</h2>
<el-button type="primary" :icon="Plus" @click="openAddCharacterDialog">{{ $t('character.add') }}</el-button>
</div>
<el-row :gutter="16" style="margin-top: 16px;">
<el-col :span="6" v-for="character in drama?.characters" :key="character.id">
<el-card shadow="hover" class="character-card">
<div class="character-preview">
<img v-if="character.image_url" :src="fixImageUrl(character.image_url)" :alt="character.name" />
<el-avatar v-else :size="120">{{ character.name[0] }}</el-avatar>
</div>
<div class="character-info">
<h4>{{ character.name }}</h4>
<el-tag :type="character.role === 'main' ? 'danger' : 'info'" size="small">
{{ character.role === 'main' ? 'Main' : character.role === 'supporting' ? 'Supporting' : 'Minor' }}
</el-tag>
<p class="desc">{{ character.appearance || character.description }}</p>
</div>
<div class="character-actions">
<el-button size="small" @click="editCharacter(character)">{{ $t('common.edit') }}</el-button>
<el-button size="small" type="danger" @click="deleteCharacter(character)">{{ $t('common.delete') }}</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-empty v-if="!drama?.characters || drama.characters.length === 0" :description="$t('drama.management.noCharacters')" />
</el-tab-pane>
<!-- 场景库管理 -->
<el-tab-pane :label="$t('drama.management.sceneList')" name="scenes">
<div class="tab-header">
<h2>{{ $t('drama.management.sceneList') }}</h2>
<el-button type="primary" :icon="Plus" @click="openAddSceneDialog">{{ $t('common.add') }}</el-button>
</div>
<el-row :gutter="16" style="margin-top: 16px;">
<el-col :span="6" v-for="scene in scenes" :key="scene.id">
<el-card shadow="hover" class="scene-card">
<div class="scene-preview">
<img v-if="scene.image_url" :src="fixImageUrl(scene.image_url)" :alt="scene.name" />
<div v-else class="scene-placeholder">
<el-icon :size="48"><Picture /></el-icon>
</div>
</div>
<div class="scene-info">
<h4>{{ scene.name }}</h4>
<p class="desc">{{ scene.description }}</p>
</div>
<div class="scene-actions">
<el-button size="small" @click="editScene(scene)">{{ $t('common.edit') }}</el-button>
<el-button size="small" type="danger" @click="deleteScene(scene)">{{ $t('common.delete') }}</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-empty v-if="scenes.length === 0" :description="$t('drama.management.noScenes')" />
</el-tab-pane>
</el-tabs>
</div>
<!-- 添加角色对话框 -->
<el-dialog v-model="addCharacterDialogVisible" :title="$t('character.add')" width="600px">
<el-form :model="newCharacter" label-width="100px">
<el-form-item :label="$t('character.name')">
<el-input v-model="newCharacter.name" :placeholder="$t('character.name')" />
</el-form-item>
<el-form-item :label="$t('character.role')">
<el-select v-model="newCharacter.role" :placeholder="$t('common.pleaseSelect')">
<el-option label="Main" value="main" />
<el-option label="Supporting" value="supporting" />
<el-option label="Minor" value="minor" />
</el-select>
</el-form-item>
<el-form-item :label="$t('character.appearance')">
<el-input v-model="newCharacter.appearance" type="textarea" :rows="3" :placeholder="$t('character.appearance')" />
</el-form-item>
<el-form-item :label="$t('character.personality')">
<el-input v-model="newCharacter.personality" type="textarea" :rows="3" :placeholder="$t('character.personality')" />
</el-form-item>
<el-form-item :label="$t('character.description')">
<el-input v-model="newCharacter.description" type="textarea" :rows="3" :placeholder="$t('common.description')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addCharacterDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="addCharacter">{{ $t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- 添加场景对话框 -->
<el-dialog v-model="addSceneDialogVisible" :title="$t('common.add')" width="600px">
<el-form :model="newScene" label-width="100px">
<el-form-item :label="$t('common.name')">
<el-input v-model="newScene.name" :placeholder="$t('common.name')" />
</el-form-item>
<el-form-item :label="$t('common.description')">
<el-input v-model="newScene.description" type="textarea" :rows="4" :placeholder="$t('common.description')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addSceneDialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="addScene">{{ $t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Document, User, Picture, Plus } from '@element-plus/icons-vue'
import { dramaAPI } from '@/api/drama'
import { characterLibraryAPI } from '@/api/character-library'
import type { Drama } from '@/types/drama'
import { AppHeader, StatCard, EmptyState } from '@/components/common'
const router = useRouter()
const route = useRoute()
const drama = ref<Drama>()
const activeTab = ref(route.query.tab as string || 'overview')
const scenes = ref<any[]>([])
const addCharacterDialogVisible = ref(false)
const addSceneDialogVisible = ref(false)
const newCharacter = ref({
name: '',
role: 'supporting',
appearance: '',
personality: '',
description: ''
})
const newScene = ref({
name: '',
description: ''
})
const episodesCount = computed(() => drama.value?.episodes?.length || 0)
const charactersCount = computed(() => drama.value?.characters?.length || 0)
const scenesCount = computed(() => scenes.value.length)
const sortedEpisodes = computed(() => {
if (!drama.value?.episodes) return []
return [...drama.value.episodes].sort((a, b) => a.episode_number - b.episode_number)
})
const loadDramaData = async () => {
try {
const data = await dramaAPI.get(route.params.id as string)
drama.value = data
loadScenes()
} catch (error: any) {
ElMessage.error(error.message || '加载项目数据失败')
}
}
const loadScenes = async () => {
// 场景数据已经在drama中加载了后端Preload了Scenes
if (drama.value?.scenes) {
scenes.value = drama.value.scenes
} else {
scenes.value = []
}
}
const getStatusType = (status?: string) => {
const map: Record<string, any> = {
draft: 'info',
in_progress: 'warning',
completed: 'success'
}
return map[status || 'draft'] || 'info'
}
const getStatusText = (status?: string) => {
const map: Record<string, string> = {
draft: '草稿',
in_progress: '制作中',
completed: '已完成'
}
return map[status || 'draft'] || '草稿'
}
const getEpisodeStatusType = (episode: any) => {
if (episode.shots && episode.shots.length > 0) return 'success'
if (episode.script_content) return 'warning'
return 'info'
}
const getEpisodeStatusText = (episode: any) => {
if (episode.shots && episode.shots.length > 0) return '已拆分'
if (episode.script_content) return '已创建'
return '草稿'
}
const formatDate = (date?: string) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
const fixImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${import.meta.env.VITE_API_BASE_URL}${url}`
}
const createNewEpisode = () => {
const nextEpisodeNumber = episodesCount.value + 1
router.push({
name: 'EpisodeWorkflowNew',
params: {
id: route.params.id,
episodeNumber: nextEpisodeNumber
}
})
}
const enterEpisodeWorkflow = (episode: any) => {
router.push({
name: 'EpisodeWorkflowNew',
params: {
id: route.params.id,
episodeNumber: episode.episode_number
}
})
}
const deleteEpisode = async (episode: any) => {
try {
await ElMessageBox.confirm(
`确定要删除第${episode.episode_number}章吗?此操作将同时删除该章节的所有相关数据(角色、场景、分镜等)。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 过滤掉要删除的章节
const existingEpisodes = drama.value?.episodes || []
const updatedEpisodes = existingEpisodes
.filter(ep => ep.episode_number !== episode.episode_number)
.map(ep => ({
episode_number: ep.episode_number,
title: ep.title,
script_content: ep.script_content,
description: ep.description,
duration: ep.duration,
status: ep.status
}))
// 保存更新后的章节列表
await dramaAPI.saveEpisodes(drama.value!.id, updatedEpisodes)
ElMessage.success(`${episode.episode_number}章删除成功`)
await loadDramaData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
const openAddCharacterDialog = () => {
newCharacter.value = {
name: '',
role: 'supporting',
appearance: '',
personality: '',
description: ''
}
addCharacterDialogVisible.value = true
}
const addCharacter = async () => {
if (!newCharacter.value.name.trim()) {
ElMessage.warning('请输入角色名称')
return
}
try {
const existingCharacters = drama.value?.characters || []
const allCharacters = [
...existingCharacters.map(c => ({
name: c.name,
role: c.role,
appearance: c.appearance,
personality: c.personality,
description: c.description
})),
newCharacter.value
]
await dramaAPI.saveCharacters(drama.value!.id, allCharacters)
ElMessage.success('角色添加成功')
addCharacterDialogVisible.value = false
await loadDramaData()
} catch (error: any) {
ElMessage.error(error.message || '添加失败')
}
}
const editCharacter = (character: any) => {
ElMessage.info('编辑功能开发中')
}
const deleteCharacter = async (character: any) => {
if (character.library_id) {
ElMessage.warning('该角色来自角色库,请前往角色库进行删除')
return
}
if (!character.id) {
ElMessage.error('角色ID不存在无法删除')
return
}
try {
await ElMessageBox.confirm(
`确定要删除角色"${character.name}"吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await characterLibraryAPI.deleteCharacter(character.id)
ElMessage.success('角色已删除')
await loadDramaData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除角色失败:', error)
ElMessage.error(error.message || '删除失败')
}
}
}
const openAddSceneDialog = () => {
newScene.value = {
name: '',
description: ''
}
addSceneDialogVisible.value = true
}
const addScene = async () => {
if (!newScene.value.name.trim()) {
ElMessage.warning('请输入场景名称')
return
}
try {
// TODO: 调用场景库API
ElMessage.success('场景添加成功')
addSceneDialogVisible.value = false
await loadScenes()
} catch (error: any) {
ElMessage.error(error.message || '添加失败')
}
}
const editScene = (scene: any) => {
ElMessage.info('编辑功能开发中')
}
const deleteScene = async (scene: any) => {
if (!scene.id) {
ElMessage.error('场景ID不存在无法删除')
return
}
try {
await ElMessageBox.confirm(
`确定要删除场景"${scene.name || scene.location}"吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await dramaAPI.deleteScene(scene.id.toString())
ElMessage.success('场景已删除')
await loadScenes()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除场景失败:', error)
ElMessage.error(error.message || '删除失败')
}
}
}
onMounted(() => {
loadDramaData()
loadScenes()
// 如果有query参数指定tab切换到对应tab
if (route.query.tab) {
activeTab.value = route.query.tab as string
}
})
</script>
<style scoped>
/* ========================================
Page Layout / 页面布局 - 紧凑边距
======================================== */
.page-container {
min-height: 100vh;
background: var(--bg-primary);
/* padding: var(--space-2) var(--space-3); */
transition: background var(--transition-normal);
}
@media (min-width: 768px) {
.page-container {
/* padding: var(--space-3) var(--space-4); */
}
}
@media (min-width: 1024px) {
.page-container {
/* padding: var(--space-4) var(--space-5); */
}
}
.content-wrapper {
margin: 0 auto;
width: 100%;
}
/* ========================================
Stats Grid / 统计网格 - 紧凑间距
======================================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
@media (min-width: 640px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
}
@media (min-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* ========================================
Tabs Wrapper / 标签页容器 - 紧凑内边距
======================================== */
.tabs-wrapper {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
box-shadow: var(--shadow-card);
}
@media (min-width: 768px) {
.tabs-wrapper {
padding: var(--space-4);
}
}
/* ========================================
Tab Header / 标签页头部
======================================== */
.tab-header {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
@media (min-width: 640px) {
.tab-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
.tab-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* ========================================
Character & Scene Cards / 角色场景卡片
======================================== */
.character-card, .scene-card {
margin-bottom: var(--space-4);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
overflow: hidden;
transition: all var(--transition-normal);
}
.character-card:hover, .scene-card:hover {
border-color: var(--border-secondary);
box-shadow: var(--shadow-card-hover);
}
.character-card :deep(.el-card__body),
.scene-card :deep(.el-card__body) {
padding: 0;
}
.character-preview, .scene-preview {
display: flex;
justify-content: center;
align-items: center;
height: 160px;
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
overflow: hidden;
}
.character-preview img, .scene-preview img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--transition-normal);
}
.character-card:hover .character-preview img,
.scene-card:hover .scene-preview img {
transform: scale(1.05);
}
.scene-placeholder {
color: rgba(255, 255, 255, 0.7);
}
.character-info, .scene-info {
text-align: center;
padding: var(--space-4);
}
.character-info h4, .scene-info h4 {
margin: 0 0 var(--space-2) 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.desc {
font-size: 0.8125rem;
color: var(--text-muted);
margin: var(--space-2) 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.character-actions, .scene-actions {
display: flex;
gap: var(--space-2);
justify-content: center;
padding: 0 var(--space-4) var(--space-4);
}
.empty-icon {
color: var(--accent);
}
/* ========================================
Dark Mode / 深色模式
======================================== */
.dark .tabs-wrapper {
background: var(--bg-card);
}
.dark :deep(.el-card) {
background: var(--bg-card);
border-color: var(--border-primary);
}
.dark :deep(.el-card__header) {
background: var(--bg-secondary);
border-color: var(--border-primary);
}
.dark :deep(.el-table) {
background: var(--bg-card);
--el-table-bg-color: var(--bg-card);
--el-table-tr-bg-color: var(--bg-card);
--el-table-header-bg-color: var(--bg-secondary);
--el-fill-color-lighter: var(--bg-secondary);
}
.dark :deep(.el-table th),
.dark :deep(.el-table tr) {
background: var(--bg-card);
}
.dark :deep(.el-table td),
.dark :deep(.el-table th) {
border-color: var(--border-primary);
}
.dark :deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {
background: var(--bg-secondary);
}
.dark :deep(.el-table__body tr:hover > td) {
background: var(--bg-card-hover) !important;
}
.dark :deep(.el-descriptions) {
background: var(--bg-card);
}
.dark :deep(.el-descriptions__label) {
background: var(--bg-secondary);
color: var(--text-secondary);
border-color: var(--border-primary);
}
.dark :deep(.el-descriptions__content) {
background: var(--bg-card);
color: var(--text-primary);
border-color: var(--border-primary);
}
.dark :deep(.el-descriptions__cell) {
border-color: var(--border-primary);
}
/* ========================================
Project Info Card / 项目信息卡片
======================================== */
.project-info-card {
margin-top: var(--space-5);
border-radius: var(--radius-lg);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.project-descriptions {
width: 100%;
}
:deep(.project-descriptions .el-descriptions__label) {
width: 120px;
font-weight: 500;
color: var(--text-secondary);
}
:deep(.project-descriptions .el-descriptions__content) {
min-width: 150px;
}
.info-value {
font-weight: 500;
color: var(--text-primary);
}
.info-desc {
color: var(--text-secondary);
line-height: 1.6;
}
.dark :deep(.el-dialog) {
background: var(--bg-card);
}
.dark :deep(.el-dialog__header) {
background: var(--bg-card);
}
.dark :deep(.el-form-item__label) {
color: var(--text-primary);
}
.dark :deep(.el-input__wrapper) {
background: var(--bg-secondary);
box-shadow: 0 0 0 1px var(--border-primary) inset;
}
.dark :deep(.el-input__inner) {
color: var(--text-primary);
}
.dark :deep(.el-textarea__inner) {
background: var(--bg-secondary);
color: var(--text-primary);
box-shadow: 0 0 0 1px var(--border-primary) inset;
}
</style>