feat: 优化

This commit is contained in:
2026-03-04 02:13:16 +08:00
parent aa06782953
commit 7f5d9d9f19
17 changed files with 1958 additions and 1727 deletions

View File

@@ -1,146 +1,147 @@
<template>
<a-modal
:open="visible"
:title="modalTitle"
:footer="null"
:closable="false"
width="800px"
class="selector-modal"
centered
class="scene-selector-modal"
@update:open="$emit('update:visible', $event)"
>
<div class="selector-container">
<!-- 分组选择器 + 批量操作按钮 -->
<div class="selector-actions">
<a-space>
<!-- 分组选择器 -->
<a-select
:value="selectorGroupId"
placeholder="使用场景默认分组"
size="small"
style="width: 160px;"
allow-clear
@change="handleSelectorGroupChange"
>
<a-select-option v-for="g in groupList" :key="g.id" :value="g.id">
{{ g.name }}
</a-select-option>
</a-select>
<a-divider type="vertical" />
<a-button size="small" @click="handleSelectAll">
<template #icon><SelectOutlined /></template>
全选
</a-button>
<a-button size="small" @click="handleClearSelection">
<template #icon><StopOutlined /></template>
清空
</a-button>
<a-button size="small" @click="handleAutoFill">
<template #icon><ThunderboltOutlined /></template>
智能填充
</a-button>
</a-space>
</div>
<!-- 候选网格 -->
<div class="selector-grid">
<div
v-for="file in selectorFiles"
:key="file.id"
class="selector-item"
:class="{ 'selector-item--selected': isSelected(file.id) }"
@click="toggleSelection(file)"
>
<div class="selector-thumb">
<img v-if="file.coverBase64" :src="file.coverBase64" />
<VideoCameraOutlined v-else />
</div>
<div class="selector-name">{{ file.displayName || file.fileName }}</div>
<div v-if="isSelected(file.id)" class="selector-checkmark">
<CheckOutlined />
</div>
<!-- 顶部 -->
<div class="modal-header">
<div class="header-left">
<div class="scene-badge">场景 {{ sceneIndex + 1 }}</div>
<div class="counter">
<span class="counter-current">{{ tempSelectedFiles.length }}</span>
<span class="counter-sep">/</span>
<span class="counter-max">{{ maxCandidates }}</span>
</div>
</div>
<!-- 底部操作按钮 -->
<div class="selector-actions-footer">
<a-space>
<a-button @click="$emit('update:visible', false)">取消</a-button>
<a-button type="primary" @click="handleConfirm">确定</a-button>
</a-space>
<!-- 分组切换 -->
<div v-if="groupList.length > 1" class="group-tabs">
<a-tag
v-for="g in groupList"
:key="g.id"
:color="selectorGroupId === g.id ? 'blue' : 'default'"
style="cursor: pointer;"
@click="handleSelectorGroupChange(g.id)"
>
{{ g.name }}
</a-tag>
</div>
<a-button type="text" size="small" @click="$emit('update:visible', false)">
<CloseOutlined />
</a-button>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<a-space size="small">
<a-button size="small" @click="handleSelectAll">
<template #icon><CheckSquareOutlined /></template>
全选
</a-button>
<a-button size="small" @click="handleClearSelection">
<template #icon><CloseSquareOutlined /></template>
清空
</a-button>
<a-button size="small" type="primary" ghost @click="handleAutoFill">
<template #icon><ThunderboltOutlined /></template>
随机
</a-button>
</a-space>
<span class="toolbar-hint">点击选择 · 双击确认 · Enter 提交</span>
</div>
<!-- 素材网格 -->
<div class="material-grid">
<template v-if="selectorFiles.length > 0">
<div
v-for="(file, index) in selectorFiles"
:key="file.id"
class="material-card"
:class="{ 'material-card--selected': isSelected(file.id) }"
:style="{ animationDelay: `${index * 20}ms` }"
@click="toggleSelection(file)"
@dblclick="handleQuickConfirm(file)"
>
<div class="card-cover">
<img v-if="file.coverBase64" :src="file.coverBase64" />
<div v-else class="cover-placeholder">
<VideoCameraOutlined />
</div>
<!-- 选中标记 -->
<div v-if="isSelected(file.id)" class="card-check">
<CheckOutlined />
</div>
<!-- 序号 -->
<div v-if="isSelected(file.id)" class="card-order">
{{ getSelectionOrder(file.id) }}
</div>
</div>
<div class="card-name">{{ file.displayName || file.fileName }}</div>
</div>
</template>
<a-empty v-else description="暂无素材" />
</div>
<!-- 底部 -->
<div class="modal-footer">
<a-space>
<a-button @click="$emit('update:visible', false)">取消</a-button>
<a-button
type="primary"
:disabled="tempSelectedFiles.length === 0"
@click="handleConfirm"
>
<template #icon><CheckOutlined /></template>
确认选择
</a-button>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import {
VideoCameraOutlined,
ThunderboltOutlined,
CheckOutlined,
SelectOutlined,
StopOutlined
CloseOutlined,
CheckSquareOutlined,
CloseSquareOutlined,
ThunderboltOutlined
} from '@ant-design/icons-vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
sceneIndex: {
type: Number,
default: -1
},
sceneGroupId: {
type: [Number, String],
default: null
},
globalGroupId: {
type: [Number, String],
default: null
},
groupList: {
type: Array,
default: () => []
},
allGroupFiles: {
type: Object,
default: () => ({})
},
groupFiles: {
type: Array,
default: () => []
},
initialCandidates: {
type: Array,
default: () => []
},
maxCandidates: {
type: Number,
default: 10
}
visible: { type: Boolean, default: false },
sceneIndex: { type: Number, default: -1 },
sceneGroupId: { type: [Number, String], default: null },
globalGroupId: { type: [Number, String], default: null },
groupList: { type: Array, default: () => [] },
allGroupFiles: { type: Object, default: () => ({}) },
groupFiles: { type: Array, default: () => [] },
initialCandidates: { type: Array, default: () => [] },
maxCandidates: { type: Number, default: 10 }
})
const emit = defineEmits(['update:visible', 'confirm', 'load-group-files'])
// 弹窗分组选择
const selectorGroupId = ref(null)
// 临时选中的文件ID列表
const tempSelectedFiles = ref([])
// 弹窗标题
const modalTitle = computed(() =>
`场景${props.sceneIndex + 1} - 已选择 ${tempSelectedFiles.value.length}/${props.maxCandidates} 个候选`
)
// 弹窗中显示的素材列表
const selectorFiles = computed(() => {
const groupId = selectorGroupId.value
if (groupId) {
return props.allGroupFiles[groupId] || []
}
// 使用场景分组或全局分组
const effectiveGroupId = props.sceneGroupId || props.globalGroupId
if (effectiveGroupId) {
return props.allGroupFiles[effectiveGroupId] || props.groupFiles
@@ -148,24 +149,36 @@ const selectorFiles = computed(() => {
return props.groupFiles
})
// 监听弹窗打开,初始化状态
watch(() => props.visible, (visible) => {
if (visible) {
// 初始化选中列表
tempSelectedFiles.value = [...props.initialCandidates]
// 设置初始分组
selectorGroupId.value = props.sceneGroupId || props.globalGroupId || null
// 如果分组素材未缓存,通知父组件加载
if (selectorGroupId.value && !props.allGroupFiles[selectorGroupId.value]) {
emit('load-group-files', selectorGroupId.value)
}
}
})
// 判断是否选中
const isSelected = (fileId) => tempSelectedFiles.value.includes(fileId)
// 键盘快捷键
const handleKeydown = (e) => {
if (!props.visible) return
if (e.key === 'Enter' && tempSelectedFiles.value.length > 0) {
e.preventDefault()
handleConfirm()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
const isSelected = (fileId) => tempSelectedFiles.value.includes(fileId)
const getSelectionOrder = (fileId) => tempSelectedFiles.value.indexOf(fileId) + 1
const getFileName = (fileId) => {
const file = selectorFiles.value.find(f => f.id === fileId)
return file ? (file.displayName || file.fileName) : '未知'
}
// 切换选中状态
const toggleSelection = (file) => {
const index = tempSelectedFiles.value.indexOf(file.id)
if (index > -1) {
@@ -174,26 +187,31 @@ const toggleSelection = (file) => {
if (tempSelectedFiles.value.length < props.maxCandidates) {
tempSelectedFiles.value.push(file.id)
} else {
message.warning(`每个场景最多只能选择${props.maxCandidates}个候选`)
message.warning(`最多选择 ${props.maxCandidates} 个素材`)
}
}
}
// 全选
const handleSelectAll = () => {
tempSelectedFiles.value = selectorFiles.value.map(f => f.id)
const removeSelection = (fileId) => {
const index = tempSelectedFiles.value.indexOf(fileId)
if (index > -1) tempSelectedFiles.value.splice(index, 1)
}
const handleQuickConfirm = (file) => {
if (!isSelected(file.id)) toggleSelection(file)
handleConfirm()
}
const handleSelectAll = () => {
tempSelectedFiles.value = selectorFiles.value.slice(0, props.maxCandidates).map(f => f.id)
}
// 清空
const handleClearSelection = () => {
tempSelectedFiles.value = []
}
// 智能填充
const handleAutoFill = () => {
if (props.sceneIndex < 0) return
const availableMaterials = props.groupFiles.filter(
const availableMaterials = selectorFiles.value.filter(
material => !tempSelectedFiles.value.includes(material.id)
)
@@ -206,18 +224,16 @@ const handleAutoFill = () => {
const targetCount = Math.min(3, maxAddCount)
if (targetCount <= 0) {
message.warning(`候选数量已达上限${props.maxCandidates}个)`)
message.warning('已达上限')
return
}
// 简单随机选择
const shuffled = [...availableMaterials].sort(() => Math.random() - 0.5)
const selected = shuffled.slice(0, targetCount)
tempSelectedFiles.value.push(...selected.map(m => m.id))
message.success(`智能选择${selected.length}候选素材`)
message.success(`随机选择 ${selected.length} 个素材`)
}
// 分组切换
const handleSelectorGroupChange = (groupId) => {
selectorGroupId.value = groupId
tempSelectedFiles.value = []
@@ -226,123 +242,337 @@ const handleSelectorGroupChange = (groupId) => {
}
}
// 确认选择
const handleConfirm = () => {
if (tempSelectedFiles.value.length === 0) {
message.warning('请至少选择一个素材')
return
}
emit('confirm', {
sceneIndex: props.sceneIndex,
fileIds: [...tempSelectedFiles.value]
fileIds: [...tempSelectedFiles.value],
setGroupId: selectorGroupId.value
})
emit('update:visible', false)
}
</script>
<style scoped lang="less">
.selector-container {
.selector-actions {
margin-bottom: var(--space-2);
padding: var(--space-4);
background: var(--color-surface);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
// ════════════════════════════════════════════════════════════════════════════
// 弹窗样式 - 使用项目设计系统
// ════════════════════════════════════════════════════════════════════════════
// 🎨 设计令牌引用
@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);
@text: var(--color-text);
@text-secondary: var(--color-text-secondary);
@text-muted: var(--color-text-muted);
@text-disabled: var(--color-text-disabled);
@bg-card: var(--color-bg-card);
@bg-elevated: var(--color-gray-100);
@border: var(--color-border);
@border-light: var(--color-gray-100);
.scene-selector-modal {
:deep(.ant-modal-content) {
padding: 0;
border-radius: var(--radius-card);
overflow: hidden;
}
:deep(.ant-modal-body) {
padding: 0;
}
}
.selector-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
max-height: 480px;
overflow-y: auto;
padding: var(--space-2);
background: var(--color-surface);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
// ════════════════════════════════════════════════════════════════════════════
// 顶部标题栏
// ════════════════════════════════════════════════════════════════════════════
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-4);
background: @bg-elevated;
border-bottom: 1px solid @border-light;
.selector-item {
cursor: pointer;
border-radius: var(--radius-md);
overflow: hidden;
border: 2px solid transparent;
transition: all var(--duration-base) ease;
position: relative;
background: var(--color-surface);
box-shadow: var(--shadow-inset-card);
&:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-inset-card);
}
&--selected {
border-color: var(--color-success);
box-shadow: var(--shadow-inset-card);
}
.selector-thumb {
aspect-ratio: 16/9;
background: var(--color-bg);
.header-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.scene-badge {
padding: var(--space-1) var(--space-3);
background: @primary-light;
border: 1px solid var(--color-primary-200);
border-radius: var(--radius-tag);
font-size: var(--font-size-sm);
font-weight: 500;
color: @primary;
}
.counter {
display: flex;
align-items: baseline;
gap: 2px;
.counter-current {
font-size: 22px;
font-weight: 600;
color: @primary;
}
.counter-sep {
color: @text-disabled;
margin: 0 2px;
}
.counter-max {
font-size: var(--font-size-base);
color: @text-muted;
}
}
.group-tabs {
display: flex;
gap: var(--space-1);
}
}
// ════════════════════════════════════════════════════════════════════════════
// 工具栏
// ════════════════════════════════════════════════════════════════════════════
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
background: @bg-card;
border-bottom: 1px solid @border-light;
.toolbar-hint {
font-size: var(--font-size-xs);
color: @text-muted;
}
}
// ════════════════════════════════════════════════════════════════════════════
// 素材网格
// ════════════════════════════════════════════════════════════════════════════
.material-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
padding: var(--space-4);
min-height: 360px;
max-height: 360px;
overflow-y: auto;
background: @bg-card;
align-content: start;
&:deep(.ant-empty) {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-size: var(--font-size-2xl);
min-height: 320px;
margin: 0;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-gray-300);
border-radius: 3px;
}
}
.material-card {
cursor: pointer;
border-radius: var(--radius-button);
overflow: hidden;
background: @bg-elevated;
border: 2px solid transparent;
transition: all var(--duration-base) ease;
animation: fadeIn var(--duration-slow) ease backwards;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
&:hover {
border-color: @primary;
box-shadow: var(--shadow-blue);
}
// ✅ 选中状态 - 青色主题
&--selected {
border-color: var(--color-primary-400);
background: var(--color-primary-50);
.card-check {
opacity: 1;
}
.card-order {
opacity: 1;
transform: scale(1);
}
}
.card-cover {
position: relative;
aspect-ratio: 16/9;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-gray-100);
color: @text-disabled;
font-size: 24px;
}
// ✓ 选中勾选
.card-check {
position: absolute;
top: var(--space-1);
left: var(--space-1);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: @primary;
color: var(--color-text-inverse);
border-radius: 50%;
font-size: 11px;
opacity: 0;
transition: opacity var(--duration-base) ease;
}
// 🏷️ 选中序号
.card-order {
position: absolute;
top: var(--space-1);
right: var(--space-1);
min-width: 20px;
height: 20px;
padding: 0 var(--space-1);
display: flex;
align-items: center;
justify-content: center;
background: @warning;
color: var(--color-text-inverse);
font-size: 11px;
font-weight: 600;
border-radius: 10px;
opacity: 0;
transform: scale(0.8);
transition: all var(--duration-base) ease;
}
}
.selector-name {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
text-align: center;
.card-name {
padding: var(--space-2);
font-size: 11px;
color: @text-secondary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: var(--color-text);
background: var(--color-bg);
background: @bg-card;
}
}
.selector-checkmark {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: var(--color-success);
color: #fff;
border-radius: 50%;
width: 24px;
height: 24px;
// ════════════════════════════════════════════════════════════════════════════
// 底部操作栏
// ════════════════════════════════════════════════════════════════════════════
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-3) var(--space-4);
background: @bg-elevated;
border-top: 1px solid @border-light;
.selected-preview {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-base);
z-index: 2;
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3);
gap: var(--space-1);
flex: 1;
overflow-x: auto;
&::-webkit-scrollbar {
height: 0;
}
.more-tag {
padding: 2px var(--space-2);
background: var(--color-gray-100);
border-radius: var(--radius-tag);
font-size: var(--font-size-xs);
color: @text-muted;
flex-shrink: 0;
}
.no-selection {
font-size: var(--font-size-sm);
color: @text-disabled;
}
}
}
.selector-actions-footer {
margin-top: var(--space-2);
padding: var(--space-4);
background: var(--color-surface);
border-radius: var(--radius-md);
text-align: right;
border: 1px solid var(--color-border);
}
@media (max-width: 1200px) {
.selector-grid {
// ════════════════════════════════════════════════════════════════════════════
// 响应式适配
// ════════════════════════════════════════════════════════════════════════════
@media (max-width: 800px) {
.material-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.selector-grid {
@media (max-width: 600px) {
.material-grid {
grid-template-columns: repeat(2, 1fr);
}
.modal-header {
flex-wrap: wrap;
gap: var(--space-2);
}
.group-tabs {
order: 3;
width: 100%;
justify-content: center;
}
}
</style>