Compare commits

...

7 Commits

Author SHA1 Message Date
fab8480f83 feat: 优化 2026-03-04 19:25:38 +08:00
2d7ba035ca 修复 2026-03-04 19:00:06 +08:00
af0b0c5070 优化 2026-03-04 04:01:48 +08:00
16043dd52e 优化 2026-03-04 03:54:54 +08:00
8dc3501990 修复 2026-03-04 03:49:03 +08:00
b5ec2bf3d0 优化 2026-03-04 03:29:51 +08:00
0d85a43082 优化 2026-03-04 03:27:16 +08:00
18 changed files with 1004 additions and 398 deletions

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}

View File

@@ -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('下载失败,请稍后重试')

View File

@@ -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); } }
}

View File

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

View File

@@ -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 {

View File

@@ -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 提取 pathCDN 域名)
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
*/

View File

@@ -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;
}
}

View File

@@ -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, "文件不是视频类型");
}
// 生成预签名URL24小时有效期
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, "文件不是音频类型");
}
// 生成预签名URL1小时有效期
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);
// 生成预签名URL1小时有效期
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();
}

View File

@@ -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竖屏输出
// 假设源素材为1920x108016: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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 * * * ?";
/**
* 任务状态检查优化配置

View File

@@ -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);
/**
* 保存任务结果

View File

@@ -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());
}
}