From aa067829530a2e1a453b794dc1c3ec5f82e48a7d Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Wed, 4 Mar 2026 00:37:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=B7=E5=89=AA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/web-gold/package.json | 1 + .../app/web-gold/src/views/material/Mix.vue | 976 ++++++++++++------ .../components/SceneSelectorModal.vue | 348 +++++++ 3 files changed, 990 insertions(+), 335 deletions(-) create mode 100644 frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue diff --git a/frontend/app/web-gold/package.json b/frontend/app/web-gold/package.json index f4b433b605..6f4d82feaa 100644 --- a/frontend/app/web-gold/package.json +++ b/frontend/app/web-gold/package.json @@ -36,6 +36,7 @@ "tailwindcss": "^4.2.1", "vue": "^3.5.22", "vue-router": "^4.5.1", + "vuedraggable": "^4.1.0", "xlsx": "^0.18.5", "xlsx-js-style": "^1.2.0" }, diff --git a/frontend/app/web-gold/src/views/material/Mix.vue b/frontend/app/web-gold/src/views/material/Mix.vue index f9093f063f..e7daa3563e 100644 --- a/frontend/app/web-gold/src/views/material/Mix.vue +++ b/frontend/app/web-gold/src/views/material/Mix.vue @@ -37,31 +37,6 @@
- - - - - {{ g.name }} - - - - - - - - - @@ -131,18 +106,6 @@
- - - - 一键填充 - -
@@ -177,66 +140,109 @@
-
-
- -
-

- 场景 {{ index + 1 }} - {{ formData.clipDuration }}s - - 候选 {{ scene.candidates.length }}/{{ constants.MAX_CANDIDATES_PER_SCENE }} - -

-
- - -
- -
- - 点击添加候选 + + +
@@ -248,12 +254,66 @@ 素材库
-
+ + +
+
+ + {{ group.name }} + +
+
+ + 共 {{ currentLibraryMaterialCount }} 个 +
+
+ + +
+
+ 目标场景 +
+ + {{ index + 1 }} + {{ scene.candidates?.length || 0 }} + +
+
+ + + 一键填充全部 + +
+ +
- - -
- -
- - - - 全选 - - - - 清空 - - - - 智能填充 - - -
- - -
-
-
- - -
-
{{ file.displayName || file.fileName }}
-
- -
-
-
- - - -
-
+
@@ -352,6 +373,7 @@ import { ref, computed, watch, onMounted } from 'vue' import { message } from 'ant-design-vue' import { useRouter } from 'vue-router' +import draggable from 'vuedraggable' // 图标导入 import { @@ -360,19 +382,19 @@ import { PlusOutlined, CloseOutlined, ThunderboltOutlined, - CheckOutlined, SettingOutlined, PlaySquareOutlined, FolderOutlined, ClearOutlined, - SelectOutlined, - StopOutlined + CopyOutlined, + HolderOutlined, } from '@ant-design/icons-vue' // API 服务 import { MaterialService, MaterialGroupService } from '@/api/material' import { MixTaskService } from '@/api/mixTask' import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue' +import SceneSelectorModal from './components/SceneSelectorModal.vue' const router = useRouter() @@ -404,7 +426,6 @@ const randomlySelectMaterials = (count, materials, seed) => { // ==================== 表单数据 ==================== const formData = ref({ - groupId: null, title: '', produceCount: Number(localStorage.getItem('mix-produce-count')) || 3, totalDuration: 15, @@ -423,12 +444,19 @@ const uiState = ref({ submitting: false, selectorVisible: false, currentSceneIndex: -1, - tempSelectedFiles: [] + // 场景选中态:当前选中的场景索引(null表示未选中) + selectedSceneIndex: null, + // 素材搜索关键词 + searchKeyword: '', + // 素材库当前选中的分组ID + selectedLibraryGroup: null }) const dataState = ref({ groupList: [], groupFiles: [], + // 新增:按分组缓存的素材 + allGroupFiles: {}, scenes: [] }) @@ -447,6 +475,46 @@ const filledCount = computed(() => dataState.value.scenes.filter(s => s.candidates?.length > 0).length ) +// 带搜索过滤的素材列表(根据素材库选中的分组) +const filteredMaterials = computed(() => { + const keyword = uiState.value.searchKeyword.toLowerCase().trim() + // 根据素材库选中的分组获取素材 + const groupId = uiState.value.selectedLibraryGroup + const materials = groupId + ? (dataState.value.allGroupFiles[groupId] || []) + : dataState.value.groupFiles + + if (!keyword) return materials + + return materials.filter(file => + file.fileName?.toLowerCase().includes(keyword) || + file.displayName?.toLowerCase().includes(keyword) + ) +}) + +// 当前素材库分组的素材数量(用于空状态判断) +const currentLibraryMaterialCount = computed(() => { + const groupId = uiState.value.selectedLibraryGroup + return groupId + ? (dataState.value.allGroupFiles[groupId]?.length || 0) + : dataState.value.groupFiles.length +}) + +// 选择目标场景 +const selectScene = (index) => { + uiState.value.selectedSceneIndex = index +} + +// 场景拖拽结束处理 +const onSceneDragEnd = () => { + // 更新场景索引 + dataState.value.scenes.forEach((scene, index) => { + scene.index = index + }) + // 清除选中态,避免索引错乱 + uiState.value.selectedSceneIndex = null +} + // 监听场景数变化,自动调整场景数组 watch(sceneCount, (newCount) => { const current = dataState.value.scenes.length @@ -455,6 +523,7 @@ watch(sceneCount, (newCount) => { dataState.value.scenes.push({ index: i, duration: formData.value.clipDuration, + groupId: null, // 场景专属分组ID,null表示使用全局分组 candidates: [] }) } @@ -464,8 +533,18 @@ watch(sceneCount, (newCount) => { }, { immediate: true }) // 工具函数 -const getFileById = (fileId) => - dataState.value.groupFiles.find(f => f.id === fileId) +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 isFileUsed = (fileId) => dataState.value.scenes.some(scene => @@ -473,13 +552,49 @@ const isFileUsed = (fileId) => ) const getFileUsageCount = (fileId) => { - let count = 0 - dataState.value.scenes.forEach(scene => { - if (scene.candidates) { - count += scene.candidates.filter(c => c.fileId === fileId).length + return dataState.value.scenes.reduce((count, scene) => { + return count + (scene.candidates?.filter(c => c.fileId === fileId).length || 0) + }, 0) +} + +// 获取素材在各场景的使用详情(用于 Tooltip) +const getFileUsageDetails = (fileId) => { + const details = [] + dataState.value.scenes.forEach((scene, index) => { + const count = scene.candidates?.filter(c => c.fileId === fileId).length || 0 + if (count > 0) { + details.push(`场景${index + 1}: ${count}次`) } }) - return count + return details.join(', ') || '未使用' +} + +// 当前场景的候选ID列表(用于弹窗) +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 handleModalConfirm = ({ sceneIndex, fileIds, setGroupId }) => { + const scene = dataState.value.scenes[sceneIndex] + if (!scene) return + + // 更新候选列表 + const candidates = fileIds.map(fileId => ({ + fileId, + fileUrl: getFileById(fileId)?.fileUrl + })) + scene.candidates = candidates + + // 如果需要设置场景分组 + if (setGroupId !== null && setGroupId !== undefined) { + scene.groupId = setGroupId + } + + message.success(`已为场景${sceneIndex + 1}设置${candidates.length}个候选`) } // ==================== 数据操作 ==================== @@ -497,14 +612,60 @@ const loadGroups = async () => { } } -const handleGroupChange = async (groupId) => { - if (!groupId) { - dataState.value.groupFiles = [] - clearScenes() - return +// 素材库分组切换(仅切换显示,不影响场景数据) +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 + } + } +} + +// ==================== 场景级分组功能 ==================== + +// 获取场景的有效分组ID(场景分组 > 素材库当前分组) +const getSceneEffectiveGroupId = (scene) => { + return scene.groupId || uiState.value.selectedLibraryGroup +} + +// 获取场景的有效分组名称(用于显示) +const getSceneEffectiveGroupName = (scene) => { + const effectiveGroupId = getSceneEffectiveGroupId(scene) + return getGroupName(effectiveGroupId) +} + +// 获取分组名称 +const getGroupName = (groupId) => { + if (!groupId) return '全局分组' + const group = dataState.value.groupList.find(g => g.id === groupId) + return group?.name || '未知分组' +} + +// 加载指定分组的素材(带缓存) +const loadGroupFiles = async (groupId) => { + if (!groupId) return [] + + // 如果已缓存,直接返回 + if (dataState.value.allGroupFiles[groupId]) { + return dataState.value.allGroupFiles[groupId] } - uiState.value.loadingFiles = true try { const res = await MaterialService.getFilePage({ groupId, @@ -513,214 +674,163 @@ const handleGroupChange = async (groupId) => { pageSize: 50 }) if (res.code === 0) { - dataState.value.groupFiles = res.data.list || [] - clearScenes() + const files = res.data.list || [] + dataState.value.allGroupFiles[groupId] = files + return files } } catch { message.error('加载素材失败') - } finally { - uiState.value.loadingFiles = false } + return [] } +// 场景分组切换处理 +const handleSceneGroupChange = (sceneIndex, groupId) => { + const scene = dataState.value.scenes[sceneIndex] + if (!scene) return + + scene.candidates = [] + scene.groupId = groupId || null + + if (groupId) loadGroupFiles(groupId) + message.success(`场景${sceneIndex + 1}已切换到 ${getGroupName(groupId)}`) +} + +// 应用分组设置到所有场景 +const applyGroupToAllScenes = (groupId) => { + dataState.value.scenes.forEach(scene => { + scene.groupId = groupId || null + scene.candidates = [] + }) + if (groupId) loadGroupFiles(groupId) + message.success(`已将所有场景设置为 ${getGroupName(groupId)}`) +} + +// 获取所有已缓存的素材 +const getAllCachedFiles = () => { + const seenIds = new Set() + const allFiles = [] + + for (const files of Object.values(dataState.value.allGroupFiles)) { + for (const file of files) { + if (!seenIds.has(file.id)) { + seenIds.add(file.id) + allFiles.push(file) + } + } + } + return allFiles +} + +// 根据分组ID获取素材(用于素材库显示) +const getFilesByGroup = (groupId) => + groupId ? (dataState.value.allGroupFiles[groupId] || []) : dataState.value.groupFiles + // ==================== 场景操作 ==================== const openSceneSelector = (index) => { uiState.value.currentSceneIndex = index - uiState.value.tempSelectedFiles = dataState.value.scenes[index]?.candidates?.map(c => c.fileId) || [] uiState.value.selectorVisible = true } -const getSceneCandidateCount = (sceneIndex) => - sceneIndex >= 0 && sceneIndex < dataState.value.scenes.length - ? dataState.value.scenes[sceneIndex].candidates?.length || 0 - : 0 - -const isFileSelectedForScene = (fileId) => - uiState.value.tempSelectedFiles.includes(fileId) - -const toggleFileForScene = (file) => { - const index = uiState.value.tempSelectedFiles.indexOf(file.id) - if (index > -1) { - uiState.value.tempSelectedFiles.splice(index, 1) - } else { - if (uiState.value.tempSelectedFiles.length < constants.MAX_CANDIDATES_PER_SCENE) { - uiState.value.tempSelectedFiles.push(file.id) - } else { - message.warning(`每个场景最多只能选择${constants.MAX_CANDIDATES_PER_SCENE}个候选`) - } - } -} - -const selectAllForScene = () => { - uiState.value.tempSelectedFiles = dataState.value.groupFiles.map(f => f.id) -} - -const clearSceneSelection = () => { - uiState.value.tempSelectedFiles = [] -} - -const autoFillCurrentScene = () => { - const sceneIndex = uiState.value.currentSceneIndex - if (sceneIndex < 0) return - - const currentScene = dataState.value.scenes[sceneIndex] - if (!currentScene?.candidates) { - message.warning('场景不存在') - return - } - - const availableMaterials = dataState.value.groupFiles.filter( - material => !currentScene.candidates.some(c => c.fileId === material.id) - ) - - if (availableMaterials.length === 0) { - message.warning('请先选择素材分组并加载素材库') - return - } - - const currentCount = currentScene.candidates.length - const maxAddCount = constants.MAX_CANDIDATES_PER_SCENE - currentCount - const targetCount = Math.min(3, maxAddCount) - - if (targetCount <= 0) { - message.warning(`当前场景候选数量已达上限(${constants.MAX_CANDIDATES_PER_SCENE}个)`) - return - } - - const selected = randomlySelectMaterials(targetCount, availableMaterials, sceneIndex) - uiState.value.tempSelectedFiles = selected.map(m => m.fileId) - - message.success(`已为场景${sceneIndex + 1}智能选择${selected.length}个候选素材`) -} - -const confirmSceneSelection = () => { - const sceneIndex = uiState.value.currentSceneIndex - if (sceneIndex < 0) return - - const candidates = uiState.value.tempSelectedFiles.map(fileId => { - const file = getFileById(fileId) - return { - fileId, - fileUrl: file.fileUrl - } - }) - - dataState.value.scenes[sceneIndex].candidates = candidates - uiState.value.selectorVisible = false - message.success(`已为场景${sceneIndex + 1}添加${candidates.length}个候选`) -} - const clearScenes = () => { dataState.value.scenes = Array(sceneCount.value).fill(null).map((_, index) => ({ index, duration: formData.value.clipDuration, + groupId: null, // 场景专属分组ID,null表示使用全局分组 candidates: [] })) } const clearScene = (index) => { - if (index >= 0 && index < dataState.value.scenes.length) { - dataState.value.scenes[index].candidates = [] - } + const scene = dataState.value.scenes[index] + if (scene) scene.candidates = [] } const removeCandidate = (sceneIndex, candidateIndex) => { - if (sceneIndex >= 0 && sceneIndex < dataState.value.scenes.length && - candidateIndex >= 0 && candidateIndex < dataState.value.scenes[sceneIndex].candidates.length) { - dataState.value.scenes[sceneIndex].candidates.splice(candidateIndex, 1) + const candidates = dataState.value.scenes[sceneIndex]?.candidates + if (candidates?.[candidateIndex] !== undefined) { + candidates.splice(candidateIndex, 1) message.success('已删除候选素材') } } const handleFileClick = (file) => { - const targetScene = dataState.value.scenes.find(scene => - scene.candidates.length < constants.MAX_CANDIDATES_PER_SCENE - ) + // 优先使用选中的目标场景 + const targetIndex = uiState.value.selectedSceneIndex - if (!targetScene) { - message.warning('所有场景都已满') + if (targetIndex !== null) { + // 有选中场景,添加到该场景(允许重复添加) + const scene = dataState.value.scenes[targetIndex] + if (!scene) return + + if (scene.candidates.length >= constants.MAX_CANDIDATES_PER_SCENE) { + message.warning(`场景${targetIndex + 1}候选数量已达上限`) + return + } + + scene.candidates.push({ fileId: file.id, fileUrl: file.fileUrl }) + message.success(`已添加到场景${targetIndex + 1}`) return } - const isAlreadySelected = targetScene.candidates.some(c => c.fileId === file.id) - if (isAlreadySelected) { - return - } - - targetScene.candidates.push({ - fileId: file.id, - fileUrl: file.fileUrl - }) + // 未选中场景,提示用户 + message.info('请先点击选择目标场景') } -const autoFillScenes = (strategy = 'empty_only', targetCount = 3) => { - let availableMaterials = [...dataState.value.groupFiles] - if (availableMaterials.length === 0) { +const autoFillScenes = async (strategy = 'empty_only', targetCount = 3) => { + const hasMaterials = dataState.value.groupFiles.length > 0 || + Object.keys(dataState.value.allGroupFiles).length > 0 + if (!hasMaterials) { message.warning('请先选择素材分组并加载素材库') return } - const fillResults = [] let totalFilledScenes = 0 let totalFilledCandidates = 0 + const incompleteScenes = [] - dataState.value.scenes.forEach((scene, sceneIndex) => { - if (!scene.candidates) { - scene.candidates = [] - } + for (let sceneIndex = 0; sceneIndex < dataState.value.scenes.length; sceneIndex++) { + const scene = dataState.value.scenes[sceneIndex] + scene.candidates ??= [] - const currentCount = scene.candidates.length - if (currentCount >= targetCount) { - return - } + if (scene.candidates.length >= targetCount) continue + + const effectiveGroupId = scene.groupId || uiState.value.selectedLibraryGroup + let availableMaterials = effectiveGroupId + ? [...(dataState.value.allGroupFiles[effectiveGroupId] || await loadGroupFiles(effectiveGroupId))] + : [...dataState.value.groupFiles] + + if (availableMaterials.length === 0) continue const sceneUsedIds = new Set(scene.candidates.map(c => c.fileId)) - const availableForScene = availableMaterials.filter( - material => !sceneUsedIds.has(material.id) - ) - - if (availableForScene.length === 0) { - return - } - - const needFillCount = targetCount - currentCount - const actualFillCount = Math.min(needFillCount, availableForScene.length) - const selectedMaterials = randomlySelectMaterials( - actualFillCount, - availableForScene, - sceneIndex - ) + const availableForScene = availableMaterials.filter(m => !sceneUsedIds.has(m.id)) + if (availableForScene.length === 0) continue + const actualFillCount = Math.min(targetCount - scene.candidates.length, availableForScene.length) + const selectedMaterials = randomlySelectMaterials(actualFillCount, availableForScene, sceneIndex) scene.candidates.push(...selectedMaterials) if (selectedMaterials.length > 0) { - fillResults.push({ - sceneIndex, - filledCount: selectedMaterials.length, - totalCount: scene.candidates.length - }) totalFilledScenes++ totalFilledCandidates += selectedMaterials.length + if (scene.candidates.length < targetCount) { + incompleteScenes.push(sceneIndex + 1) + } } - }) + } if (totalFilledScenes === 0) { message.info('所有场景已满,无需填充') } else { - const messageText = `已为${totalFilledScenes}个场景填充${totalFilledCandidates}个候选` - const incompleteScenes = fillResults.filter(r => r.totalCount < targetCount).length - - if (incompleteScenes > 0) { - message.warning(`${messageText},其中${incompleteScenes}个场景未达到目标数量(素材不足)`) - } else { - message.success(messageText) - } + const msg = `已为${totalFilledScenes}个场景填充${totalFilledCandidates}个候选` + incompleteScenes.length > 0 + ? message.warning(`${msg},其中${incompleteScenes.length}个场景未达到目标数量(素材不足)`) + : message.success(msg) } } const canSubmit = computed(() => - formData.value.groupId && - formData.value.title.trim() && + uiState.value.selectedLibraryGroup && filledCount.value === sceneCount.value ) @@ -729,24 +839,17 @@ const handleSubmit = async () => { uiState.value.submitting = true try { - const sceneConfigs = dataState.value.scenes.map(scene => { - const candidates = scene.candidates.map(candidate => { - const file = getFileById(candidate.fileId) - return { - fileId: candidate.fileId, - fileUrl: candidate.fileUrl, - fileDuration: file?.duration || null - } - }) - - return { - duration: scene.duration, - candidates - } - }) + 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, + title: formData.value.title || `混剪视频_${new Date().toLocaleString()}`, scenes: sceneConfigs, produceCount: formData.value.produceCount, cropMode: formData.value.cropMode @@ -827,6 +930,32 @@ onMounted(() => { flex-shrink: 0; } +// 表单标签带图标 +.label-with-icon { + display: flex; + align-items: center; + gap: var(--space-2); + font-weight: 600; + color: var(--color-text); + + .anticon { + color: var(--color-primary); + font-size: var(--font-size-lg); + } +} + +// 标签帮助图标 +.label-help { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + cursor: help; + margin-left: var(--space-1); + + &:hover { + color: var(--color-primary); + } +} + .card { background: var(--color-surface); border: 1px solid var(--color-border); @@ -838,6 +967,7 @@ onMounted(() => { display: flex; padding: var(--space-3) var(--space-4); justify-content: space-between; + align-items: center; border-bottom: 1px solid var(--color-border); } @@ -949,10 +1079,89 @@ onMounted(() => { .scene-container { width: 100%; + padding: var(--space-3); + border: 2px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(var(--color-primary-rgb, 59, 130, 246), 0.03); + } + + &--selected { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb, 59, 130, 246), 0.08); + } } .scene-header { margin-bottom: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.scene-title-row { + display: flex; + align-items: center; + gap: var(--space-2); +} + +// 拖拽手柄样式 +.scene-drag-handle { + cursor: grab; + color: var(--color-text-secondary); + padding: var(--space-1); + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--color-primary); + } + + &:active { + cursor: grabbing; + } +} + +// 拖拽时的幽灵样式 +.scene-ghost { + opacity: 0.5; + background: var(--color-primary-light); +} + +// 场景素材源样式(只读显示) +.scene-source { + display: flex; + align-items: center; + gap: var(--space-1); + + &__text { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + } + + .reset-source-btn, + .apply-all-btn { + padding: 0 4px; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + + &:hover { + color: var(--color-primary); + } + } +} + +.apply-all-btn { + padding: 2px 6px; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } } .scene-title { @@ -1117,6 +1326,83 @@ onMounted(() => { } } +// 素材库筛选层样式 +.library-filter { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + + &__tabs { + display: flex; + gap: var(--space-2); + flex: 1; + overflow-x: auto; + + &::-webkit-scrollbar { + display: none; + } + } + + &__actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + } + + &__count { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + white-space: nowrap; + } +} + +// 素材库操作层样式 +.library-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + background: var(--color-bg); + border-bottom: 1px solid var(--color-border); + + &__scenes { + display: flex; + align-items: center; + gap: var(--space-2); + flex: 1; + min-width: 0; + } + + &__label { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + flex-shrink: 0; + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + } +} + +.library-tab { + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + user-select: none; + + &:hover { + opacity: 0.8; + } +} + .material-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); @@ -1374,4 +1660,24 @@ onMounted(() => { gap: var(--space-1); } } + +// 场景 Tag 样式 +.scene-tag { + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + padding: 0 6px; + font-size: var(--font-size-xs); + line-height: 20px; + + &:hover { + opacity: 0.8; + } + + &__count { + font-size: 10px; + opacity: 0.7; + margin-left: 2px; + } +} diff --git a/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue b/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue new file mode 100644 index 0000000000..a64da25598 --- /dev/null +++ b/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue @@ -0,0 +1,348 @@ + + + + +