Files
sionrui/frontend/app/web-gold/src/views/material/Mix.vue
2025-12-21 22:24:16 +08:00

802 lines
20 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">
<!-- 页面头部 -->
<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" @change="saveProduceCount">
<a-radio-button :value="1">1</a-radio-button>
<a-radio-button :value="3">3</a-radio-button>
<a-radio-button :value="5">5</a-radio-button>
<a-radio-button :value="10">10</a-radio-button>
<a-radio-button :value="15">15</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: loadProduceCount(),
totalDuration: 15, // 成品总时长 15-30s
clipDuration: 3, // 单切片时长 3-5s
cropMode: 'center' // 裁剪模式,默认居中裁剪
})
// 本地存储键名
const STORAGE_KEY = 'mix-produce-count'
// 从本地存储加载生成数量
function loadProduceCount() {
const saved = localStorage.getItem(STORAGE_KEY)
return saved ? parseInt(saved, 10) : 3
}
// 保存生成数量到本地存储
function saveProduceCount() {
localStorage.setItem(STORAGE_KEY, formData.value.produceCount.toString())
}
// 状态
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: 340px;
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>