/** * 文件预览 Composable * 提供文件预览 URL 生成、媒体元数据获取等功能 */ import { ref } from 'vue' import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { normalizeFilePath, getExt } from '@/utils/fileUtils' import { detectFileTypeByContent } from '@/api/system' import { connectionManager } from '@/api/connection-manager' import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType, isTextEditable, isConfigFile } from '@/utils/fileTypeHelpers' import type { FilePreviewMetadata, FileType } from '@/types/file-system' // 内容检测大小限制(与后端一致) const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB // 缓存检测结果 const contentDetectCache = new Map() const CACHE_TTL = 60000 // 1分钟缓存 export interface UseFilePreviewOptions { filePath?: string isBrowsingZip?: boolean } function getLocalServerURL(): string { return 'http://localhost:8073' } function resolveFileServerBase(): string { // 单一数据源:从 connectionManager 实时读取,不缓存 if (!connectionManager.isRemote()) return getLocalServerURL() const base = connectionManager.getFileServerBaseURL() if (!base) return getLocalServerURL() // 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs return base.replace(/\/$/, '') + '/api/v1/proxy/localfs' } export function useFilePreview(options: UseFilePreviewOptions = {}) { const { filePath = ref(''), isBrowsingZip = ref(false) } = options // 预览 URL const previewUrl = ref('') // 媒体加载状态 const imageLoading = ref(false) const currentImageDimensions = ref('') /** * 获取预览 URL(本地/远程自适应,每次实时计算) * 本地: http://localhost:8073/localfs/{encoded_path} * 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证) */ const getPreviewUrl = (path: string): string => { if (!path) return '' const isRemote = connectionManager.isRemote() const base = resolveFileServerBase() let normalized = normalizeFilePath(path, true) // 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向) if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1) const sep = base.endsWith('/') ? '' : '/' return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}` } /** * 通过内容检测文件类型(用于小文件) */ const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | null> => { // 如果文件太大,跳过内容检测 if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) { return null } // 检查缓存 const cached = contentDetectCache.get(path) if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.result } try { const result = await detectFileTypeByContent(path) const data = { category: result.category, ext: result.extension } contentDetectCache.set(path, { timestamp: Date.now(), result: data }) return data } catch { return null } } /** * 更新预览 URL */ const updatePreviewUrl = async (path: string) => { previewUrl.value = getPreviewUrl(path) } /** * 获取文件类型 */ const getFileType = (filename: string): FileType => { if (!filename || typeof filename !== 'string') return 'Binary' as FileType if (isImageFile(filename)) return 'Image' as FileType if (isVideoFile(filename)) return 'Video' as FileType if (isAudioFile(filename)) return 'Audio' as FileType if (isPdfFile(filename)) return 'Pdf' as FileType if (isHtmlFile(filename)) return 'Html' as FileType if (isMarkdownFile(filename)) return 'Markdown' as FileType if (FILE_EXTENSIONS.CODE.includes(getExt(filename))) return 'Code' as FileType if (isConfigFile(filename)) return 'Code' as FileType if (isTextEditable(filename)) return 'Text' as FileType return 'Binary' as FileType } /** * 判断文件是否可预览 */ const isPreviewable = (filename: string): boolean => { if (!filename || typeof filename !== 'string') return false return isPreviewableType(filename) } /** * 判断文件是否可编辑 */ const isEditable = (filename: string, fileSize: number): boolean => { if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) { return false } if (!filename || typeof filename !== 'string') return false const ext = getExt(filename) return FILE_EXTENSIONS.CODE.includes(ext) || isTextEditable(filename) || isConfigFile(filename) || isHtmlFile(filename) || isMarkdownFile(filename) } /** * 图片加载完成 */ const onImageLoad = (event: Event) => { const img = event.target as HTMLImageElement if (img.naturalWidth && img.naturalHeight) { currentImageDimensions.value = `${img.naturalWidth} × ${img.naturalHeight}` } imageLoading.value = false } /** * 图片加载失败 */ const onImageError = () => { imageLoading.value = false currentImageDimensions.value = '' } /** * 开始加载图片 */ const startImageLoad = () => { imageLoading.value = true currentImageDimensions.value = '' } /** * 获取媒体元数据 */ const getMediaMetadata = async (url: string): Promise => { const metadata: FilePreviewMetadata = {} // 对于图片,使用 Image 对象 if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) { return new Promise((resolve) => { const img = new Image() img.onload = () => { metadata.width = img.naturalWidth metadata.height = img.naturalHeight resolve(metadata) } img.onerror = () => resolve(metadata) img.src = url }) } // 对于视频/音频,可以使用 Video/Audio 对象 // 但由于跨域等问题,这里简化处理 return metadata } return { // 状态 previewUrl, imageLoading, currentImageDimensions, // URL 相关 getPreviewUrl, updatePreviewUrl, // 文件类型判断(同步,基于扩展名) getFileType, isPreviewable, isEditable, // 内容检测(异步,基于文件内容) detectByContent, // 事件处理 onImageLoad, onImageError, startImageLoad, // 工具方法 getMediaMetadata } }