修复
This commit is contained in:
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"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div class="video-duration">{{ formatDuration(video.duration) }}</div>
|
<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">
|
<div class="video-selected-mark" v-if="selectedVideoId === video.id">
|
||||||
<CheckOutlined />
|
<CheckOutlined />
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +131,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 视频预览弹窗 -->
|
||||||
|
<VideoPreviewModal
|
||||||
|
v-model:open="previewVisible"
|
||||||
|
:video-url="previewUrl"
|
||||||
|
:title="previewVideo?.fileName || '视频预览'"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -138,9 +149,11 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
CloseOutlined
|
CloseOutlined,
|
||||||
|
PlayCircleOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { MaterialService } from '@/api/material'
|
import { MaterialService } from '@/api/material'
|
||||||
|
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: {
|
open: {
|
||||||
@@ -169,6 +182,11 @@ const defaultCover = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9
|
|||||||
|
|
||||||
const modalTitle = '选择视频'
|
const modalTitle = '选择视频'
|
||||||
|
|
||||||
|
// 视频预览状态
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewVideo = ref(null)
|
||||||
|
|
||||||
// 获取视频列表
|
// 获取视频列表
|
||||||
const fetchVideoList = async () => {
|
const fetchVideoList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -225,6 +243,35 @@ const selectVideo = (video) => {
|
|||||||
selectedVideo.value = 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) => {
|
const handleImageError = (event) => {
|
||||||
event.target.src = defaultCover
|
event.target.src = defaultCover
|
||||||
}
|
}
|
||||||
@@ -239,7 +286,7 @@ const formatDuration = (seconds) => {
|
|||||||
const formatFileSize = (bytes) => {
|
const formatFileSize = (bytes) => {
|
||||||
if (!bytes) return '0 B'
|
if (!bytes) return '0 B'
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
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)
|
const size = bytes / Math.pow(1024, index)
|
||||||
return `${size.toFixed(1)} ${units[index]}`
|
return `${size.toFixed(1)} ${units[index]}`
|
||||||
}
|
}
|
||||||
@@ -436,6 +483,27 @@ watch(() => props.open, (isOpen) => {
|
|||||||
font-size: 14px;
|
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 {
|
.video-info {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
<div class="material-item__overlay">
|
<div class="material-item__overlay">
|
||||||
<span class="material-item__type-tag">{{ getFileTypeText(file.fileName) }}</span>
|
<span class="material-item__type-tag">{{ getFileTypeText(file.fileName) }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 文件信息 -->
|
<!-- 文件信息 -->
|
||||||
@@ -261,6 +266,13 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 视频预览弹窗 -->
|
||||||
|
<VideoPreviewModal
|
||||||
|
v-model:open="previewVisible"
|
||||||
|
:video-url="previewUrl"
|
||||||
|
:title="previewTitle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FullWidthLayout>
|
</FullWidthLayout>
|
||||||
</template>
|
</template>
|
||||||
@@ -279,13 +291,15 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
CheckSquareOutlined
|
CheckSquareOutlined,
|
||||||
|
PlayCircleOutlined
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
||||||
import MaterialService, { MaterialGroupService } from '@/api/material';
|
import MaterialService, { MaterialGroupService } from '@/api/material';
|
||||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import VideoPreviewModal from '@/components/VideoPreviewModal.vue';
|
||||||
|
|
||||||
// 用户状态(获取存储配额)
|
// 用户状态(获取存储配额)
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -313,6 +327,11 @@ const createGroupForm = reactive({
|
|||||||
// 文件选择
|
// 文件选择
|
||||||
const selectedFileIds = ref([])
|
const selectedFileIds = ref([])
|
||||||
|
|
||||||
|
// 视频预览状态
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewTitle = ref('')
|
||||||
|
|
||||||
// 编辑状态
|
// 编辑状态
|
||||||
const editingFileId = ref(null)
|
const editingFileId = ref(null)
|
||||||
const editingDisplayName = ref('')
|
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) => {
|
const handleDeleteGroup = async (group, event) => {
|
||||||
event?.stopPropagation?.()
|
event?.stopPropagation?.()
|
||||||
|
|
||||||
@@ -407,39 +442,17 @@ const handleDeleteGroup = async (group, event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await new Promise((resolve) => {
|
const confirmed = await showConfirmDelete('删除分组', `确定要删除分组「${group.name}」吗?此操作不可恢复。`)
|
||||||
Modal.confirm({
|
|
||||||
title: '删除分组',
|
|
||||||
content: `确定要删除分组「${group.name}」吗?此操作不可恢复。`,
|
|
||||||
okText: '确认删除',
|
|
||||||
cancelText: '取消',
|
|
||||||
okType: 'danger',
|
|
||||||
centered: true,
|
|
||||||
class: 'delete-group-modal',
|
|
||||||
onOk() {
|
|
||||||
resolve(true)
|
|
||||||
},
|
|
||||||
onCancel() {
|
|
||||||
resolve(false)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await MaterialGroupService.deleteGroup(group.id)
|
await MaterialGroupService.deleteGroup(group.id)
|
||||||
|
|
||||||
// 移除已删除的分组
|
// 移除已删除的分组并重新选择
|
||||||
const index = groupList.value.findIndex(g => g.id === group.id)
|
groupList.value = groupList.value.filter(g => g.id !== group.id)
|
||||||
if (index > -1) {
|
|
||||||
groupList.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新选择分组
|
|
||||||
if (selectedGroupId.value === 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()
|
await loadFileList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,34 +595,16 @@ const handleBatchDelete = async () => {
|
|||||||
|
|
||||||
const count = selectedFileIds.value.length
|
const count = selectedFileIds.value.length
|
||||||
const fileIdsToDelete = [...selectedFileIds.value]
|
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
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await MaterialService.deleteFiles(fileIdsToDelete)
|
await MaterialService.deleteFiles(fileIdsToDelete)
|
||||||
|
|
||||||
// 移除已删除的文件
|
// 移除已删除的文件并更新状态
|
||||||
fileList.value = fileList.value.filter(file => !fileIdsToDelete.includes(file.id))
|
fileList.value = fileList.value.filter(file => !fileIdsToDelete.includes(file.id))
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
totalFileCount.value = Math.max(0, totalFileCount.value - count)
|
totalFileCount.value = Math.max(0, totalFileCount.value - count)
|
||||||
selectedFileIds.value = []
|
selectedFileIds.value = []
|
||||||
|
|
||||||
@@ -618,7 +613,7 @@ const handleBatchDelete = async () => {
|
|||||||
|
|
||||||
// 如果删除后当前页没有数据了,则加载上一页
|
// 如果删除后当前页没有数据了,则加载上一页
|
||||||
if (fileList.value.length === 0 && pagination.current > 1) {
|
if (fileList.value.length === 0 && pagination.current > 1) {
|
||||||
pagination.current = pagination.current - 1
|
pagination.current--
|
||||||
await loadFileList()
|
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']
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
let unitIndex = 0
|
const index = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
const size = bytes / Math.pow(1024, index)
|
||||||
size /= 1024
|
return `${size.toFixed(1)} ${units[index]}`
|
||||||
unitIndex++
|
|
||||||
}
|
|
||||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
const formatDate = (date) => {
|
||||||
@@ -707,6 +700,43 @@ const handleImageError = (e) => {
|
|||||||
e.target.style.display = 'none'
|
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, () => {
|
watch(activeCategory, () => {
|
||||||
selectedFileIds.value = []
|
selectedFileIds.value = []
|
||||||
@@ -1251,6 +1281,37 @@ onMounted(async () => {
|
|||||||
transition: opacity var(--duration-slow) ease;
|
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 {
|
&__type-tag {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1358,8 +1419,7 @@ onMounted(async () => {
|
|||||||
// ========================================
|
// ========================================
|
||||||
// 弹窗样式
|
// 弹窗样式
|
||||||
// ========================================
|
// ========================================
|
||||||
:deep(.batch-delete-modal),
|
:deep(.ant-modal-confirm) {
|
||||||
:deep(.delete-group-modal) {
|
|
||||||
.ant-modal-content {
|
.ant-modal-content {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
:min="15"
|
:min="15"
|
||||||
:max="30"
|
:max="30"
|
||||||
:step="1"
|
:step="1"
|
||||||
|
:tooltip-visible="false"
|
||||||
style="width: 100px;"
|
style="width: 100px;"
|
||||||
/>
|
/>
|
||||||
<span class="param-value">{{ formData.totalDuration }}s</span>
|
<span class="param-value">{{ formData.totalDuration }}s</span>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
:min="3"
|
:min="3"
|
||||||
:max="5"
|
:max="5"
|
||||||
:step="1"
|
:step="1"
|
||||||
|
:tooltip-visible="false"
|
||||||
style="width: 80px;"
|
style="width: 80px;"
|
||||||
/>
|
/>
|
||||||
<span class="param-value">{{ formData.clipDuration }}s</span>
|
<span class="param-value">{{ formData.clipDuration }}s</span>
|
||||||
@@ -321,8 +323,8 @@ const showSettings = ref(false)
|
|||||||
const formData = ref({
|
const formData = ref({
|
||||||
title: '',
|
title: '',
|
||||||
produceCount: Number(localStorage.getItem('mix-produce-count')) || 3,
|
produceCount: Number(localStorage.getItem('mix-produce-count')) || 3,
|
||||||
totalDuration: 15,
|
totalDuration: Number(localStorage.getItem('mix-total-duration')) || 15,
|
||||||
clipDuration: 5,
|
clipDuration: Number(localStorage.getItem('mix-clip-duration')) || 5,
|
||||||
cropMode: localStorage.getItem('mix-crop-mode') || 'center'
|
cropMode: localStorage.getItem('mix-crop-mode') || 'center'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,6 +420,19 @@ watch(sceneCount, (newCount) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 当 clipDuration 变化时,同步更新所有场景的 duration,并保存到本地
|
||||||
|
watch(() => formData.value.clipDuration, (newDuration) => {
|
||||||
|
localStorage.setItem('mix-clip-duration', newDuration.toString())
|
||||||
|
dataState.value.scenes.forEach(scene => {
|
||||||
|
scene.duration = newDuration
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当 totalDuration 变化时,保存到本地
|
||||||
|
watch(() => formData.value.totalDuration, (newDuration) => {
|
||||||
|
localStorage.setItem('mix-total-duration', newDuration.toString())
|
||||||
|
})
|
||||||
|
|
||||||
const onSceneDragEnd = () => {
|
const onSceneDragEnd = () => {
|
||||||
dataState.value.scenes.forEach((scene, index) => {
|
dataState.value.scenes.forEach((scene, index) => {
|
||||||
scene.index = index
|
scene.index = index
|
||||||
|
|||||||
@@ -96,6 +96,16 @@
|
|||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<a-space>
|
<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
|
<a-button
|
||||||
v-if="isStatus(record.status, 'success')"
|
v-if="isStatus(record.status, 'success')"
|
||||||
type="link"
|
type="link"
|
||||||
@@ -122,19 +132,27 @@
|
|||||||
</a-table>
|
</a-table>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频预览弹窗 -->
|
||||||
|
<VideoPreviewModal
|
||||||
|
v-model:open="previewVisible"
|
||||||
|
:video-url="previewUrl"
|
||||||
|
:title="previewTitle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { SearchOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
import { SearchOutlined, DownloadOutlined, DeleteOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
|
||||||
import { getDigitalHumanTaskPage, cancelTask, deleteTask } from '@/api/digitalHuman'
|
import { getDigitalHumanTaskPage, cancelTask, deleteTask, getSignedUrls } from '@/api/digitalHuman'
|
||||||
import { formatDate } from '@/utils/file'
|
import { formatDate } from '@/utils/file'
|
||||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||||
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||||
|
import VideoPreviewModal from '@/components/VideoPreviewModal.vue'
|
||||||
|
|
||||||
// 进度状态映射
|
// 进度状态映射
|
||||||
const PROGRESS_STATUS = {
|
const PROGRESS_STATUS = {
|
||||||
@@ -157,9 +175,43 @@ const rowSelection = {
|
|||||||
onChange: (keys) => { selectedRowKeys.value = keys }
|
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 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) => {
|
const handleDownload = (record) => {
|
||||||
if (!record.resultVideoUrl) {
|
if (!record.resultVideoUrl) {
|
||||||
@@ -268,6 +320,7 @@ onMounted(fetchList)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.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); } }
|
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
|
||||||
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,70 +331,49 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
log.info("[deleteFiles][用户({})删除文件成功,数量({})]", userId, fileIds.size());
|
log.info("[deleteFiles][用户({})删除文件成功,数量({})]", userId, fileIds.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public String getVideoPlayUrl(Long fileId) {
|
* 查询用户文件(校验归属权)
|
||||||
|
*/
|
||||||
|
private TikUserFileDO getUserFile(Long fileId) {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
|
||||||
// 查询文件(根据 fileId 字段查询)
|
|
||||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||||
.eq(TikUserFileDO::getFileId, fileId)
|
.eq(TikUserFileDO::getId, fileId)
|
||||||
.eq(TikUserFileDO::getUserId, userId));
|
.eq(TikUserFileDO::getUserId, userId));
|
||||||
|
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
throw exception(FILE_NOT_EXISTS, "文件不存在");
|
throw exception(FILE_NOT_EXISTS, "文件不存在");
|
||||||
}
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getVideoPlayUrl(Long fileId) {
|
||||||
|
TikUserFileDO file = getUserFile(fileId);
|
||||||
|
|
||||||
// 校验文件URL是否为空
|
|
||||||
if (StrUtil.isBlank(file.getFileUrl())) {
|
if (StrUtil.isBlank(file.getFileUrl())) {
|
||||||
throw exception(FILE_NOT_EXISTS, "文件URL为空");
|
throw exception(FILE_NOT_EXISTS, "文件URL为空");
|
||||||
}
|
}
|
||||||
|
if (!StrUtil.containsIgnoreCase(file.getFileType(), "video")) {
|
||||||
// 校验是否为视频文件
|
|
||||||
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
|
||||||
if (!isVideo) {
|
|
||||||
throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型");
|
throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成预签名URL(24小时有效期)
|
|
||||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getAudioPlayUrl(Long fileId) {
|
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")) {
|
if (!StrUtil.containsIgnoreCase(file.getFileType(), "audio")) {
|
||||||
throw exception(FILE_CATEGORY_INVALID, "文件不是音频类型");
|
throw exception(FILE_CATEGORY_INVALID, "文件不是音频类型");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成预签名URL(1小时有效期)
|
|
||||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getPreviewUrl(Long fileId, String type) {
|
public String getPreviewUrl(Long fileId, String type) {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
TikUserFileDO file = getUserFile(fileId);
|
||||||
|
|
||||||
// 查询文件(根据主键id查询)
|
|
||||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
|
||||||
if (file == null || !file.getUserId().equals(userId)) {
|
|
||||||
throw exception(FILE_NOT_EXISTS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据类型返回预览URL
|
|
||||||
String previewUrl = determinePreviewUrl(file, type);
|
String previewUrl = determinePreviewUrl(file, type);
|
||||||
|
|
||||||
// 生成预签名URL(1小时有效期)
|
|
||||||
return getCachedPresignUrl(previewUrl, PRESIGN_URL_EXPIRATION_SECONDS);
|
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();
|
return file.getCoverUrl();
|
||||||
}
|
}
|
||||||
if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
if ("thumbnail".equals(type) && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||||
return file.getThumbnailUrl();
|
return file.getThumbnailUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据文件类型自动选择
|
// 根据文件类型自动选择:图片优先缩略图,否则原图
|
||||||
if (FileTypeUtils.isImage(file.getFileType())) {
|
if (FileTypeUtils.isImage(file.getFileType())) {
|
||||||
return StrUtil.isNotBlank(file.getThumbnailUrl()) ? file.getThumbnailUrl() : file.getFileUrl();
|
return StrUtil.isNotBlank(file.getThumbnailUrl()) ? file.getThumbnailUrl() : file.getFileUrl();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user