feat: 优化

This commit is contained in:
2025-11-16 22:59:26 +08:00
parent 1540e44288
commit bd2e8b3291
10 changed files with 550 additions and 178 deletions

View File

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

View File

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

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

View 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)
// 转换为 base64JPEG 格式,指定质量)
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
}

View File

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