feat: 增加头,交互优化

This commit is contained in:
kongweigen
2026-01-16 13:14:56 +08:00
parent 249ba3d4aa
commit bfba6342dc
11 changed files with 582 additions and 266 deletions

View File

@@ -1,12 +1,8 @@
<template> <template>
<router-view /> <router-view />
<!-- <AppLayout>
<router-view />
</AppLayout> -->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppLayout from '@/components/common/AppLayout.vue'
</script> </script>
<style> <style>

View File

@@ -774,21 +774,21 @@ body {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background-color: var(--bg-primary); background-color: var(--bg-primary);
padding: var(--space-2) var(--space-3); /* padding: var(--space-2) var(--space-3); */
transition: background-color var(--transition-normal); transition: background-color var(--transition-normal);
} }
@media (min-width: 768px) { /* @media (min-width: 768px) {
.page-container { .page-container {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
} }
} } */
@media (min-width: 1024px) { /* @media (min-width: 1024px) {
.page-container { .page-container {
padding: var(--space-4) var(--space-5); padding: var(--space-4) var(--space-5);
} }
} } */
.content-wrapper { .content-wrapper {
margin: 0 auto; margin: 0 auto;

View File

@@ -236,6 +236,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
'config-updated': []
}>() }>()
const visible = computed({ const visible = computed({
@@ -567,6 +568,7 @@ const handleSubmit = async () => {
editDialogVisible.value = false editDialogVisible.value = false
loadConfigs() loadConfigs()
emit('config-updated')
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '操作失败') ElMessage.error(error.message || '操作失败')
} finally { } finally {
@@ -625,45 +627,84 @@ const handleQuickSetup = async () => {
const apiKey = quickSetupApiKey.value.trim() const apiKey = quickSetupApiKey.value.trim()
try { try {
// 创建文本配置 // 加载所有类型的配置,检查是否已存在相同 baseUrl 的配置
const textProvider = providerConfigs.text.find(p => p.id === 'chatfire')! const [textConfigs, imageConfigs, videoConfigs] = await Promise.all([
await aiAPI.create({ aiAPI.list('text'),
service_type: 'text', aiAPI.list('image'),
provider: 'chatfire', aiAPI.list('video')
name: generateConfigName('chatfire', 'text'), ])
base_url: baseUrl,
api_key: apiKey,
model: [textProvider.models[0]],
priority: 0
})
// 创建图片配置 const createdServices: string[] = []
const imageProvider = providerConfigs.image.find(p => p.id === 'chatfire')! const skippedServices: string[] = []
await aiAPI.create({
service_type: 'image',
provider: 'chatfire',
name: generateConfigName('chatfire', 'image'),
base_url: baseUrl,
api_key: apiKey,
model: [imageProvider.models[0]],
priority: 0
})
// 创建视频配置 // 创建文本配置(如果不存在)
const videoProvider = providerConfigs.video.find(p => p.id === 'chatfire')! const existingTextConfig = textConfigs.find(c => c.base_url === baseUrl)
await aiAPI.create({ if (!existingTextConfig) {
service_type: 'video', const textProvider = providerConfigs.text.find(p => p.id === 'chatfire')!
provider: 'chatfire', await aiAPI.create({
name: generateConfigName('chatfire', 'video'), service_type: 'text',
base_url: baseUrl, provider: 'chatfire',
api_key: apiKey, name: generateConfigName('chatfire', 'text'),
model: [videoProvider.models[0]], base_url: baseUrl,
priority: 0 api_key: apiKey,
}) model: [textProvider.models[0]],
priority: 0
})
createdServices.push('文本')
} else {
skippedServices.push('文本')
}
// 创建图片配置(如果不存在)
const existingImageConfig = imageConfigs.find(c => c.base_url === baseUrl)
if (!existingImageConfig) {
const imageProvider = providerConfigs.image.find(p => p.id === 'chatfire')!
await aiAPI.create({
service_type: 'image',
provider: 'chatfire',
name: generateConfigName('chatfire', 'image'),
base_url: baseUrl,
api_key: apiKey,
model: [imageProvider.models[0]],
priority: 0
})
createdServices.push('图片')
} else {
skippedServices.push('图片')
}
// 创建视频配置(如果不存在)
const existingVideoConfig = videoConfigs.find(c => c.base_url === baseUrl)
if (!existingVideoConfig) {
const videoProvider = providerConfigs.video.find(p => p.id === 'chatfire')!
await aiAPI.create({
service_type: 'video',
provider: 'chatfire',
name: generateConfigName('chatfire', 'video'),
base_url: baseUrl,
api_key: apiKey,
model: [videoProvider.models[0]],
priority: 0
})
createdServices.push('视频')
} else {
skippedServices.push('视频')
}
// 显示结果消息
if (createdServices.length > 0 && skippedServices.length > 0) {
ElMessage.success(`已创建 ${createdServices.join('、')} 配置,${skippedServices.join('、')} 配置已存在`)
} else if (createdServices.length > 0) {
ElMessage.success(`一键配置成功!已创建 ${createdServices.join('、')} 服务配置`)
} else {
ElMessage.info('所有配置已存在,无需重复创建')
}
ElMessage.success('一键配置成功!已创建文本、图片、视频三个服务配置')
quickSetupVisible.value = false quickSetupVisible.value = false
loadConfigs() loadConfigs()
if (createdServices.length > 0) {
emit('config-updated')
}
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '配置失败') ElMessage.error(error.message || '配置失败')
} finally { } finally {

View File

@@ -0,0 +1,271 @@
<template>
<div class="app-header-wrapper">
<header class="app-header" :class="{ 'header-fixed': fixed }">
<div class="header-content">
<!-- Left section: Logo + Left slot -->
<div class="header-left">
<router-link v-if="showLogo" to="/" class="logo">
<span class="logo-text">🎬 AI Drama</span>
</router-link>
<!-- Left slot for business content | 左侧插槽用于业务内容 -->
<slot name="left" />
</div>
<!-- Center section: Center slot -->
<div class="header-center">
<slot name="center" />
</div>
<!-- Right section: Actions + Right slot -->
<div class="header-right">
<!-- Language Switcher | 语言切换 -->
<LanguageSwitcher v-if="showLanguage" />
<!-- Theme Toggle | 主题切换 -->
<ThemeToggle v-if="showTheme" />
<!-- AI Config (Model Switch) | AI 配置模型切换 -->
<el-button v-if="showAIConfig" @click="handleOpenAIConfig" class="header-btn">
<el-icon><Setting /></el-icon>
<span class="btn-text">{{ $t('drama.aiConfig') }}</span>
</el-button>
<!-- Right slot for business content (before actions) | 右侧插槽在操作按钮前 -->
<slot name="right" />
</div>
</div>
</header>
<!-- AI Config Dialog | AI 配置对话框 -->
<AIConfigDialog v-model="showConfigDialog" @config-updated="emit('config-updated')" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Setting } from '@element-plus/icons-vue'
import ThemeToggle from './ThemeToggle.vue'
import AIConfigDialog from './AIConfigDialog.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
/**
* AppHeader - Global application header component
* 应用顶部头组件
*
* Features | 功能:
* - Fixed position at top | 固定在顶部
* - Model/Theme/Language switch | 模型/主题/语言切换
* - Slots support for business content | 支持插槽放置业务内容
*
* Slots | 插槽:
* - left: Content after logo | logo 右侧内容
* - center: Center content | 中间内容
* - right: Content before actions | 操作按钮左侧内容
*/
interface Props {
/** Fixed position at top | 是否固定在顶部 */
fixed?: boolean
/** Show logo | 是否显示 logo */
showLogo?: boolean
/** Show language switcher | 是否显示语言切换 */
showLanguage?: boolean
/** Show theme toggle | 是否显示主题切换 */
showTheme?: boolean
/** Show AI config button | 是否显示 AI 配置按钮 */
showAIConfig?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fixed: true,
showLogo: true,
showLanguage: true,
showTheme: true,
showAIConfig: true
})
const emit = defineEmits<{
(e: 'open-ai-config'): void
(e: 'config-updated'): void
}>()
// AI Config dialog state | AI 配置对话框状态
const showConfigDialog = ref(false)
// Handle open AI config | 处理打开 AI 配置
const handleOpenAIConfig = () => {
showConfigDialog.value = true
emit('open-ai-config')
}
// Expose methods for external control | 暴露方法供外部控制
defineExpose({
openAIConfig: () => {
showConfigDialog.value = true
}
})
</script>
<style scoped>
.app-header {
background: var(--bg-card);
border-bottom: 1px solid var(--border-primary);
backdrop-filter: blur(8px);
z-index: 1000;
}
.app-header.header-fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
height: 70px;
max-width: 100%;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.header-center {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.125rem;
transition: opacity var(--transition-fast);
}
.logo:hover {
opacity: 0.8;
}
.logo-text {
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-btn {
border-radius: var(--radius-lg);
font-weight: 500;
}
.header-btn .btn-text {
margin-left: 4px;
}
/* Dark mode adjustments | 深色模式适配 */
.dark .app-header {
background: rgba(26, 33, 41, 0.95);
}
/* ========================================
Common Slot Styles / 插槽通用样式
======================================== */
/* Back Button | 返回按钮 */
:deep(.back-btn) {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
:deep(.back-btn:hover) {
color: var(--text-primary);
background: var(--bg-hover);
}
:deep(.back-btn .el-icon) {
font-size: 1rem;
}
/* Page Title | 页面标题 */
:deep(.page-title) {
display: flex;
flex-direction: column;
gap: 2px;
}
:deep(.page-title h1),
:deep(.header-title),
:deep(.drama-title) {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
:deep(.page-title .subtitle) {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* Episode Title | 章节标题 */
:deep(.episode-title) {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
/* Responsive | 响应式 */
@media (max-width: 768px) {
.header-content {
padding: 0 var(--space-3);
}
.btn-text {
display: none;
}
.header-btn {
padding: 8px;
}
:deep(.page-title h1),
:deep(.header-title),
:deep(.drama-title) {
font-size: 1rem;
}
:deep(.back-btn span) {
display: none;
}
}
</style>

View File

@@ -20,3 +20,4 @@ export { default as AIConfigDialog } from './AIConfigDialog.vue'
// Layout Components / 布局组件 // Layout Components / 布局组件
export { default as AppLayout } from './AppLayout.vue' export { default as AppLayout } from './AppLayout.vue'
export { default as AppHeader } from './AppHeader.vue'

View File

@@ -3,13 +3,18 @@
<div class="page-container"> <div class="page-container">
<div class="content-wrapper animate-fade-in"> <div class="content-wrapper animate-fade-in">
<!-- Header / 头部 --> <!-- Header / 头部 -->
<PageHeader <AppHeader :fixed="false" :show-logo="false">
title="创建新项目" <template #left>
subtitle="填写基本信息来创建你的短剧项目" <el-button text @click="goBack" class="back-btn">
:show-back="true" <el-icon><ArrowLeft /></el-icon>
back-text="返回" <span>返回</span>
:show-border="false" </el-button>
/> <div class="page-title">
<h1>创建新项目</h1>
<span class="subtitle">填写基本信息来创建你的短剧项目</span>
</div>
</template>
</AppHeader>
<!-- Form Card / 表单卡片 --> <!-- Form Card / 表单卡片 -->
<div class="form-card"> <div class="form-card">
@@ -69,7 +74,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, Plus } from '@element-plus/icons-vue' import { ArrowLeft, Plus } from '@element-plus/icons-vue'
import { dramaAPI } from '@/api/drama' import { dramaAPI } from '@/api/drama'
import type { CreateDramaRequest } from '@/types/drama' import type { CreateDramaRequest } from '@/types/drama'
import { PageHeader } from '@/components/common' import { AppHeader } from '@/components/common'
const router = useRouter() const router = useRouter()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()

View File

@@ -3,114 +3,68 @@
<!-- 短剧列表页面 - 使用现代简约设计重构 --> <!-- 短剧列表页面 - 使用现代简约设计重构 -->
<div class="page-container"> <div class="page-container">
<div class="content-wrapper animate-fade-in"> <div class="content-wrapper animate-fade-in">
<!-- Page Header / 页面头部 --> <!-- App Header / 应用头部 -->
<PageHeader <AppHeader :fixed="false">
:title="$t('drama.title')" <template #left>
:subtitle="$t('drama.totalProjects', { count: total })" <div class="page-title">
> <h1>{{ $t('drama.title') }}</h1>
<template #actions> <span class="subtitle">{{ $t('drama.totalProjects', { count: total }) }}</span>
<LanguageSwitcher /> </div>
<ThemeToggle /> </template>
<el-button @click="showAIConfig = true" class="header-btn"> <template #right>
<el-icon><Setting /></el-icon>
<span class="btn-text">{{ $t('drama.aiConfig') }}</span>
</el-button>
<el-button type="primary" @click="handleCreate" class="header-btn primary"> <el-button type="primary" @click="handleCreate" class="header-btn primary">
<el-icon><Plus /></el-icon> <el-icon>
<Plus />
</el-icon>
<span class="btn-text">{{ $t('drama.createNew') }}</span> <span class="btn-text">{{ $t('drama.createNew') }}</span>
</el-button> </el-button>
</template> </template>
</PageHeader> </AppHeader>
<!-- Project Grid / 项目网格 --> <!-- Project Grid / 项目网格 -->
<div v-loading="loading" class="projects-grid" :class="{ 'is-empty': !loading && dramas.length === 0 }"> <div v-loading="loading" class="projects-grid" :class="{ 'is-empty': !loading && dramas.length === 0 }">
<!-- Empty state / 空状态 --> <!-- Empty state / 空状态 -->
<EmptyState <EmptyState v-if="!loading && dramas.length === 0" :title="$t('drama.empty')"
v-if="!loading && dramas.length === 0" :description="$t('drama.emptyHint')" :icon="Film">
:title="$t('drama.empty')"
:description="$t('drama.emptyHint')"
:icon="Film"
>
<el-button type="primary" @click="handleCreate"> <el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon> <el-icon>
<Plus />
</el-icon>
{{ $t('drama.createNew') }} {{ $t('drama.createNew') }}
</el-button> </el-button>
</EmptyState> </EmptyState>
<!-- Project Cards / 项目卡片列表 --> <!-- Project Cards / 项目卡片列表 -->
<ProjectCard <ProjectCard v-for="drama in dramas" :key="drama.id" :title="drama.title" :description="drama.description"
v-for="drama in dramas" :updated-at="drama.updated_at" :episode-count="drama.total_episodes || 0" @click="viewDrama(drama.id)">
:key="drama.id"
:title="drama.title"
:description="drama.description"
:updated-at="drama.updated_at"
:episode-count="drama.total_episodes || 0"
@click="viewDrama(drama.id)"
>
<template #actions> <template #actions>
<ActionButton <ActionButton :icon="Edit" :tooltip="$t('common.edit')" @click="editDrama(drama.id)" />
:icon="Edit" <el-popconfirm :title="$t('drama.deleteConfirm')" :confirm-button-text="$t('common.confirm')"
:tooltip="$t('common.edit')" :cancel-button-text="$t('common.cancel')" @confirm="deleteDrama(drama.id)">
@click="editDrama(drama.id)"
/>
<el-popconfirm
:title="$t('drama.deleteConfirm')"
:confirm-button-text="$t('common.confirm')"
:cancel-button-text="$t('common.cancel')"
@confirm="deleteDrama(drama.id)"
>
<template #reference> <template #reference>
<el-button <el-button :icon="Delete" class="action-button danger" link />
:icon="Delete"
class="action-button danger"
link
/>
</template> </template>
</el-popconfirm> </el-popconfirm>
</template> </template>
</ProjectCard> </ProjectCard>
</div> </div>
<!-- Edit Dialog / 编辑对话框 --> <!-- Edit Dialog / 编辑对话框 -->
<el-dialog <el-dialog v-model="editDialogVisible" :title="$t('drama.editProject')" width="520px"
v-model="editDialogVisible" :close-on-click-modal="false" class="edit-dialog">
:title="$t('drama.editProject')" <el-form :model="editForm" label-position="top" v-loading="editLoading" class="edit-form">
width="520px"
:close-on-click-modal="false"
class="edit-dialog"
>
<el-form
:model="editForm"
label-position="top"
v-loading="editLoading"
class="edit-form"
>
<el-form-item :label="$t('drama.projectName')" required> <el-form-item :label="$t('drama.projectName')" required>
<el-input <el-input v-model="editForm.title" :placeholder="$t('drama.projectNamePlaceholder')" size="large" />
v-model="editForm.title"
:placeholder="$t('drama.projectNamePlaceholder')"
size="large"
/>
</el-form-item> </el-form-item>
<el-form-item :label="$t('drama.projectDesc')"> <el-form-item :label="$t('drama.projectDesc')">
<el-input <el-input v-model="editForm.description" type="textarea" :rows="4"
v-model="editForm.description" :placeholder="$t('drama.projectDescPlaceholder')" resize="none" />
type="textarea"
:rows="4"
:placeholder="$t('drama.projectDescPlaceholder')"
resize="none"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="editDialogVisible = false" size="large">{{ $t('common.cancel') }}</el-button> <el-button @click="editDialogVisible = false" size="large">{{ $t('common.cancel') }}</el-button>
<el-button <el-button type="primary" @click="saveEdit" :loading="editLoading" size="large">
type="primary"
@click="saveEdit"
:loading="editLoading"
size="large"
>
{{ $t('common.save') }} {{ $t('common.save') }}
</el-button> </el-button>
</div> </div>
@@ -118,13 +72,8 @@
</el-dialog> </el-dialog>
<!-- Create Drama Dialog / 创建短剧弹窗 --> <!-- Create Drama Dialog / 创建短剧弹窗 -->
<CreateDramaDialog <CreateDramaDialog v-model="createDialogVisible" @created="loadDramas" />
v-model="createDialogVisible"
@created="loadDramas"
/>
<!-- AI Config Dialog / AI配置弹窗 -->
<AIConfigDialog v-model="showAIConfig" />
</div> </div>
<!-- Sticky Pagination / 吸底分页器 --> <!-- Sticky Pagination / 吸底分页器 -->
@@ -134,25 +83,13 @@
<span class="pagination-total">{{ $t('drama.totalProjects', { count: total }) }}</span> <span class="pagination-total">{{ $t('drama.totalProjects', { count: total }) }}</span>
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls">
<el-pagination <el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
v-model:current-page="queryParams.page" :total="total" :page-sizes="[12, 24, 36, 48]" :pager-count="5" layout="prev, pager, next"
v-model:page-size="queryParams.page_size" @size-change="loadDramas" @current-change="loadDramas" />
:total="total"
:page-sizes="[12, 24, 36, 48]"
:pager-count="5"
layout="prev, pager, next"
@size-change="loadDramas"
@current-change="loadDramas"
/>
</div> </div>
<div class="pagination-size"> <div class="pagination-size">
<span class="size-label">{{ $t('common.perPage') }}</span> <span class="size-label">{{ $t('common.perPage') }}</span>
<el-select <el-select v-model="queryParams.page_size" size="small" class="size-select" @change="loadDramas">
v-model="queryParams.page_size"
size="small"
class="size-select"
@change="loadDramas"
>
<el-option :value="12" label="12" /> <el-option :value="12" label="12" />
<el-option :value="24" label="24" /> <el-option :value="24" label="24" />
<el-option :value="36" label="36" /> <el-option :value="36" label="36" />
@@ -168,19 +105,18 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
Plus, Plus,
Film, Film,
Setting, Setting,
Edit, Edit,
View, View,
Delete, Delete,
InfoFilled InfoFilled
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { dramaAPI } from '@/api/drama' import { dramaAPI } from '@/api/drama'
import type { Drama, DramaListQuery } from '@/types/drama' import type { Drama, DramaListQuery } from '@/types/drama'
import { PageHeader, ProjectCard, ThemeToggle, ActionButton, CreateDramaDialog, EmptyState, AIConfigDialog } from '@/components/common' import { AppHeader, ProjectCard, ActionButton, CreateDramaDialog, EmptyState } from '@/components/common'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@@ -194,7 +130,6 @@ const queryParams = ref<DramaListQuery>({
// Create dialog state / 创建弹窗状态 // Create dialog state / 创建弹窗状态
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const showAIConfig = ref(false)
// Load drama list / 加载短剧列表 // Load drama list / 加载短剧列表
const loadDramas = async () => { const loadDramas = async () => {
@@ -248,7 +183,7 @@ const saveEdit = async () => {
ElMessage.warning('请输入项目名称') ElMessage.warning('请输入项目名称')
return return
} }
editLoading.value = true editLoading.value = true
try { try {
await dramaAPI.update(editForm.value.id, { await dramaAPI.update(editForm.value.id, {
@@ -288,19 +223,19 @@ onMounted(() => {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background: var(--bg-primary); background: var(--bg-primary);
padding: var(--space-2) var(--space-3); /* padding: var(--space-2) var(--space-3); */
transition: background var(--transition-normal); transition: background var(--transition-normal);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.page-container { .page-container {
padding: var(--space-3) var(--space-4); /* padding: var(--space-3) var(--space-4); */
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.page-container { .page-container {
padding: var(--space-4) var(--space-5); /* padding: var(--space-4) var(--space-5); */
} }
} }
@@ -309,6 +244,28 @@ onMounted(() => {
width: 100%; width: 100%;
} }
/* ========================================
Page Title / 页面标题
======================================== */
.page-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.page-title h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
.page-title .subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* ======================================== /* ========================================
Header Buttons / 头部按钮 Header Buttons / 头部按钮
======================================== */ ======================================== */
@@ -332,7 +289,7 @@ onMounted(() => {
.btn-text { .btn-text {
display: none; display: none;
} }
.header-btn { .header-btn {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
} }
@@ -342,7 +299,9 @@ onMounted(() => {
Projects Grid / 项目网格 - 紧凑间距 Projects Grid / 项目网格 - 紧凑间距
======================================== */ ======================================== */
.projects-grid { .projects-grid {
padding: 12px;
display: flex; display: flex;
flex-wrap: wrap;
/* grid-template-columns: repeat(2, 1fr); */ /* grid-template-columns: repeat(2, 1fr); */
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@@ -386,6 +345,7 @@ onMounted(() => {
Sticky Pagination / 吸底分页器 Sticky Pagination / 吸底分页器
======================================== */ ======================================== */
.pagination-sticky { .pagination-sticky {
/* padding: 12px; */
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;

View File

@@ -2,12 +2,18 @@
<div class="page-container"> <div class="page-container">
<div class="content-wrapper animate-fade-in"> <div class="content-wrapper animate-fade-in">
<!-- Page Header / 页面头部 --> <!-- Page Header / 页面头部 -->
<PageHeader <AppHeader :fixed="false" :show-logo="false">
:title="drama?.title || ''" <template #left>
:subtitle="drama?.description || $t('drama.management.overview')" <el-button text @click="$router.back()" class="back-btn">
:show-back="true" <el-icon><ArrowLeft /></el-icon>
:back-text="$t('common.back')" <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 / 标签页 --> <!-- Tabs / 标签页 -->
<div class="tabs-wrapper"> <div class="tabs-wrapper">
@@ -60,15 +66,22 @@
</template> </template>
</el-alert> </el-alert>
<el-card shadow="never" style="margin-top: 20px;"> <el-card shadow="never" class="project-info-card">
<template #header> <template #header>
<h3 class="card-title">{{ $t('drama.management.projectInfo') }}</h3> <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> </template>
<el-descriptions :column="2" border> <el-descriptions :column="2" border class="project-descriptions">
<el-descriptions-item :label="$t('drama.management.projectName')">{{ drama?.title }}</el-descriptions-item> <el-descriptions-item :label="$t('drama.management.projectName')">
<el-descriptions-item :label="$t('common.createdAt')">{{ formatDate(drama?.created_at) }}</el-descriptions-item> <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"> <el-descriptions-item :label="$t('drama.management.projectDesc')" :span="2">
{{ drama?.description || $t('drama.management.noDescription') }} <span class="info-desc">{{ drama?.description || $t('drama.management.noDescription') }}</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -250,7 +263,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Document, User, Picture, Plus } from '@element-plus/icons-vue' import { ArrowLeft, Document, User, Picture, Plus } from '@element-plus/icons-vue'
import { dramaAPI } from '@/api/drama' import { dramaAPI } from '@/api/drama'
import type { Drama } from '@/types/drama' import type { Drama } from '@/types/drama'
import { PageHeader, StatCard, EmptyState } from '@/components/common' import { AppHeader, StatCard, EmptyState } from '@/components/common'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -543,19 +556,19 @@ onMounted(() => {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background: var(--bg-primary); background: var(--bg-primary);
padding: var(--space-2) var(--space-3); /* padding: var(--space-2) var(--space-3); */
transition: background var(--transition-normal); transition: background var(--transition-normal);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.page-container { .page-container {
padding: var(--space-3) var(--space-4); /* padding: var(--space-3) var(--space-4); */
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.page-container { .page-container {
padding: var(--space-4) var(--space-5); /* padding: var(--space-4) var(--space-5); */
} }
} }
@@ -762,6 +775,20 @@ onMounted(() => {
border-color: var(--border-primary); 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 { .card-title {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
@@ -769,6 +796,30 @@ onMounted(() => {
color: var(--text-primary); 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) { .dark :deep(.el-dialog) {
background: var(--bg-card); background: var(--bg-card);
} }

View File

@@ -1,37 +1,34 @@
<template> <template>
<div class="workflow-container"> <div class="workflow-container">
<div class="workflow-header"> <AppHeader :fixed="false" :show-logo="false">
<div class="header-single-line"> <template #left>
<div class="header-left-section"> <el-button text @click="goBack" class="back-btn">
<el-button text @click="goBack" class="back-btn"> <el-icon><ArrowLeft /></el-icon>
<el-icon><ArrowLeft /></el-icon> <span>{{ $t('dramaWorkflow.returnToList') }}</span>
<span>{{ $t('dramaWorkflow.returnToList') }}</span> </el-button>
</el-button> <h2 class="drama-title">{{ drama?.title }}</h2>
<h2 class="drama-title">{{ drama?.title }}</h2> <el-tag :type="getStatusType(drama?.status)" size="small">{{ getStatusText(drama?.status) }}</el-tag>
<el-tag :type="getStatusType(drama?.status)" size="small">{{ getStatusText(drama?.status) }}</el-tag> </template>
</div> <template #center>
<!-- 步骤进度条 --> <!-- 步骤进度条 -->
<div class="steps-inline"> <div class="custom-steps">
<div class="custom-steps"> <div class="step-item" :class="{ active: currentStep >= 0, current: currentStep === 0 }">
<div class="step-item" :class="{ active: currentStep >= 0, current: currentStep === 0 }"> <div class="step-circle">1</div>
<div class="step-circle">1</div> <span class="step-text">{{ $t('dramaWorkflow.episodeScript', { number: currentEpisodeNumber }) }}</span>
<span class="step-text">{{ $t('dramaWorkflow.episodeScript', { number: currentEpisodeNumber }) }}</span> </div>
</div> <el-icon class="step-arrow"><ArrowRight /></el-icon>
<el-icon class="step-arrow"><ArrowRight /></el-icon> <div class="step-item" :class="{ active: currentStep >= 1, current: currentStep === 1 }">
<div class="step-item" :class="{ active: currentStep >= 1, current: currentStep === 1 }"> <div class="step-circle">2</div>
<div class="step-circle">2</div> <span class="step-text">{{ $t('dramaWorkflow.storyboardBreakdown') }}</span>
<span class="step-text">{{ $t('dramaWorkflow.storyboardBreakdown') }}</span> </div>
</div> <el-icon class="step-arrow"><ArrowRight /></el-icon>
<el-icon class="step-arrow"><ArrowRight /></el-icon> <div class="step-item" :class="{ active: currentStep >= 2, current: currentStep === 2 }">
<div class="step-item" :class="{ active: currentStep >= 2, current: currentStep === 2 }"> <div class="step-circle">3</div>
<div class="step-circle">3</div> <span class="step-text">{{ $t('dramaWorkflow.characterImages') }}</span>
<span class="step-text">{{ $t('dramaWorkflow.characterImages') }}</span>
</div>
</div> </div>
</div> </div>
</div> </template>
</div> </AppHeader>
<!-- 当前阶段内容区域 --> <!-- 当前阶段内容区域 -->
<div class="stage-area"> <div class="stage-area">
@@ -567,6 +564,7 @@ import { generationAPI } from '@/api/generation'
import { characterLibraryAPI } from '@/api/character-library' import { characterLibraryAPI } from '@/api/character-library'
import request from '@/utils/request' import request from '@/utils/request'
import type { Drama, DramaStatus } from '@/types/drama' import type { Drama, DramaStatus } from '@/types/drama'
import { AppHeader } from '@/components/common'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -1,39 +1,38 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="content-wrapper animate-fade-in"> <div class="content-wrapper animate-fade-in">
<header class="page-header"> <AppHeader :fixed="false" :show-logo="false">
<div class="header-content"> <template #left>
<div class="header-left"> <el-button text @click="$router.back()" class="back-btn">
<button class="back-btn" @click="$router.back()"> <el-icon><ArrowLeft /></el-icon>
<el-icon><ArrowLeft /></el-icon> <span>{{ $t('workflow.backToProject') }}</span>
<span>{{ $t('workflow.backToProject') }}</span> </el-button>
</button> <h1 class="header-title">{{ $t('workflow.episodeProduction', { number: episodeNumber }) }}</h1>
<div class="nav-divider"></div> </template>
<h1 class="header-title">{{ $t('workflow.episodeProduction', { number: episodeNumber }) }}</h1> <template #center>
</div> <div class="custom-steps">
<div class="header-center"> <div class="step-item" :class="{ active: currentStep >= 0, current: currentStep === 0 }">
<div class="custom-steps"> <div class="step-circle">1</div>
<div class="step-item" :class="{ active: currentStep >= 0, current: currentStep === 0 }"> <span class="step-text">{{ $t('workflow.steps.content') }}</span>
<div class="step-circle">1</div> </div>
<span class="step-text">{{ $t('workflow.steps.content') }}</span> <el-icon class="step-arrow"><ArrowRight /></el-icon>
</div> <div class="step-item" :class="{ active: currentStep >= 1, current: currentStep === 1 }">
<el-icon class="step-arrow"><ArrowRight /></el-icon> <div class="step-circle">2</div>
<div class="step-item" :class="{ active: currentStep >= 1, current: currentStep === 1 }"> <span class="step-text">{{ $t('workflow.steps.generateImages') }}</span>
<div class="step-circle">2</div> </div>
<span class="step-text">{{ $t('workflow.steps.generateImages') }}</span> <el-icon class="step-arrow"><ArrowRight /></el-icon>
</div> <div class="step-item" :class="{ active: currentStep >= 2, current: currentStep === 2 }">
<el-icon class="step-arrow"><ArrowRight /></el-icon> <div class="step-circle">3</div>
<div class="step-item" :class="{ active: currentStep >= 2, current: currentStep === 2 }"> <span class="step-text">{{ $t('workflow.steps.splitStoryboard') }}</span>
<div class="step-circle">3</div>
<span class="step-text">{{ $t('workflow.steps.splitStoryboard') }}</span>
</div>
</div> </div>
</div> </div>
<div class="header-right"> </template>
<el-button :icon="Setting" circle @click="showModelConfigDialog" :title="$t('workflow.modelConfig')" /> <template #right>
</div> <el-button :icon="Setting" @click="showModelConfigDialog" :title="$t('workflow.modelConfig')">
</div> 图文配置
</header> </el-button>
</template>
</AppHeader>
<!-- 阶段 0: 章节内容 + 提取角色场景 --> <!-- 阶段 0: 章节内容 + 提取角色场景 -->
<el-card v-show="currentStep === 0" shadow="never" class="stage-card stage-card-fullscreen"> <el-card v-show="currentStep === 0" shadow="never" class="stage-card stage-card-fullscreen">
@@ -822,7 +821,7 @@ import { aiAPI } from '@/api/ai'
import type { AIServiceConfig } from '@/types/ai' import type { AIServiceConfig } from '@/types/ai'
import { imageAPI } from '@/api/image' import { imageAPI } from '@/api/image'
import type { Drama } from '@/types/drama' import type { Drama } from '@/types/drama'
import PageHeader from '@/components/common/PageHeader.vue' import { AppHeader } from '@/components/common'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -1750,19 +1749,19 @@ onMounted(() => {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background: var(--bg-primary); background: var(--bg-primary);
padding: var(--space-2) var(--space-3); // padding: var(--space-2) var(--space-3);
transition: background var(--transition-normal); transition: background var(--transition-normal);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.page-container { .page-container {
padding: var(--space-3) var(--space-4); // padding: var(--space-3) var(--space-4);
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.page-container { .page-container {
padding: var(--space-4) var(--space-5); // padding: var(--space-4) var(--space-5);
} }
} }
@@ -1841,7 +1840,7 @@ onMounted(() => {
} }
.workflow-card { .workflow-card {
margin-bottom: var(--space-4); margin: 12px;
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
@@ -1915,7 +1914,7 @@ onMounted(() => {
} }
.stage-card { .stage-card {
margin-bottom: 24px; margin: 12px;
&.stage-card-fullscreen { &.stage-card-fullscreen {
.stage-body-fullscreen { .stage-body-fullscreen {
@@ -1950,14 +1949,13 @@ onMounted(() => {
} }
.stage-body { .stage-body {
padding: 32px;
background: var(--bg-card); background: var(--bg-card);
} }
.action-buttons { .action-buttons {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: 24px; margin: 12px 0;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -1990,8 +1988,8 @@ onMounted(() => {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 16px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 8px; // border-radius: 8px;
border: 1px solid var(--border-primary); // border: 1px solid var(--border-primary);
.section-title { .section-title {
display: flex; display: flex;
@@ -2178,6 +2176,7 @@ onMounted(() => {
.character-image-list, .character-image-list,
.scene-image-list { .scene-image-list {
padding: 5px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px; gap: 16px;

View File

@@ -1,22 +1,15 @@
<template> <template>
<div class="professional-editor"> <div class="professional-editor">
<!-- 顶部工具栏 --> <!-- 顶部工具栏 -->
<div class="editor-toolbar"> <AppHeader :fixed="false" :show-logo="false" @config-updated="loadVideoModels">
<div class="toolbar-left"> <template #left>
<el-button link @click="goBack" class="back-btn"> <el-button text @click="goBack" class="back-btn">
<el-icon> <el-icon><ArrowLeft /></el-icon>
<ArrowLeft /> <span>{{ $t('editor.backToEpisode') }}</span>
</el-icon>
{{ $t('editor.backToEpisode') }}
</el-button> </el-button>
<el-divider direction="vertical" />
<span class="episode-title">{{ drama?.title }} - {{ $t('editor.episode', { number: episodeNumber }) }}</span> <span class="episode-title">{{ drama?.title }} - {{ $t('editor.episode', { number: episodeNumber }) }}</span>
</div> </template>
</AppHeader>
<div class="toolbar-right">
<!-- <el-button :icon="Setting" circle @click="showSettings = true" /> -->
</div>
</div>
<!-- 主编辑区域 --> <!-- 主编辑区域 -->
<div class="editor-main"> <div class="editor-main">
@@ -906,6 +899,7 @@ import type { Asset } from '@/types/asset'
import type { VideoMerge } from '@/api/videoMerge' import type { VideoMerge } from '@/api/videoMerge'
import VideoTimelineEditor from '@/components/editor/VideoTimelineEditor.vue' import VideoTimelineEditor from '@/components/editor/VideoTimelineEditor.vue'
import type { Drama, Episode, Storyboard } from '@/types/drama' import type { Drama, Episode, Storyboard } from '@/types/drama'
import { AppHeader } from '@/components/common'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()