feat: 优化
This commit is contained in:
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user