Private
Public Access
1
0

重构: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:
2026-05-01 11:03:53 +08:00
parent 44847e0d40
commit f54bf1c28d
185 changed files with 7768 additions and 914 deletions

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 拷贝路径 composable3-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 }
}

View File

@@ -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 keyWindows 返回 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 }
}

View 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
}
}

View 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
}
}

View File

@@ -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

View 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
}
}

View File

@@ -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 }