From bd2e8b329102ebd2e3f7222dfca12ee674b0f899 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sun, 16 Nov 2025 22:59:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/web-gold/src/api/material.js | 13 +- .../material/MaterialUploadModal.vue | 113 +++++------ frontend/app/web-gold/src/utils/file.js | 60 ++++++ .../app/web-gold/src/utils/video-cover.ts | 180 +++++++++++++++++ .../src/views/material/MaterialList.vue | 183 +++++++++++------- .../controller/AppTikUserFileController.java | 6 +- .../file/dal/dataobject/TikUserFileDO.java | 8 +- .../tik/file/service/TikUserFileService.java | 3 +- .../file/service/TikUserFileServiceImpl.java | 159 ++++++++++----- .../tik/file/vo/app/AppTikUserFileRespVO.java | 3 + 10 files changed, 550 insertions(+), 178 deletions(-) create mode 100644 frontend/app/web-gold/src/utils/file.js create mode 100644 frontend/app/web-gold/src/utils/video-cover.ts diff --git a/frontend/app/web-gold/src/api/material.js b/frontend/app/web-gold/src/api/material.js index ce58eedc38..b6fc765660 100644 --- a/frontend/app/web-gold/src/api/material.js +++ b/frontend/app/web-gold/src/api/material.js @@ -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分钟 diff --git a/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue b/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue index ee14966859..602b354e57 100644 --- a/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue +++ b/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue @@ -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 变化 diff --git a/frontend/app/web-gold/src/utils/file.js b/frontend/app/web-gold/src/utils/file.js new file mode 100644 index 0000000000..bc9d38a3d3 --- /dev/null +++ b/frontend/app/web-gold/src/utils/file.js @@ -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 +} + diff --git a/frontend/app/web-gold/src/utils/video-cover.ts b/frontend/app/web-gold/src/utils/video-cover.ts new file mode 100644 index 0000000000..2d84b5793f --- /dev/null +++ b/frontend/app/web-gold/src/utils/video-cover.ts @@ -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 封面 base64 数据 + */ +export function extractVideoCover( + file: File, + options: VideoCoverOptions = {} +): Promise { + 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> 文件到封面的映射(key: 文件名或 uid) + */ +export async function extractVideoCovers( + files: File[], + options: VideoCoverOptions = {} +): Promise> { + const result = new Map() + + // 并行提取所有视频封面 + 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 +} + diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue index 8ea8602772..6c5582b285 100644 --- a/frontend/app/web-gold/src/views/material/MaterialList.vue +++ b/frontend/app/web-gold/src/views/material/MaterialList.vue @@ -62,9 +62,26 @@
+ + +