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

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