Compare commits
7 Commits
f03dc678cf
...
fab8480f83
| Author | SHA1 | Date | |
|---|---|---|---|
| fab8480f83 | |||
| 2d7ba035ca | |||
| af0b0c5070 | |||
| 16043dd52e | |||
| 8dc3501990 | |||
| b5ec2bf3d0 | |||
| 0d85a43082 |
115
frontend/app/web-gold/src/components/VideoPreviewModal.vue
Normal file
115
frontend/app/web-gold/src/components/VideoPreviewModal.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:footer="null"
|
||||
width="auto"
|
||||
centered
|
||||
@cancel="handleClose"
|
||||
class="video-preview-modal"
|
||||
>
|
||||
<div class="video-container">
|
||||
<video
|
||||
v-if="visible && videoUrl"
|
||||
ref="videoRef"
|
||||
:src="videoUrl"
|
||||
controls
|
||||
autoplay
|
||||
class="preview-video"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
videoUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '视频预览'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open'])
|
||||
|
||||
const videoRef = ref(null)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
// 关闭时暂停视频
|
||||
if (videoRef.value) {
|
||||
videoRef.value.pause()
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.video-preview-modal {
|
||||
:deep(.ant-modal-content) {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-header) {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.video-container {
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 800px;
|
||||
max-height: 70vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 响应式:小屏幕
|
||||
@media (max-width: 768px) {
|
||||
.video-preview-modal {
|
||||
:deep(.ant-modal-content) {
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -62,6 +62,10 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="video-duration">{{ formatDuration(video.duration) }}</div>
|
||||
<!-- hover 播放按钮 -->
|
||||
<div class="video-play-btn" @click.stop="handlePreview(video)">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
<div class="video-selected-mark" v-if="selectedVideoId === video.id">
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
@@ -127,6 +131,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<VideoPreviewModal
|
||||
v-model:open="previewVisible"
|
||||
:video-url="previewUrl"
|
||||
:title="previewVideo?.fileName || '视频预览'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -138,9 +149,11 @@ import {
|
||||
VideoCameraOutlined,
|
||||
ClockCircleOutlined,
|
||||
SearchOutlined,
|
||||
CloseOutlined
|
||||
CloseOutlined,
|
||||
PlayCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -169,6 +182,11 @@ const defaultCover = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9
|
||||
|
||||
const modalTitle = '选择视频'
|
||||
|
||||
// 视频预览状态
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewVideo = ref(null)
|
||||
|
||||
// 获取视频列表
|
||||
const fetchVideoList = async () => {
|
||||
loading.value = true
|
||||
@@ -225,6 +243,35 @@ const selectVideo = (video) => {
|
||||
selectedVideo.value = video
|
||||
}
|
||||
|
||||
// 预览视频
|
||||
const handlePreview = async (video) => {
|
||||
if (!video.id) {
|
||||
message.warning('视频信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 显示加载提示
|
||||
const hideLoading = message.loading('正在获取视频地址...', 0)
|
||||
|
||||
try {
|
||||
// 调用后端API获取带签名的视频播放URL
|
||||
const res = await MaterialService.getVideoPlayUrl(video.id)
|
||||
hideLoading()
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
previewVideo.value = video
|
||||
previewUrl.value = res.data
|
||||
previewVisible.value = true
|
||||
} else {
|
||||
message.error(res.msg || '获取视频地址失败')
|
||||
}
|
||||
} catch (error) {
|
||||
hideLoading()
|
||||
console.error('获取视频播放URL失败:', error)
|
||||
message.error('获取视频地址失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.src = defaultCover
|
||||
}
|
||||
@@ -239,7 +286,7 @@ const formatDuration = (seconds) => {
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.floor(Math.log2(bytes) / 10)
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = bytes / Math.pow(1024, index)
|
||||
return `${size.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
@@ -436,6 +483,27 @@ watch(() => props.open, (isOpen) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* hover 播放按钮 */
|
||||
.video-play-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-play-btn .anticon {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.video-thumbnail:hover .video-play-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 信息区域样式 */
|
||||
.video-info {
|
||||
padding: 14px;
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
<div class="material-item__overlay">
|
||||
<span class="material-item__type-tag">{{ getFileTypeText(file.fileName) }}</span>
|
||||
</div>
|
||||
<!-- hover 播放按钮 -->
|
||||
<div class="material-item__play-overlay" @click.stop="handlePreview(file)">
|
||||
<PlayCircleOutlined />
|
||||
<span v-if="file.duration" class="duration-tag">{{ formatDuration(file.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
@@ -261,6 +266,13 @@
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<VideoPreviewModal
|
||||
v-model:open="previewVisible"
|
||||
:video-url="previewUrl"
|
||||
:title="previewTitle"
|
||||
/>
|
||||
</div>
|
||||
</FullWidthLayout>
|
||||
</template>
|
||||
@@ -279,13 +291,15 @@ import {
|
||||
VideoCameraOutlined,
|
||||
UserOutlined,
|
||||
CheckOutlined,
|
||||
CheckSquareOutlined
|
||||
CheckSquareOutlined,
|
||||
PlayCircleOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
||||
import MaterialService, { MaterialGroupService } from '@/api/material';
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import VideoPreviewModal from '@/components/VideoPreviewModal.vue';
|
||||
|
||||
// 用户状态(获取存储配额)
|
||||
const userStore = useUserStore()
|
||||
@@ -313,6 +327,11 @@ const createGroupForm = reactive({
|
||||
// 文件选择
|
||||
const selectedFileIds = ref([])
|
||||
|
||||
// 视频预览状态
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
|
||||
// 编辑状态
|
||||
const editingFileId = ref(null)
|
||||
const editingDisplayName = ref('')
|
||||
@@ -398,6 +417,22 @@ const handleSaveGroupName = async (group) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的确认删除弹窗
|
||||
const showConfirmDelete = (title, content) => {
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteGroup = async (group, event) => {
|
||||
event?.stopPropagation?.()
|
||||
|
||||
@@ -407,39 +442,17 @@ const handleDeleteGroup = async (group, event) => {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '删除分组',
|
||||
content: `确定要删除分组「${group.name}」吗?此操作不可恢复。`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
class: 'delete-group-modal',
|
||||
onOk() {
|
||||
resolve(true)
|
||||
},
|
||||
onCancel() {
|
||||
resolve(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const confirmed = await showConfirmDelete('删除分组', `确定要删除分组「${group.name}」吗?此操作不可恢复。`)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await MaterialGroupService.deleteGroup(group.id)
|
||||
|
||||
// 移除已删除的分组
|
||||
const index = groupList.value.findIndex(g => g.id === group.id)
|
||||
if (index > -1) {
|
||||
groupList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 重新选择分组
|
||||
// 移除已删除的分组并重新选择
|
||||
groupList.value = groupList.value.filter(g => g.id !== group.id)
|
||||
if (selectedGroupId.value === group.id) {
|
||||
selectedGroupId.value = groupList.value.length > 0 ? groupList.value[0].id : null
|
||||
selectedGroupId.value = groupList.value[0]?.id ?? null
|
||||
await loadFileList()
|
||||
}
|
||||
|
||||
@@ -582,34 +595,16 @@ const handleBatchDelete = async () => {
|
||||
|
||||
const count = selectedFileIds.value.length
|
||||
const fileIdsToDelete = [...selectedFileIds.value]
|
||||
const confirmed = await new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${count} 个文件吗?此操作不可恢复。`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
class: 'batch-delete-modal',
|
||||
onOk() {
|
||||
resolve(true)
|
||||
},
|
||||
onCancel() {
|
||||
resolve(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const confirmed = await showConfirmDelete('确认删除', `确定要删除选中的 ${count} 个文件吗?此操作不可恢复。`)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await MaterialService.deleteFiles(fileIdsToDelete)
|
||||
|
||||
// 移除已删除的文件
|
||||
// 移除已删除的文件并更新状态
|
||||
fileList.value = fileList.value.filter(file => !fileIdsToDelete.includes(file.id))
|
||||
|
||||
// 更新状态
|
||||
totalFileCount.value = Math.max(0, totalFileCount.value - count)
|
||||
selectedFileIds.value = []
|
||||
|
||||
@@ -618,7 +613,7 @@ const handleBatchDelete = async () => {
|
||||
|
||||
// 如果删除后当前页没有数据了,则加载上一页
|
||||
if (fileList.value.length === 0 && pagination.current > 1) {
|
||||
pagination.current = pagination.current - 1
|
||||
pagination.current--
|
||||
await loadFileList()
|
||||
}
|
||||
|
||||
@@ -631,14 +626,12 @@ const handleBatchDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (size) => {
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = bytes / Math.pow(1024, index)
|
||||
return `${size.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
@@ -707,6 +700,43 @@ const handleImageError = (e) => {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
// 视频预览
|
||||
const handlePreview = async (file) => {
|
||||
if (!file.id) {
|
||||
message.warning('文件信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 显示加载提示
|
||||
const hideLoading = message.loading('正在获取视频地址...', 0)
|
||||
|
||||
try {
|
||||
// 调用后端API获取带签名的视频播放URL
|
||||
const res = await MaterialService.getVideoPlayUrl(file.id)
|
||||
hideLoading()
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
previewUrl.value = res.data
|
||||
previewTitle.value = file.displayName || file.fileName || '视频预览'
|
||||
previewVisible.value = true
|
||||
} else {
|
||||
message.error(res.msg || '获取视频地址失败')
|
||||
}
|
||||
} catch (error) {
|
||||
hideLoading()
|
||||
console.error('获取视频播放URL失败:', error)
|
||||
message.error('获取视频地址失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return ''
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 监听分类变化
|
||||
watch(activeCategory, () => {
|
||||
selectedFileIds.value = []
|
||||
@@ -1251,6 +1281,37 @@ onMounted(async () => {
|
||||
transition: opacity var(--duration-slow) ease;
|
||||
}
|
||||
|
||||
&__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-base);
|
||||
cursor: pointer;
|
||||
|
||||
.anticon {
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.duration-tag {
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
&__preview:hover &__play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__type-tag {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
@@ -1358,8 +1419,7 @@ onMounted(async () => {
|
||||
// ========================================
|
||||
// 弹窗样式
|
||||
// ========================================
|
||||
:deep(.batch-delete-modal),
|
||||
:deep(.delete-group-modal) {
|
||||
:deep(.ant-modal-confirm) {
|
||||
.ant-modal-content {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
:min="15"
|
||||
:max="30"
|
||||
:step="1"
|
||||
:tooltip-visible="false"
|
||||
style="width: 100px;"
|
||||
/>
|
||||
<span class="param-value">{{ formData.totalDuration }}s</span>
|
||||
@@ -35,6 +36,7 @@
|
||||
:min="3"
|
||||
:max="5"
|
||||
:step="1"
|
||||
:tooltip-visible="false"
|
||||
style="width: 80px;"
|
||||
/>
|
||||
<span class="param-value">{{ formData.clipDuration }}s</span>
|
||||
@@ -146,24 +148,30 @@
|
||||
<!-- 场景内容 -->
|
||||
<div class="scene-body">
|
||||
<template v-if="scene.candidates?.length > 0">
|
||||
<div
|
||||
v-for="(candidate, cIndex) in scene.candidates"
|
||||
:key="cIndex"
|
||||
class="candidate-thumb"
|
||||
@click.stop="removeCandidate(index, cIndex)"
|
||||
<!-- 候选素材可拖拽排序 -->
|
||||
<draggable
|
||||
v-model="scene.candidates"
|
||||
item-key="fileId"
|
||||
animation="200"
|
||||
class="candidates-draggable"
|
||||
:group="{ name: `scene-${index}` }"
|
||||
>
|
||||
<img
|
||||
v-if="getFileById(candidate.fileId)?.coverBase64"
|
||||
:src="getFileById(candidate.fileId).coverBase64"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<div class="thumb-order">{{ cIndex + 1 }}</div>
|
||||
<div class="thumb-remove">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<template #item="{ element: candidate, index: cIndex }">
|
||||
<div class="candidate-thumb">
|
||||
<img
|
||||
v-if="getFileById(candidate.fileId)?.coverBase64"
|
||||
:src="getFileById(candidate.fileId).coverBase64"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<div class="thumb-order">{{ cIndex + 1 }}</div>
|
||||
<div class="thumb-remove" @click.stop="removeCandidate(index, cIndex)">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="scene.candidates.length < constants.MAX_CANDIDATES_PER_SCENE"
|
||||
class="candidate-add"
|
||||
@@ -321,9 +329,9 @@ const showSettings = ref(false)
|
||||
const formData = ref({
|
||||
title: '',
|
||||
produceCount: Number(localStorage.getItem('mix-produce-count')) || 3,
|
||||
totalDuration: 15,
|
||||
clipDuration: 5,
|
||||
cropMode: 'center'
|
||||
totalDuration: Number(localStorage.getItem('mix-total-duration')) || 15,
|
||||
clipDuration: Number(localStorage.getItem('mix-clip-duration')) || 5,
|
||||
cropMode: localStorage.getItem('mix-crop-mode') || 'center'
|
||||
})
|
||||
|
||||
const uiState = ref({
|
||||
@@ -377,6 +385,11 @@ const saveProduceCount = () => {
|
||||
localStorage.setItem('mix-produce-count', formData.value.produceCount.toString())
|
||||
}
|
||||
|
||||
// 监听裁剪模式变化,保存到 localStorage
|
||||
watch(() => formData.value.cropMode, (newVal) => {
|
||||
localStorage.setItem('mix-crop-mode', newVal)
|
||||
})
|
||||
|
||||
const getFileById = (fileId) => {
|
||||
let file = dataState.value.groupFiles.find(f => f.id === fileId)
|
||||
if (file) return file
|
||||
@@ -413,6 +426,18 @@ watch(sceneCount, (newCount) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 同步场景时长并保存参数到本地
|
||||
watch([
|
||||
() => formData.value.clipDuration,
|
||||
() => formData.value.totalDuration
|
||||
], ([clipDuration, totalDuration]) => {
|
||||
localStorage.setItem('mix-clip-duration', clipDuration.toString())
|
||||
localStorage.setItem('mix-total-duration', totalDuration.toString())
|
||||
dataState.value.scenes.forEach(scene => {
|
||||
scene.duration = clipDuration
|
||||
})
|
||||
})
|
||||
|
||||
const onSceneDragEnd = () => {
|
||||
dataState.value.scenes.forEach((scene, index) => {
|
||||
scene.index = index
|
||||
@@ -430,11 +455,8 @@ const clearScenes = () => {
|
||||
}
|
||||
|
||||
const removeCandidate = (sceneIndex, candidateIndex) => {
|
||||
const candidates = dataState.value.scenes[sceneIndex]?.candidates
|
||||
if (candidates?.[candidateIndex] !== undefined) {
|
||||
candidates.splice(candidateIndex, 1)
|
||||
message.success('已移除候选')
|
||||
}
|
||||
dataState.value.scenes[sceneIndex]?.candidates?.splice(candidateIndex, 1)
|
||||
message.success('已移除候选')
|
||||
}
|
||||
|
||||
const openSceneSelector = (index) => {
|
||||
@@ -442,20 +464,6 @@ const openSceneSelector = (index) => {
|
||||
uiState.value.selectorVisible = true
|
||||
}
|
||||
|
||||
const openGroupSelector = (index) => {
|
||||
const groups = dataState.value.groupList
|
||||
if (groups.length === 0) return
|
||||
|
||||
const currentGroupId = dataState.value.scenes[index].groupId
|
||||
const currentIndex = groups.findIndex(g => g.id === currentGroupId)
|
||||
const nextIndex = (currentIndex + 1) % groups.length
|
||||
const nextGroup = groups[nextIndex]
|
||||
|
||||
dataState.value.scenes[index].groupId = nextGroup.id
|
||||
dataState.value.scenes[index].candidates = []
|
||||
loadGroupFiles(nextGroup.id)
|
||||
message.success(`场景${index + 1} 切换到 ${nextGroup.name}`)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 素材操作
|
||||
@@ -587,7 +595,7 @@ const handleModalConfirm = ({ sceneIndex, fileIds, setGroupId }) => {
|
||||
fileUrl: getFileById(fileId)?.fileUrl
|
||||
}))
|
||||
|
||||
if (setGroupId !== null && setGroupId !== undefined) {
|
||||
if (setGroupId != null) {
|
||||
scene.groupId = setGroupId
|
||||
}
|
||||
|
||||
@@ -939,6 +947,12 @@ onMounted(() => {
|
||||
min-height: 90px;
|
||||
overflow-x: auto;
|
||||
|
||||
// 可拖拽容器
|
||||
.candidates-draggable {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.candidate-thumb {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
@@ -946,10 +960,14 @@ onMounted(() => {
|
||||
border-radius: var(--radius-button);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
border: 2px solid transparent;
|
||||
transition: all var(--duration-base) ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -981,24 +999,38 @@ onMounted(() => {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// 🗑️ 删除遮罩
|
||||
// 🗑️ 删除按钮 - 右上角小按钮
|
||||
.thumb-remove {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
font-size: 10px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-base) ease;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-error-500);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// hover 效果 - 柔和的高亮边框
|
||||
&:hover {
|
||||
border-color: @danger;
|
||||
border-color: var(--color-primary-400);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
|
||||
.thumb-remove {
|
||||
opacity: 1;
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
|
||||
<!-- 分组切换 -->
|
||||
<div v-if="groupList.length > 1" class="group-tabs">
|
||||
<a-tag
|
||||
<div
|
||||
v-for="g in groupList"
|
||||
:key="g.id"
|
||||
:color="selectorGroupId === g.id ? 'blue' : 'default'"
|
||||
style="cursor: pointer;"
|
||||
class="group-tab-item"
|
||||
:class="{ 'group-tab-item--active': selectorGroupId === g.id }"
|
||||
@click="handleSelectorGroupChange(g.id)"
|
||||
>
|
||||
{{ g.name }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-button type="text" size="small" @click="$emit('update:visible', false)">
|
||||
@@ -134,6 +134,8 @@ const props = defineProps({
|
||||
|
||||
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([])
|
||||
|
||||
@@ -152,7 +154,28 @@ const selectorFiles = computed(() => {
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
tempSelectedFiles.value = [...props.initialCandidates]
|
||||
selectorGroupId.value = props.sceneGroupId || props.globalGroupId || null
|
||||
|
||||
// 优先级: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)
|
||||
}
|
||||
@@ -174,11 +197,6 @@ onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
|
||||
const isSelected = (fileId) => tempSelectedFiles.value.includes(fileId)
|
||||
const getSelectionOrder = (fileId) => tempSelectedFiles.value.indexOf(fileId) + 1
|
||||
|
||||
const getFileName = (fileId) => {
|
||||
const file = selectorFiles.value.find(f => f.id === fileId)
|
||||
return file ? (file.displayName || file.fileName) : '未知'
|
||||
}
|
||||
|
||||
const toggleSelection = (file) => {
|
||||
const index = tempSelectedFiles.value.indexOf(file.id)
|
||||
if (index > -1) {
|
||||
@@ -192,11 +210,6 @@ const toggleSelection = (file) => {
|
||||
}
|
||||
}
|
||||
|
||||
const removeSelection = (fileId) => {
|
||||
const index = tempSelectedFiles.value.indexOf(fileId)
|
||||
if (index > -1) tempSelectedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleQuickConfirm = (file) => {
|
||||
if (!isSelected(file.id)) toggleSelection(file)
|
||||
handleConfirm()
|
||||
@@ -236,6 +249,8 @@ const handleAutoFill = () => {
|
||||
|
||||
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)
|
||||
@@ -345,7 +360,35 @@ const handleConfirm = () => {
|
||||
|
||||
.group-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +45,7 @@
|
||||
</a-button>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<a-popconfirm
|
||||
v-if="canDelete"
|
||||
title="确定删除这个任务吗?删除后无法恢复。"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<a-button size="small" danger>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button size="small" danger @click="handleDelete">删除</a-button>
|
||||
|
||||
<!-- 插槽用于自定义操作 -->
|
||||
<slot name="extra" :task="task"></slot>
|
||||
@@ -125,11 +117,6 @@ const canRetry = computed(() => {
|
||||
return props.task.status === 'failed'
|
||||
})
|
||||
|
||||
// 计算属性:是否可删除
|
||||
const canDelete = computed(() => {
|
||||
return true // 所有任务都可以删除
|
||||
})
|
||||
|
||||
// 处理预览
|
||||
const handlePreview = async () => {
|
||||
if (getSignedUrlsApi) {
|
||||
@@ -162,8 +149,7 @@ const handleDelete = () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保按钮内的图标和文字对齐 */
|
||||
<style scoped lang="less">
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@@ -118,15 +118,25 @@ export function useTaskOperations(apiHandlers, onSuccess) {
|
||||
})
|
||||
}
|
||||
|
||||
// 下载单个文件
|
||||
function downloadFile(url, filename = 'download') {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// 下载单个文件(使用 fetch + blob 强制下载)
|
||||
async function downloadFile(url, filename = 'download') {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('下载失败')
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
// 降级:直接打开让浏览器处理
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取签名URL
|
||||
@@ -156,17 +166,21 @@ export function useTaskOperations(apiHandlers, onSuccess) {
|
||||
}
|
||||
|
||||
message.destroy()
|
||||
message.loading('正在下载文件...', 0)
|
||||
message.loading(`正在下载 ${downloadUrls.length} 个文件...`, 0)
|
||||
|
||||
// 逐个触发下载,避免浏览器阻止多个弹窗
|
||||
downloadUrls.forEach((url, index) => {
|
||||
setTimeout(() => {
|
||||
downloadFile(url)
|
||||
}, index * DOWNLOAD_INTERVAL)
|
||||
})
|
||||
// 逐个下载文件
|
||||
for (let i = 0; i < downloadUrls.length; i++) {
|
||||
const url = downloadUrls[i]
|
||||
const filename = `video_${taskId}_${i + 1}.mp4`
|
||||
await downloadFile(url, filename)
|
||||
// 短暂延迟避免浏览器阻止
|
||||
if (i < downloadUrls.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, DOWNLOAD_INTERVAL))
|
||||
}
|
||||
}
|
||||
|
||||
message.destroy()
|
||||
message.success(`已触发 ${downloadUrls.length} 个文件的下载`)
|
||||
message.success(`成功下载 ${downloadUrls.length} 个文件`)
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
message.error('下载失败,请稍后重试')
|
||||
|
||||
@@ -48,11 +48,9 @@
|
||||
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
|
||||
<a-alert :message="`已选中 ${selectedRowKeys.length} 项`" type="info" show-icon>
|
||||
<template #action>
|
||||
<a-popconfirm title="确定要删除选中的任务吗?" @confirm="handleBatchDelete">
|
||||
<a-button size="small" danger>
|
||||
<DeleteOutlined /> 批量删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button size="small" danger @click="confirmBatchDelete">
|
||||
<DeleteOutlined /> 批量删除
|
||||
</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
@@ -83,7 +81,7 @@
|
||||
<div class="progress-cell">
|
||||
<a-progress
|
||||
:percent="record.progress"
|
||||
:status="PROGRESS_STATUS[record.status]"
|
||||
:status="PROGRESS_STATUS[record.status?.toLowerCase()] || 'normal'"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
/>
|
||||
@@ -98,6 +96,16 @@
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
size="small"
|
||||
class="action-btn action-btn--primary"
|
||||
@click="handlePreview(record)"
|
||||
>
|
||||
<PlayCircleOutlined /> 预览
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
@@ -117,33 +125,42 @@
|
||||
取消
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm title="确定删除?删除后无法恢复。" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" class="action-btn action-btn--danger">删除</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button type="link" size="small" class="action-btn action-btn--danger" @click="handleDelete(record.id)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<VideoPreviewModal
|
||||
v-model:open="previewVisible"
|
||||
:video-url="previewUrl"
|
||||
:title="previewTitle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { getDigitalHumanTaskPage, cancelTask, deleteTask } from '@/api/digitalHuman'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { SearchOutlined, DownloadOutlined, DeleteOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { getDigitalHumanTaskPage, cancelTask, deleteTask, getSignedUrls } from '@/api/digitalHuman'
|
||||
import { formatDate } from '@/utils/file'
|
||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
|
||||
|
||||
// 进度状态映射
|
||||
const PROGRESS_STATUS = {
|
||||
pending: 'normal', running: 'active', success: 'success', failed: 'exception', canceled: 'normal',
|
||||
PENDING: 'normal', RUNNING: 'active', SUCCESS: 'success', FAILED: 'exception', CANCELED: 'normal'
|
||||
pending: 'normal',
|
||||
running: 'active',
|
||||
success: 'success',
|
||||
failed: 'exception',
|
||||
canceled: 'normal'
|
||||
}
|
||||
|
||||
// Composables
|
||||
@@ -158,9 +175,43 @@ const rowSelection = {
|
||||
onChange: (keys) => { selectedRowKeys.value = keys }
|
||||
}
|
||||
|
||||
// 视频预览状态
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
|
||||
// 状态判断
|
||||
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||
|
||||
// 预览视频
|
||||
const handlePreview = async (record) => {
|
||||
if (!record.id) {
|
||||
message.warning('任务信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 显示加载提示
|
||||
const hideLoading = message.loading('正在获取视频地址...', 0)
|
||||
|
||||
try {
|
||||
// 调用后端API获取带签名的视频URL
|
||||
const res = await getSignedUrls(record.id)
|
||||
hideLoading()
|
||||
|
||||
if (res.code === 0 && res.data && res.data.length > 0) {
|
||||
previewUrl.value = res.data[0]
|
||||
previewTitle.value = record.taskName || '视频预览'
|
||||
previewVisible.value = true
|
||||
} else {
|
||||
message.error(res.msg || '获取视频地址失败')
|
||||
}
|
||||
} catch (error) {
|
||||
hideLoading()
|
||||
console.error('获取视频播放URL失败:', error)
|
||||
message.error('获取视频地址失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 下载视频
|
||||
const handleDownload = (record) => {
|
||||
if (!record.resultVideoUrl) {
|
||||
@@ -185,6 +236,18 @@ const handleBatchDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 确认批量删除
|
||||
const confirmBatchDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除选中的任务吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: handleBatchDelete
|
||||
})
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, fixed: 'left' },
|
||||
@@ -257,6 +320,7 @@ onMounted(fetchList)
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&--primary { color: var(--color-primary-500); &:hover { color: var(--color-primary-600); } }
|
||||
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
|
||||
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
||||
}
|
||||
|
||||
@@ -50,10 +50,37 @@
|
||||
:row-key="record => record.id"
|
||||
:pagination="paginationConfig"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
@change="handleTableChange"
|
||||
@expandedRowsChange="handleExpandedRowsChange"
|
||||
>
|
||||
<!-- 批量操作工具栏 -->
|
||||
<template #title>
|
||||
<div class="batch-toolbar">
|
||||
<a-space>
|
||||
<span v-if="selectedRowKeys.length > 0">
|
||||
已选择 <strong>{{ selectedRowKeys.length }}</strong> 项
|
||||
</span>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!hasDownloadableSelected"
|
||||
:loading="batchDownloading"
|
||||
@click="handleBatchDownloadSelected"
|
||||
>
|
||||
<DownloadOutlined /> 批量下载 ({{ downloadableCount }})
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
@click="handleBatchDeleteSelected"
|
||||
>
|
||||
<DeleteOutlined /> 批量删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 标题列 -->
|
||||
<template v-if="column.key === 'title'">
|
||||
@@ -124,9 +151,7 @@
|
||||
重试
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm title="确定删除?删除后无法恢复。" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" class="action-btn action-btn--danger">删除</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button type="link" size="small" class="action-btn action-btn--danger" @click="handleDelete(record.id)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
@@ -189,8 +214,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { SearchOutlined, PlayCircleOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { SearchOutlined, PlayCircleOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { MixTaskService } from '@/api/mixTask'
|
||||
import { formatDate } from '@/utils/file'
|
||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||
@@ -200,7 +226,7 @@ import TaskStatusTag from '@/views/system/task-management/components/TaskStatusT
|
||||
|
||||
// Composables
|
||||
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(MixTaskService.getTaskPage)
|
||||
const { handleDelete, handleCancel, handleRetry, handleBatchDownload } = useTaskOperations(
|
||||
const { handleDelete, handleCancel, handleRetry, handleBatchDownload, handleBatchDelete } = useTaskOperations(
|
||||
{ deleteApi: MixTaskService.deleteTask, cancelApi: MixTaskService.cancelTask, retryApi: MixTaskService.retryTask, getSignedUrlsApi: MixTaskService.getSignedUrls },
|
||||
fetchList
|
||||
)
|
||||
@@ -210,21 +236,131 @@ useTaskPolling(MixTaskService.getTaskPage, { onTaskUpdate: fetchList })
|
||||
const expandedRowKeys = ref([])
|
||||
const handleExpandedRowsChange = (keys) => { expandedRowKeys.value = keys }
|
||||
|
||||
// 批量选择
|
||||
const selectedRowKeys = ref([])
|
||||
const batchDownloading = ref(false)
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
}))
|
||||
|
||||
// 可下载的选中项
|
||||
const downloadableCount = computed(() => {
|
||||
return list.value.filter(item =>
|
||||
selectedRowKeys.value.includes(item.id) &&
|
||||
isStatus(item.status, 'success') &&
|
||||
item.outputUrls?.length > 0
|
||||
).length
|
||||
})
|
||||
|
||||
const hasDownloadableSelected = computed(() => downloadableCount.value > 0)
|
||||
|
||||
// 批量下载选中的任务(控制并发)
|
||||
const handleBatchDownloadSelected = async () => {
|
||||
const downloadableTasks = list.value.filter(item =>
|
||||
selectedRowKeys.value.includes(item.id) &&
|
||||
isStatus(item.status, 'success') &&
|
||||
item.outputUrls?.length > 0
|
||||
)
|
||||
|
||||
if (downloadableTasks.length === 0) {
|
||||
message.warning('没有可下载的任务')
|
||||
return
|
||||
}
|
||||
|
||||
batchDownloading.value = true
|
||||
message.loading(`正在准备下载 ${downloadableTasks.length} 个任务的视频...`, 0)
|
||||
|
||||
try {
|
||||
// 并发控制:同时最多下载 3 个任务
|
||||
const CONCURRENCY = 3
|
||||
let completed = 0
|
||||
const total = downloadableTasks.length
|
||||
|
||||
for (let i = 0; i < downloadableTasks.length; i += CONCURRENCY) {
|
||||
const batch = downloadableTasks.slice(i, i + CONCURRENCY)
|
||||
await Promise.all(batch.map(async (task) => {
|
||||
try {
|
||||
const res = await MixTaskService.getSignedUrls(task.id)
|
||||
if (res.code === 0 && res.data?.length > 0) {
|
||||
// 下载该任务的所有视频
|
||||
for (let j = 0; j < res.data.length; j++) {
|
||||
await downloadFile(res.data[j], `video_${task.id}_${j + 1}.mp4`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`任务 ${task.id} 下载失败:`, e)
|
||||
}
|
||||
completed++
|
||||
message.destroy()
|
||||
message.loading(`下载进度: ${completed}/${total}`, 0)
|
||||
}))
|
||||
}
|
||||
|
||||
message.destroy()
|
||||
message.success(`成功下载 ${total} 个任务的视频`)
|
||||
} catch (e) {
|
||||
message.destroy()
|
||||
message.error('批量下载失败')
|
||||
console.error('批量下载失败:', e)
|
||||
} finally {
|
||||
batchDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除选中的任务
|
||||
const handleBatchDeleteSelected = () => {
|
||||
const count = selectedRowKeys.value.length
|
||||
Modal.confirm({
|
||||
title: '确认批量删除',
|
||||
content: `确定要删除选中的 ${count} 个任务吗?删除后无法恢复。`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
for (const id of selectedRowKeys.value) {
|
||||
await MixTaskService.deleteTask(id)
|
||||
}
|
||||
message.success(`成功删除 ${count} 个任务`)
|
||||
selectedRowKeys.value = []
|
||||
fetchList()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下载单个文件(使用 fetch + blob 强制下载)
|
||||
const downloadFile = async (url, filename) => {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('下载失败')
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
// 预览状态
|
||||
const preview = reactive({ visible: false, title: '', url: '' })
|
||||
|
||||
// 状态判断
|
||||
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||
const isStatus = (status, target) => status?.toLowerCase() === target.toLowerCase()
|
||||
|
||||
const canOperate = (record, action) => {
|
||||
const isSuccess = isStatus(record.status, 'success')
|
||||
const hasUrls = record.outputUrls?.length > 0
|
||||
const actions = {
|
||||
preview: isSuccess && hasUrls,
|
||||
download: isSuccess && hasUrls,
|
||||
cancel: isStatus(record.status, 'running'),
|
||||
retry: isStatus(record.status, 'failed')
|
||||
const hasOutput = record.outputUrls?.length > 0
|
||||
|
||||
switch (action) {
|
||||
case 'preview': return isSuccess && hasOutput
|
||||
case 'download': return isSuccess && hasOutput
|
||||
case 'cancel': return isStatus(record.status, 'running')
|
||||
case 'retry': return isStatus(record.status, 'failed')
|
||||
default: return false
|
||||
}
|
||||
return actions[action]
|
||||
}
|
||||
|
||||
// 预览视频
|
||||
@@ -242,26 +378,25 @@ const previewVideo = async (record, index) => {
|
||||
|
||||
const openPreview = (record) => previewVideo(record, 0)
|
||||
|
||||
// 下载视频
|
||||
// 下载视频(使用 fetch + blob 强制下载)
|
||||
const downloadVideo = async (taskId, index) => {
|
||||
try {
|
||||
const res = await MixTaskService.getSignedUrls(taskId)
|
||||
if (res.code === 0 && res.data?.[index]) {
|
||||
const link = document.createElement('a')
|
||||
link.href = res.data[index]
|
||||
link.download = `video_${taskId}_${index + 1}.mp4`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
const url = res.data[index]
|
||||
const filename = `video_${taskId}_${index + 1}.mp4`
|
||||
await downloadFile(url, filename)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取下载链接失败:', e)
|
||||
console.error('下载失败:', e)
|
||||
message.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (record) => {
|
||||
if (record.outputUrls?.length) handleBatchDownload([], MixTaskService.getSignedUrls, record.id)
|
||||
if (record.outputUrls?.length) {
|
||||
handleBatchDownload(record.outputUrls, MixTaskService.getSignedUrls, record.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
@@ -312,6 +447,10 @@ onMounted(fetchList)
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.batch-toolbar {
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -416,10 +555,4 @@ onMounted(fetchList)
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* 修复 popconfirm 按钮对齐 */
|
||||
:deep(.ant-popover .ant-popover-buttons) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -171,7 +171,6 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="user-name">{{ userStore.displayName }}</h2>
|
||||
<div class="user-id">ID: {{ userStore.userId || '未设置' }}</div>
|
||||
<div class="user-role-badge">普通用户</div>
|
||||
</div>
|
||||
|
||||
@@ -370,14 +369,8 @@ onMounted(async () => {
|
||||
.user-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.user-id {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.user-role-badge {
|
||||
|
||||
@@ -198,9 +198,8 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
@Override
|
||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
// 1. 将 url 转换为 path
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
path = HttpUtils.removeUrlQuery(path);
|
||||
// 1. 将 url 转换为 path(支持 CDN 域名和 OSS 原始域名)
|
||||
String path = extractPathFromUrl(url);
|
||||
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
||||
|
||||
// 2. 公开访问:无需签名
|
||||
@@ -272,6 +271,54 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
return StrUtil.format("https://{}", config.getEndpoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 中提取 path(支持 CDN 域名和 OSS 原始域名)
|
||||
*
|
||||
* @param url 完整的 URL
|
||||
* @return 相对路径(不含查询参数)
|
||||
*/
|
||||
private String extractPathFromUrl(String url) {
|
||||
if (StrUtil.isEmpty(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 移除查询参数
|
||||
String cleanUrl = HttpUtils.removeUrlQuery(url);
|
||||
|
||||
// 1. 尝试使用配置的 domain 提取 path(CDN 域名)
|
||||
if (StrUtil.isNotEmpty(config.getDomain()) && cleanUrl.startsWith(config.getDomain() + "/")) {
|
||||
return StrUtil.removePrefix(cleanUrl, config.getDomain() + "/");
|
||||
}
|
||||
|
||||
// 2. 尝试从 OSS 原始域名提取 path(阿里云格式)
|
||||
// 格式:https://{bucket}.oss-{region}.aliyuncs.com/{path}
|
||||
if (cleanUrl.contains(".aliyuncs.com/")) {
|
||||
int pathStart = cleanUrl.indexOf(".aliyuncs.com/") + ".aliyuncs.com/".length();
|
||||
if (pathStart < cleanUrl.length()) {
|
||||
return cleanUrl.substring(pathStart);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试从 buildDomain() 格式提取 path
|
||||
String builtDomain = buildDomain();
|
||||
if (cleanUrl.startsWith(builtDomain + "/")) {
|
||||
return StrUtil.removePrefix(cleanUrl, builtDomain + "/");
|
||||
}
|
||||
|
||||
// 4. 兜底:如果 URL 包含路径分隔符,尝试提取路径部分
|
||||
int slashIndex = cleanUrl.indexOf("://");
|
||||
if (slashIndex > 0) {
|
||||
String afterProtocol = cleanUrl.substring(slashIndex + 3);
|
||||
int pathIndex = afterProtocol.indexOf('/');
|
||||
if (pathIndex > 0 && pathIndex < afterProtocol.length() - 1) {
|
||||
return afterProtocol.substring(pathIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 最后兜底:直接返回原始 URL(可能已经是 path)
|
||||
return cleanUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 endpoint 中提取 region
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserOssInitDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserOssInitMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -45,35 +46,50 @@ public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
return BeanUtils.toBean(existing, AppTikUserOssInitRespVO.class);
|
||||
}
|
||||
|
||||
// 获取用户信息(获取手机号)
|
||||
MemberUserRespDTO user = memberUserApi.getUser(userId);
|
||||
if (user == null || StrUtil.isBlank(user.getMobile())) {
|
||||
throw exception(OSS_INIT_FAILED, "用户手机号不存在");
|
||||
}
|
||||
|
||||
// 计算手机号MD5和OSS路径
|
||||
// 获取用户信息(优先使用手机号MD5,否则使用userId)
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
String mobileMd5 = DigestUtil.md5Hex(user.getMobile());
|
||||
OssPathInfo pathInfo = buildOssPaths(mobileMd5, tenantId);
|
||||
String pathIdentifier = getPathIdentifier(userId, tenantId);
|
||||
OssPathInfo pathInfo = buildOssPaths(pathIdentifier, tenantId);
|
||||
|
||||
// 创建OSS初始化记录
|
||||
TikUserOssInitDO ossInit = createOssInitDO(userId, pathIdentifier, pathInfo);
|
||||
|
||||
// 创建或更新OSS初始化记录
|
||||
// 注意:OSS中目录是虚拟的,不需要显式创建,直接上传文件时包含路径即可自动创建
|
||||
TikUserOssInitDO ossInit;
|
||||
if (existing != null) {
|
||||
// 更新现有记录(补充缺失的字段或重新初始化)
|
||||
ossInit = existing;
|
||||
updateOssInitFields(ossInit, mobileMd5, pathInfo);
|
||||
// 更新现有记录
|
||||
ossInit.setId(existing.getId());
|
||||
ossInitMapper.updateById(ossInit);
|
||||
} else {
|
||||
// 创建新记录
|
||||
ossInit = createOssInitDO(userId, mobileMd5, pathInfo);
|
||||
ossInitMapper.insert(ossInit);
|
||||
// 尝试插入,如果并发冲突则更新
|
||||
try {
|
||||
ossInitMapper.insert(ossInit);
|
||||
} catch (DuplicateKeyException e) {
|
||||
log.info("[initOssDirectory][用户({})并发插入冲突,改为更新]", userId);
|
||||
existing = ossInitMapper.selectByUserId(userId);
|
||||
if (existing != null) {
|
||||
ossInit.setId(existing.getId());
|
||||
ossInitMapper.updateById(ossInit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[initOssDirectory][用户({})OSS初始化成功,根路径({})]", userId, pathInfo.ossRootPath);
|
||||
return BeanUtils.toBean(ossInit, AppTikUserOssInitRespVO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径标识符
|
||||
* 优先使用手机号MD5,否则使用userId
|
||||
*/
|
||||
private String getPathIdentifier(Long userId, Long tenantId) {
|
||||
MemberUserRespDTO user = memberUserApi.getUser(userId);
|
||||
if (user != null && StrUtil.isNotBlank(user.getMobile())) {
|
||||
return DigestUtil.md5Hex(user.getMobile());
|
||||
}
|
||||
// 无手机号时使用userId作为标识
|
||||
log.info("[getPathIdentifier][用户({})无手机号,使用userId作为路径标识]", userId);
|
||||
return "u" + userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS路径信息
|
||||
*/
|
||||
@@ -88,9 +104,12 @@ public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
|
||||
/**
|
||||
* 构建OSS路径信息
|
||||
*
|
||||
* @param pathIdentifier 路径标识符(手机号MD5或u{userId}格式)
|
||||
* @param tenantId 租户ID
|
||||
*/
|
||||
private OssPathInfo buildOssPaths(String mobileMd5, Long tenantId) {
|
||||
String ossRootPath = mobileMd5 + "/" + tenantId;
|
||||
private OssPathInfo buildOssPaths(String pathIdentifier, Long tenantId) {
|
||||
String ossRootPath = pathIdentifier + "/" + tenantId;
|
||||
return new OssPathInfo(
|
||||
ossRootPath,
|
||||
ossRootPath + "/video",
|
||||
@@ -103,25 +122,15 @@ public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
|
||||
/**
|
||||
* 创建OSS初始化DO对象
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param pathIdentifier 路径标识符(手机号MD5或u{userId}格式)
|
||||
* @param pathInfo 路径信息
|
||||
*/
|
||||
private TikUserOssInitDO createOssInitDO(Long userId, String mobileMd5, OssPathInfo pathInfo) {
|
||||
private TikUserOssInitDO createOssInitDO(Long userId, String pathIdentifier, OssPathInfo pathInfo) {
|
||||
return new TikUserOssInitDO()
|
||||
.setUserId(userId)
|
||||
.setMobileMd5(mobileMd5)
|
||||
.setOssRootPath(pathInfo.ossRootPath)
|
||||
.setVideoPath(pathInfo.videoPath)
|
||||
.setGeneratePath(pathInfo.generatePath)
|
||||
.setAudioPath(pathInfo.audioPath)
|
||||
.setMixPath(pathInfo.mixPath)
|
||||
.setVoicePath(pathInfo.voicePath)
|
||||
.setInitStatus(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新OSS初始化DO对象的字段
|
||||
*/
|
||||
private void updateOssInitFields(TikUserOssInitDO ossInit, String mobileMd5, OssPathInfo pathInfo) {
|
||||
ossInit.setMobileMd5(mobileMd5)
|
||||
.setMobileMd5(pathIdentifier)
|
||||
.setOssRootPath(pathInfo.ossRootPath)
|
||||
.setVideoPath(pathInfo.videoPath)
|
||||
.setGeneratePath(pathInfo.generatePath)
|
||||
@@ -139,27 +148,27 @@ public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOssRootPath(Long userId) {
|
||||
/**
|
||||
* 获取已初始化的OSS记录,未初始化则抛出异常
|
||||
*/
|
||||
private TikUserOssInitDO getRequiredOssInit(Long userId) {
|
||||
TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId);
|
||||
if (ossInit == null || ossInit.getInitStatus() == 0) {
|
||||
throw exception(OSS_INIT_FAILED);
|
||||
}
|
||||
return ossInit.getOssRootPath();
|
||||
return ossInit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOssRootPath(Long userId) {
|
||||
return getRequiredOssInit(userId).getOssRootPath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOssDirectoryByCategory(Long userId, String fileCategory) {
|
||||
// 确保OSS已初始化
|
||||
ensureOssInitialized(userId);
|
||||
TikUserOssInitDO ossInit = getRequiredOssInit(userId);
|
||||
|
||||
// 获取OSS初始化记录
|
||||
TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId);
|
||||
if (ossInit == null || ossInit.getInitStatus() == 0) {
|
||||
throw exception(OSS_INIT_FAILED);
|
||||
}
|
||||
|
||||
// 根据分类返回对应基础目录路径
|
||||
return switch (fileCategory) {
|
||||
case "video" -> ossInit.getVideoPath();
|
||||
case "generate" -> ossInit.getGeneratePath();
|
||||
@@ -173,7 +182,7 @@ public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
|
||||
/**
|
||||
* 基于分类和分组获取OSS目录路径
|
||||
* 新路径格式:/user-files/{category}/{date}/{groupName}/
|
||||
* 路径格式:{mobileMd5}/user-files/{category}/{date}/{groupName}
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param category 分类:MIX 或 DIGITAL_HUMAN
|
||||
@@ -182,36 +191,18 @@ public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
* @return OSS目录路径
|
||||
*/
|
||||
public String getOssDirectoryByCategoryAndGroup(Long userId, String category, String groupName, String dateStr) {
|
||||
// 确保OSS已初始化
|
||||
ensureOssInitialized(userId);
|
||||
TikUserOssInitDO ossInit = getRequiredOssInit(userId);
|
||||
|
||||
// 构建新格式的路径
|
||||
// 路径格式:{mobileMd5}/{tenantId}/user-files/{category}/{date}/{groupName}
|
||||
TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId);
|
||||
if (ossInit == null || ossInit.getInitStatus() == 0) {
|
||||
throw exception(OSS_INIT_FAILED);
|
||||
String path = ossInit.getMobileMd5() + "/user-files/" + category.toLowerCase() + "/" + dateStr;
|
||||
|
||||
if (StrUtil.isNotBlank(groupName)) {
|
||||
// 对分组名进行URL安全处理:保留中文、字母、数字、下划线和连字符
|
||||
String safeGroupName = groupName.trim().replaceAll("[^a-zA-Z0-9一-鿿_-]", "_");
|
||||
path += "/" + safeGroupName;
|
||||
}
|
||||
|
||||
// 获取基础路径(去掉tenantId部分)
|
||||
String basePath = ossInit.getMobileMd5();
|
||||
|
||||
// 构建完整路径
|
||||
StringBuilder pathBuilder = new StringBuilder();
|
||||
pathBuilder.append(basePath)
|
||||
.append("/user-files/")
|
||||
.append(category.toLowerCase())
|
||||
.append("/")
|
||||
.append(dateStr);
|
||||
|
||||
// 如果有分组名,添加到路径
|
||||
if (groupName != null && !groupName.trim().isEmpty()) {
|
||||
// 对分组名进行URL安全处理
|
||||
String safeGroupName = groupName.trim()
|
||||
.replaceAll("[^a-zA-Z0-9一-鿿_-]", "_"); // 保留中文、字母、数字、下划线和连字符
|
||||
pathBuilder.append("/").append(safeGroupName);
|
||||
}
|
||||
|
||||
return pathBuilder.toString();
|
||||
return path;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -331,70 +331,49 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
log.info("[deleteFiles][用户({})删除文件成功,数量({})]", userId, fileIds.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVideoPlayUrl(Long fileId) {
|
||||
/**
|
||||
* 查询用户文件(校验归属权)
|
||||
*/
|
||||
private TikUserFileDO getUserFile(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件(根据 fileId 字段查询)
|
||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getFileId, fileId)
|
||||
.eq(TikUserFileDO::getId, fileId)
|
||||
.eq(TikUserFileDO::getUserId, userId));
|
||||
|
||||
if (file == null) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件不存在");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVideoPlayUrl(Long fileId) {
|
||||
TikUserFileDO file = getUserFile(fileId);
|
||||
|
||||
// 校验文件URL是否为空
|
||||
if (StrUtil.isBlank(file.getFileUrl())) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件URL为空");
|
||||
}
|
||||
|
||||
// 校验是否为视频文件
|
||||
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
||||
if (!isVideo) {
|
||||
if (!StrUtil.containsIgnoreCase(file.getFileType(), "video")) {
|
||||
throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型");
|
||||
}
|
||||
|
||||
// 生成预签名URL(24小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAudioPlayUrl(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
TikUserFileDO file = getUserFile(fileId);
|
||||
|
||||
// 查询文件(根据 fileId 字段查询)
|
||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getFileId, fileId)
|
||||
.eq(TikUserFileDO::getUserId, userId));
|
||||
|
||||
if (file == null) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件不存在");
|
||||
}
|
||||
|
||||
// 校验是否为音频文件
|
||||
if (!StrUtil.containsIgnoreCase(file.getFileType(), "audio")) {
|
||||
throw exception(FILE_CATEGORY_INVALID, "文件不是音频类型");
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPreviewUrl(Long fileId, String type) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件(根据主键id查询)
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
// 根据类型返回预览URL
|
||||
TikUserFileDO file = getUserFile(fileId);
|
||||
String previewUrl = determinePreviewUrl(file, type);
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(previewUrl, PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
@@ -408,14 +387,14 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
// 明确指定类型
|
||||
if (StrUtil.equals(type, "cover") && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
if ("cover".equals(type) && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
return file.getCoverUrl();
|
||||
}
|
||||
if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||
if ("thumbnail".equals(type) && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||
return file.getThumbnailUrl();
|
||||
}
|
||||
|
||||
// 根据文件类型自动选择
|
||||
// 根据文件类型自动选择:图片优先缩略图,否则原图
|
||||
if (FileTypeUtils.isImage(file.getFileType())) {
|
||||
return StrUtil.isNotBlank(file.getThumbnailUrl()) ? file.getThumbnailUrl() : file.getFileUrl();
|
||||
}
|
||||
|
||||
@@ -167,8 +167,9 @@ public class BatchProduceAlignment {
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl);
|
||||
// 返回原始URL,签名URL在用户下载时生成
|
||||
return jobId + " : " + outputMediaUrl;
|
||||
// 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL)
|
||||
String cdnUrl = properties.getCdnStorageUrl(outputMediaPath);
|
||||
return jobId + " : " + cdnUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,8 +283,9 @@ public class BatchProduceAlignment {
|
||||
Integer fileDuration = material.getFileDuration();
|
||||
int startOffset = 0;
|
||||
int endOffset = duration;
|
||||
int actualDuration = duration; // 实际截取时长
|
||||
|
||||
if (fileDuration != null && fileDuration > duration) {
|
||||
if (fileDuration != null && fileDuration >= duration) {
|
||||
// 有实际时长且足够:随机起点范围 0 到 (实际时长 - 截取时长)
|
||||
long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) +
|
||||
(videoIndex * 10000L) + (material.getFileUrl().hashCode() % 1000);
|
||||
@@ -293,14 +295,20 @@ public class BatchProduceAlignment {
|
||||
endOffset = startOffset + duration;
|
||||
log.debug("[ICE][随机截取] fileId={}, fileDuration={}s, In={}, Out={}",
|
||||
material.getFileId(), fileDuration, startOffset, endOffset);
|
||||
} else if (fileDuration != null && fileDuration < duration) {
|
||||
// 素材时长不足:使用素材全部时长,避免超出范围
|
||||
actualDuration = fileDuration;
|
||||
endOffset = fileDuration;
|
||||
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s, 将使用全部时长",
|
||||
material.getFileId(), duration, fileDuration);
|
||||
} else {
|
||||
// 无时长或时长不足:从0开始截取(兜底)
|
||||
log.debug("[ICE][兜底截取] fileId={}, fileDuration={}, In=0, Out={}",
|
||||
material.getFileId(), fileDuration, duration);
|
||||
// 无时长信息:从0开始截取(兜底,ICE可能会失败)
|
||||
log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}, ICE可能会失败",
|
||||
material.getFileId(), duration);
|
||||
}
|
||||
|
||||
log.debug("[ICE][添加视频片段][视频{}: {}, In={}, Out={}, TimelineIn={}, TimelineOut={}]",
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + duration);
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration);
|
||||
|
||||
// 构建视频片段(带 In/Out 参数)
|
||||
JSONObject videoClip = new JSONObject();
|
||||
@@ -308,7 +316,7 @@ public class BatchProduceAlignment {
|
||||
videoClip.put("In", startOffset);
|
||||
videoClip.put("Out", endOffset);
|
||||
videoClip.put("TimelineIn", timelinePos);
|
||||
videoClip.put("TimelineOut", timelinePos + duration);
|
||||
videoClip.put("TimelineOut", timelinePos + actualDuration);
|
||||
|
||||
// 添加裁剪效果(9:16竖屏输出)
|
||||
// 假设源素材为1920x1080(16:9),可根据实际情况调整
|
||||
@@ -340,7 +348,7 @@ public class BatchProduceAlignment {
|
||||
audioClip.put("In", startOffset);
|
||||
audioClip.put("Out", endOffset);
|
||||
audioClip.put("TimelineIn", timelinePos);
|
||||
audioClip.put("TimelineOut", timelinePos + duration);
|
||||
audioClip.put("TimelineOut", timelinePos + actualDuration);
|
||||
audioClip.put("Effects", new JSONArray() {{
|
||||
add(new JSONObject() {{
|
||||
put("Type", "Volume");
|
||||
@@ -349,7 +357,7 @@ public class BatchProduceAlignment {
|
||||
}});
|
||||
audioClipArray.add(audioClip);
|
||||
|
||||
timelinePos += duration;
|
||||
timelinePos += actualDuration;
|
||||
}
|
||||
|
||||
// 构建时间线
|
||||
@@ -381,7 +389,9 @@ public class BatchProduceAlignment {
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][videoIndex={}, jobId={}, outputUrl={}]", videoIndex, jobId, outputMediaUrl);
|
||||
return jobId + " : " + outputMediaUrl;
|
||||
// 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL)
|
||||
String cdnUrl = properties.getCdnStorageUrl(outputMediaPath);
|
||||
return jobId + " : " + cdnUrl;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -87,4 +87,16 @@ public class IceProperties {
|
||||
String ossDomain = "https://" + bucket + ".oss-" + regionId + ".aliyuncs.com";
|
||||
return url.replace(cdnDomain, ossDomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于存储的CDN URL
|
||||
*/
|
||||
public String getCdnStorageUrl(String path) {
|
||||
String cdnDomain = fileApi.getMasterFileDomain();
|
||||
if (StrUtil.isNotBlank(cdnDomain)) {
|
||||
return cdnDomain + "/" + path;
|
||||
}
|
||||
// 没有配置CDN,回退到OSS原始域名
|
||||
return getOssWriteUrl(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ public class MixTaskConstants {
|
||||
|
||||
/**
|
||||
* 定时任务配置
|
||||
* 改为每30秒检查一次,提供更实时的进度更新
|
||||
* 每1分钟检查一次,平衡响应速度和系统压力
|
||||
*/
|
||||
public static final String CRON_CHECK_STATUS = "*/30 * * * * ?";
|
||||
public static final String CRON_CHECK_STATUS = "0 */1 * * * ?";
|
||||
|
||||
/**
|
||||
* 任务状态检查优化配置
|
||||
|
||||
@@ -63,9 +63,9 @@ public interface MixTaskService {
|
||||
void checkTaskStatusBatch();
|
||||
|
||||
/**
|
||||
* 同步任务状态(从阿里云 ICE 查询)
|
||||
* 同步任务状态(从阿里云 ICE 查询所有jobId,综合判断)
|
||||
*/
|
||||
void syncTaskStatus(Long taskId, String jobId);
|
||||
void syncTaskStatus(Long taskId, List<String> jobIds);
|
||||
|
||||
/**
|
||||
* 保存任务结果
|
||||
|
||||
@@ -3,8 +3,11 @@ package cn.iocoder.yudao.module.tik.mix.service;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileService;
|
||||
import com.alibaba.ttl.TtlRunnable;
|
||||
import cn.iocoder.yudao.module.tik.mix.client.IceClient;
|
||||
import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants;
|
||||
import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO;
|
||||
@@ -65,15 +68,15 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
// 2. 保存到数据库
|
||||
mixTaskMapper.insert(task);
|
||||
|
||||
// 3. 异步提交到阿里云 ICE
|
||||
CompletableFuture.runAsync(() -> {
|
||||
// 3. 异步提交到阿里云 ICE(使用 TTL 自动传递上下文)
|
||||
CompletableFuture.runAsync(TtlRunnable.get(() -> {
|
||||
try {
|
||||
submitToICE(task.getId(), createReqVO, userId);
|
||||
} catch (Exception e) {
|
||||
log.error("[MixTask][提交ICE失败] taskId={}", task.getId(), e);
|
||||
updateTaskError(task.getId(), "提交任务失败: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
return task.getId();
|
||||
}
|
||||
@@ -175,8 +178,8 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTask.setOutputUrlList(null);
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
|
||||
// 3. 重新提交到ICE
|
||||
CompletableFuture.runAsync(() -> {
|
||||
// 3. 重新提交到ICE(使用 TTL 自动传递上下文)
|
||||
CompletableFuture.runAsync(TtlRunnable.get(() -> {
|
||||
try {
|
||||
// 从 materialsJson 重建请求对象
|
||||
List<MixTaskSaveReqVO.MaterialItem> materials = null;
|
||||
@@ -207,7 +210,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
log.error("[MixTask][重新提交失败] taskId={}", id, e);
|
||||
updateTaskError(id, "重新提交失败: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -247,28 +250,29 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
// 4. 频率优化:定时任务频率从30秒改为2分钟
|
||||
LocalDateTime startTime = LocalDateTime.now().minusHours(MixTaskConstants.CHECK_HOURS_LIMIT);
|
||||
|
||||
// 查询运行中的任务(限制时间和数量)
|
||||
List<MixTaskDO> runningTasks = mixTaskMapper.selectList(
|
||||
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<MixTaskDO>()
|
||||
.eq(MixTaskDO::getStatus, MixTaskConstants.STATUS_RUNNING)
|
||||
.ge(MixTaskDO::getCreateTime, startTime)
|
||||
.orderByDesc(MixTaskDO::getCreateTime)
|
||||
.last("LIMIT " + MixTaskConstants.CHECK_BATCH_SIZE) // 限制数量
|
||||
// 查询运行中的任务(忽略租户过滤,因为定时任务没有租户上下文)
|
||||
List<MixTaskDO> runningTasks = TenantUtils.executeIgnore(() ->
|
||||
mixTaskMapper.selectList(
|
||||
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<MixTaskDO>()
|
||||
.eq(MixTaskDO::getStatus, MixTaskConstants.STATUS_RUNNING)
|
||||
.ge(MixTaskDO::getCreateTime, startTime)
|
||||
.orderByDesc(MixTaskDO::getCreateTime)
|
||||
.last("LIMIT " + MixTaskConstants.CHECK_BATCH_SIZE)
|
||||
)
|
||||
);
|
||||
|
||||
if (runningTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐个检查任务状态
|
||||
// 逐个检查任务状态(每个任务使用自己的租户上下文)
|
||||
int failureCount = 0;
|
||||
for (MixTaskDO task : runningTasks) {
|
||||
try {
|
||||
List<String> jobIds = task.getJobIdList();
|
||||
if (jobIds != null && !jobIds.isEmpty()) {
|
||||
// 每个任务可能有多个jobId,取第一个进行检查
|
||||
String jobId = jobIds.get(0);
|
||||
syncTaskStatus(task.getId(), jobId);
|
||||
// 使用任务的租户ID执行状态同步(传递所有jobIds)
|
||||
TenantUtils.execute(task.getTenantId(), () -> syncTaskStatus(task.getId(), jobIds));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[MixTask][单个任务检查失败] taskId={}", task.getId(), e);
|
||||
@@ -278,8 +282,17 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步任务状态(检查所有jobId,综合判断)
|
||||
*
|
||||
* 状态判断逻辑:
|
||||
* - 全部成功 → 任务成功
|
||||
* - 全部失败 → 任务失败
|
||||
* - 部分成功 → 任务成功(只保留成功的视频URL)
|
||||
* - 任一运行中/待处理 → 任务继续运行
|
||||
*/
|
||||
@Override
|
||||
public void syncTaskStatus(Long taskId, String jobId) {
|
||||
public void syncTaskStatus(Long taskId, List<String> jobIds) {
|
||||
try {
|
||||
// 检查任务是否超时(超过12小时则标记为失败)
|
||||
MixTaskDO task = mixTaskMapper.selectById(taskId);
|
||||
@@ -295,60 +308,106 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用阿里云 ICE API 查询任务状态
|
||||
String status = iceClient.getMediaProducingJobStatus(jobId);
|
||||
// 检查所有jobId的状态
|
||||
List<String> successJobIds = new ArrayList<>();
|
||||
List<String> failedJobIds = new ArrayList<>();
|
||||
List<String> runningJobIds = new ArrayList<>();
|
||||
List<String> pendingJobIds = new ArrayList<>();
|
||||
List<String> unknownJobIds = new ArrayList<>();
|
||||
|
||||
// 根据ICE状态更新任务
|
||||
if ("Success".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status)) {
|
||||
// 任务成功完成,需要获取实际的输出URL
|
||||
log.info("[MixTask][ICE任务执行成功] taskId={}, jobId={}", taskId, jobId);
|
||||
// 重新生成签名URL并更新任务
|
||||
updateTaskSuccess(taskId, jobId);
|
||||
} else if ("Failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
|
||||
// 任务失败 - 获取详细错误信息
|
||||
String errorMsg = "ICE任务执行失败";
|
||||
for (String jobId : jobIds) {
|
||||
try {
|
||||
// 尝试获取更详细的失败信息(如果ICE API支持)
|
||||
errorMsg = "ICE任务执行失败,状态: " + status;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[MixTask][获取详细失败信息失败] taskId={}", taskId, ex);
|
||||
}
|
||||
log.error("[MixTask][ICE任务执行失败] taskId={}, jobId={}, iceStatus={}, errorMsg={}",
|
||||
taskId, jobId, status, errorMsg);
|
||||
updateTaskError(taskId, errorMsg);
|
||||
} else if ("Running".equalsIgnoreCase(status) || "running".equalsIgnoreCase(status) || "Processing".equalsIgnoreCase(status)) {
|
||||
// 任务仍在运行,逐步更新进度
|
||||
// 1. 获取当前进度
|
||||
MixTaskDO currentTask = mixTaskMapper.selectById(taskId);
|
||||
if (currentTask != null) {
|
||||
int currentProgress = currentTask.getProgress() != null ? currentTask.getProgress() : 0;
|
||||
// 2. 根据当前进度逐步提升,但不超过95%
|
||||
int newProgress;
|
||||
if (currentProgress < 50) {
|
||||
newProgress = 50; // 首次看到Running时更新到50%
|
||||
} else if (currentProgress < 80) {
|
||||
newProgress = 80; // 第二次更新到80%
|
||||
} else if (currentProgress < 95) {
|
||||
newProgress = 95; // 最后更新到95%
|
||||
String status = iceClient.getMediaProducingJobStatus(jobId);
|
||||
log.debug("[MixTask][jobId状态] taskId={}, jobId={}, status={}", taskId, jobId, status);
|
||||
if ("Success".equalsIgnoreCase(status)) {
|
||||
successJobIds.add(jobId);
|
||||
} else if ("Failed".equalsIgnoreCase(status) || "Failure".equalsIgnoreCase(status)) {
|
||||
failedJobIds.add(jobId);
|
||||
} else if ("Running".equalsIgnoreCase(status) || "Processing".equalsIgnoreCase(status)
|
||||
|| "Producing".equalsIgnoreCase(status) || "Analyzing".equalsIgnoreCase(status)) {
|
||||
// 阿里云ICE状态:Running, Processing, Producing, Analyzing 都算运行中
|
||||
runningJobIds.add(jobId);
|
||||
} else if ("Pending".equalsIgnoreCase(status) || "Init".equalsIgnoreCase(status)) {
|
||||
// Init 是初始化状态,也属于待处理
|
||||
pendingJobIds.add(jobId);
|
||||
} else if ("Canceled".equalsIgnoreCase(status)) {
|
||||
// 取消的任务算失败
|
||||
failedJobIds.add(jobId);
|
||||
} else {
|
||||
newProgress = currentProgress; // 已经很高了,保持不变
|
||||
// 未知状态,继续等待,不直接算失败
|
||||
log.warn("[MixTask][未知状态] taskId={}, jobId={}, status={}", taskId, jobId, status);
|
||||
unknownJobIds.add(jobId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[MixTask][查询jobId状态异常] taskId={}, jobId={}", taskId, jobId, e);
|
||||
// 查询异常不应该直接当作失败,可能是临时网络问题,放入unknown等待下次检查
|
||||
unknownJobIds.add(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
if (newProgress != currentProgress) {
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, newProgress);
|
||||
log.info("[MixTask][进度更新] taskId={}, from={}%, to={}%", taskId, currentProgress, newProgress);
|
||||
int total = jobIds.size();
|
||||
log.info("[MixTask][状态汇总] taskId={}, total={}, success={}, failed={}, running={}, pending={}, unknown={}",
|
||||
taskId, total, successJobIds.size(), failedJobIds.size(), runningJobIds.size(), pendingJobIds.size(), unknownJobIds.size());
|
||||
|
||||
// 综合判断任务状态
|
||||
if (!runningJobIds.isEmpty() || !pendingJobIds.isEmpty() || !unknownJobIds.isEmpty()) {
|
||||
// 任一运行中、待处理或未知状态 → 更新进度,继续等待
|
||||
int currentProgress = task.getProgress() != null ? task.getProgress() : 0;
|
||||
int newProgress;
|
||||
if (currentProgress < 50) {
|
||||
newProgress = 50;
|
||||
} else if (currentProgress < 80) {
|
||||
newProgress = 80;
|
||||
} else if (currentProgress < 95) {
|
||||
newProgress = 95;
|
||||
} else {
|
||||
newProgress = currentProgress;
|
||||
}
|
||||
if (newProgress != currentProgress) {
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, newProgress);
|
||||
log.info("[MixTask][进度更新] taskId={}, from={}%, to={}%, 完成数={}/{}",
|
||||
taskId, currentProgress, newProgress, successJobIds.size(), total);
|
||||
}
|
||||
} else if (!successJobIds.isEmpty()) {
|
||||
// 至少有一个成功 → 任务成功(过滤掉失败的URL)
|
||||
List<String> outputUrls = task.getOutputUrlList();
|
||||
List<String> successUrls = new ArrayList<>();
|
||||
|
||||
// 根据成功的jobId索引获取对应的URL
|
||||
for (String successJobId : successJobIds) {
|
||||
int index = jobIds.indexOf(successJobId);
|
||||
if (index >= 0 && index < outputUrls.size()) {
|
||||
successUrls.add(outputUrls.get(index));
|
||||
}
|
||||
}
|
||||
} else if ("Pending".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) {
|
||||
// 任务等待中,更新进度为30%
|
||||
updateTaskStatus(taskId, MixTaskConstants.STATUS_RUNNING, 30);
|
||||
|
||||
if (successUrls.isEmpty()) {
|
||||
successUrls = outputUrls; // 兜底:使用原始URL列表
|
||||
}
|
||||
|
||||
// 更新任务为成功状态
|
||||
MixTaskDO updateTask = new MixTaskDO();
|
||||
updateTask.setId(taskId);
|
||||
updateTask.setStatus(MixTaskConstants.STATUS_SUCCESS);
|
||||
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
|
||||
updateTask.setOutputUrlList(successUrls);
|
||||
updateTask.setFinishTime(LocalDateTime.now());
|
||||
if (!failedJobIds.isEmpty()) {
|
||||
updateTask.setErrorMsg("部分视频生成失败,成功 " + successJobIds.size() + "/" + total + " 个");
|
||||
}
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
|
||||
log.info("[MixTask][任务完成] taskId={}, 成功数={}/{}, 失败数={}",
|
||||
taskId, successJobIds.size(), total, failedJobIds.size());
|
||||
} else {
|
||||
// 未知状态,记录日志但不更新
|
||||
log.warn("[MixTask][未知ICE状态] taskId={}, jobId={}, iceStatus={}", taskId, jobId, status);
|
||||
// 全部失败 → 任务失败
|
||||
String errorMsg = "全部ICE任务执行失败,共 " + total + " 个";
|
||||
log.error("[MixTask][ICE任务全部失败] taskId={}, jobIds={}", taskId, jobIds);
|
||||
updateTaskError(taskId, errorMsg);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MixTask][状态同步异常] taskId={}, jobId={}", taskId, jobId, e);
|
||||
log.error("[MixTask][状态同步异常] taskId={}, jobIds={}", taskId, jobIds, e);
|
||||
updateTaskError(taskId, "查询任务状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user