feat: 功能优化

This commit is contained in:
2025-12-15 23:33:02 +08:00
parent 7f7551f74f
commit 870ea10351
36 changed files with 3289 additions and 40 deletions

View File

@@ -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",

View File

@@ -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分钟

View File

@@ -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' },
]
},

View File

@@ -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') },
]

View File

@@ -20,8 +20,7 @@
<a-button
type="primary"
ghost
@click="handleOpenMixModal"
:disabled="groupList.length === 0"
@click="$router.push('/material/mix')"
>
素材混剪
</a-button>

View 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>