feat: 优化
This commit is contained in:
@@ -31,14 +31,23 @@ export const MaterialService = {
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {FormData} formData - 文件表单数据
|
||||
* @param {File} file - 文件对象
|
||||
* @param {string} fileCategory - 文件分类(video/generate/audio/mix/voice)
|
||||
* @param {string} coverBase64 - 视频封面 base64(可选,data URI 格式)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
uploadFile(file, fileCategory) {
|
||||
uploadFile(file, fileCategory, coverBase64 = null) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('fileCategory', fileCategory)
|
||||
|
||||
// 如果有封面 base64,添加到表单数据
|
||||
if (coverBase64) {
|
||||
// base64 格式:data:image/jpeg;base64,/9j/4AAQ...
|
||||
// 后端会解析这个格式
|
||||
formData.append('coverBase64', coverBase64)
|
||||
}
|
||||
|
||||
// 大文件上传需要更长的超时时间(30分钟)
|
||||
return http.post(`${BASE_URL}/upload`, formData, {
|
||||
timeout: 30 * 60 * 1000 // 30分钟
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, FileOutlined } from '@ant-design/icons-vue'
|
||||
import { isVideoFile, extractVideoCover } from '@/utils/video-cover'
|
||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -94,53 +96,30 @@ const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
|
||||
|
||||
// 数据
|
||||
const fileList = ref([])
|
||||
// 文件分类使用默认值,不再在UI中显示
|
||||
const fileCoverMap = ref(new Map())
|
||||
const DEFAULT_FILE_CATEGORY = 'video'
|
||||
|
||||
// 支持的文件类型
|
||||
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
|
||||
|
||||
// 常量
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
// 监听 visible 变化,重置文件列表
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
fileList.value = []
|
||||
fileCoverMap.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
// 获取文件名
|
||||
const getFileName = (fileItem) => {
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem.name
|
||||
}
|
||||
return fileItem.name || fileItem.file?.name || fileItem.originFileObj?.name || '未知文件'
|
||||
}
|
||||
|
||||
// 获取文件大小
|
||||
const getFileSize = (fileItem) => {
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem.size
|
||||
}
|
||||
return fileItem.size || fileItem.file?.size || fileItem.originFileObj?.size || 0
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 上传前处理
|
||||
const handleBeforeUpload = (file) => {
|
||||
// 检查文件大小(100MB)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
// 检查文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.warning(`文件 ${file.name} 超过 100MB,已跳过`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否已存在相同文件(使用当前的 fileList)
|
||||
// 检查是否已存在相同文件
|
||||
const exists = fileList.value.some(item => {
|
||||
const itemName = getFileName(item)
|
||||
const itemSize = getFileSize(item)
|
||||
@@ -151,24 +130,41 @@ const handleBeforeUpload = (file) => {
|
||||
return false
|
||||
}
|
||||
|
||||
// 阻止自动上传,文件会通过 change 事件添加到列表
|
||||
return false
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 文件列表变化
|
||||
const handleFileChange = (info) => {
|
||||
// 使用 v-model:file-list 后,fileList 会自动更新
|
||||
// 这里只需要处理文件验证和状态
|
||||
const handleFileChange = async (info) => {
|
||||
const { file, fileList: newFileList } = info
|
||||
|
||||
if (file && file.status !== 'uploading') {
|
||||
// 确保文件对象正确保存
|
||||
fileList.value = newFileList.map(item => {
|
||||
if (!item.file && item.originFileObj) {
|
||||
item.file = item.originFileObj
|
||||
fileList.value = newFileList
|
||||
.map(item => {
|
||||
if (!item.file && item.originFileObj) {
|
||||
item.file = item.originFileObj
|
||||
}
|
||||
return item
|
||||
})
|
||||
.filter(item => item.status !== 'removed')
|
||||
|
||||
// 如果是视频文件,自动提取封面
|
||||
const fileObj = file.file || file.originFileObj || file
|
||||
if (fileObj instanceof File && isVideoFile(fileObj)) {
|
||||
const fileKey = file.uid || fileObj.name
|
||||
|
||||
if (!fileCoverMap.value.has(fileKey)) {
|
||||
try {
|
||||
const coverResult = await extractVideoCover(fileObj, {
|
||||
maxWidth: 800,
|
||||
quality: 0.8
|
||||
})
|
||||
fileCoverMap.value.set(fileKey, coverResult.base64)
|
||||
console.log(`[封面提取成功] ${fileObj.name}`)
|
||||
} catch (error) {
|
||||
console.warn(`[封面提取失败] ${fileObj.name}:`, error)
|
||||
}
|
||||
}
|
||||
return item
|
||||
}).filter(item => item.status !== 'removed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +175,9 @@ const handleRemove = (fileItem) => {
|
||||
(getFileName(item) === getFileName(fileItem))
|
||||
)
|
||||
if (index > -1) {
|
||||
const removed = fileList.value[index]
|
||||
const fileKey = removed.uid || getFileName(removed)
|
||||
fileCoverMap.value.delete(fileKey)
|
||||
fileList.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
@@ -190,32 +189,26 @@ const handleConfirm = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 提取文件对象,优先使用 file,其次 originFileObj,最后是 item 本身
|
||||
const files = fileList.value
|
||||
// 提取文件对象和对应的封面
|
||||
const filesWithCover = fileList.value
|
||||
.map(item => {
|
||||
// 优先使用 file 属性
|
||||
if (item.file instanceof File) {
|
||||
return item.file
|
||||
const fileObj = item.file || item.originFileObj || item
|
||||
if (!(fileObj instanceof File)) return null
|
||||
|
||||
const fileKey = item.uid || fileObj.name
|
||||
return {
|
||||
file: fileObj,
|
||||
coverBase64: fileCoverMap.value.get(fileKey) || null
|
||||
}
|
||||
// 其次使用 originFileObj
|
||||
if (item.originFileObj instanceof File) {
|
||||
return item.originFileObj
|
||||
}
|
||||
// 最后尝试 item 本身(如果是 File 对象)
|
||||
if (item instanceof File) {
|
||||
return item
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(file => file instanceof File)
|
||||
.filter(item => item !== null)
|
||||
|
||||
if (files.length === 0) {
|
||||
if (filesWithCover.length === 0) {
|
||||
message.error('无法获取文件对象,请重新选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 使用默认分类
|
||||
emit('confirm', files, DEFAULT_FILE_CATEGORY)
|
||||
emit('confirm', filesWithCover, DEFAULT_FILE_CATEGORY)
|
||||
}
|
||||
|
||||
// 处理 visible 变化
|
||||
|
||||
60
frontend/app/web-gold/src/utils/file.js
Normal file
60
frontend/app/web-gold/src/utils/file.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 文件工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 文件大小(字节)
|
||||
* @returns {string} 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param {string|Date} dateStr - 日期字符串或Date对象
|
||||
* @param {string} format - 格式(可选,默认 'YYYY-MM-DD')
|
||||
* @returns {string} 格式化后的日期
|
||||
*/
|
||||
export function formatDate(dateStr, format = 'YYYY-MM-DD') {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
if (format === 'YYYY-MM-DD') {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件项中获取文件名
|
||||
* @param {File|Object} fileItem - 文件对象或文件项
|
||||
* @returns {string} 文件名
|
||||
*/
|
||||
export function getFileName(fileItem) {
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem.name
|
||||
}
|
||||
return fileItem.name || fileItem.file?.name || fileItem.originFileObj?.name || '未知文件'
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件项中获取文件大小
|
||||
* @param {File|Object} fileItem - 文件对象或文件项
|
||||
* @returns {number} 文件大小(字节)
|
||||
*/
|
||||
export function getFileSize(fileItem) {
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem.size
|
||||
}
|
||||
return fileItem.size || fileItem.file?.size || fileItem.originFileObj?.size || 0
|
||||
}
|
||||
|
||||
180
frontend/app/web-gold/src/utils/video-cover.ts
Normal file
180
frontend/app/web-gold/src/utils/video-cover.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 视频封面提取工具
|
||||
* 使用 HTML5 Video API 在浏览器中提取视频第一帧作为封面
|
||||
*/
|
||||
|
||||
export interface VideoCoverOptions {
|
||||
/** 封面最大宽度(默认 800px) */
|
||||
maxWidth?: number
|
||||
/** 封面质量 0-1(默认 0.8) */
|
||||
quality?: number
|
||||
/** 提取时间点(秒),默认取视频的 1/4 位置或第1秒(取较小值) */
|
||||
seekTime?: number
|
||||
/** 超时时间(毫秒,默认 10秒) */
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface VideoCoverResult {
|
||||
/** 封面 base64 数据(data URI 格式) */
|
||||
base64: string
|
||||
/** 封面宽度 */
|
||||
width: number
|
||||
/** 封面高度 */
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为视频文件
|
||||
* @param file 文件对象
|
||||
* @returns 是否为视频文件
|
||||
*/
|
||||
export function isVideoFile(file: File): boolean {
|
||||
return file.type.startsWith('video/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频封面
|
||||
* @param file 视频文件
|
||||
* @param options 提取选项
|
||||
* @returns Promise<VideoCoverResult> 封面 base64 数据
|
||||
*/
|
||||
export function extractVideoCover(
|
||||
file: File,
|
||||
options: VideoCoverOptions = {}
|
||||
): Promise<VideoCoverResult> {
|
||||
const {
|
||||
maxWidth = 800,
|
||||
quality = 0.8,
|
||||
seekTime,
|
||||
timeout = 10000
|
||||
} = options
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.setAttribute('webkit-playsinline', 'true')
|
||||
video.setAttribute('playsinline', 'true')
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
video.src = url
|
||||
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
video.src = ''
|
||||
video.remove()
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
timeoutId = setTimeout(() => {
|
||||
handleError(new Error('提取封面超时'))
|
||||
}, timeout)
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
try {
|
||||
// 计算提取时间点
|
||||
let targetTime = seekTime
|
||||
if (targetTime === undefined) {
|
||||
// 默认取视频的 1/4 位置或第1秒(取较小值),避免黑屏
|
||||
targetTime = Math.min(1, video.duration / 4)
|
||||
}
|
||||
// 确保时间在有效范围内
|
||||
targetTime = Math.max(0, Math.min(targetTime, video.duration - 0.1))
|
||||
video.currentTime = targetTime
|
||||
} catch (error) {
|
||||
handleError(new Error('设置视频时间点失败'))
|
||||
}
|
||||
}
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const videoWidth = video.videoWidth
|
||||
const videoHeight = video.videoHeight
|
||||
|
||||
if (videoWidth === 0 || videoHeight === 0) {
|
||||
handleError(new Error('无法获取视频尺寸'))
|
||||
return
|
||||
}
|
||||
|
||||
// 计算缩放比例,限制最大宽度
|
||||
const scale = Math.min(1, maxWidth / videoWidth)
|
||||
canvas.width = Math.floor(videoWidth * scale)
|
||||
canvas.height = Math.floor(videoHeight * scale)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
handleError(new Error('无法获取 Canvas 上下文'))
|
||||
return
|
||||
}
|
||||
|
||||
// 绘制视频帧到 Canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 转换为 base64(JPEG 格式,指定质量)
|
||||
const base64 = canvas.toDataURL('image/jpeg', quality)
|
||||
|
||||
cleanup()
|
||||
|
||||
resolve({
|
||||
base64,
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error instanceof Error ? error : new Error('提取封面失败'))
|
||||
}
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
handleError(new Error('视频加载失败'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量提取视频封面
|
||||
* @param files 文件数组
|
||||
* @param options 提取选项
|
||||
* @returns Promise<Map<string, VideoCoverResult>> 文件到封面的映射(key: 文件名或 uid)
|
||||
*/
|
||||
export async function extractVideoCovers(
|
||||
files: File[],
|
||||
options: VideoCoverOptions = {}
|
||||
): Promise<Map<string, VideoCoverResult>> {
|
||||
const result = new Map<string, VideoCoverResult>()
|
||||
|
||||
// 并行提取所有视频封面
|
||||
const promises = files
|
||||
.filter(isVideoFile)
|
||||
.map(async (file) => {
|
||||
try {
|
||||
const cover = await extractVideoCover(file, options)
|
||||
result.set(file.name, cover)
|
||||
} catch (error) {
|
||||
console.warn(`[extractVideoCovers] 提取封面失败: ${file.name}`, error)
|
||||
// 提取失败不影响其他文件
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -62,9 +62,26 @@
|
||||
<div class="material-item__content">
|
||||
<!-- 预览图 -->
|
||||
<div class="material-item__preview">
|
||||
<!-- 视频文件:统一使用 coverBase64,如果没有则使用 coverUrl,最后使用视频标签显示第一帧 -->
|
||||
<img
|
||||
v-if="file.previewUrl"
|
||||
v-if="file.isVideo && (file.coverBase64 || file.coverUrl)"
|
||||
:src="file.coverBase64"
|
||||
:alt="file.fileName"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- 视频文件:如果没有封面,使用视频标签显示第一帧 -->
|
||||
<video
|
||||
v-else-if="file.isVideo && file.previewUrl"
|
||||
:src="file.previewUrl"
|
||||
muted
|
||||
preload="metadata"
|
||||
class="preview-video"
|
||||
/>
|
||||
<!-- 图片或其他文件:使用图片标签 -->
|
||||
<!-- 优先级:coverUrl > coverBase64 > previewUrl -->
|
||||
<img
|
||||
v-else-if="file.coverUrl || file.coverBase64 || file.previewUrl"
|
||||
:src="file.coverUrl || file.coverBase64 || file.previewUrl"
|
||||
:alt="file.fileName"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -130,7 +147,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
UploadOutlined,
|
||||
SearchOutlined,
|
||||
@@ -138,6 +155,7 @@ import {
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
|
||||
import { formatFileSize, formatDate } from '@/utils/file'
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
@@ -159,24 +177,28 @@ const pagination = reactive({
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 构建查询参数
|
||||
const buildQueryParams = () => {
|
||||
const params = {
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters
|
||||
}
|
||||
// 处理日期范围
|
||||
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
|
||||
params.createTime = [
|
||||
`${filters.createTime[0]} 00:00:00`,
|
||||
`${filters.createTime[1]} 23:59:59`
|
||||
]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// 加载文件列表
|
||||
const loadFileList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters
|
||||
}
|
||||
// 处理日期范围(a-range-picker返回的是数组格式的字符串)
|
||||
// 日期区间:将开始日期设置为 00:00:00,结束日期设置为 23:59:59
|
||||
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
|
||||
params.createTime = [
|
||||
filters.createTime[0] + ' 00:00:00',
|
||||
filters.createTime[1] + ' 23:59:59'
|
||||
]
|
||||
}
|
||||
const res = await MaterialService.getFilePage(params)
|
||||
const res = await MaterialService.getFilePage(buildQueryParams())
|
||||
if (res.code === 0) {
|
||||
fileList.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
@@ -197,8 +219,8 @@ const handleOpenUploadModal = () => {
|
||||
}
|
||||
|
||||
// 确认上传
|
||||
const handleConfirmUpload = async (files, fileCategory) => {
|
||||
if (!files || files.length === 0) {
|
||||
const handleConfirmUpload = async (filesWithCover, fileCategory) => {
|
||||
if (!filesWithCover?.length) {
|
||||
message.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
@@ -208,10 +230,9 @@ const handleConfirmUpload = async (files, fileCategory) => {
|
||||
let failCount = 0
|
||||
|
||||
try {
|
||||
// 逐个上传文件,显示进度
|
||||
for (const file of files) {
|
||||
for (const { file, coverBase64 } of filesWithCover) {
|
||||
try {
|
||||
await MaterialService.uploadFile(file, fileCategory)
|
||||
await MaterialService.uploadFile(file, fileCategory, coverBase64)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', file.name, error)
|
||||
@@ -240,33 +261,37 @@ const handleUploadCancel = () => {
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedFileIds.value.length === 0) {
|
||||
return
|
||||
}
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedFileIds.value.length === 0) return
|
||||
|
||||
try {
|
||||
await MaterialService.deleteFiles(selectedFileIds.value)
|
||||
message.success('删除成功')
|
||||
selectedFileIds.value = []
|
||||
loadFileList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error(error.message || '删除失败,请重试')
|
||||
}
|
||||
// 二次确认弹窗
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${selectedFileIds.value.length} 个文件吗?删除后无法恢复。`,
|
||||
okText: '确定删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MaterialService.deleteFiles(selectedFileIds.value)
|
||||
message.success('删除成功')
|
||||
selectedFileIds.value = []
|
||||
loadFileList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error(error.message || '删除失败,请重试')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 选择文件
|
||||
// 选择文件(简化逻辑)
|
||||
const handleSelectFile = (fileId, checked) => {
|
||||
if (checked) {
|
||||
if (!selectedFileIds.value.includes(fileId)) {
|
||||
selectedFileIds.value.push(fileId)
|
||||
}
|
||||
} else {
|
||||
const index = selectedFileIds.value.indexOf(fileId)
|
||||
if (index > -1) {
|
||||
selectedFileIds.value.splice(index, 1)
|
||||
}
|
||||
const index = selectedFileIds.value.indexOf(fileId)
|
||||
if (checked && index === -1) {
|
||||
selectedFileIds.value.push(fileId)
|
||||
} else if (!checked && index > -1) {
|
||||
selectedFileIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,27 +324,50 @@ const handlePageChange = (page, pageSize) => {
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 图片加载错误处理(支持回退到其他图片源)
|
||||
const handleImageError = (e) => {
|
||||
e.target.style.display = 'none'
|
||||
const img = e.target
|
||||
const currentSrc = img.src
|
||||
|
||||
// 找到对应的文件对象
|
||||
const file = fileList.value.find(f =>
|
||||
f.coverBase64 === currentSrc ||
|
||||
f.coverUrl === currentSrc ||
|
||||
f.previewUrl === currentSrc
|
||||
)
|
||||
|
||||
if (file) {
|
||||
// 视频文件:优先使用 coverBase64,如果失败则尝试 coverUrl,最后尝试 previewUrl
|
||||
if (file.isVideo) {
|
||||
if (currentSrc === file.coverBase64 && file.coverUrl && file.coverUrl !== currentSrc) {
|
||||
// coverBase64 失败,尝试 coverUrl
|
||||
img.src = file.coverUrl
|
||||
return
|
||||
}
|
||||
if ((currentSrc === file.coverBase64 || currentSrc === file.coverUrl) && file.previewUrl && file.previewUrl !== currentSrc) {
|
||||
// coverBase64 和 coverUrl 都失败,尝试 previewUrl(视频第一帧)
|
||||
img.src = file.previewUrl
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 非视频文件:尝试其他图片源
|
||||
if (file.coverUrl && file.coverUrl !== currentSrc) {
|
||||
img.src = file.coverUrl
|
||||
return
|
||||
}
|
||||
if (file.coverBase64 && file.coverBase64 !== currentSrc && file.coverBase64.startsWith('data:image')) {
|
||||
img.src = file.coverBase64
|
||||
return
|
||||
}
|
||||
if (file.previewUrl && file.previewUrl !== currentSrc) {
|
||||
img.src = file.previewUrl
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有图片源都失败,隐藏图片
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
// 初始化
|
||||
@@ -421,7 +469,8 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.material-item__preview img {
|
||||
.material-item__preview img,
|
||||
.material-item__preview video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -430,6 +479,10 @@ onMounted(() => {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.material-item__preview video {
|
||||
pointer-events: none; /* 禁用视频交互,避免点击播放 */
|
||||
}
|
||||
|
||||
.material-item__placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -41,8 +41,10 @@ public class AppTikUserFileController {
|
||||
public CommonResult<Long> uploadFile(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "文件分类(video/generate/audio/mix/voice)", required = true)
|
||||
@RequestParam("fileCategory") String fileCategory) {
|
||||
return success(userFileService.uploadFile(file, fileCategory));
|
||||
@RequestParam("fileCategory") String fileCategory,
|
||||
@Parameter(description = "视频封面 base64(可选,data URI 格式)")
|
||||
@RequestParam(value = "coverBase64", required = false) String coverBase64) {
|
||||
return success(userFileService.uploadFile(file, fileCategory, coverBase64));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
|
||||
@@ -59,14 +59,14 @@ public class TikUserFileDO extends TenantBaseDO {
|
||||
* 文件存储路径
|
||||
*/
|
||||
private String filePath;
|
||||
/**
|
||||
* OSS根路径(用户手机MD5)
|
||||
*/
|
||||
private String ossRootPath;
|
||||
/**
|
||||
* 封面图URL(视频文件的封面图)
|
||||
*/
|
||||
private String coverUrl;
|
||||
/**
|
||||
* 封面图Base64(视频文件的封面图原始base64数据,可选)
|
||||
*/
|
||||
private String coverBase64;
|
||||
/**
|
||||
* 缩略图URL(图片文件的缩略图)
|
||||
*/
|
||||
|
||||
@@ -19,9 +19,10 @@ public interface TikUserFileService {
|
||||
*
|
||||
* @param file 文件
|
||||
* @param fileCategory 文件分类(video/generate/audio/mix/voice)
|
||||
* @param coverBase64 视频封面 base64(可选,data URI 格式)
|
||||
* @return 文件编号
|
||||
*/
|
||||
Long uploadFile(MultipartFile file, String fileCategory);
|
||||
Long uploadFile(MultipartFile file, String fileCategory, String coverBase64);
|
||||
|
||||
/**
|
||||
* 分页查询文件列表
|
||||
|
||||
@@ -42,6 +42,9 @@ import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*;
|
||||
@Slf4j
|
||||
public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
/** 预签名URL过期时间(1小时,单位:秒) */
|
||||
private static final int PRESIGN_URL_EXPIRATION_SECONDS = 3600;
|
||||
|
||||
@Resource
|
||||
private TikUserFileMapper userFileMapper;
|
||||
|
||||
@@ -58,7 +61,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
private FileMapper fileMapper;
|
||||
|
||||
@Override
|
||||
public Long uploadFile(MultipartFile file, String fileCategory) {
|
||||
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
|
||||
@@ -112,7 +115,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
// ========== 第三阶段:保存数据库(在事务中,如果失败则删除OSS文件) ==========
|
||||
try {
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath);
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory);
|
||||
} catch (Exception e) {
|
||||
// 数据库保存失败,删除已上传的OSS文件
|
||||
log.error("[uploadFile][保存数据库失败,准备删除OSS文件,URL({})]", fileUrl, e);
|
||||
@@ -126,11 +129,55 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||
String fileUrl, String filePath) {
|
||||
// 7. 获取OSS根路径
|
||||
String ossRootPath = ossInitService.getOssRootPath(userId);
|
||||
String fileUrl, String filePath, String coverBase64, String baseDirectory) {
|
||||
// 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录)
|
||||
String coverUrl = null;
|
||||
if (StrUtil.isNotBlank(coverBase64) && StrUtil.containsIgnoreCase(file.getContentType(), "video")) {
|
||||
try {
|
||||
// 解析 base64(格式:data:image/jpeg;base64,/9j/4AAQ...)
|
||||
String base64Data = coverBase64;
|
||||
if (base64Data.contains(",")) {
|
||||
base64Data = base64Data.substring(base64Data.indexOf(",") + 1);
|
||||
}
|
||||
|
||||
// 验证 base64 数据不为空
|
||||
if (StrUtil.isBlank(base64Data)) {
|
||||
log.warn("[saveFileRecord][视频封面 base64 数据为空,跳过封面处理]");
|
||||
} else {
|
||||
// 解码 base64
|
||||
byte[] coverBytes = java.util.Base64.getDecoder().decode(base64Data);
|
||||
|
||||
// 验证解码后的数据不为空
|
||||
if (coverBytes == null || coverBytes.length == 0) {
|
||||
log.warn("[saveFileRecord][视频封面 base64 解码后数据为空,跳过封面处理]");
|
||||
} else {
|
||||
// 生成封面文件名(原文件名_cover.jpg)
|
||||
String originalName = file.getOriginalFilename();
|
||||
String coverFileName = originalName != null && originalName.contains(".")
|
||||
? originalName.replaceFirst("\\.[^.]+$", "_cover.jpg")
|
||||
: "cover.jpg";
|
||||
|
||||
// 上传封面到 OSS(使用相同的目录结构)
|
||||
String uploadedUrl = fileApi.createFile(coverBytes, coverFileName, baseDirectory, "image/jpeg");
|
||||
|
||||
// 严格验证:确保返回的是有效的 URL,而不是 base64 字符串
|
||||
if (StrUtil.isNotBlank(uploadedUrl) && !uploadedUrl.equals(coverBase64) && !uploadedUrl.contains("data:image")) {
|
||||
coverUrl = uploadedUrl;
|
||||
log.info("[saveFileRecord][视频封面上传成功,封面URL({})]", coverUrl);
|
||||
} else {
|
||||
log.error("[saveFileRecord][视频封面上传返回无效URL,跳过保存封面。返回URL: {}", uploadedUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[saveFileRecord][视频封面 base64 解析失败,格式错误: {}]", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("[saveFileRecord][视频封面上传失败,错误信息: {}]", e.getMessage(), e);
|
||||
// 封面处理失败不影响主流程,继续保存文件记录
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 创建文件记录(保存完整路径)
|
||||
// 8. 创建文件记录(保存完整路径,包含封面URL和Base64)
|
||||
TikUserFileDO userFile = new TikUserFileDO()
|
||||
.setUserId(userId)
|
||||
.setFileId(null) // 显式设置为null,file_id是可选的,用于关联infra_file表
|
||||
@@ -140,20 +187,11 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
.setFileSize(file.getSize())
|
||||
.setFileUrl(fileUrl)
|
||||
.setFilePath(filePath) // 保存完整的OSS路径(由FileService生成)
|
||||
.setOssRootPath(ossRootPath);
|
||||
.setCoverUrl(coverUrl) // 设置封面URL(如果有)
|
||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据(如果有)
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
// 9. 异步生成预览图(视频封面或图片缩略图)
|
||||
// TODO: 后续实现视频封面和图片缩略图生成
|
||||
// if (StrUtil.containsIgnoreCase(file.getContentType(), "video")) {
|
||||
// generateVideoCoverAsync(userFile.getId(), fileContent, file.getOriginalFilename(),
|
||||
// file.getContentType(), baseDirectory);
|
||||
// } else if (FileTypeUtils.isImage(file.getContentType())) {
|
||||
// generateImageThumbnailAsync(userFile.getId(), fileContent, file.getOriginalFilename(),
|
||||
// file.getContentType(), baseDirectory);
|
||||
// }
|
||||
|
||||
// 10. 更新配额
|
||||
// 9. 更新配额
|
||||
quotaService.increaseUsedStorage(userId, file.getSize());
|
||||
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({})]", userId, userFile.getId());
|
||||
@@ -229,15 +267,30 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
vo.setIsVideo(isVideo);
|
||||
vo.setIsImage(isImage);
|
||||
|
||||
// 生成封面和缩略图的预签名URL(避免重复调用)
|
||||
String coverUrlPresigned = StrUtil.isNotBlank(file.getCoverUrl())
|
||||
? getCachedPresignUrl(file.getCoverUrl(), PRESIGN_URL_EXPIRATION_SECONDS)
|
||||
: null;
|
||||
String thumbnailUrlPresigned = StrUtil.isNotBlank(file.getThumbnailUrl())
|
||||
? getCachedPresignUrl(file.getThumbnailUrl(), PRESIGN_URL_EXPIRATION_SECONDS)
|
||||
: null;
|
||||
|
||||
// 设置封面和缩略图URL
|
||||
vo.setCoverUrl(coverUrlPresigned);
|
||||
vo.setThumbnailUrl(thumbnailUrlPresigned);
|
||||
|
||||
// 生成预览URL(优先使用封面/缩略图,否则使用原文件URL)
|
||||
String previewUrl = null;
|
||||
if (isVideo && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
previewUrl = getCachedPresignUrl(file.getCoverUrl(), 3600);
|
||||
} else if (isImage && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||
previewUrl = getCachedPresignUrl(file.getThumbnailUrl(), 3600);
|
||||
if (isVideo) {
|
||||
// 视频:优先使用封面,没有封面时使用原视频URL
|
||||
previewUrl = coverUrlPresigned != null
|
||||
? coverUrlPresigned
|
||||
: getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
} else if (isImage) {
|
||||
// 图片没有缩略图时,使用原图
|
||||
previewUrl = getCachedPresignUrl(file.getFileUrl(), 3600);
|
||||
// 图片:优先使用缩略图,没有缩略图时使用原图
|
||||
previewUrl = thumbnailUrlPresigned != null
|
||||
? thumbnailUrlPresigned
|
||||
: getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
vo.setPreviewUrl(previewUrl);
|
||||
|
||||
@@ -318,7 +371,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), 3600);
|
||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -337,7 +390,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), 3600);
|
||||
return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -351,26 +404,44 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
// 根据类型返回预览URL
|
||||
String previewUrl = null;
|
||||
if (StrUtil.equals(type, "cover") && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
// 视频封面
|
||||
previewUrl = file.getCoverUrl();
|
||||
} else if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||
// 图片缩略图
|
||||
previewUrl = file.getThumbnailUrl();
|
||||
} else if (FileTypeUtils.isImage(file.getFileType())) {
|
||||
// 图片没有缩略图时,使用原图
|
||||
previewUrl = file.getFileUrl();
|
||||
} else if (StrUtil.containsIgnoreCase(file.getFileType(), "video") && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
// 视频使用封面
|
||||
previewUrl = file.getCoverUrl();
|
||||
} else {
|
||||
// 其他情况返回原文件URL
|
||||
previewUrl = file.getFileUrl();
|
||||
}
|
||||
String previewUrl = determinePreviewUrl(file, type);
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(previewUrl, 3600);
|
||||
return getCachedPresignUrl(previewUrl, PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定预览URL
|
||||
*
|
||||
* @param file 文件对象
|
||||
* @param type 预览类型(cover/thumbnail,可选)
|
||||
* @return 预览URL
|
||||
*/
|
||||
private String determinePreviewUrl(TikUserFileDO file, String type) {
|
||||
// 明确指定封面类型
|
||||
if (StrUtil.equals(type, "cover") && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
return file.getCoverUrl();
|
||||
}
|
||||
// 明确指定缩略图类型
|
||||
if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||
return file.getThumbnailUrl();
|
||||
}
|
||||
|
||||
// 根据文件类型自动选择
|
||||
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
||||
boolean isImage = FileTypeUtils.isImage(file.getFileType());
|
||||
|
||||
if (isVideo && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||
return file.getCoverUrl();
|
||||
}
|
||||
if (isImage) {
|
||||
return StrUtil.isNotBlank(file.getThumbnailUrl())
|
||||
? file.getThumbnailUrl()
|
||||
: file.getFileUrl();
|
||||
}
|
||||
|
||||
// 默认返回原文件URL
|
||||
return file.getFileUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -44,6 +44,9 @@ public class AppTikUserFileRespVO {
|
||||
@Schema(description = "封面图URL(视频文件的封面图)")
|
||||
private String coverUrl;
|
||||
|
||||
@Schema(description = "封面图Base64(视频文件的封面图原始base64数据,可选)")
|
||||
private String coverBase64;
|
||||
|
||||
@Schema(description = "缩略图URL(图片文件的缩略图)")
|
||||
private String thumbnailUrl;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user