重构:文件系统模块化架构,增强 Markdown 渲染
- 拆分 FileSystem.vue 为模块化组件架构 - 新增 Markdown Mermaid 图表渲染支持 - 新增 180+ 编程语言代码高亮 - 修复编辑/预览模式切换渲染问题 - 优化亮色/暗色模式主题适配 - 新增 TypeScript 类型定义
This commit is contained in:
319
web/src/components/FileSystem/composables/useFilePreview.ts
Normal file
319
web/src/components/FileSystem/composables/useFilePreview.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 文件预览 Composable
|
||||
* 提供文件预览 URL 生成、媒体元数据获取等功能
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
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
|
||||
}
|
||||
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
|
||||
// 文件服务器 URL(硬编码,与旧版本保持一致)
|
||||
const fileServerURL = 'http://localhost:18765'
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
|
||||
// 媒体加载状态
|
||||
const imageLoading = ref(false)
|
||||
const currentImageDimensions = ref('')
|
||||
|
||||
/**
|
||||
* 获取预览 URL(与旧版本保持一致)
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
||||
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过内容检测文件类型(用于小文件)
|
||||
*/
|
||||
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 = (path: string) => {
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型
|
||||
*/
|
||||
const getFileType = (filename: string): FileType => {
|
||||
if (!filename || typeof filename !== 'string') return 'Binary' as FileType
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
// 图片
|
||||
if (FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
||||
return 'Image' as FileType
|
||||
}
|
||||
|
||||
// 视频
|
||||
if (FILE_EXTENSIONS.VIDEO.includes(ext)) {
|
||||
return 'Video' as FileType
|
||||
}
|
||||
|
||||
// 音频
|
||||
if (FILE_EXTENSIONS.AUDIO.includes(ext)) {
|
||||
return 'Audio' as FileType
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (ext === 'pdf') {
|
||||
return 'Pdf' as FileType
|
||||
}
|
||||
|
||||
// HTML
|
||||
if (['html', 'htm'].includes(ext)) {
|
||||
return 'Html' as FileType
|
||||
}
|
||||
|
||||
// Markdown
|
||||
if (['md', 'markdown'].includes(ext)) {
|
||||
return 'Markdown' as FileType
|
||||
}
|
||||
|
||||
// 代码
|
||||
if (FILE_EXTENSIONS.CODE.includes(ext)) {
|
||||
return 'Code' as FileType
|
||||
}
|
||||
|
||||
// 文本
|
||||
if (FILE_EXTENSIONS.TEXT.includes(ext)) {
|
||||
return 'Text' as FileType
|
||||
}
|
||||
|
||||
// 默认为二进制
|
||||
return 'Binary' as FileType
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
*/
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
*/
|
||||
const isVideoFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.VIDEO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
*/
|
||||
const isAudioFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 PDF 文件
|
||||
*/
|
||||
const isPdfFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 HTML 文件
|
||||
*/
|
||||
const isHtmlFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ['html', 'htm'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Markdown 文件
|
||||
*/
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ['md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为代码文件
|
||||
*/
|
||||
const isCodeFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.CODE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本文件
|
||||
*/
|
||||
const isTextFile = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.TEXT.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可预览
|
||||
*/
|
||||
const isPreviewable = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext) ||
|
||||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
|
||||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
|
||||
ext === 'pdf' ||
|
||||
['html', 'htm'].includes(ext) ||
|
||||
['md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可编辑
|
||||
*/
|
||||
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 = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||
FILE_EXTENSIONS.TEXT.includes(ext) ||
|
||||
['html', 'htm', 'md', 'markdown', 'json', 'xml'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载完成
|
||||
*/
|
||||
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,
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isHtmlFile,
|
||||
isMarkdownFile,
|
||||
isCodeFile,
|
||||
isTextFile,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
// 内容检测(异步,基于文件内容)
|
||||
detectByContent,
|
||||
|
||||
// 事件处理
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
startImageLoad,
|
||||
|
||||
// 工具方法
|
||||
getMediaMetadata
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user