重构:Wails v3 迁移 + 前端目录规范化 + Sidebar滚动优化
- web/ → frontend/ 目录重命名(Wails v3 标准结构) - main.go: Middleware 修复 custom.js 404 + DevTools 延迟启动 - Sidebar: 收藏夹内部独立滚动 + 帮助区块固定底部 - useFavorites.ts: longPressTimer const→let 修复 TypeError - App.vue: Arco Tabs padding-top 覆盖 - build: config.yml / Taskfile.yml 对齐官方模板,devtools build tag - 新增 v3 bindings、vite.config.js、跨平台构建配置 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 拷贝路径 composable(3-tier fallback: Wails native → clipboard API → execCommand)
|
||||
*/
|
||||
export function useClipboardCopy() {
|
||||
const copied = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const copy = async (path: string) => {
|
||||
if (!path || copied.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
copied.value = true
|
||||
} catch {
|
||||
try {
|
||||
const input = document.createElement('input')
|
||||
input.style.position = 'fixed'
|
||||
input.style.opacity = '0'
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
} catch {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (copyTimer) { clearTimeout(copyTimer); copyTimer = null }
|
||||
}
|
||||
|
||||
return { copied, copy, cleanup }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 系统常用路径 Composable
|
||||
* 提供系统路径获取和快捷访问路径管理
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { PATH_ICONS } from '@/utils/constants'
|
||||
import { getCommonPaths } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { ShortcutPath } from '@/types/file-system'
|
||||
|
||||
export function useCommonPaths() {
|
||||
const commonPaths = ref<ShortcutPath[]>([])
|
||||
const systemPaths = ref<Record<string, string>>({})
|
||||
|
||||
const loadCommonPaths = async () => {
|
||||
try {
|
||||
const paths = await getCommonPaths()
|
||||
if (!paths) throw new Error('无法获取系统路径')
|
||||
|
||||
systemPaths.value = paths
|
||||
const pathList: ShortcutPath[] = []
|
||||
// 根据返回数据判断平台(Linux agent 返回 root key,Windows 返回 root_ 前缀)
|
||||
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
|
||||
|
||||
if (isWin) {
|
||||
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
||||
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
||||
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
|
||||
const drives: Array<{ letter: string; path: string }> = []
|
||||
for (const key in paths) {
|
||||
if (key.startsWith('root_')) {
|
||||
drives.push({ letter: key.substring(5), path: paths[key] })
|
||||
}
|
||||
}
|
||||
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}盘`, path: d.path }))
|
||||
} else {
|
||||
// Linux 远程模式
|
||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
||||
if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
||||
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
|
||||
}
|
||||
|
||||
commonPaths.value = pathList.length > 0 ? pathList : (
|
||||
connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载系统路径失败:', error)
|
||||
commonPaths.value = connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
}
|
||||
}
|
||||
|
||||
return { commonPaths, systemPaths, loadCommonPaths }
|
||||
}
|
||||
257
frontend/src/components/FileSystem/composables/useFavorites.ts
Normal file
257
frontend/src/components/FileSystem/composables/useFavorites.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 收藏夹管理 Composable
|
||||
* 提供收藏文件的添加、删除、排序等功能
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
|
||||
|
||||
export function useFavorites() {
|
||||
// 收藏列表
|
||||
const favorites = ref<FavoriteFile[]>([])
|
||||
|
||||
// 拖拽状态
|
||||
const draggingState = ref<DraggingState>({
|
||||
isDragging: false,
|
||||
draggedIndex: -1,
|
||||
pressedIndex: -1
|
||||
})
|
||||
|
||||
/**
|
||||
* 排序收藏列表:置顶项归到前面,组内保持原有顺序(尊重拖拽)
|
||||
*/
|
||||
const sortFavorites = () => {
|
||||
const pinned = favorites.value.filter(f => f.pinnedAt)
|
||||
const unpinned = favorites.value.filter(f => !f.pinnedAt)
|
||||
favorites.value = [...pinned, ...unpinned]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载收藏列表
|
||||
*/
|
||||
const loadFavorites = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
|
||||
if (stored) {
|
||||
const loaded = JSON.parse(stored) as FavoriteFile[]
|
||||
|
||||
// 数据迁移:将旧字段 is_dir 转换为 isDir
|
||||
favorites.value = loaded.map(fav => ({
|
||||
...fav,
|
||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||
}))
|
||||
|
||||
// 仅排序(置顶项归组到前面),保持用户拖拽顺序
|
||||
sortFavorites()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载收藏列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存收藏列表到 localStorage
|
||||
*/
|
||||
const saveFavorites = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.FAVORITE_FILES, JSON.stringify(favorites.value))
|
||||
} catch (error) {
|
||||
console.error('保存收藏列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路径用于比较(Windows 大小写不敏感)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return path.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收藏
|
||||
*/
|
||||
const addFavorite = (file: FileItem) => {
|
||||
if (isFavorite(file.path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (favorites.value.length >= DEFAULTS.MAX_FAVORITES_LENGTH) {
|
||||
Message.warning(`收藏夹已满,最多收藏 ${DEFAULTS.MAX_FAVORITES_LENGTH} 项`)
|
||||
return false
|
||||
}
|
||||
|
||||
favorites.value.push({
|
||||
...file,
|
||||
addedAt: Date.now()
|
||||
} as FavoriteFile)
|
||||
sortFavorites()
|
||||
saveFavorites()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收藏
|
||||
*/
|
||||
const removeFavorite = (path: string) => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const index = favorites.value.findIndex(fav => normalizePath(fav.path) === normalizedPath)
|
||||
if (index !== -1) {
|
||||
favorites.value.splice(index, 1)
|
||||
saveFavorites()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
*/
|
||||
const toggleFavorite = (file: FileItem) => {
|
||||
if (isFavorite(file.path)) {
|
||||
removeFavorite(file.path)
|
||||
return false
|
||||
}
|
||||
addFavorite(file)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已收藏
|
||||
*/
|
||||
const isFavorite = (path: string): boolean => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
return favorites.value.some(fav => normalizePath(fav.path) === normalizedPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换置顶状态
|
||||
*/
|
||||
const togglePin = (path: string) => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||
if (fav) {
|
||||
fav.pinnedAt = fav.pinnedAt ? undefined : Date.now()
|
||||
sortFavorites()
|
||||
saveFavorites()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已置顶
|
||||
*/
|
||||
const isPinned = (path: string): boolean => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||
return !!fav?.pinnedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
|
||||
*/
|
||||
const updateFavoritePath = (oldPath: string, newName: string) => {
|
||||
const normalizedOld = normalizePath(oldPath)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedOld)
|
||||
if (!fav) return
|
||||
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const parentPath = oldPath.substring(
|
||||
0,
|
||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
)
|
||||
fav.path = parentPath + separator + newName
|
||||
fav.name = newName
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
// 拖拽方法
|
||||
let longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return
|
||||
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
draggingState.value.pressedIndex = index
|
||||
draggingState.value.draggedIndex = index
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onLongPressCancel = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
if (!draggingState.value.isDragging) {
|
||||
draggingState.value.pressedIndex = -1
|
||||
draggingState.value.draggedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
const onDragStart = (event: DragEvent, index: number) => {
|
||||
draggingState.value.isDragging = true
|
||||
draggingState.value.draggedIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent, targetIndex: number) => {
|
||||
event.preventDefault()
|
||||
|
||||
const fromIndex = draggingState.value.draggedIndex
|
||||
if (fromIndex === targetIndex || fromIndex === -1) {
|
||||
resetDragging()
|
||||
return
|
||||
}
|
||||
|
||||
const item = favorites.value.splice(fromIndex, 1)[0]
|
||||
favorites.value.splice(targetIndex, 0, item)
|
||||
saveFavorites()
|
||||
resetDragging()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragging()
|
||||
}
|
||||
|
||||
const resetDragging = () => {
|
||||
draggingState.value.isDragging = false
|
||||
draggingState.value.draggedIndex = -1
|
||||
draggingState.value.pressedIndex = -1
|
||||
}
|
||||
|
||||
// 组件挂载时加载收藏列表
|
||||
loadFavorites()
|
||||
|
||||
return {
|
||||
favorites,
|
||||
draggingState,
|
||||
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
togglePin,
|
||||
isPinned,
|
||||
updateFavoritePath,
|
||||
|
||||
onLongPressStart,
|
||||
onLongPressCancel,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
|
||||
loadFavorites,
|
||||
saveFavorites,
|
||||
resetDragging
|
||||
}
|
||||
}
|
||||
610
frontend/src/components/FileSystem/composables/useFileEdit.ts
Normal file
610
frontend/src/components/FileSystem/composables/useFileEdit.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 文件编辑 Composable
|
||||
* 提供文件编辑相关的逻辑,包括草稿管理、保存、撤销等
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { STORAGE_KEYS, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { getExt } from '@/utils/fileUtils'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isExcelFile, isWordFile, isCsvFile,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import { useFileOperations } from './useFileOperations'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface UseFileEditOptions {
|
||||
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||
currentDirectory?: import('vue').Ref<string>
|
||||
}
|
||||
|
||||
// 文件大小限制(5MB)
|
||||
const MAX_TEXT_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
const { currentFilePath = ref(''), currentDirectory = ref('') } = options
|
||||
|
||||
// 文件内容
|
||||
const fileContent = ref('')
|
||||
const originalContent = ref('')
|
||||
|
||||
// 当前文件路径(用于验证更新是否来自当前文件)
|
||||
const currentFilePathRef = ref('')
|
||||
|
||||
// 编辑状态
|
||||
const isEditMode = ref(false)
|
||||
const fileContentHeight = ref(400)
|
||||
const isBinaryFile = ref(false)
|
||||
|
||||
// 草稿管理
|
||||
const draftKey = ref('')
|
||||
|
||||
// 保存状态
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||
const fileVersion = ref(0)
|
||||
|
||||
// 使用文件操作 composable
|
||||
const { readFile, writeFile } = useFileOperations({
|
||||
onSuccess: (operation, data) => {
|
||||
// 可以在这里添加成功处理逻辑
|
||||
},
|
||||
onError: (operation, error) => {
|
||||
Message.error(`${operation} 失败: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取文件路径(从 FileItem 对象或字符串中提取)
|
||||
*/
|
||||
const getFilePath = (input: any): string => {
|
||||
if (!input) return ''
|
||||
if (typeof input === 'string') return input
|
||||
if (input.path) return input.path
|
||||
return ''
|
||||
}
|
||||
|
||||
// 已知二进制扩展名(无需读取内容即可判定)
|
||||
const KNOWN_BINARY_EXTS = new Set([
|
||||
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite', 'pdb', 'idb',
|
||||
'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||
'msi', 'jar', 'war', 'ear', 'apk'
|
||||
])
|
||||
|
||||
/**
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||
*/
|
||||
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||
const path = getFilePath(filepath)
|
||||
const ext = getExt(path)
|
||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||
|
||||
// 已知二进制扩展名 → 直接判定
|
||||
if (KNOWN_BINARY_EXTS.has(ext)) return true
|
||||
|
||||
// 媒体文件(可预览,不算二进制)
|
||||
const isMediaFile = isImageFile(path) ||
|
||||
isVideoFile(path) ||
|
||||
isAudioFile(path) ||
|
||||
isPdfFile(path) ||
|
||||
['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
|
||||
// Office 文件和 CSV(可预览)
|
||||
const isOfficeFile = isExcelFile(path) || isWordFile(path) || isCsvFile(path)
|
||||
|
||||
// 文本或代码文件(可编辑)
|
||||
const isTextFile = isTextEditable(path) || isConfigFile(path) ||
|
||||
FILE_EXTENSIONS.CODE.includes(ext)
|
||||
|
||||
// 如果是媒体文件、Office 文件或文本文件,就不是二进制
|
||||
if (isMediaFile || isOfficeFile || isTextFile) return false
|
||||
|
||||
// 其他扩展名未知,需要内容检测
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算属性:当前视图是否可编辑
|
||||
* 图片、视频、音频、PDF、二进制文件不可编辑
|
||||
*/
|
||||
const isEditableView = computed(() => {
|
||||
const path = getFilePath(currentFilePath.value)
|
||||
if (!path) return false
|
||||
const binaryCheck = isBinaryFileByExt(path)
|
||||
return !isImageFile(path) &&
|
||||
!isVideoFile(path) &&
|
||||
!isAudioFile(path) &&
|
||||
!isPdfFile(path) &&
|
||||
binaryCheck !== true // true 表示是二进制,不可编辑;false 或 null 表示可尝试编辑
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算属性:文件内容是否改变
|
||||
*/
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算属性:是否可以保存
|
||||
*/
|
||||
const canSaveFile = computed(() => {
|
||||
return isEditableView.value && contentChanged.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算属性:是否可以重置
|
||||
*/
|
||||
const canResetContent = computed(() => {
|
||||
return contentChanged.value && originalContent.value !== undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 检测文件内容是否为二进制
|
||||
*/
|
||||
const detectBinaryContent = (content: string): boolean => {
|
||||
if (!content || content.length === 0) return false
|
||||
|
||||
// 检查前 1000 个字符中二进制字符的比例
|
||||
const checkLength = Math.min(content.length, 1000)
|
||||
let binaryCharCount = 0
|
||||
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const charCode = content.charCodeAt(i)
|
||||
// 空字节肯定是二进制
|
||||
// 控制字符(charCode < 32)除了 Tab(9)、LF(10)、CR(13) 外都是二进制
|
||||
if (charCode === 0 || (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13)) {
|
||||
binaryCharCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果二进制字符超过 5%,认为是二进制文件
|
||||
const binaryRatio = binaryCharCount / checkLength
|
||||
return binaryRatio > 0.05
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
const loadFile = async (path: string) => {
|
||||
try {
|
||||
isBinaryFile.value = false
|
||||
|
||||
// 记录当前加载的文件路径,用于后续验证更新
|
||||
currentFilePathRef.value = path
|
||||
|
||||
// 增加文件版本号,使之前的过期更新失效
|
||||
fileVersion.value++
|
||||
|
||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
const filename = getFilePath(path)
|
||||
const ext = getExt(filename)
|
||||
|
||||
// Office 文件直接读取内容进行预览,跳过二进制检测
|
||||
if (isExcelFile(filename) || isWordFile(filename)) {
|
||||
const content = await readFile(path)
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 先检查扩展名,如果是已知的二进制文件,直接生成提示信息
|
||||
const binaryCheck = isBinaryFileByExt(filename)
|
||||
if (binaryCheck === true) {
|
||||
isBinaryFile.value = true
|
||||
|
||||
const fileTypeDescriptions: Record<string, string> = {
|
||||
'exe': '可执行文件',
|
||||
'dll': '动态链接库',
|
||||
'so': '共享库',
|
||||
'bin': '二进制文件',
|
||||
'dat': '数据文件',
|
||||
'db': '数据库文件',
|
||||
'sqlite': 'SQLite 数据库',
|
||||
'zip': 'ZIP 压缩文件',
|
||||
'rar': 'RAR 压缩文件',
|
||||
'7z': '7Z 压缩文件',
|
||||
'tar': 'TAR 归档文件',
|
||||
'gz': 'GZ 压缩文件',
|
||||
'bz2': 'BZ2 压缩文件',
|
||||
'xz': 'XZ 压缩文件',
|
||||
'iso': '光盘镜像',
|
||||
'img': '磁盘镜像',
|
||||
'dmg': 'DMG 镜像',
|
||||
'pdb': '程序数据库',
|
||||
'idb': 'IDA 数据库',
|
||||
'lib': '库文件',
|
||||
'obj': '目标文件',
|
||||
'o': '目标文件',
|
||||
'a': '静态库'
|
||||
}
|
||||
|
||||
const fileTypeDesc = fileTypeDescriptions[ext] || `${ext.toUpperCase()} 文件`
|
||||
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
|
||||
|
||||
fileContent.value = `================================================================
|
||||
文件信息:${fileTypeDesc}
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filename}
|
||||
文件类型: ${fileTypeDesc}
|
||||
|
||||
================================================================
|
||||
ℹ️ 这是已知的二进制文件类型,不支持文本预览
|
||||
|
||||
💡 提示:
|
||||
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
|
||||
• 右键菜单 → "在资源管理器中显示" 查看文件位置
|
||||
================================================================`
|
||||
originalContent.value = fileContent.value
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 对于无扩展名或未知类型文件,先尝试读取
|
||||
const content = await readFile(path)
|
||||
|
||||
// 检查文件大小
|
||||
const fileSize = content.length // UTF-16 字符数
|
||||
if (fileSize > MAX_TEXT_FILE_SIZE) {
|
||||
const sizeMB = (fileSize / 1024 / 1024).toFixed(2)
|
||||
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
|
||||
|
||||
fileContent.value = `================================================================
|
||||
⚠️ 文件过大 (${sizeMB} MB)
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filename}
|
||||
文件大小: ${sizeMB} MB
|
||||
|
||||
================================================================
|
||||
当前文件大小超过 5MB,不适合在编辑器中打开。
|
||||
|
||||
💡 建议:
|
||||
• 使用命令行工具查看部分内容
|
||||
• 将文件拆分成多个小文件
|
||||
• 使用专门的工具处理大文件
|
||||
================================================================`
|
||||
originalContent.value = fileContent.value
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 检测是否为二进制内容
|
||||
if (detectBinaryContent(content)) {
|
||||
isBinaryFile.value = true
|
||||
const fileTypeDesc = ext ? `${ext.toUpperCase()} 文件` : '未知类型文件'
|
||||
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
|
||||
|
||||
// 根据是否有扩展名,显示不同提示
|
||||
const isUnknownType = !ext
|
||||
const messageTitle = isUnknownType ? '文件信息(未知类型)' : `文件信息:${fileTypeDesc}`
|
||||
const messageDesc = isUnknownType
|
||||
? '此文件没有扩展名,且内容检测显示为二进制格式'
|
||||
: `此文件扩展名为 .${ext},但内容检测显示为二进制格式`
|
||||
|
||||
fileContent.value = `================================================================
|
||||
${messageTitle}
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filename}
|
||||
${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
|
||||
================================================================
|
||||
ℹ️ ${messageDesc},不支持文本预览
|
||||
|
||||
💡 提示:
|
||||
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
|
||||
• 右键菜单 → "在资源管理器中显示" 查看文件位置
|
||||
================================================================`
|
||||
originalContent.value = fileContent.value
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 正常文本文件
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
|
||||
// 加载草稿(如果存在)
|
||||
loadDraft(path)
|
||||
} catch (error) {
|
||||
Message.error(`读取文件失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件内容
|
||||
*/
|
||||
const saveFile = async (path?: string, isShortcut: boolean = false) => {
|
||||
// 获取目标路径(优先使用传入的 path,否则从 currentFilePath 中提取)
|
||||
let targetPath = path
|
||||
if (!targetPath && currentFilePath.value) {
|
||||
targetPath = getFilePath(currentFilePath.value)
|
||||
}
|
||||
|
||||
if (!targetPath) {
|
||||
Message.error('没有选中的文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查内容是否真的改变了
|
||||
if (fileContent.value === originalContent.value) {
|
||||
if (!isShortcut) {
|
||||
Message.info('文件内容未变更')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await writeFile(targetPath, fileContent.value)
|
||||
originalContent.value = fileContent.value
|
||||
|
||||
// 清除草稿
|
||||
clearDraft()
|
||||
|
||||
if (!isShortcut) {
|
||||
Message.success('保存成功')
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(`保存失败: ${error}`)
|
||||
} finally {
|
||||
// 延迟清除保存状态
|
||||
setTimeout(() => {
|
||||
isSaving.value = false
|
||||
}, isShortcut ? 300 : 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存草稿
|
||||
*/
|
||||
const saveDraft = () => {
|
||||
if (!currentFilePath.value) return
|
||||
|
||||
// Office 文件不支持草稿功能
|
||||
const path = getFilePath(currentFilePath.value)
|
||||
if (isExcelFile(path) || isWordFile(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${currentFilePath.value}`
|
||||
const draft = {
|
||||
content: fileContent.value,
|
||||
savedAt: Date.now()
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(draft))
|
||||
draftKey.value = key
|
||||
} catch (error) {
|
||||
console.error('保存草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载草稿
|
||||
*/
|
||||
const loadDraft = (path: string) => {
|
||||
// Office 文件不支持草稿功能,并清除已有的草稿
|
||||
if (isExcelFile(path) || isWordFile(path)) {
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
console.debug('[useFileEdit] 已清除 Office 文件草稿:', path)
|
||||
} catch (error) {
|
||||
console.error('清除草稿失败:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||
draftKey.value = key
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored) {
|
||||
const draft = JSON.parse(stored)
|
||||
const ageInHours = (Date.now() - draft.savedAt) / (1000 * 60 * 60)
|
||||
|
||||
// 如果草稿超过 24 小时,自动清除
|
||||
if (ageInHours > 24) {
|
||||
clearDraft()
|
||||
return
|
||||
}
|
||||
|
||||
// 恢复草稿内容
|
||||
fileContent.value = draft.content
|
||||
Message.info('已恢复未保存的草稿')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除草稿
|
||||
*/
|
||||
const clearDraft = () => {
|
||||
if (!draftKey.value) return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(draftKey.value)
|
||||
draftKey.value = ''
|
||||
} catch (error) {
|
||||
console.error('清除草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新文件路径(重命名场景:内容不变,只切换路径关联)
|
||||
* 迁移草稿 key,更新 currentFilePathRef
|
||||
*/
|
||||
const updateFilePath = (newPath: string) => {
|
||||
const oldPath = currentFilePathRef.value
|
||||
|
||||
// 迁移草稿(旧 key → 新 key)
|
||||
if (draftKey.value && oldPath !== newPath) {
|
||||
try {
|
||||
const draft = localStorage.getItem(draftKey.value)
|
||||
if (draft) {
|
||||
const newKey = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${newPath}`
|
||||
localStorage.setItem(newKey, draft)
|
||||
localStorage.removeItem(draftKey.value)
|
||||
draftKey.value = newKey
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[useFileEdit] 草稿迁移失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 只更新内部路径字符串引用,不触碰 currentFilePath(它是 FileItem 对象,由父组件管理)
|
||||
// 这样不会触发 watch → clearDraft
|
||||
currentFilePathRef.value = newPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置文件内容
|
||||
*/
|
||||
const resetContent = () => {
|
||||
if (originalContent.value !== undefined) {
|
||||
fileContent.value = originalContent.value
|
||||
Message.info('已恢复原始内容')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空文件内容
|
||||
*/
|
||||
const clearContent = () => {
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换编辑模式
|
||||
*/
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入编辑模式
|
||||
*/
|
||||
const enterEditMode = () => {
|
||||
isEditMode.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出编辑模式
|
||||
*/
|
||||
const exitEditMode = () => {
|
||||
// 如果有未保存的更改,提示用户
|
||||
if (contentChanged.value) {
|
||||
// 这里可以添加确认对话框
|
||||
// 暂时直接退出
|
||||
}
|
||||
isEditMode.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
|
||||
*/
|
||||
const updateContent = (content: string, expectedVersion?: number) => {
|
||||
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||
expected: expectedVersion,
|
||||
current: fileVersion.value,
|
||||
content: content.substring(0, 50)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (fileContent.value !== content) {
|
||||
fileContent.value = content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置编辑器高度
|
||||
*/
|
||||
const setEditorHeight = (height: number) => {
|
||||
fileContentHeight.value = Math.max(200, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否在当前目录
|
||||
*/
|
||||
const isFileInCurrentDirectory = (filePathInput: any): boolean => {
|
||||
const filePath = getFilePath(filePathInput)
|
||||
if (!filePath || !currentDirectory.value) {
|
||||
return true
|
||||
}
|
||||
return filePath.startsWith(currentDirectory.value)
|
||||
}
|
||||
|
||||
// 监听文件路径变化,清除草稿
|
||||
watch(currentFilePath, (newPath, oldPath) => {
|
||||
if (newPath !== oldPath) {
|
||||
clearDraft()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
fileContent,
|
||||
originalContent,
|
||||
isEditMode,
|
||||
fileContentHeight,
|
||||
isSaving,
|
||||
isBinaryFile,
|
||||
draftKey,
|
||||
fileVersion,
|
||||
|
||||
// 计算属性
|
||||
contentChanged,
|
||||
canSaveFile,
|
||||
canResetContent,
|
||||
isEditableView,
|
||||
|
||||
// 文件操作
|
||||
loadFile,
|
||||
saveFile,
|
||||
updateContent,
|
||||
|
||||
// 草稿管理
|
||||
saveDraft,
|
||||
loadDraft,
|
||||
clearDraft,
|
||||
|
||||
// 编辑模式
|
||||
toggleEditMode,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
|
||||
// 其他
|
||||
resetContent,
|
||||
clearContent,
|
||||
updateFilePath,
|
||||
setEditorHeight,
|
||||
|
||||
// 文件类型检查
|
||||
isBinaryFileByExt,
|
||||
isFileInCurrentDirectory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 文件操作 Composable
|
||||
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
|
||||
*/
|
||||
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import {
|
||||
listDir,
|
||||
readFile as readFileApi,
|
||||
writeFile as writeFileApi,
|
||||
deletePath as deletePathApi,
|
||||
createFile,
|
||||
createDir,
|
||||
renamePath as renamePathApi,
|
||||
listZipContents as listZipContentsApi,
|
||||
extractFileFromZip,
|
||||
extractFileFromZipToTemp as extractZipToTempApi,
|
||||
getFileServerURL as getFileServerUrlApi
|
||||
} from '@/api'
|
||||
import type { FileItem, FileOperationResult } from '@/types/file-system'
|
||||
|
||||
export interface UseFileOperationsOptions {
|
||||
onSuccess?: (operation: string, data: any) => void
|
||||
onError?: (operation: string, error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件操作结果
|
||||
*/
|
||||
export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||
const { onSuccess, onError } = options
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
const listDirectory = async (path: string): Promise<FileItem[]> => {
|
||||
try {
|
||||
const result = await listDir(path)
|
||||
onSuccess?.('listDirectory', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('listDirectory', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
const readFile = async (path: string): Promise<string> => {
|
||||
try {
|
||||
const content = await readFileApi(path)
|
||||
onSuccess?.('readFile', { path, size: content.length })
|
||||
return content
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('readFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
*/
|
||||
const writeFile = async (
|
||||
path: string,
|
||||
content: string,
|
||||
createBackup: boolean = false
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await writeFileApi(path, content)
|
||||
onSuccess?.('writeFile', { path, size: content.length })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('writeFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除路径(文件或目录),返回被删除的文件信息
|
||||
*/
|
||||
const deletePath = async (path: string): Promise<FileItem> => {
|
||||
try {
|
||||
const result = await deletePathApi(path)
|
||||
onSuccess?.('deletePath', { path, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('deletePath', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新文件,返回创建的文件信息
|
||||
*/
|
||||
const createNewFile = async (
|
||||
dirPath: string,
|
||||
filename: string
|
||||
): Promise<FileItem> => {
|
||||
try {
|
||||
const result = await createFile(dirPath, filename)
|
||||
onSuccess?.('createFile', { dirPath, filename, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('createFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新目录,返回创建的目录信息
|
||||
*/
|
||||
const createNewDir = async (parentPath: string, dirname: string): Promise<FileItem> => {
|
||||
try {
|
||||
const result = await createDir(parentPath, dirname)
|
||||
onSuccess?.('createDir', { parentPath, dirname, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('createDir', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或目录,返回新文件信息
|
||||
*/
|
||||
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
||||
// 构造新路径
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const parentPath = oldPath.substring(
|
||||
0,
|
||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
)
|
||||
const newPath = parentPath + separator + newName
|
||||
|
||||
try {
|
||||
const result = await renamePathApi(oldPath, newPath)
|
||||
onSuccess?.('rename', { oldPath, newPath, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('rename', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件或目录
|
||||
*/
|
||||
const copy = async (fromPath: string, toPath: string): Promise<void> => {
|
||||
try {
|
||||
// TODO: 实现复制逻辑
|
||||
Message.warning('复制功能暂未实现')
|
||||
onSuccess?.('copy', { fromPath, toPath })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('copy', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件或目录
|
||||
*/
|
||||
const move = async (fromPath: string, toPath: string): Promise<void> => {
|
||||
try {
|
||||
// TODO: 实现移动逻辑
|
||||
Message.warning('移动功能暂未实现')
|
||||
onSuccess?.('move', { fromPath, toPath })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('move', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 ZIP 文件内容
|
||||
*/
|
||||
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
||||
try {
|
||||
const result = await listZipContentsApi(zipPath)
|
||||
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
||||
return result
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('listZipContents', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ZIP 中提取文件内容(文本)
|
||||
*/
|
||||
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
|
||||
try {
|
||||
const content = await extractFileFromZip(zipPath, filePath)
|
||||
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
|
||||
return content
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('extractZipFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ZIP 中提取文件到临时目录(二进制文件,如图片)
|
||||
*/
|
||||
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
||||
try {
|
||||
const tempPath = await extractZipToTempApi(zipPath, filePath)
|
||||
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
||||
return tempPath
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('extractZipFileToTemp', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件服务器 URL
|
||||
*/
|
||||
const getFileServerURL = async (): Promise<string> => {
|
||||
try {
|
||||
const url = await getFileServerUrlApi()
|
||||
onSuccess?.('getFileServerURL', { url })
|
||||
return url
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('getFileServerURL', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 基础操作
|
||||
listDirectory,
|
||||
readFile,
|
||||
writeFile,
|
||||
deletePath,
|
||||
|
||||
// 创建操作
|
||||
createNewFile,
|
||||
createNewDir,
|
||||
|
||||
// 高级操作
|
||||
rename,
|
||||
copy,
|
||||
move,
|
||||
|
||||
// ZIP 操作
|
||||
listZipContents,
|
||||
extractZipFile,
|
||||
extractZipFileToTemp,
|
||||
getFileServerURL
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:FileItem 类型已统一定义在 @/types/file-system.ts
|
||||
223
frontend/src/components/FileSystem/composables/useFilePreview.ts
Normal file
223
frontend/src/components/FileSystem/composables/useFilePreview.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 文件预览 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 路径导航 Composable
|
||||
* 提供路径输入、历史记录、前进/后退等功能
|
||||
*/
|
||||
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import { normalizePathSeparators } from '@/utils/fileUtils'
|
||||
import type { PathHistory } from '@/types/file-system'
|
||||
|
||||
export interface UsePathNavigationOptions {
|
||||
onListDirectory?: (path: string) => Promise<void>
|
||||
initialPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 恢复上次的路径
|
||||
*/
|
||||
const restoreLastPath = (): string | null => {
|
||||
try {
|
||||
const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH)
|
||||
if (lastPath) {
|
||||
// 规范化旧路径(可能包含反斜杠)
|
||||
return normalizePathSeparators(lastPath)
|
||||
}
|
||||
return lastPath
|
||||
} catch (error) {
|
||||
console.error('恢复路径失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存路径到 localStorage
|
||||
*/
|
||||
const saveLastPath = (path: string) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH, path)
|
||||
} catch (error) {
|
||||
console.error('保存路径失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
const { onListDirectory, initialPath = '' } = options
|
||||
|
||||
// 尝试恢复上次的路径,如果没有则使用初始路径
|
||||
const savedPath = restoreLastPath()
|
||||
const filePath = ref(savedPath || initialPath)
|
||||
|
||||
// 历史记录
|
||||
const history = ref<PathHistory>({
|
||||
paths: [],
|
||||
currentIndex: -1
|
||||
})
|
||||
|
||||
/**
|
||||
* 导航到指定路径(带错误处理)
|
||||
*/
|
||||
const navigate = async (path: string) => {
|
||||
if (!path || path === filePath.value) return
|
||||
|
||||
try {
|
||||
// 路径规范化(处理反斜杠并统一为正斜杠)
|
||||
const normalizedPath = normalizePathSeparators(path)
|
||||
filePath.value = normalizedPath
|
||||
|
||||
// 添加到历史记录
|
||||
addToHistory(normalizedPath)
|
||||
|
||||
// 触发目录列出
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(normalizedPath)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导航失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到历史记录
|
||||
*/
|
||||
const addToHistory = (path: string) => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
// 如果当前不在历史记录末尾,删除当前位置之后的所有记录
|
||||
if (currentIndex < paths.length - 1) {
|
||||
history.value.paths = paths.slice(0, currentIndex + 1)
|
||||
}
|
||||
|
||||
// 避免重复添加相同路径
|
||||
const lastPath = history.value.paths[history.value.paths.length - 1]
|
||||
if (lastPath !== path) {
|
||||
history.value.paths.push(path)
|
||||
history.value.currentIndex = history.value.paths.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后退(带错误处理)
|
||||
*/
|
||||
const back = async () => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex <= 0) return
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex - 1
|
||||
history.value.currentIndex = newIndex
|
||||
filePath.value = paths[newIndex]
|
||||
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('后退失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进(带错误处理)
|
||||
*/
|
||||
const forward = async () => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex >= paths.length - 1) return
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex + 1
|
||||
history.value.currentIndex = newIndex
|
||||
filePath.value = paths[newIndex]
|
||||
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('前进失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入选择
|
||||
*/
|
||||
const onPathSelect = (value: string) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入回车
|
||||
*/
|
||||
const onPathEnter = (value: string) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览目录(双击或回车)
|
||||
*/
|
||||
const browseDirectory = async (path: string) => {
|
||||
await navigate(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
*/
|
||||
const getParentPath = (path: string): string => {
|
||||
const separator = path.includes('\\') ? '\\' : '/'
|
||||
const lastSeparator = path.lastIndexOf(separator)
|
||||
return lastSeparator > 0 ? path.substring(0, lastSeparator) : path
|
||||
}
|
||||
|
||||
/**
|
||||
* 上级目录
|
||||
*/
|
||||
const goUp = async () => {
|
||||
const parentPath = getParentPath(filePath.value)
|
||||
if (parentPath !== filePath.value) {
|
||||
await navigate(parentPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径规范化(统一为正斜杠)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return normalizePathSeparators(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否可以后退
|
||||
*/
|
||||
const canGoBack = computed(() => {
|
||||
return history.value.currentIndex > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断是否可以前进
|
||||
*/
|
||||
const canGoForward = computed(() => {
|
||||
return history.value.currentIndex < history.value.paths.length - 1
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取历史记录列表(用于自动完成)
|
||||
*/
|
||||
const getPathHistory = computed(() => {
|
||||
return history.value.paths.slice().reverse() // 最新的在前
|
||||
})
|
||||
|
||||
// 监听路径变化,自动保存到 localStorage
|
||||
watch(filePath, (newPath) => {
|
||||
if (newPath) {
|
||||
saveLastPath(newPath)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filePath,
|
||||
history,
|
||||
|
||||
// 导航方法
|
||||
navigate,
|
||||
back,
|
||||
forward,
|
||||
goUp,
|
||||
browseDirectory,
|
||||
|
||||
// 事件处理
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
|
||||
// 工具方法
|
||||
getParentPath,
|
||||
normalizePath,
|
||||
|
||||
// 计算属性
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
getPathHistory
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型(用于外部使用)
|
||||
export type { PathHistory }
|
||||
Reference in New Issue
Block a user