216 lines
7.0 KiB
TypeScript
216 lines
7.0 KiB
TypeScript
/**
|
||
* 文件预览 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<string, { timestamp: number; result: any }>()
|
||
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<string> {
|
||
// 需要下载到本地的传输类型
|
||
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<string> {
|
||
if (!path) return ''
|
||
return resolveWithTransport(connectionManager.getTransport(), path, stream)
|
||
}
|
||
|
||
/**
|
||
* 按指定 profileId 解析文件 URL(播放列表恢复用)
|
||
* 不切换当前活跃连接,直接从连接池取对应 transport
|
||
*/
|
||
export async function resolveFileUrlForProfile(
|
||
path: string, profileId: string | undefined, stream = false
|
||
): Promise<string> {
|
||
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
|
||
}
|
||
}
|