import type React from 'react' const FALLBACK_IMAGE_EXTENSIONS = new Set([ 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp', ]) export interface ImageFileValidationOptions { maxBytes?: number maxCount?: number existingCount?: number /** * 可选的业务格式白名单。浏览器的 image/* MIME 范围比实际处理能力更宽, * 需要在特定上传链路中按扩展名提前拦截不兼容格式。 */ allowedExtensions?: readonly string[] } export interface ImageFileValidationResult { acceptedFiles: File[] errors: string[] } export function hasDragLeftDropZone(event: React.DragEvent): boolean { const nextTarget = event.relatedTarget as Node | null if (nextTarget && nextTarget !== event.currentTarget) { return !event.currentTarget.contains(nextTarget) } const rect = event.currentTarget.getBoundingClientRect() const { clientX, clientY } = event return clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom } export function prepareFileDrag(event: React.DragEvent, disabled = false): void { event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = disabled ? 'none' : 'copy' } /** * 让拖拽上传区域也能通过键盘打开文件选择器,避免只能用鼠标点击。 */ export function handleDropZoneKeyDown( event: React.KeyboardEvent, openFilePicker: () => void, ): void { if (event.target !== event.currentTarget) return if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() openFilePicker() } export function isSupportedImageFile(file: File): boolean { if (file.type) { return file.type.startsWith('image/') } const extension = getFileExtension(file) return FALLBACK_IMAGE_EXTENSIONS.has(extension) } export function getFileExtension(file: File): string { return file.name.split('.').pop()?.toLowerCase() || '' } export function hasAllowedFileExtension(file: File, allowedExtensions: readonly string[]): boolean { const extension = getFileExtension(file) return allowedExtensions.some((allowedExtension) => allowedExtension.replace(/^\./, '').toLowerCase() === extension) } export function validateImageFiles( files: FileList | File[], options: ImageFileValidationOptions = {}, ): ImageFileValidationResult { const { maxBytes, maxCount, existingCount = 0, allowedExtensions } = options const unsupportedNames: string[] = [] const oversizedNames: string[] = [] const validFiles: File[] = [] for (const file of Array.from(files)) { const isValidImage = allowedExtensions ? hasAllowedFileExtension(file, allowedExtensions) : isSupportedImageFile(file) if (!isValidImage) { unsupportedNames.push(file.name) continue } if (maxBytes !== undefined && file.size > maxBytes) { oversizedNames.push(file.name) continue } validFiles.push(file) } const remainingCount = maxCount === undefined ? validFiles.length : Math.max(0, maxCount - existingCount) const acceptedFiles = validFiles.slice(0, remainingCount) const ignoredByCount = Math.max(0, validFiles.length - acceptedFiles.length) const errors: string[] = [] if (unsupportedNames.length > 0) { const supportedLabel = allowedExtensions?.length ? `,请上传 ${allowedExtensions.map((extension) => extension.replace(/^\./, '').toUpperCase()).join('、')} 格式` : '' errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}${supportedLabel}`) } if (oversizedNames.length > 0 && maxBytes !== undefined) { const maxMegabytes = Math.round(maxBytes / 1024 / 1024) errors.push(`以下图片超过 ${maxMegabytes}MB:${oversizedNames.join('、')}`) } if (ignoredByCount > 0 && maxCount !== undefined) { errors.push(`最多只能选择 ${maxCount} 张图片,已忽略 ${ignoredByCount} 张`) } return { acceptedFiles, errors } }