feat: 功能优化
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
"eslint-plugin-oxlint": "~1.11.0",
|
||||
"eslint-plugin-vue": "~10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"less": "^4.4.2",
|
||||
"normalize.css": "^8.0.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.11.0",
|
||||
|
||||
@@ -9,6 +9,39 @@ import { API_BASE } from '@gold/config/api'
|
||||
// 使用 webApi 前缀,确保能够被代理
|
||||
const BASE_URL = `${API_BASE.APP_TIK}/file`
|
||||
|
||||
/**
|
||||
* 获取视频时长(秒)
|
||||
* @param {File} file - 视频文件对象
|
||||
* @returns {Promise<number>} 时长(秒)
|
||||
*/
|
||||
function getVideoDuration(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 只处理视频文件
|
||||
if (!file.type.startsWith('video/')) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true; // 静音,避免浏览器阻止自动播放
|
||||
|
||||
video.onloadedmetadata = function() {
|
||||
const duration = Math.round(video.duration);
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(duration);
|
||||
};
|
||||
|
||||
video.onerror = function() {
|
||||
URL.revokeObjectURL(video.src);
|
||||
console.warn('[视频时长] 获取失败,使用默认值60秒');
|
||||
resolve(60); // 返回默认值
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材库 API 服务
|
||||
*/
|
||||
@@ -34,20 +67,33 @@ export const MaterialService = {
|
||||
* @param {File} file - 文件对象
|
||||
* @param {string} fileCategory - 文件分类(video/generate/audio/mix/voice)
|
||||
* @param {string} coverBase64 - 视频封面 base64(可选,data URI 格式)
|
||||
* @param {number} duration - 视频时长(秒,可选,自动获取)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
uploadFile(file, fileCategory, coverBase64 = null) {
|
||||
async uploadFile(file, fileCategory, coverBase64 = null, duration = null) {
|
||||
// 如果没有提供时长且是视频文件,自动获取
|
||||
if (duration === null && file.type.startsWith('video/')) {
|
||||
duration = await getVideoDuration(file);
|
||||
console.log('[上传] 获取到视频时长:', duration, '秒');
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('fileCategory', fileCategory)
|
||||
|
||||
|
||||
// 添加时长(如果是视频文件)
|
||||
if (duration !== null) {
|
||||
formData.append('duration', duration.toString());
|
||||
console.log('[上传] 附加视频时长:', duration, '秒');
|
||||
}
|
||||
|
||||
// 如果有封面 base64,添加到表单数据
|
||||
if (coverBase64) {
|
||||
// base64 格式:data:image/jpeg;base64,/9j/4AAQ...
|
||||
// 后端会解析这个格式
|
||||
formData.append('coverBase64', coverBase64)
|
||||
}
|
||||
|
||||
|
||||
// 大文件上传需要更长的超时时间(30分钟)
|
||||
return http.post(`${BASE_URL}/upload`, formData, {
|
||||
timeout: 30 * 60 * 1000 // 30分钟
|
||||
|
||||
@@ -44,7 +44,8 @@ const items = computed(() => {
|
||||
title: '素材库',
|
||||
children: [
|
||||
{ path: '/material/list', label: '素材列表', icon: 'grid' },
|
||||
{ path: '/material/mix-task', label: '混剪任务', icon: 'scissors' },
|
||||
{ path: '/material/mix', label: '智能混剪', icon: 'scissors' },
|
||||
{ path: '/material/mix-task', label: '混剪任务', icon: 'video' },
|
||||
{ path: '/material/group', label: '素材分组', icon: 'folder' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ const routes = [
|
||||
children: [
|
||||
{ path: '', redirect: '/material/list' },
|
||||
{ path: 'list', name: '素材列表', component: () => import('../views/material/MaterialList.vue') },
|
||||
{ path: 'mix', name: '智能混剪', component: () => import('../views/material/Mix.vue') },
|
||||
{ path: 'mix-task', name: '混剪任务', component: () => import('../views/material/MixTaskList.vue') },
|
||||
{ path: 'group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue') },
|
||||
]
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
@click="handleOpenMixModal"
|
||||
:disabled="groupList.length === 0"
|
||||
@click="$router.push('/material/mix')"
|
||||
>
|
||||
素材混剪
|
||||
</a-button>
|
||||
|
||||
785
frontend/app/web-gold/src/views/material/Mix.vue
Normal file
785
frontend/app/web-gold/src/views/material/Mix.vue
Normal file
@@ -0,0 +1,785 @@
|
||||
<template>
|
||||
<div class="mix-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="mix-page__header">
|
||||
<h1 class="mix-page__title">智能混剪</h1>
|
||||
<a-button @click="$router.push('/material/list')">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回素材列表
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="mix-page__content">
|
||||
<!-- 左侧:参数配置 -->
|
||||
<div class="mix-page__params">
|
||||
<a-card title="混剪参数" :bordered="false">
|
||||
<a-form layout="vertical">
|
||||
<!-- 分组选择 -->
|
||||
<a-form-item label="选择素材分组" required>
|
||||
<a-select
|
||||
v-model:value="formData.groupId"
|
||||
placeholder="请选择素材分组"
|
||||
:loading="loadingGroups"
|
||||
@change="handleGroupChange"
|
||||
>
|
||||
<a-select-option v-for="g in groupList" :key="g.id" :value="g.id">
|
||||
{{ g.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 视频标题 -->
|
||||
<a-form-item label="视频标题" required>
|
||||
<a-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入生成视频的标题"
|
||||
:maxlength="50"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 生成数量 -->
|
||||
<a-form-item label="生成数量">
|
||||
<a-radio-group v-model:value="formData.produceCount" button-style="solid">
|
||||
<a-radio-button :value="1">1个</a-radio-button>
|
||||
<a-radio-button :value="2">2个</a-radio-button>
|
||||
<a-radio-button :value="3">3个</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 成品总时长 -->
|
||||
<a-form-item label="成品总时长">
|
||||
<div class="mix-page__slider-box">
|
||||
<a-slider
|
||||
v-model:value="formData.totalDuration"
|
||||
:min="15"
|
||||
:max="30"
|
||||
:step="1"
|
||||
:marks="{ 15: '15s', 20: '20s', 25: '25s', 30: '30s' }"
|
||||
/>
|
||||
<div class="slider-value">{{ formData.totalDuration }}秒</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 单切片时长 -->
|
||||
<a-form-item label="单切片时长">
|
||||
<div class="mix-page__slider-box">
|
||||
<a-slider
|
||||
v-model:value="formData.clipDuration"
|
||||
:min="3"
|
||||
:max="5"
|
||||
:step="1"
|
||||
:marks="{ 3: '3s', 4: '4s', 5: '5s' }"
|
||||
/>
|
||||
<div class="slider-value">{{ formData.clipDuration }}秒</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 裁剪模式 -->
|
||||
<a-form-item label="裁剪模式">
|
||||
<a-radio-group v-model:value="formData.cropMode" button-style="solid">
|
||||
<a-radio-button value="center" class="crop-btn">
|
||||
居中裁剪
|
||||
</a-radio-button>
|
||||
<a-radio-button value="fill" class="crop-btn">
|
||||
填充模式
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 自动计算的场景数 -->
|
||||
<div class="mix-page__scene-info">
|
||||
<div class="scene-row">
|
||||
<span>场景数</span>
|
||||
<strong>{{ sceneCount }} 个</strong>
|
||||
</div>
|
||||
<div class="scene-row">
|
||||
<span>实际总时长</span>
|
||||
<strong>{{ actualTotalDuration }}秒</strong>
|
||||
</div>
|
||||
<div class="scene-row">
|
||||
<span>已填充</span>
|
||||
<strong :class="{ 'text-green': filledCount === sceneCount }">
|
||||
{{ filledCount }} / {{ sceneCount }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一键填充按钮 -->
|
||||
<a-button
|
||||
block
|
||||
size="large"
|
||||
style="margin-bottom: 12px"
|
||||
:disabled="!groupFiles.length"
|
||||
@click="autoFillScenes"
|
||||
>
|
||||
<template #icon><ThunderboltOutlined /></template>
|
||||
一键填充
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<template #icon><RocketOutlined /></template>
|
||||
开始混剪
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:场景格子 + 素材列表 -->
|
||||
<div class="mix-page__preview">
|
||||
<!-- 场景格子区域 -->
|
||||
<a-card title="场景编排" :bordered="false" style="margin-bottom: 16px">
|
||||
<template #extra>
|
||||
<a-button size="small" @click="clearScenes">清空</a-button>
|
||||
</template>
|
||||
|
||||
<div class="mix-page__scenes">
|
||||
<div
|
||||
v-for="(scene, index) in scenes"
|
||||
:key="index"
|
||||
class="mix-page__scene"
|
||||
:class="{ 'mix-page__scene--filled': scene.fileId }"
|
||||
@click="openSceneSelector(index)"
|
||||
>
|
||||
<!-- 场景序号 -->
|
||||
<span class="scene-index">{{ index + 1 }}</span>
|
||||
|
||||
<!-- 已填充:显示封面 -->
|
||||
<template v-if="scene.fileId">
|
||||
<img
|
||||
v-if="getFileById(scene.fileId)?.coverBase64"
|
||||
:src="getFileById(scene.fileId).coverBase64"
|
||||
class="scene-thumb"
|
||||
/>
|
||||
<div v-else class="scene-placeholder filled">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<div class="scene-name">{{ getFileById(scene.fileId)?.fileName }}</div>
|
||||
<a-button
|
||||
class="scene-remove"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
@click.stop="removeScene(index)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 未填充:空白格子 -->
|
||||
<template v-else>
|
||||
<div class="scene-placeholder">
|
||||
<PlusOutlined />
|
||||
</div>
|
||||
<div class="scene-hint">点击选择</div>
|
||||
</template>
|
||||
|
||||
<!-- 时长标签 -->
|
||||
<span class="scene-duration">{{ formData.clipDuration }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 素材库 -->
|
||||
<a-card title="素材库" :bordered="false">
|
||||
<a-spin :spinning="loadingFiles">
|
||||
<div v-if="groupFiles.length > 0" class="mix-page__grid">
|
||||
<div
|
||||
v-for="file in groupFiles"
|
||||
:key="file.id"
|
||||
class="mix-page__item"
|
||||
:class="{ 'mix-page__item--used': isFileUsed(file.id) }"
|
||||
@click="handleFileClick(file)"
|
||||
>
|
||||
<!-- 封面图 -->
|
||||
<div class="mix-page__thumb">
|
||||
<img v-if="file.isVideo && file.coverBase64" :src="file.coverBase64" :alt="file.fileName" />
|
||||
<div v-else class="mix-page__placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已使用标记 -->
|
||||
<span v-if="isFileUsed(file.id)" class="mix-page__used-badge">
|
||||
已使用 ×{{ getFileUsageCount(file.id) }}
|
||||
</span>
|
||||
|
||||
<!-- 文件名 -->
|
||||
<div class="mix-page__name" :title="file.fileName">
|
||||
{{ file.fileName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-empty v-else description="请先选择素材分组" />
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 素材选择弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="selectorVisible"
|
||||
title="选择素材"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
>
|
||||
<div class="mix-page__selector-grid">
|
||||
<div
|
||||
v-for="file in groupFiles"
|
||||
:key="file.id"
|
||||
class="mix-page__selector-item"
|
||||
@click="selectFileForScene(file)"
|
||||
>
|
||||
<div class="selector-thumb">
|
||||
<img v-if="file.isVideo && file.coverBase64" :src="file.coverBase64" />
|
||||
<VideoCameraOutlined v-else />
|
||||
</div>
|
||||
<div class="selector-name">{{ file.fileName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
RocketOutlined,
|
||||
VideoCameraOutlined,
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
ThunderboltOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MaterialService, MaterialGroupService } from '@/api/material'
|
||||
import { MixTaskService } from '@/api/mixTask'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
groupId: null,
|
||||
title: '',
|
||||
produceCount: 3,
|
||||
totalDuration: 15, // 成品总时长 15-30s
|
||||
clipDuration: 3, // 单切片时长 3-5s
|
||||
cropMode: 'center' // 裁剪模式,默认居中裁剪
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loadingGroups = ref(false)
|
||||
const loadingFiles = ref(false)
|
||||
const submitting = ref(false)
|
||||
const selectorVisible = ref(false)
|
||||
const currentSceneIndex = ref(-1)
|
||||
|
||||
// 分组和文件
|
||||
const groupList = ref([])
|
||||
const groupFiles = ref([])
|
||||
|
||||
// 场景列表 [{ fileId, fileUrl }, ...]
|
||||
const scenes = ref([])
|
||||
|
||||
// 计算场景数 = 总时长 / 单切片时长
|
||||
const sceneCount = computed(() => {
|
||||
return Math.floor(formData.value.totalDuration / formData.value.clipDuration)
|
||||
})
|
||||
|
||||
// 实际总时长 = 场景数 × 单切片时长
|
||||
const actualTotalDuration = computed(() => {
|
||||
return sceneCount.value * formData.value.clipDuration
|
||||
})
|
||||
|
||||
// 已填充数量
|
||||
const filledCount = computed(() => {
|
||||
return scenes.value.filter(s => s.fileId).length
|
||||
})
|
||||
|
||||
// 监听场景数变化,自动调整场景数组
|
||||
watch(sceneCount, (newCount) => {
|
||||
const current = scenes.value.length
|
||||
if (newCount > current) {
|
||||
// 增加空场景
|
||||
for (let i = current; i < newCount; i++) {
|
||||
scenes.value.push({ fileId: null, fileUrl: null })
|
||||
}
|
||||
} else if (newCount < current) {
|
||||
// 减少场景
|
||||
scenes.value = scenes.value.slice(0, newCount)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 根据fileId获取文件信息
|
||||
const getFileById = (fileId) => {
|
||||
return groupFiles.value.find(f => f.id === fileId)
|
||||
}
|
||||
|
||||
// 检查文件是否已使用
|
||||
const isFileUsed = (fileId) => {
|
||||
return scenes.value.some(s => s.fileId === fileId)
|
||||
}
|
||||
|
||||
// 获取文件使用次数
|
||||
const getFileUsageCount = (fileId) => {
|
||||
return scenes.value.filter(s => s.fileId === fileId).length
|
||||
}
|
||||
|
||||
// 加载分组列表
|
||||
const loadGroups = async () => {
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const res = await MaterialGroupService.getGroupList()
|
||||
if (res.code === 0) {
|
||||
groupList.value = res.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载分组失败')
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分组变更时加载素材
|
||||
const handleGroupChange = async (groupId) => {
|
||||
if (!groupId) {
|
||||
groupFiles.value = []
|
||||
clearScenes()
|
||||
return
|
||||
}
|
||||
|
||||
loadingFiles.value = true
|
||||
try {
|
||||
const res = await MaterialService.getFilePage({
|
||||
groupId,
|
||||
fileCategory: 'video',
|
||||
pageNo: 1,
|
||||
pageSize: 50
|
||||
})
|
||||
if (res.code === 0) {
|
||||
groupFiles.value = res.data.list || []
|
||||
clearScenes()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载素材失败')
|
||||
} finally {
|
||||
loadingFiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开场景选择器
|
||||
const openSceneSelector = (index) => {
|
||||
currentSceneIndex.value = index
|
||||
selectorVisible.value = true
|
||||
}
|
||||
|
||||
// 为场景选择文件
|
||||
const selectFileForScene = (file) => {
|
||||
if (currentSceneIndex.value >= 0 && currentSceneIndex.value < scenes.value.length) {
|
||||
scenes.value[currentSceneIndex.value] = {
|
||||
fileId: file.id,
|
||||
fileUrl: file.fileUrl
|
||||
}
|
||||
}
|
||||
selectorVisible.value = false
|
||||
}
|
||||
|
||||
// 点击素材库文件:填充到第一个空场景
|
||||
const handleFileClick = (file) => {
|
||||
const emptyIndex = scenes.value.findIndex(s => !s.fileId)
|
||||
if (emptyIndex >= 0) {
|
||||
scenes.value[emptyIndex] = {
|
||||
fileId: file.id,
|
||||
fileUrl: file.fileUrl
|
||||
}
|
||||
} else {
|
||||
message.info('所有场景已填满')
|
||||
}
|
||||
}
|
||||
|
||||
// 移除场景
|
||||
const removeScene = (index) => {
|
||||
scenes.value[index] = { fileId: null, fileUrl: null }
|
||||
}
|
||||
|
||||
// 清空场景
|
||||
const clearScenes = () => {
|
||||
scenes.value = Array(sceneCount.value).fill(null).map(() => ({ fileId: null, fileUrl: null }))
|
||||
}
|
||||
|
||||
// 一键填充:随机分配素材到空场景
|
||||
const autoFillScenes = () => {
|
||||
if (!groupFiles.value.length) {
|
||||
message.warning('请先选择素材分组')
|
||||
return
|
||||
}
|
||||
|
||||
// 打乱素材顺序
|
||||
const shuffled = [...groupFiles.value].sort(() => Math.random() - 0.5)
|
||||
let fileIndex = 0
|
||||
|
||||
// 填充每个空场景
|
||||
scenes.value = scenes.value.map(scene => {
|
||||
if (!scene.fileId) {
|
||||
const file = shuffled[fileIndex % shuffled.length]
|
||||
fileIndex++
|
||||
return { fileId: file.id, fileUrl: file.fileUrl }
|
||||
}
|
||||
return scene
|
||||
})
|
||||
|
||||
message.success('已随机填充所有场景')
|
||||
}
|
||||
|
||||
// 是否可提交
|
||||
const canSubmit = computed(() => {
|
||||
return formData.value.groupId &&
|
||||
formData.value.title.trim() &&
|
||||
filledCount.value === sceneCount.value
|
||||
})
|
||||
|
||||
// 提交混剪
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 构建素材列表(带上素材实际时长 fileDuration)
|
||||
const materials = scenes.value.map(scene => {
|
||||
const file = getFileById(scene.fileId)
|
||||
return {
|
||||
fileId: scene.fileId,
|
||||
fileUrl: scene.fileUrl,
|
||||
duration: formData.value.clipDuration,
|
||||
fileDuration: file?.duration || null // 素材实际时长
|
||||
}
|
||||
})
|
||||
|
||||
const res = await MixTaskService.createTask({
|
||||
title: formData.value.title,
|
||||
materials: materials,
|
||||
produceCount: formData.value.produceCount,
|
||||
cropMode: formData.value.cropMode
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
message.success('混剪任务创建成功!')
|
||||
router.push('/material/mix-task')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('提交失败:' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.mix-page {
|
||||
padding: 24px;
|
||||
background: var(--color-bg-2);
|
||||
min-height: 100vh;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&__params {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.ant-card {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__slider-box {
|
||||
.slider-value {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__scene-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-3);
|
||||
border-radius: 8px;
|
||||
|
||||
.scene-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
|
||||
&.text-green {
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 场景格子样式
|
||||
&__scenes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__scene {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 100px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
&--filled {
|
||||
border-style: solid;
|
||||
border-color: #1890ff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.scene-index {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scene-thumb {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.scene-placeholder {
|
||||
font-size: 24px;
|
||||
color: #bfbfbf;
|
||||
|
||||
&.filled {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.scene-name {
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-duration {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scene-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
// 素材库格子样式
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&--used {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #f0f0f0;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #bfbfbf;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&__used-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 选择器弹窗样式
|
||||
&__selector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__selector-item {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.selector-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #bfbfbf;
|
||||
font-size: 24px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.selector-name {
|
||||
padding: 6px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user