Files
sionrui/frontend/app/web-gold/src/views/material/Mix.vue
2026-03-04 03:49:03 +08:00

1161 lines
36 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="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>