This commit is contained in:
2026-03-04 19:00:06 +08:00
parent af0b0c5070
commit 2d7ba035ca
6 changed files with 391 additions and 101 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" 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;

View File

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

View File

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

View File

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

View File

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