- 工具栏:面包屑与右侧组件像素级等高(:deep 34px)、合并重复search handler、统一分隔符样式、删除死代码 - 面板对齐:三面板header统一padding/font-size、文件列表分页固定底部(自定义紧凑)、表头默认隐藏、滚动条统一样式 - 预览区:始终显示空白预览面板、重启自动恢复上次打开文件 - 收藏夹:简化计数显示(共N项) - 远程连接:ConnectionIndicator自适应UI(无远程显示mini云图标)、ConnectionDialog支持编辑配置、transport抽象层(本地Wails/远程HTTP双模式)、agent后端模块
224 lines
6.5 KiB
TypeScript
224 lines
6.5 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 {
|
||
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<string, { timestamp: number; result: any }>()
|
||
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<FilePreviewMetadata> => {
|
||
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
|
||
}
|
||
}
|