/** * 文件预览 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 { getFileServerBaseURL } from '@/api/file-server' import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isTextEditable, isConfigFile } from '@/utils/fileTypeHelpers' import type { FileType } from '@/types/file-system' // 内容检测大小限制(与后端一致) const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB // 缓存检测结果 const contentDetectCache = new Map() const CACHE_TTL = 60000 // 1分钟缓存 const CACHE_MAX_SIZE = 200 // ====== URL 工具函数(导出供外部使用) ====== /** 解析文件服务器 base URL(区分本地/远程模式) */ export function resolveFileServerBase(): string { if (!connectionManager.isRemote()) return getFileServerBaseURL() const base = connectionManager.getFileServerBaseURL() if (!base) return getFileServerBaseURL() return base.replace(/\/$/, '') + '/api/v1/proxy/localfs' } /** 拼接本地文件服务器 URL(远程文件下载到临时目录后使用) */ export function buildLocalUrl(localPath: string): string { const base = getFileServerBaseURL() let normalized = normalizeFilePath(localPath, true) if (normalized.startsWith('/')) normalized = normalized.slice(1) const sep = base.endsWith('/') ? '' : '/' return `${base}${sep}localfs/${normalized}` } /** 统一 URL 解析核心(传入 transport 实例,消除 instanceof 分支) */ async function resolveWithTransport( transport: import('@/api/transport').FsTransport, path: string, stream: boolean ): Promise { // 需要下载到本地的传输类型 if (transport.downloadForPreview) { // OSS 支持签名 URL 流式播放 if (stream && transport.getSignedUrl) { return await transport.getSignedUrl(path) } const tempPath = await transport.downloadForPreview(path) if (tempPath !== path) return buildLocalUrl(tempPath) // downloadForPreview 返回原路径 → 本地/HTTP,直接拼 URL } // 本地/HTTP: 直接拼文件服务器 URL const base = resolveFileServerBase() let normalized = normalizeFilePath(path, true) if (normalized.startsWith('/')) normalized = normalized.slice(1) const sep = base.endsWith('/') ? '' : '/' return `${base}${sep}${connectionManager.isRemote() ? '' : 'localfs/'}${normalized}` } /** * 统一文件 URL 解析(使用当前活跃 transport) */ export async function resolveFileUrl(path: string, stream = false): Promise { if (!path) return '' return resolveWithTransport(connectionManager.getTransport(), path, stream) } /** * 按指定 profileId 解析文件 URL(播放列表恢复用) * 不切换当前活跃连接,直接从连接池取对应 transport */ export async function resolveFileUrlForProfile( path: string, profileId: string | undefined, stream = false ): Promise { if (!path) return '' if (profileId) { const transport = connectionManager.getTransportFor(profileId) if (transport) return resolveWithTransport(transport, path, stream) } return resolveFileUrl(path, stream) } export function useFilePreview() { // 预览 URL const previewUrl = ref('') // 媒体加载状态 const imageLoading = ref(false) const currentImageDimensions = ref('') /** * 获取预览 URL(本地/远程/SFTP 自适应,每次实时计算) */ const getPreviewUrl = (path: string): string => { if (!path) return '' const base = resolveFileServerBase() let normalized = normalizeFilePath(path, true) if (normalized.startsWith('/')) normalized = normalized.slice(1) const sep = base.endsWith('/') ? '' : '/' return `${base}${sep}${connectionManager.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 } if (contentDetectCache.size >= CACHE_MAX_SIZE) contentDetectCache.clear() contentDetectCache.set(path, { timestamp: Date.now(), result: data }) return data } catch { return null } } /** * 更新预览 URL */ const updatePreviewUrl = async (path: string) => { if (!path) { previewUrl.value = ''; return } try { previewUrl.value = await resolveFileUrl(path) } catch (e) { console.warn('[Preview] 下载失败:', path, e) previewUrl.value = '' } } /** * 获取文件类型 */ 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 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 = '' } return { previewUrl, imageLoading, currentImageDimensions, getPreviewUrl, updatePreviewUrl, resolveFileUrl, getFileType, isEditable, detectByContent, onImageLoad, onImageError, startImageLoad } }