1161 lines
36 KiB
Vue
1161 lines
36 KiB
Vue
<template>
|
||
<div class="mix-page">
|
||
<!-- 顶部操作栏 -->
|
||
<header class="page-header">
|
||
<div class="header-left">
|
||
<a-button type="text" @click="router.push('/material/list')">
|
||
<template #icon><ArrowLeftOutlined /></template>
|
||
返回
|
||
</a-button>
|
||
<a-divider type="vertical" />
|
||
<h1 class="page-title">智能混剪</h1>
|
||
<a-tag color="processing" size="small">{{ sceneCount }} 场景</a-tag>
|
||
</div>
|
||
|
||
<div class="header-center">
|
||
<!-- 核心参数 -->
|
||
<div class="param-item">
|
||
<span class="param-label">总时长</span>
|
||
<a-slider
|
||
v-model:value="formData.totalDuration"
|
||
:min="15"
|
||
:max="30"
|
||
:step="1"
|
||
style="width: 100px;"
|
||
/>
|
||
<span class="param-value">{{ formData.totalDuration }}s</span>
|
||
</div>
|
||
|
||
<a-divider type="vertical" />
|
||
|
||
<div class="param-item">
|
||
<span class="param-label">切片</span>
|
||
<a-slider
|
||
v-model:value="formData.clipDuration"
|
||
:min="3"
|
||
:max="5"
|
||
:step="1"
|
||
style="width: 80px;"
|
||
/>
|
||
<span class="param-value">{{ formData.clipDuration }}s</span>
|
||
</div>
|
||
|
||
<a-divider type="vertical" />
|
||
|
||
<div class="param-item">
|
||
<span class="param-label">生成</span>
|
||
<a-select
|
||
v-model:value="formData.produceCount"
|
||
size="small"
|
||
style="width: 70px;"
|
||
@change="saveProduceCount"
|
||
>
|
||
<a-select-option :value="1">1 个</a-select-option>
|
||
<a-select-option :value="3">3 个</a-select-option>
|
||
<a-select-option :value="5">5 个</a-select-option>
|
||
<a-select-option :value="10">10 个</a-select-option>
|
||
<a-select-option :value="15">15 个</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
<a-button @click="showSettings = true">
|
||
<template #icon><SettingOutlined /></template>
|
||
高级设置
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
:loading="uiState.submitting"
|
||
:disabled="!canSubmit"
|
||
@click="handleSubmit"
|
||
>
|
||
<template #icon><RocketOutlined /></template>
|
||
开始混剪
|
||
</a-button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 进度条 -->
|
||
<div class="progress-bar">
|
||
<div class="progress-track">
|
||
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
|
||
</div>
|
||
<span class="progress-text">
|
||
<CheckCircleFilled v-if="filledCount === sceneCount" class="icon-complete" />
|
||
<span v-else class="icon-pending">{{ filledCount }}/{{ sceneCount }}</span>
|
||
场景已就绪
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 主内容区:场景轨道全宽 -->
|
||
<main class="page-main">
|
||
<section class="scenes-section">
|
||
<div class="section-header">
|
||
<span class="section-title">
|
||
<PlaySquareOutlined />
|
||
场景编排
|
||
</span>
|
||
<div class="header-actions">
|
||
<a-button size="small" @click="clearScenes">
|
||
<template #icon><ClearOutlined /></template>
|
||
清空
|
||
</a-button>
|
||
<a-button size="small" type="primary" ghost @click="autoFillAllScenes">
|
||
<template #icon><ThunderboltOutlined /></template>
|
||
一键填充
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scenes-list">
|
||
<draggable
|
||
v-model="dataState.scenes"
|
||
item-key="index"
|
||
handle=".scene-drag"
|
||
animation="200"
|
||
class="draggable-list"
|
||
@end="onSceneDragEnd"
|
||
>
|
||
<template #item="{ element: scene, index }">
|
||
<div
|
||
class="scene-card"
|
||
:class="{
|
||
'scene-card--complete': scene.candidates?.length > 0
|
||
}"
|
||
>
|
||
<!-- 场景头部 -->
|
||
<div class="scene-header">
|
||
<div class="scene-drag">
|
||
<HolderOutlined />
|
||
</div>
|
||
<div class="scene-index">{{ index + 1 }}</div>
|
||
<div class="scene-duration">{{ formData.clipDuration }}s</div>
|
||
<div class="scene-status">
|
||
<a-badge
|
||
:count="scene.candidates?.length || 0"
|
||
:number-style="{
|
||
backgroundColor: scene.candidates?.length
|
||
? 'var(--color-primary-500)'
|
||
: 'var(--color-gray-300)'
|
||
}"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 场景内容 -->
|
||
<div class="scene-body">
|
||
<template v-if="scene.candidates?.length > 0">
|
||
<div
|
||
v-for="(candidate, cIndex) in scene.candidates"
|
||
:key="cIndex"
|
||
class="candidate-thumb"
|
||
@click.stop="removeCandidate(index, cIndex)"
|
||
>
|
||
<img
|
||
v-if="getFileById(candidate.fileId)?.coverBase64"
|
||
:src="getFileById(candidate.fileId).coverBase64"
|
||
/>
|
||
<div v-else class="thumb-placeholder">
|
||
<VideoCameraOutlined />
|
||
</div>
|
||
<div class="thumb-order">{{ cIndex + 1 }}</div>
|
||
<div class="thumb-remove">
|
||
<CloseOutlined />
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="scene.candidates.length < constants.MAX_CANDIDATES_PER_SCENE"
|
||
class="candidate-add"
|
||
@click.stop="openSceneSelector(index)"
|
||
>
|
||
<PlusOutlined />
|
||
<span>继续添加</span>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="scene-empty" @click.stop="openSceneSelector(index)">
|
||
<PlusCircleOutlined />
|
||
<span>点击添加候选素材</span>
|
||
<span class="empty-hint">最多 {{ constants.MAX_CANDIDATES_PER_SCENE }} 个</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 场景底部:素材来源(只读) -->
|
||
<div class="scene-footer">
|
||
<span class="source-label">来源:</span>
|
||
<a-tag
|
||
size="small"
|
||
:color="scene.groupId ? 'blue' : 'default'"
|
||
>
|
||
{{ getSceneEffectiveGroupName(scene) }}
|
||
</a-tag>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</draggable>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<!-- 高级设置抽屉 -->
|
||
<a-drawer
|
||
v-model:open="showSettings"
|
||
title="高级设置"
|
||
placement="right"
|
||
:width="320"
|
||
>
|
||
<a-form layout="vertical">
|
||
<a-form-item label="裁剪模式">
|
||
<a-radio-group v-model:value="formData.cropMode" button-style="solid">
|
||
<a-radio-button value="center">居中裁剪</a-radio-button>
|
||
<a-radio-button value="fill">填充模式</a-radio-button>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
|
||
<a-divider />
|
||
|
||
<a-form-item label="统计信息">
|
||
<div class="stats-row">
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ sceneCount }}</span>
|
||
<span class="stat-label">场景数</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ filledCount }}</span>
|
||
<span class="stat-label">已填充</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ actualTotalDuration }}s</span>
|
||
<span class="stat-label">总时长</span>
|
||
</div>
|
||
</div>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-drawer>
|
||
|
||
<!-- 场景选择弹窗 -->
|
||
<SceneSelectorModal
|
||
v-model:visible="uiState.selectorVisible"
|
||
:scene-index="uiState.currentSceneIndex"
|
||
:scene-group-id="dataState.scenes[uiState.currentSceneIndex]?.groupId"
|
||
:global-group-id="uiState.selectedLibraryGroup"
|
||
:group-list="dataState.groupList"
|
||
:all-group-files="dataState.allGroupFiles"
|
||
:group-files="dataState.groupFiles"
|
||
:initial-candidates="currentSceneCandidateIds"
|
||
:max-candidates="constants.MAX_CANDIDATES_PER_SCENE"
|
||
@confirm="handleModalConfirm"
|
||
@load-group-files="loadGroupFiles"
|
||
/>
|
||
|
||
<!-- 一键填充分组选择弹窗 -->
|
||
<GroupSelectModal
|
||
v-model:visible="uiState.groupSelectVisible"
|
||
:group-list="dataState.groupList"
|
||
@confirm="handleGroupSelectConfirm"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, onMounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { useRouter } from 'vue-router'
|
||
import draggable from 'vuedraggable'
|
||
|
||
import {
|
||
ArrowLeftOutlined,
|
||
SettingOutlined,
|
||
RocketOutlined,
|
||
PlaySquareOutlined,
|
||
ClearOutlined,
|
||
ThunderboltOutlined,
|
||
HolderOutlined,
|
||
PlusOutlined,
|
||
PlusCircleOutlined,
|
||
CheckCircleFilled,
|
||
VideoCameraOutlined,
|
||
CloseOutlined
|
||
} from '@ant-design/icons-vue'
|
||
|
||
import { MaterialService, MaterialGroupService } from '@/api/material'
|
||
import { MixTaskService } from '@/api/mixTask'
|
||
import SceneSelectorModal from './components/SceneSelectorModal.vue'
|
||
import GroupSelectModal from './components/GroupSelectModal.vue'
|
||
|
||
const router = useRouter()
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 常量 & 工具函数
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const constants = { MAX_CANDIDATES_PER_SCENE: 10 }
|
||
|
||
const createDeterministicRandom = (seed) => {
|
||
let x = Math.sin(seed) * 10000
|
||
return () => {
|
||
x = Math.sin(x) * 10000
|
||
return x - Math.floor(x)
|
||
}
|
||
}
|
||
|
||
const randomlySelectMaterials = (count, materials, seed) => {
|
||
const shuffled = [...materials]
|
||
const random = createDeterministicRandom(seed)
|
||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||
const j = Math.floor(random() * (i + 1))
|
||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||
}
|
||
return shuffled.slice(0, Math.min(count, shuffled.length)).map(m => ({
|
||
fileId: m.id,
|
||
fileUrl: m.fileUrl
|
||
}))
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 状态
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const showSettings = ref(false)
|
||
|
||
const formData = ref({
|
||
title: '',
|
||
produceCount: Number(localStorage.getItem('mix-produce-count')) || 3,
|
||
totalDuration: 15,
|
||
clipDuration: 5,
|
||
cropMode: localStorage.getItem('mix-crop-mode') || 'center'
|
||
})
|
||
|
||
const uiState = ref({
|
||
loadingGroups: false,
|
||
loadingFiles: false,
|
||
submitting: false,
|
||
selectorVisible: false,
|
||
currentSceneIndex: -1,
|
||
selectedLibraryGroup: null,
|
||
groupSelectVisible: false
|
||
})
|
||
|
||
const dataState = ref({
|
||
groupList: [],
|
||
groupFiles: [],
|
||
allGroupFiles: {},
|
||
scenes: []
|
||
})
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 计算属性
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const sceneCount = computed(() =>
|
||
Math.floor(formData.value.totalDuration / formData.value.clipDuration)
|
||
)
|
||
|
||
const actualTotalDuration = computed(() =>
|
||
sceneCount.value * formData.value.clipDuration
|
||
)
|
||
|
||
const filledCount = computed(() =>
|
||
dataState.value.scenes.filter(s => s.candidates?.length > 0).length
|
||
)
|
||
|
||
const progressPercent = computed(() =>
|
||
sceneCount.value > 0 ? (filledCount.value / sceneCount.value) * 100 : 0
|
||
)
|
||
|
||
const currentSceneCandidateIds = computed(() => {
|
||
const index = uiState.value.currentSceneIndex
|
||
if (index < 0 || index >= dataState.value.scenes.length) return []
|
||
return dataState.value.scenes[index]?.candidates?.map(c => c.fileId) || []
|
||
})
|
||
|
||
const canSubmit = computed(() => filledCount.value === sceneCount.value)
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 工具方法
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const saveProduceCount = () => {
|
||
localStorage.setItem('mix-produce-count', formData.value.produceCount.toString())
|
||
}
|
||
|
||
// 监听裁剪模式变化,保存到 localStorage
|
||
watch(() => formData.value.cropMode, (newVal) => {
|
||
localStorage.setItem('mix-crop-mode', newVal)
|
||
})
|
||
|
||
const getFileById = (fileId) => {
|
||
let file = dataState.value.groupFiles.find(f => f.id === fileId)
|
||
if (file) return file
|
||
for (const files of Object.values(dataState.value.allGroupFiles)) {
|
||
file = files.find(f => f.id === fileId)
|
||
if (file) return file
|
||
}
|
||
return null
|
||
}
|
||
|
||
const getSceneEffectiveGroupName = (scene) => {
|
||
const effectiveGroupId = scene.groupId || uiState.value.selectedLibraryGroup
|
||
if (!effectiveGroupId) return '默认'
|
||
const group = dataState.value.groupList.find(g => g.id === effectiveGroupId)
|
||
return group?.name || '未知'
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 场景管理
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
watch(sceneCount, (newCount) => {
|
||
const current = dataState.value.scenes.length
|
||
if (newCount > current) {
|
||
for (let i = current; i < newCount; i++) {
|
||
dataState.value.scenes.push({
|
||
index: i,
|
||
duration: formData.value.clipDuration,
|
||
groupId: null,
|
||
candidates: []
|
||
})
|
||
}
|
||
} else if (newCount < current) {
|
||
dataState.value.scenes = dataState.value.scenes.slice(0, newCount)
|
||
}
|
||
}, { immediate: true })
|
||
|
||
const onSceneDragEnd = () => {
|
||
dataState.value.scenes.forEach((scene, index) => {
|
||
scene.index = index
|
||
})
|
||
}
|
||
|
||
const clearScenes = () => {
|
||
dataState.value.scenes = Array(sceneCount.value).fill(null).map((_, index) => ({
|
||
index,
|
||
duration: formData.value.clipDuration,
|
||
groupId: null,
|
||
candidates: []
|
||
}))
|
||
message.success('已清空所有场景')
|
||
}
|
||
|
||
const removeCandidate = (sceneIndex, candidateIndex) => {
|
||
const candidates = dataState.value.scenes[sceneIndex]?.candidates
|
||
if (candidates?.[candidateIndex] !== undefined) {
|
||
candidates.splice(candidateIndex, 1)
|
||
message.success('已移除候选')
|
||
}
|
||
}
|
||
|
||
const openSceneSelector = (index) => {
|
||
uiState.value.currentSceneIndex = index
|
||
uiState.value.selectorVisible = true
|
||
}
|
||
|
||
const openGroupSelector = (index) => {
|
||
const groups = dataState.value.groupList
|
||
if (groups.length === 0) return
|
||
|
||
const currentGroupId = dataState.value.scenes[index].groupId
|
||
const currentIndex = groups.findIndex(g => g.id === currentGroupId)
|
||
const nextIndex = (currentIndex + 1) % groups.length
|
||
const nextGroup = groups[nextIndex]
|
||
|
||
dataState.value.scenes[index].groupId = nextGroup.id
|
||
dataState.value.scenes[index].candidates = []
|
||
loadGroupFiles(nextGroup.id)
|
||
message.success(`场景${index + 1} 切换到 ${nextGroup.name}`)
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 素材操作
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const autoFillAllScenes = async () => {
|
||
// 检查是否有分组
|
||
if (dataState.value.groupList.length === 0) {
|
||
message.warning('暂无素材分组,请先创建分组')
|
||
return
|
||
}
|
||
|
||
// 如果没有选中分组,弹出选择弹窗
|
||
if (!uiState.value.selectedLibraryGroup) {
|
||
uiState.value.groupSelectVisible = true
|
||
return
|
||
}
|
||
|
||
// 已有选中分组,直接填充
|
||
await doAutoFill()
|
||
}
|
||
|
||
const handleGroupSelectConfirm = async (groupId) => {
|
||
uiState.value.selectedLibraryGroup = groupId
|
||
|
||
// 加载该分组的素材
|
||
await handleLibraryGroupChange(groupId)
|
||
|
||
// 执行填充
|
||
await doAutoFill()
|
||
}
|
||
|
||
const doAutoFill = async () => {
|
||
let filled = 0
|
||
for (let i = 0; i < dataState.value.scenes.length; i++) {
|
||
const scene = dataState.value.scenes[i]
|
||
if (scene.candidates?.length >= 3) continue
|
||
|
||
const groupId = scene.groupId || uiState.value.selectedLibraryGroup
|
||
const materials = dataState.value.allGroupFiles[groupId] || await loadGroupFiles(groupId)
|
||
|
||
if (materials.length === 0) continue
|
||
|
||
const needed = 3 - (scene.candidates?.length || 0)
|
||
const selected = randomlySelectMaterials(needed, materials, i + Date.now())
|
||
scene.candidates = [...(scene.candidates || []), ...selected]
|
||
filled++
|
||
}
|
||
|
||
if (filled > 0) {
|
||
message.success(`已为 ${filled} 个场景填充候选素材`)
|
||
} else {
|
||
message.info('所有场景已满')
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 数据加载
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const loadGroups = async () => {
|
||
uiState.value.loadingGroups = true
|
||
try {
|
||
const res = await MaterialGroupService.getGroupList()
|
||
if (res.code === 0) {
|
||
dataState.value.groupList = res.data || []
|
||
}
|
||
} catch {
|
||
message.error('加载分组失败')
|
||
} finally {
|
||
uiState.value.loadingGroups = false
|
||
}
|
||
}
|
||
|
||
const handleLibraryGroupChange = async (groupId) => {
|
||
uiState.value.selectedLibraryGroup = groupId
|
||
|
||
if (!dataState.value.allGroupFiles[groupId]) {
|
||
uiState.value.loadingFiles = true
|
||
try {
|
||
const res = await MaterialService.getFilePage({
|
||
groupId,
|
||
fileCategory: 'video',
|
||
pageNo: 1,
|
||
pageSize: 50
|
||
})
|
||
if (res.code === 0) {
|
||
dataState.value.allGroupFiles[groupId] = res.data.list || []
|
||
}
|
||
} catch {
|
||
message.error('加载素材失败')
|
||
} finally {
|
||
uiState.value.loadingFiles = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadGroupFiles = async (groupId) => {
|
||
if (!groupId) return []
|
||
if (dataState.value.allGroupFiles[groupId]) {
|
||
return dataState.value.allGroupFiles[groupId]
|
||
}
|
||
|
||
try {
|
||
const res = await MaterialService.getFilePage({
|
||
groupId,
|
||
fileCategory: 'video',
|
||
pageNo: 1,
|
||
pageSize: 50
|
||
})
|
||
if (res.code === 0) {
|
||
const files = res.data.list || []
|
||
dataState.value.allGroupFiles[groupId] = files
|
||
return files
|
||
}
|
||
} catch {
|
||
message.error('加载素材失败')
|
||
}
|
||
return []
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 弹窗处理
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const handleModalConfirm = ({ sceneIndex, fileIds, setGroupId }) => {
|
||
const scene = dataState.value.scenes[sceneIndex]
|
||
if (!scene) return
|
||
|
||
scene.candidates = fileIds.map(fileId => ({
|
||
fileId,
|
||
fileUrl: getFileById(fileId)?.fileUrl
|
||
}))
|
||
|
||
if (setGroupId !== null && setGroupId !== undefined) {
|
||
scene.groupId = setGroupId
|
||
}
|
||
|
||
message.success(`场景 ${sceneIndex + 1} 已设置 ${scene.candidates.length} 个候选`)
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 提交
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
const handleSubmit = async () => {
|
||
if (!canSubmit.value) return
|
||
|
||
uiState.value.submitting = true
|
||
try {
|
||
const sceneConfigs = dataState.value.scenes.map(scene => ({
|
||
duration: scene.duration,
|
||
candidates: scene.candidates.map(candidate => ({
|
||
fileId: candidate.fileId,
|
||
fileUrl: candidate.fileUrl,
|
||
fileDuration: getFileById(candidate.fileId)?.duration || null
|
||
}))
|
||
}))
|
||
|
||
const res = await MixTaskService.createTask({
|
||
title: formData.value.title || `混剪视频_${new Date().toLocaleString()}`,
|
||
scenes: sceneConfigs,
|
||
produceCount: formData.value.produceCount,
|
||
cropMode: formData.value.cropMode
|
||
})
|
||
|
||
if (res.code === 0) {
|
||
message.success('混剪任务创建成功!')
|
||
}
|
||
} catch (error) {
|
||
message.error('提交失败:' + error.message)
|
||
} finally {
|
||
uiState.value.submitting = false
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 生命周期
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
onMounted(() => {
|
||
loadGroups()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 混剪页面 - 使用项目设计系统
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
// 🎨 使用 CSS 变量引用项目设计令牌
|
||
@primary: var(--color-primary-500);
|
||
@primary-light: var(--color-primary-50);
|
||
@primary-hover: var(--color-primary-400);
|
||
|
||
@success: var(--color-success-500);
|
||
@success-light: var(--color-success-50);
|
||
|
||
@warning: var(--color-warning-500);
|
||
@danger: var(--color-error-500);
|
||
|
||
@text: var(--color-text);
|
||
@text-secondary: var(--color-text-secondary);
|
||
@text-muted: var(--color-text-muted);
|
||
@text-disabled: var(--color-text-disabled);
|
||
|
||
@bg-page: var(--color-bg-page);
|
||
@bg-card: var(--color-bg-card);
|
||
@bg-elevated: var(--color-gray-100);
|
||
|
||
@border: var(--color-border);
|
||
@border-light: var(--color-gray-100);
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 页面容器
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
.mix-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
background: @bg-page;
|
||
overflow: hidden;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 顶部操作栏
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: var(--header-height);
|
||
padding: 0 var(--space-5);
|
||
background: @bg-card;
|
||
border-bottom: 1px solid @border;
|
||
flex-shrink: 0;
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
|
||
.page-title {
|
||
margin: 0;
|
||
font-size: var(--font-size-md);
|
||
font-weight: 600;
|
||
color: @text;
|
||
}
|
||
}
|
||
|
||
.header-center {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-4);
|
||
|
||
.param-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
|
||
.param-label {
|
||
font-size: var(--font-size-sm);
|
||
color: @text-secondary;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.param-value {
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
color: @primary;
|
||
min-width: 28px;
|
||
}
|
||
|
||
:deep(.ant-slider) {
|
||
margin: 0;
|
||
|
||
.ant-slider-track {
|
||
background: @primary;
|
||
}
|
||
|
||
.ant-slider-handle {
|
||
border-color: @primary;
|
||
}
|
||
}
|
||
|
||
:deep(.ant-select) {
|
||
.ant-select-selector {
|
||
height: 24px !important;
|
||
line-height: 22px !important;
|
||
padding: 0 var(--space-2) !important;
|
||
}
|
||
|
||
.ant-select-selection-item {
|
||
line-height: 22px !important;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 进度条 - 状态指示器
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
.progress-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
height: 36px;
|
||
padding: 0 var(--space-5);
|
||
background: @bg-card;
|
||
border-bottom: 1px solid @border;
|
||
|
||
.progress-track {
|
||
flex: 1;
|
||
max-width: 300px;
|
||
height: 6px;
|
||
background: @border;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--color-primary-400), var(--color-primary-600));
|
||
border-radius: 3px;
|
||
transition: width var(--duration-slow) ease;
|
||
}
|
||
}
|
||
|
||
.progress-text {
|
||
font-size: var(--font-size-sm);
|
||
color: @text-secondary;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
|
||
.icon-complete {
|
||
color: var(--color-primary-400);
|
||
}
|
||
|
||
.icon-pending {
|
||
font-weight: 500;
|
||
color: @primary;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 主内容区
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
.page-main {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 场景区 - 全宽
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
.scenes-section {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
background: @bg-card;
|
||
|
||
.section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: 48px;
|
||
padding: 0 var(--space-5);
|
||
border-bottom: 1px solid @border-light;
|
||
flex-shrink: 0;
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
font-size: var(--font-size-base);
|
||
font-weight: 500;
|
||
color: @text;
|
||
|
||
.anticon {
|
||
color: @primary;
|
||
}
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
}
|
||
|
||
.scenes-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--space-4) var(--space-5);
|
||
|
||
.draggable-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
}
|
||
}
|
||
|
||
.scene-card {
|
||
background: @bg-elevated;
|
||
border: 1px solid @border;
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
transition: all var(--duration-base) ease;
|
||
|
||
&:hover {
|
||
border-color: @primary;
|
||
box-shadow: var(--shadow-blue);
|
||
}
|
||
|
||
// ✅ 已完成状态 - 青色主题
|
||
&--complete {
|
||
border-left: 3px solid var(--color-primary-400);
|
||
background: var(--color-primary-50);
|
||
}
|
||
|
||
.scene-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-3) var(--space-4);
|
||
background: @bg-card;
|
||
border-bottom: 1px solid @border-light;
|
||
|
||
.scene-drag {
|
||
cursor: grab;
|
||
color: @text-disabled;
|
||
padding: var(--space-1);
|
||
|
||
&:hover {
|
||
color: @primary;
|
||
}
|
||
|
||
&:active {
|
||
cursor: grabbing;
|
||
}
|
||
}
|
||
|
||
.scene-index {
|
||
width: 28px;
|
||
height: 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: @primary;
|
||
color: var(--color-text-inverse);
|
||
font-size: var(--font-size-base);
|
||
font-weight: 600;
|
||
border-radius: var(--radius-button);
|
||
}
|
||
|
||
.scene-duration {
|
||
font-size: var(--font-size-sm);
|
||
color: @text-secondary;
|
||
padding: 3px var(--space-2);
|
||
background: var(--color-gray-100);
|
||
border-radius: var(--radius-tag);
|
||
}
|
||
|
||
.scene-status {
|
||
margin-left: auto;
|
||
}
|
||
}
|
||
|
||
.scene-body {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-4);
|
||
min-height: 90px;
|
||
overflow-x: auto;
|
||
|
||
.candidate-thumb {
|
||
position: relative;
|
||
width: 100px;
|
||
height: 65px;
|
||
border-radius: var(--radius-button);
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
border: 2px solid transparent;
|
||
transition: all var(--duration-base) ease;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.thumb-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-gray-100);
|
||
color: @text-disabled;
|
||
}
|
||
|
||
// 🏷️ 候选序号
|
||
.thumb-order {
|
||
position: absolute;
|
||
top: var(--space-1);
|
||
left: var(--space-1);
|
||
width: 18px;
|
||
height: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: @warning;
|
||
color: var(--color-text-inverse);
|
||
font-size: var(--font-size-xs);
|
||
font-weight: 600;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
// 🗑️ 删除遮罩
|
||
.thumb-remove {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(239, 68, 68, 0.9);
|
||
color: var(--color-text-inverse);
|
||
font-size: var(--font-size-md);
|
||
opacity: 0;
|
||
transition: opacity var(--duration-base) ease;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: @danger;
|
||
|
||
.thumb-remove {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ➕ 添加更多
|
||
.candidate-add {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
padding: 18px var(--space-5);
|
||
border: 2px dashed var(--color-gray-300);
|
||
border-radius: var(--radius-button);
|
||
color: @text-muted;
|
||
font-size: var(--font-size-sm);
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
transition: all var(--duration-base) ease;
|
||
|
||
&:hover {
|
||
border-color: @primary;
|
||
color: @primary;
|
||
background: @primary-light;
|
||
}
|
||
}
|
||
|
||
// 📭 空状态
|
||
.scene-empty {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-6);
|
||
border: 2px dashed var(--color-gray-300);
|
||
border-radius: var(--radius-lg);
|
||
color: @text-muted;
|
||
font-size: var(--font-size-base);
|
||
cursor: pointer;
|
||
transition: all var(--duration-base) ease;
|
||
|
||
.anticon {
|
||
font-size: 28px;
|
||
color: @text-disabled;
|
||
}
|
||
|
||
.empty-hint {
|
||
font-size: var(--font-size-xs);
|
||
color: @text-disabled;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: @primary;
|
||
color: @primary;
|
||
background: @primary-light;
|
||
|
||
.anticon {
|
||
color: @primary;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 📎 来源标签
|
||
.scene-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
padding: var(--space-2) var(--space-4);
|
||
border-top: 1px solid @border-light;
|
||
background: @bg-card;
|
||
|
||
.source-label {
|
||
font-size: var(--font-size-xs);
|
||
color: @text-muted;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 统计信息 - 抽屉内
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
.stats-row {
|
||
display: flex;
|
||
gap: var(--space-3);
|
||
|
||
.stat-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: var(--space-3);
|
||
background: var(--color-gray-100);
|
||
border-radius: var(--radius-button);
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: var(--font-size-xl);
|
||
font-weight: 600;
|
||
color: @primary;
|
||
}
|
||
|
||
.stat-label {
|
||
display: block;
|
||
font-size: var(--font-size-xs);
|
||
color: @text-muted;
|
||
margin-top: var(--space-1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 响应式适配
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
@media (max-width: 1000px) {
|
||
.header-center {
|
||
display: none !important;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.page-header {
|
||
flex-wrap: wrap;
|
||
height: auto;
|
||
padding: var(--space-3);
|
||
gap: var(--space-2);
|
||
|
||
.header-left {
|
||
order: 1;
|
||
}
|
||
|
||
.header-right {
|
||
order: 2;
|
||
}
|
||
|
||
.header-center {
|
||
order: 3;
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
display: flex !important;
|
||
}
|
||
}
|
||
|
||
.scene-card {
|
||
.scene-body {
|
||
.candidate-thumb {
|
||
width: 80px;
|
||
height: 55px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|