Files
sionrui/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue
2026-03-05 21:01:34 +08:00

622 lines
17 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>
<a-modal
:open="visible"
:footer="null"
:closable="false"
width="800px"
centered
class="scene-selector-modal"
@update:open="$emit('update:visible', $event)"
>
<!-- 顶部 -->
<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 v-if="groupList.length > 1" class="group-tabs">
<div
v-for="g in groupList"
:key="g.id"
class="group-tab-item"
:class="{ 'group-tab-item--active': selectorGroupId === g.id }"
@click="handleSelectorGroupChange(g.id)"
>
{{ g.name }}
</div>
</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.imgUrl" :src="file.imgUrl" />
<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, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import {
VideoCameraOutlined,
CheckOutlined,
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 }
})
const emit = defineEmits(['update:visible', 'confirm', 'load-group-files'])
const LOCAL_STORAGE_KEY = 'mix-scene-selector-group-id'
const selectorGroupId = ref(null)
const tempSelectedFiles = ref([])
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
}
return props.groupFiles
})
watch(() => props.visible, (visible) => {
if (visible) {
tempSelectedFiles.value = [...props.initialCandidates]
// 优先级1. 场景自带分组 2. 本地存储的分组 3. 全局分组 4. 第一个分组
let targetGroupId = props.sceneGroupId
if (!targetGroupId) {
const savedGroupId = localStorage.getItem(LOCAL_STORAGE_KEY)
if (savedGroupId && props.groupList.some(g => String(g.id) === savedGroupId)) {
targetGroupId = savedGroupId
}
}
if (!targetGroupId && props.globalGroupId) {
targetGroupId = props.globalGroupId
}
if (!targetGroupId && props.groupList.length > 0) {
targetGroupId = props.groupList[0].id
}
selectorGroupId.value = targetGroupId
// 加载分组文件
if (selectorGroupId.value && !props.allGroupFiles[selectorGroupId.value]) {
emit('load-group-files', selectorGroupId.value)
}
}
})
// 键盘快捷键
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 toggleSelection = (file) => {
const index = tempSelectedFiles.value.indexOf(file.id)
if (index > -1) {
tempSelectedFiles.value.splice(index, 1)
} else {
if (tempSelectedFiles.value.length < props.maxCandidates) {
tempSelectedFiles.value.push(file.id)
} else {
message.warning(`最多选择 ${props.maxCandidates} 个素材`)
}
}
}
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 = () => {
const availableMaterials = selectorFiles.value.filter(
material => !tempSelectedFiles.value.includes(material.id)
)
if (availableMaterials.length === 0) {
message.warning('没有更多可用素材')
return
}
const maxAddCount = props.maxCandidates - tempSelectedFiles.value.length
const targetCount = Math.min(3, maxAddCount)
if (targetCount <= 0) {
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} 个素材`)
}
const handleSelectorGroupChange = (groupId) => {
selectorGroupId.value = groupId
// 保存到本地存储
localStorage.setItem(LOCAL_STORAGE_KEY, String(groupId))
tempSelectedFiles.value = []
if (groupId && !props.allGroupFiles[groupId]) {
emit('load-group-files', groupId)
}
}
const handleConfirm = () => {
if (tempSelectedFiles.value.length === 0) {
message.warning('请至少选择一个素材')
return
}
emit('confirm', {
sceneIndex: props.sceneIndex,
fileIds: [...tempSelectedFiles.value],
setGroupId: selectorGroupId.value
})
emit('update:visible', false)
}
</script>
<style scoped lang="less">
// ════════════════════════════════════════════════════════════════════════════
// 弹窗样式 - 使用项目设计系统
// ════════════════════════════════════════════════════════════════════════════
// 🎨 设计令牌引用
@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;
}
}
// ════════════════════════════════════════════════════════════════════════════
// 顶部标题栏
// ════════════════════════════════════════════════════════════════════════════
.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;
.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-2);
flex: 1;
justify-content: center;
}
.group-tab-item {
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
color: @text-secondary;
background: var(--color-gray-100);
border-radius: var(--radius-button);
cursor: pointer;
transition: all var(--duration-base) ease;
user-select: none;
&:hover {
background: var(--color-gray-200);
color: @text;
}
&--active {
background: @primary;
color: var(--color-text-inverse);
&:hover {
background: @primary-hover;
}
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// 工具栏
// ════════════════════════════════════════════════════════════════════════════
.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;
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;
}
}
.card-name {
padding: var(--space-2);
font-size: 11px;
color: @text-secondary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: @bg-card;
}
}
// ════════════════════════════════════════════════════════════════════════════
// 底部操作栏
// ════════════════════════════════════════════════════════════════════════════
.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;
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;
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// 响应式适配
// ════════════════════════════════════════════════════════════════════════════
@media (max-width: 800px) {
.material-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@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>