refactor(files): P1 split composables, Pinia store, directory cache
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Changelog: 文件管理器 P1 — Composable 拆分 + 目录缓存
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**类型**: 重构 (Refactor)
|
||||
**影响范围**: `frontend/src/pages/FilesPage.vue`、`frontend/src/stores/files.ts`、`frontend/src/composables/files/*`
|
||||
|
||||
---
|
||||
|
||||
## 变更摘要
|
||||
|
||||
按 `docs/design/specs/2026-06-02-files-refactor-design.md` 完成 P1:将 1500+ 行 `FilesPage.vue` 业务逻辑拆分为 Pinia store 与域 composable;页面 script 约 110 行,仅负责装配与模板绑定。
|
||||
|
||||
---
|
||||
|
||||
## 动机
|
||||
|
||||
- 单文件难以维护,三处 `browse()` 入口难追踪
|
||||
- 为 P2 组件拆分、P3 缓存、P4 REST API 打基础
|
||||
|
||||
---
|
||||
|
||||
## 主要改动
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| `stores/files.ts` | 服务器/路径/列表/权限状态;5s TTL 目录缓存(LRU 50 项) |
|
||||
| `useFilesBrowse.ts` | 浏览、路由同步、序号与 in-flight 去重 |
|
||||
| `useFilesNavigation.ts` | 面包屑、初始化、路由 watch、服务器切换 |
|
||||
| `useFilesPermissions.ts` | capability、权限横幅、一键 sudo |
|
||||
| `useFilesActions.ts` | 上传/删除/重命名/压缩/剪贴板等 |
|
||||
| `useFilesEditor.ts` | 预览、Monaco 编辑 |
|
||||
| `useFilesPage.ts` | 页面级装配入口 |
|
||||
| `FilesPage.vue` | 模板不变,script 瘦身为 `useFilesPage()` |
|
||||
|
||||
---
|
||||
|
||||
## 行为变化
|
||||
|
||||
- **重复进入同一目录(5s 内)**:命中内存缓存,0 次 `/sync/browse`
|
||||
- **写操作后**:`invalidateCache` 强制下次 `browse({ force: true })`
|
||||
- 其余 UX 与 P0 后一致
|
||||
|
||||
---
|
||||
|
||||
## 是否需迁移 / 重启
|
||||
|
||||
- 仅前端重新构建部署
|
||||
- 后端无变更
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. `cd frontend && npx vite build`
|
||||
2. 部署后 `prune_frontend_assets.py`
|
||||
3. 文件管理:切换服务器、进目录、上传/删除/编辑/粘贴/sudo 配置
|
||||
4. Network:同目录 5s 内二次进入无 browse 请求
|
||||
@@ -0,0 +1,35 @@
|
||||
# 文件管理器重构 P1 — 拆分 Composable
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P1
|
||||
**前置**: P0 JWT 主动续期(已完成)
|
||||
|
||||
## 目标
|
||||
|
||||
- 将 `FilesPage.vue` 业务逻辑迁入 `stores/files.ts` + `composables/files/*`
|
||||
- `FilesPage.vue` 仅保留模板、筛选 UI 状态、组合 composable(目标 script < 400 行,P2 再压至 ≤250)
|
||||
- 引入 Pinia 单一真相 + 5s 目录缓存(P3 子集)
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| `frontend/src/stores/files.ts` | 服务器/路径/列表/loading/权限/缓存 |
|
||||
| `frontend/src/composables/files/constants.ts` | sessionStorage 键 |
|
||||
| `frontend/src/composables/files/useFilesBrowse.ts` | browse、错误文案、emptyHint |
|
||||
| `frontend/src/composables/files/useFilesNavigation.ts` | 路由同步、面包屑、初始化、watch |
|
||||
| `frontend/src/composables/files/useFilesPermissions.ts` | capability、横幅、一键 sudo |
|
||||
| `frontend/src/composables/files/useFilesEditor.ts` | 预览/编辑/保存回调 |
|
||||
| `frontend/src/composables/files/useFilesActions.ts` | 上传/删除/重命名/压缩等 |
|
||||
| `frontend/src/composables/files/index.ts` | 桶导出 |
|
||||
| `frontend/src/pages/FilesPage.vue` | 瘦身后页面 |
|
||||
|
||||
## 测试要点
|
||||
|
||||
- `npx vite build` 通过
|
||||
- 进目录仍 ≤1 次 `/sync/browse`(缓存命中 0 次)
|
||||
- 切换服务器、面包屑、粘贴、编辑、sudo 一键配置行为不变
|
||||
|
||||
## 回滚
|
||||
|
||||
`git revert` P1 commit;`FilesPage.vue` 单文件可独立回退。
|
||||
@@ -0,0 +1,5 @@
|
||||
export const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id'
|
||||
export const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed'
|
||||
|
||||
export const FILES_CACHE_TTL_MS = 5000
|
||||
export const FILES_CACHE_MAX_ENTRIES = 50
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useFilesBrowse, isNotFoundError, friendlyBrowseError } from './useFilesBrowse'
|
||||
export { useFilesNavigation } from './useFilesNavigation'
|
||||
export { useFilesPermissions } from './useFilesPermissions'
|
||||
export { useFilesEditor } from './useFilesEditor'
|
||||
export { useFilesActions } from './useFilesActions'
|
||||
export { useFilesSelection } from './useFilesSelection'
|
||||
export { useFilesPage } from './useFilesPage'
|
||||
@@ -0,0 +1,601 @@
|
||||
import { computed, nextTick, ref, type Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { http } from '@/api'
|
||||
import { useFilesClipboard } from '@/composables/useFilesClipboard'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { isArchiveFile } from '@/utils/fileSort'
|
||||
import { saveBlobAsFile } from '@/utils/fileDownload'
|
||||
import { invalidateCachedFile } from '@/utils/filePreload'
|
||||
import {
|
||||
joinRemotePath,
|
||||
normalizeRemotePath,
|
||||
validatePathSegment,
|
||||
} from '@/utils/remotePath'
|
||||
import type { useFilesSelection } from './useFilesSelection'
|
||||
|
||||
type SelectionApi = ReturnType<typeof useFilesSelection>
|
||||
|
||||
export function useFilesActions(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
invalidateAfterMutation: (parentPath?: string) => void
|
||||
selection: SelectionApi
|
||||
openEntry: (item: FileEntry) => void
|
||||
editFile: (item: FileEntry) => Promise<void>
|
||||
previewFile: (item: FileEntry) => Promise<void>
|
||||
openInTerminal: () => void
|
||||
editorWorkbench: Ref<{ openFile: (p: { path: string; name: string; content: string }) => void } | null>
|
||||
actionLoading?: Ref<boolean>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const store = useFilesStore()
|
||||
const { selectedServer, currentPath } = storeToRefs(store)
|
||||
const { selectedFiles, selectedAbsolutePaths, clearSelection } = options.selection
|
||||
const {
|
||||
setFromSelection,
|
||||
canPasteOnServer,
|
||||
pasteToDirectory,
|
||||
} = useFilesClipboard()
|
||||
|
||||
const dropActive = ref(false)
|
||||
const showUpload = ref(false)
|
||||
const showNewFile = ref(false)
|
||||
const newFileName = ref('')
|
||||
const showMkdir = ref(false)
|
||||
const showCompress = ref(false)
|
||||
const compressDest = ref('')
|
||||
const compressFormat = ref<'tar.gz' | 'zip'>('tar.gz')
|
||||
const compressPaths = ref<string[]>([])
|
||||
const showDecompress = ref(false)
|
||||
const decompressSource = ref('')
|
||||
const decompressDest = ref('.')
|
||||
const showFileDelete = ref(false)
|
||||
const uploadFiles = ref<File[]>([])
|
||||
const uploading = ref(false)
|
||||
const mkdirName = ref('')
|
||||
const actionLoading = options.actionLoading ?? ref(false)
|
||||
const deletingFile = ref<FileEntry | null>(null)
|
||||
const showRename = ref(false)
|
||||
const newName = ref('')
|
||||
const renamingFile = ref<FileEntry | null>(null)
|
||||
const showChmod = ref(false)
|
||||
const chmodLoading = ref(false)
|
||||
const chmodFile = ref<FileEntry | null>(null)
|
||||
const showContextMenu = ref(false)
|
||||
const contextX = ref(0)
|
||||
const contextY = ref(0)
|
||||
const contextFile = ref<FileEntry | null>(null)
|
||||
|
||||
function resolvePasteDestination(explicitDest?: string): string {
|
||||
if (explicitDest) return normalizeRemotePath(explicitDest)
|
||||
const dirs = selectedFiles.value.filter((f) => f.type === 'directory')
|
||||
if (dirs.length === 1 && selectedFiles.value.length === 1) {
|
||||
return joinRemotePath(currentPath.value, dirs[0].name)
|
||||
}
|
||||
return normalizeRemotePath(currentPath.value)
|
||||
}
|
||||
|
||||
const pasteDestLabel = computed(() => {
|
||||
const dest = resolvePasteDestination()
|
||||
const cur = normalizeRemotePath(currentPath.value)
|
||||
return dest === cur ? '当前目录' : dest
|
||||
})
|
||||
|
||||
function copyToClipboard(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) return
|
||||
const paths = selectedAbsolutePaths(items)
|
||||
if (!paths.length) {
|
||||
snackbar('请先选择文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
if (setFromSelection('copy', selectedServer.value, paths)) {
|
||||
snackbar(`已复制 ${paths.length} 项`)
|
||||
}
|
||||
}
|
||||
|
||||
function cutToClipboard(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) return
|
||||
const paths = selectedAbsolutePaths(items)
|
||||
if (!paths.length) {
|
||||
snackbar('请先选择文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
if (setFromSelection('cut', selectedServer.value, paths)) {
|
||||
snackbar(`已剪切 ${paths.length} 项`)
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteClipboard(explicitDest?: string) {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
if (!canPasteOnServer(selectedServer.value)) {
|
||||
snackbar('剪贴板为空或服务器不一致', 'warning')
|
||||
return
|
||||
}
|
||||
const targetDir = resolvePasteDestination(explicitDest)
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await pasteToDirectory(targetDir)
|
||||
clearSelection()
|
||||
options.invalidateAfterMutation(targetDir)
|
||||
await options.browse({ force: true })
|
||||
snackbar(`已粘贴到 ${targetDir}`)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '粘贴失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDropFiles(e: DragEvent) {
|
||||
dropActive.value = false
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
const dropped = e.dataTransfer?.files
|
||||
if (!dropped?.length) return
|
||||
uploadFiles.value = Array.from(dropped)
|
||||
await doUpload()
|
||||
}
|
||||
|
||||
function contextAction(action: string) {
|
||||
showContextMenu.value = false
|
||||
if (!contextFile.value) return
|
||||
const item = contextFile.value
|
||||
switch (action) {
|
||||
case 'open':
|
||||
options.openEntry(item)
|
||||
break
|
||||
case 'copy':
|
||||
copyToClipboard([item])
|
||||
break
|
||||
case 'cut':
|
||||
cutToClipboard([item])
|
||||
break
|
||||
case 'paste':
|
||||
void pasteClipboard()
|
||||
break
|
||||
case 'pasteInto':
|
||||
if (item.type === 'directory') {
|
||||
void pasteClipboard(joinRemotePath(currentPath.value, item.name))
|
||||
}
|
||||
break
|
||||
case 'edit':
|
||||
void options.editFile(item)
|
||||
break
|
||||
case 'preview':
|
||||
void options.previewFile(item)
|
||||
break
|
||||
case 'download':
|
||||
void downloadFile(item)
|
||||
break
|
||||
case 'copyPath':
|
||||
void copyRemotePath(item)
|
||||
break
|
||||
case 'terminal':
|
||||
options.openInTerminal()
|
||||
break
|
||||
case 'compress':
|
||||
openCompressDialog([item])
|
||||
break
|
||||
case 'decompress':
|
||||
openDecompressDialog(item)
|
||||
break
|
||||
case 'rename':
|
||||
startRename(item)
|
||||
break
|
||||
case 'chmod':
|
||||
startChmod(item)
|
||||
break
|
||||
case 'delete':
|
||||
void confirmDeleteFile(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRemotePath(item: FileEntry) {
|
||||
const path = joinRemotePath(currentPath.value, item.name)
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
snackbar('路径已复制')
|
||||
} catch {
|
||||
snackbar('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function openCompressDialog(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) return
|
||||
const targets = items?.length
|
||||
? items
|
||||
: selectedFiles.value.length
|
||||
? selectedFiles.value
|
||||
: []
|
||||
if (!targets.length) {
|
||||
snackbar('请选择要压缩的文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
compressPaths.value = targets.map((f) =>
|
||||
joinRemotePath(currentPath.value, f.name),
|
||||
)
|
||||
const base =
|
||||
targets.length === 1 ? targets[0].name.replace(/\.[^.]+$/, '') : 'archive'
|
||||
compressDest.value = joinRemotePath(currentPath.value, `${base}.tar.gz`)
|
||||
compressFormat.value = 'tar.gz'
|
||||
showCompress.value = true
|
||||
}
|
||||
|
||||
function openDecompressDialog(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (!isArchiveFile(item.name)) {
|
||||
snackbar('仅支持 .tar.gz / .tgz / .zip', 'warning')
|
||||
return
|
||||
}
|
||||
decompressSource.value = joinRemotePath(currentPath.value, item.name)
|
||||
decompressDest.value = currentPath.value
|
||||
showDecompress.value = true
|
||||
}
|
||||
|
||||
async function doCompress() {
|
||||
if (!selectedServer.value || !compressPaths.value.length || !compressDest.value) {
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
try {
|
||||
let dest = compressDest.value
|
||||
if (compressFormat.value === 'zip' && !dest.endsWith('.zip')) {
|
||||
dest = dest.replace(/\.tar\.gz$/i, '.zip')
|
||||
if (!dest.endsWith('.zip')) dest += '.zip'
|
||||
}
|
||||
await http.post('/sync/compress', {
|
||||
server_id: selectedServer.value,
|
||||
paths: compressPaths.value,
|
||||
dest,
|
||||
format: compressFormat.value,
|
||||
})
|
||||
showCompress.value = false
|
||||
clearSelection()
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('压缩完成')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '压缩失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doDecompress() {
|
||||
if (!selectedServer.value || !decompressSource.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/decompress', {
|
||||
server_id: selectedServer.value,
|
||||
path: decompressSource.value,
|
||||
dest: decompressDest.value || '.',
|
||||
})
|
||||
showDecompress.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('解压完成')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '解压失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doNewFile() {
|
||||
if (!selectedServer.value || !newFileName.value) return
|
||||
const check = validatePathSegment(newFileName.value)
|
||||
if (check !== true) {
|
||||
snackbar(check, 'warning')
|
||||
return
|
||||
}
|
||||
const name = newFileName.value
|
||||
const path = joinRemotePath(currentPath.value, name)
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/write-file', {
|
||||
server_id: selectedServer.value,
|
||||
path,
|
||||
content: '',
|
||||
})
|
||||
showNewFile.value = false
|
||||
newFileName.value = ''
|
||||
invalidateCachedFile(selectedServer.value, path)
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('文件已创建')
|
||||
await nextTick()
|
||||
options.editorWorkbench.value?.openFile({ path, name, content: '' })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(item: FileEntry) {
|
||||
renamingFile.value = item
|
||||
newName.value = item.name
|
||||
showRename.value = true
|
||||
}
|
||||
|
||||
async function doRename() {
|
||||
if (!selectedServer.value || !renamingFile.value || !newName.value) return
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'rename',
|
||||
path: joinRemotePath(currentPath.value, renamingFile.value.name),
|
||||
new_path: joinRemotePath(currentPath.value, newName.value),
|
||||
})
|
||||
showRename.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('重命名成功')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '重命名失败'
|
||||
snackbar(msg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function startChmod(item: FileEntry) {
|
||||
chmodFile.value = item
|
||||
showChmod.value = true
|
||||
}
|
||||
|
||||
async function doChmod(payload: {
|
||||
mode: string
|
||||
owner?: string
|
||||
group?: string
|
||||
recursive?: boolean
|
||||
}) {
|
||||
if (!selectedServer.value || !chmodFile.value) return
|
||||
chmodLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/chmod', {
|
||||
server_id: selectedServer.value,
|
||||
path: joinRemotePath(currentPath.value, chmodFile.value.name),
|
||||
mode: payload.mode,
|
||||
owner: payload.owner,
|
||||
group: payload.group,
|
||||
recursive: payload.recursive ?? false,
|
||||
})
|
||||
showChmod.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('权限已修改')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '修改权限失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
chmodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
if (!selectedServer.value || !selectedFiles.value.length) return
|
||||
if (!confirm(`确定删除 ${selectedFiles.value.length} 个文件?`)) return
|
||||
let ok = 0
|
||||
let fail = 0
|
||||
for (const f of selectedFiles.value) {
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'delete',
|
||||
path: joinRemotePath(currentPath.value, f.name),
|
||||
})
|
||||
ok++
|
||||
} catch {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
clearSelection()
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
if (fail === 0) snackbar(`已删除 ${ok} 项`)
|
||||
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
|
||||
}
|
||||
|
||||
async function downloadFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
try {
|
||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
||||
const { blob, filename } = await http.download('/sync/download', {
|
||||
server_id: selectedServer.value,
|
||||
path: remotePath,
|
||||
})
|
||||
saveBlobAsFile(blob, filename)
|
||||
snackbar(`已下载 ${filename}`)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '下载失败'
|
||||
snackbar(msg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteFile(item: FileEntry) {
|
||||
deletingFile.value = item
|
||||
showFileDelete.value = true
|
||||
}
|
||||
|
||||
async function doDeleteFile() {
|
||||
if (!selectedServer.value || !deletingFile.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'delete',
|
||||
path: joinRemotePath(currentPath.value, deletingFile.value.name),
|
||||
})
|
||||
showFileDelete.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('已删除')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '删除失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doMkdir() {
|
||||
if (!selectedServer.value || !mkdirName.value) return
|
||||
const check = validatePathSegment(mkdirName.value)
|
||||
if (check !== true) {
|
||||
snackbar(check, 'warning')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'mkdir',
|
||||
path: joinRemotePath(currentPath.value, mkdirName.value),
|
||||
})
|
||||
showMkdir.value = false
|
||||
mkdirName.value = ''
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('目录已创建')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!selectedServer.value || !uploadFiles.value.length) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
for (const f of uploadFiles.value) form.append('files', f)
|
||||
form.append('server_id', String(selectedServer.value))
|
||||
form.append('remote_path', currentPath.value)
|
||||
await http.upload('/sync/upload', form)
|
||||
showUpload.value = false
|
||||
uploadFiles.value = []
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('上传成功')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '上传失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isRowActionTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return Boolean(target.closest('button, a, input, .v-checkbox, .v-selection-control'))
|
||||
}
|
||||
|
||||
function fileRowProps(data: { item: FileEntry }) {
|
||||
const item = data.item
|
||||
return {
|
||||
class: item.type === 'directory' ? 'files-row--dir' : undefined,
|
||||
style: {
|
||||
cursor:
|
||||
item.type === 'directory' || item.type === 'symlink' ? 'pointer' : 'default',
|
||||
},
|
||||
onClick: (e: MouseEvent) => {
|
||||
if (isRowActionTarget(e.target)) return
|
||||
if (e.detail !== 1) return
|
||||
onRowClick(e, { item })
|
||||
},
|
||||
onDblclick: (e: MouseEvent) => {
|
||||
if (isRowActionTarget(e.target)) return
|
||||
onRowDblClick(e, { item })
|
||||
},
|
||||
onContextmenu: (e: MouseEvent) => {
|
||||
if (isRowActionTarget(e.target)) return
|
||||
onRowContext(e, { item })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(_event: Event, { item }: { item: FileEntry }) {
|
||||
if (item.type === 'directory' || item.type === 'symlink') {
|
||||
options.openEntry(item)
|
||||
}
|
||||
}
|
||||
|
||||
function onRowDblClick(_event: Event, { item }: { item: FileEntry }) {
|
||||
if (item.type === 'file') {
|
||||
void options.editFile(item)
|
||||
}
|
||||
}
|
||||
|
||||
function onRowContext(e: MouseEvent, { item }: { item: FileEntry }) {
|
||||
e.preventDefault()
|
||||
contextFile.value = item
|
||||
contextX.value = e.clientX
|
||||
contextY.value = e.clientY
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
dropActive,
|
||||
showUpload,
|
||||
showNewFile,
|
||||
newFileName,
|
||||
showMkdir,
|
||||
showCompress,
|
||||
compressDest,
|
||||
compressFormat,
|
||||
compressPaths,
|
||||
showDecompress,
|
||||
decompressSource,
|
||||
decompressDest,
|
||||
showFileDelete,
|
||||
uploadFiles,
|
||||
uploading,
|
||||
mkdirName,
|
||||
actionLoading,
|
||||
deletingFile,
|
||||
showRename,
|
||||
newName,
|
||||
renamingFile,
|
||||
showChmod,
|
||||
chmodLoading,
|
||||
chmodFile,
|
||||
showContextMenu,
|
||||
contextX,
|
||||
contextY,
|
||||
contextFile,
|
||||
copyToClipboard,
|
||||
cutToClipboard,
|
||||
pasteClipboard,
|
||||
onDropFiles,
|
||||
contextAction,
|
||||
openCompressDialog,
|
||||
openDecompressDialog,
|
||||
doCompress,
|
||||
doDecompress,
|
||||
doNewFile,
|
||||
startRename,
|
||||
doRename,
|
||||
startChmod,
|
||||
doChmod,
|
||||
batchDelete,
|
||||
downloadFile,
|
||||
confirmDeleteFile,
|
||||
doDeleteFile,
|
||||
doMkdir,
|
||||
doUpload,
|
||||
fileRowProps,
|
||||
pasteDestLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { BrowseResponse, FileEntry } from '@/types/api'
|
||||
import { parseBrowseResponse } from '@/utils/fileBrowse'
|
||||
import {
|
||||
clearFilePreloadCache,
|
||||
preloadDirectoryFiles,
|
||||
pruneFilePreloadCache,
|
||||
} from '@/utils/filePreload'
|
||||
import { DEFAULT_FILES_ROOT, normalizeRemotePath, parentRemotePath } from '@/utils/remotePath'
|
||||
|
||||
let _browseSeq = 0
|
||||
let _browseInFlight: Promise<void> | null = null
|
||||
let _browseInFlightKey = ''
|
||||
|
||||
export function isNotFoundError(err: string): boolean {
|
||||
return err.includes('No such file or directory') || err.includes('not a directory')
|
||||
}
|
||||
|
||||
export function friendlyBrowseError(err: string, path: string): string {
|
||||
if (isNotFoundError(err)) {
|
||||
return `路径不存在:${path},请检查服务器「target_path」设置`
|
||||
}
|
||||
if (err.includes('Permission denied') || err.includes('Operation not permitted')) {
|
||||
return `无读取权限(${path})`
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
export function useFilesBrowse(fileFilter?: Ref<string>) {
|
||||
const snackbar = useSnackbar()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
browseError,
|
||||
pageReady,
|
||||
filesCapability,
|
||||
} = storeToRefs(store)
|
||||
|
||||
function syncRouteQuery() {
|
||||
const query: Record<string, string | undefined> = {
|
||||
...(route.query as Record<string, string>),
|
||||
}
|
||||
if (selectedServer.value) {
|
||||
query.server_id = String(selectedServer.value)
|
||||
query.path =
|
||||
currentPath.value === DEFAULT_FILES_ROOT ? undefined : currentPath.value
|
||||
} else {
|
||||
delete query.server_id
|
||||
delete query.path
|
||||
}
|
||||
router.replace({ query })
|
||||
}
|
||||
|
||||
const emptyHint = computed(() => {
|
||||
if (!pageReady.value) return '正在加载…'
|
||||
if (!selectedServer.value) return '请先选择服务器'
|
||||
if (browseError.value) return browseError.value
|
||||
if (fileFilter?.value.trim()) return '无匹配文件'
|
||||
const cap = filesCapability.value
|
||||
if (cap?.is_root) {
|
||||
return `目录为空(${currentPath.value}),可在上方修改路径或到「服务器」核对 target_path`
|
||||
}
|
||||
return `目录为空(${currentPath.value})`
|
||||
})
|
||||
|
||||
async function browse(options?: { force?: boolean }) {
|
||||
if (!selectedServer.value) return
|
||||
const path = normalizeRemotePath(currentPath.value)
|
||||
const serverId = selectedServer.value
|
||||
const browseKey = `${serverId}:${path}`
|
||||
|
||||
if (!options?.force) {
|
||||
const cached = store.getCached(serverId, path)
|
||||
if (cached) {
|
||||
files.value = cached
|
||||
browseError.value = ''
|
||||
loading.value = false
|
||||
syncRouteQuery()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (_browseInFlight && _browseInFlightKey === browseKey) {
|
||||
return _browseInFlight
|
||||
}
|
||||
_browseInFlightKey = browseKey
|
||||
|
||||
const seq = ++_browseSeq
|
||||
loading.value = true
|
||||
currentPath.value = path
|
||||
|
||||
_browseInFlight = (async () => {
|
||||
try {
|
||||
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
|
||||
server_id: serverId,
|
||||
path,
|
||||
})
|
||||
if (seq !== _browseSeq) return
|
||||
const { items, error } = parseBrowseResponse(res)
|
||||
|
||||
if (error && isNotFoundError(error) && path !== '/') {
|
||||
browseError.value = ''
|
||||
loading.value = false
|
||||
const parent = parentRemotePath(path)
|
||||
if (parent) {
|
||||
snackbar(`路径 "${path}" 不存在,已自动跳转到上级目录`, 'info')
|
||||
currentPath.value = parent
|
||||
void browse()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
browseError.value = error ? friendlyBrowseError(error, path) : ''
|
||||
if (error && !isNotFoundError(error)) {
|
||||
snackbar(friendlyBrowseError(error, path), 'warning')
|
||||
}
|
||||
files.value = items
|
||||
if (!error) {
|
||||
store.setCached(serverId, path, items)
|
||||
}
|
||||
if (serverId && items.length) {
|
||||
pruneFilePreloadCache(serverId, path)
|
||||
preloadDirectoryFiles(serverId, path, items)
|
||||
}
|
||||
} catch {
|
||||
if (seq !== _browseSeq) return
|
||||
files.value = []
|
||||
snackbar('浏览目录失败', 'error')
|
||||
} finally {
|
||||
if (seq === _browseSeq) {
|
||||
loading.value = false
|
||||
syncRouteQuery()
|
||||
}
|
||||
if (_browseInFlightKey === browseKey) {
|
||||
_browseInFlight = null
|
||||
_browseInFlightKey = ''
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return _browseInFlight
|
||||
}
|
||||
|
||||
function browseCurrent() {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
currentPath.value = normalizeRemotePath(currentPath.value)
|
||||
store.invalidateCache(selectedServer.value, currentPath.value)
|
||||
void browse({ force: true })
|
||||
}
|
||||
|
||||
function invalidateAfterMutation(parentPath?: string) {
|
||||
if (!selectedServer.value) return
|
||||
const p = parentPath ?? currentPath.value
|
||||
store.invalidateCache(selectedServer.value, normalizeRemotePath(p))
|
||||
clearFilePreloadCache()
|
||||
}
|
||||
|
||||
return {
|
||||
emptyHint,
|
||||
browse,
|
||||
browseCurrent,
|
||||
invalidateAfterMutation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { isPermissionDeniedMessage } from '@/utils/fileMode'
|
||||
import {
|
||||
exceedsEditorMaxSize,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
FILE_PRELOAD_MAX_BYTES,
|
||||
invalidateCachedFile,
|
||||
readRemoteFileContent,
|
||||
} from '@/utils/filePreload'
|
||||
import { joinRemotePath } from '@/utils/remotePath'
|
||||
|
||||
function formatSize(bytes: number | undefined) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function useFilesEditor(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
invalidateAfterMutation: (parentPath?: string) => void
|
||||
actionLoading: Ref<boolean>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const store = useFilesStore()
|
||||
const { selectedServer, currentPath, lastReadDeniedPath, accessHintDismissed } =
|
||||
storeToRefs(store)
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewTitle = ref('')
|
||||
const previewContent = ref('')
|
||||
const previewLoading = ref(false)
|
||||
|
||||
const editorWorkbench = ref<{
|
||||
openFile: (p: { path: string; name: string; content: string }) => void
|
||||
} | null>(null)
|
||||
|
||||
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
||||
if (exceedsEditorMaxSize(item.size)) {
|
||||
snackbar(
|
||||
`文件超过 2MB(${formatSize(item.size)}),无法${action},请下载或使用终端`,
|
||||
'warning',
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onEditorSaved(path: string) {
|
||||
if (selectedServer.value && path) {
|
||||
invalidateCachedFile(selectedServer.value, path)
|
||||
}
|
||||
options.invalidateAfterMutation()
|
||||
void options.browse({ force: true })
|
||||
}
|
||||
|
||||
async function previewFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (rejectOversizedFile(item, '查看')) return
|
||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
||||
previewTitle.value = item.name
|
||||
previewContent.value = ''
|
||||
previewLoading.value = true
|
||||
showPreview.value = true
|
||||
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
try {
|
||||
const raw = await readRemoteFileContent(
|
||||
selectedServer.value,
|
||||
remotePath,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
)
|
||||
previewContent.value = raw || '(空文件)'
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
previewContent.value = msg
|
||||
if (isPermissionDeniedMessage(msg)) {
|
||||
lastReadDeniedPath.value = item.name
|
||||
accessHintDismissed.value = false
|
||||
}
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function editFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (rejectOversizedFile(item, '编辑')) return
|
||||
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
options.actionLoading.value = true
|
||||
try {
|
||||
const path = joinRemotePath(currentPath.value, item.name)
|
||||
const content = await readRemoteFileContent(
|
||||
selectedServer.value,
|
||||
path,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
)
|
||||
await nextTick()
|
||||
editorWorkbench.value?.openFile({ path, name: item.name, content })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
if (isPermissionDeniedMessage(msg)) {
|
||||
lastReadDeniedPath.value = item.name
|
||||
accessHintDismissed.value = false
|
||||
}
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
options.actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showPreview,
|
||||
previewTitle,
|
||||
previewContent,
|
||||
previewLoading,
|
||||
editorWorkbench,
|
||||
previewFile,
|
||||
editFile,
|
||||
onEditorSaved,
|
||||
formatSize,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useServerList } from '@/composables/useServerList'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import {
|
||||
buildRemoteBreadcrumbs,
|
||||
DEFAULT_FILES_ROOT,
|
||||
joinRemotePath,
|
||||
normalizeRemotePath,
|
||||
parentRemotePath,
|
||||
type RemoteBreadcrumbItem,
|
||||
} from '@/utils/remotePath'
|
||||
import { FILES_HINT_DISMISSED_KEY, FILES_LAST_SERVER_KEY } from './constants'
|
||||
|
||||
export function useFilesNavigation(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
refreshFilesCapability: () => Promise<void>
|
||||
clearSelection: () => void
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { servers: serverList, loadServers } = useServerList()
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
pageReady,
|
||||
routeReady,
|
||||
initing,
|
||||
accessHintDismissed,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const breadcrumbs = computed((): RemoteBreadcrumbItem[] =>
|
||||
buildRemoteBreadcrumbs(currentPath.value),
|
||||
)
|
||||
|
||||
const canGoUp = computed(() => parentRemotePath(currentPath.value) !== null)
|
||||
|
||||
function defaultPathForServer(serverId: number | null): string {
|
||||
if (serverId == null) return DEFAULT_FILES_ROOT
|
||||
const srv = serverList.value.find((s) => s.id === serverId)
|
||||
const tp = srv?.target_path?.trim()
|
||||
return tp ? normalizeRemotePath(tp) : DEFAULT_FILES_ROOT
|
||||
}
|
||||
|
||||
function pathFromRouteQuery(): string {
|
||||
const qPath = route.query.path
|
||||
if (typeof qPath === 'string' && qPath.trim()) {
|
||||
return normalizeRemotePath(qPath)
|
||||
}
|
||||
return defaultPathForServer(selectedServer.value)
|
||||
}
|
||||
|
||||
function resolveInitialServerId(): number | null {
|
||||
const qServer = route.query.server_id
|
||||
if (qServer) {
|
||||
const id = Number(qServer)
|
||||
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
|
||||
}
|
||||
const stored = sessionStorage.getItem(FILES_LAST_SERVER_KEY)
|
||||
if (stored) {
|
||||
const id = Number(stored)
|
||||
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
|
||||
}
|
||||
return serverList.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
function navigateToPath(path: string) {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
currentPath.value = normalizeRemotePath(path)
|
||||
options.clearSelection()
|
||||
void options.browse()
|
||||
}
|
||||
|
||||
function openEntry(item: FileEntry) {
|
||||
if (item.type === 'directory') {
|
||||
navigateToPath(joinRemotePath(currentPath.value, item.name))
|
||||
return
|
||||
}
|
||||
if (item.type === 'symlink') {
|
||||
const target = item.link_target?.trim()
|
||||
if (target?.startsWith('/')) {
|
||||
navigateToPath(target)
|
||||
} else {
|
||||
snackbar(
|
||||
target ? `符号链接 → ${target}` : '相对符号链接(请在终端中打开)',
|
||||
'info',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parent = parentRemotePath(currentPath.value)
|
||||
if (parent != null) navigateToPath(parent)
|
||||
}
|
||||
|
||||
function onBreadcrumbClick(index: number) {
|
||||
const bc = breadcrumbs.value[index]
|
||||
if (bc && !bc.disabled) navigateToPath(bc.path)
|
||||
}
|
||||
|
||||
function openInTerminal() {
|
||||
if (!selectedServer.value) return
|
||||
router.push({
|
||||
path: '/terminal',
|
||||
query: {
|
||||
server_id: String(selectedServer.value),
|
||||
path: currentPath.value || '/',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onServerChange() {
|
||||
if (initing.value) return
|
||||
if (selectedServer.value) {
|
||||
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
|
||||
}
|
||||
store.invalidateCache()
|
||||
currentPath.value = defaultPathForServer(selectedServer.value)
|
||||
options.clearSelection()
|
||||
store.resetForServerChange()
|
||||
sessionStorage.removeItem(FILES_HINT_DISMISSED_KEY)
|
||||
void options.refreshFilesCapability()
|
||||
void options.browse({ force: true })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initing.value = true
|
||||
try {
|
||||
await loadServers()
|
||||
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
|
||||
accessHintDismissed.value = true
|
||||
}
|
||||
if (!serverList.value.length) {
|
||||
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
|
||||
pageReady.value = true
|
||||
return
|
||||
}
|
||||
const initialId = resolveInitialServerId()
|
||||
if (initialId == null) {
|
||||
pageReady.value = true
|
||||
return
|
||||
}
|
||||
selectedServer.value = initialId
|
||||
const qPath = route.query.path
|
||||
currentPath.value =
|
||||
typeof qPath === 'string' && qPath
|
||||
? normalizeRemotePath(qPath)
|
||||
: defaultPathForServer(initialId)
|
||||
await options.refreshFilesCapability()
|
||||
await options.browse()
|
||||
pageReady.value = true
|
||||
routeReady.value = true
|
||||
} finally {
|
||||
initing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.server_id,
|
||||
(id) => {
|
||||
if (!routeReady.value) return
|
||||
if (id && Number(id) !== selectedServer.value) {
|
||||
selectedServer.value = Number(id)
|
||||
currentPath.value = pathFromRouteQuery()
|
||||
store.resetForServerChange()
|
||||
void options.refreshFilesCapability()
|
||||
void options.browse({ force: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.path,
|
||||
() => {
|
||||
if (!routeReady.value || !selectedServer.value) return
|
||||
const next = pathFromRouteQuery()
|
||||
if (next !== normalizeRemotePath(currentPath.value)) {
|
||||
currentPath.value = next
|
||||
options.clearSelection()
|
||||
void options.browse()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
serverList,
|
||||
breadcrumbs,
|
||||
canGoUp,
|
||||
navigateToPath,
|
||||
openEntry,
|
||||
goUp,
|
||||
onBreadcrumbClick,
|
||||
openInTerminal,
|
||||
onServerChange,
|
||||
defaultPathForServer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 文件管理页 — 组合各域 composable,供 FilesPage.vue 使用
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useFilesClipboard } from '@/composables/useFilesClipboard'
|
||||
import { useFilesHotkeys } from '@/composables/useFilesHotkeys'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import {
|
||||
FILE_EXT_FILTERS,
|
||||
isArchiveFile,
|
||||
matchesExtensionFilter,
|
||||
sortFileEntries,
|
||||
type FileSortKey,
|
||||
} from '@/utils/fileSort'
|
||||
import {
|
||||
formatOwnerChip,
|
||||
isEntryLikelyUnreadable,
|
||||
ownerChipColor,
|
||||
permissionTooltip,
|
||||
} from '@/utils/fileMode'
|
||||
import { isPreloadEligible } from '@/utils/filePreload'
|
||||
import { validatePathSegment } from '@/utils/remotePath'
|
||||
import { required } from '@/utils/validation'
|
||||
import { useFilesActions } from './useFilesActions'
|
||||
import { useFilesBrowse } from './useFilesBrowse'
|
||||
import { useFilesEditor } from './useFilesEditor'
|
||||
import { useFilesNavigation } from './useFilesNavigation'
|
||||
import { useFilesPermissions } from './useFilesPermissions'
|
||||
import { useFilesSelection } from './useFilesSelection'
|
||||
|
||||
export function useFilesPage() {
|
||||
const fileFilter = ref('')
|
||||
const extFilter = ref('')
|
||||
const sortBy = ref<FileSortKey>('name')
|
||||
const sortOptions = [
|
||||
{ title: '名称', value: 'name' as const },
|
||||
{ title: '大小', value: 'size' as const },
|
||||
{ title: '修改时间', value: 'modified' as const },
|
||||
]
|
||||
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
browseError,
|
||||
pageReady,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const selection = useFilesSelection()
|
||||
const { selectedFiles, selectedFileCount, clearSelection } = selection
|
||||
|
||||
const { emptyHint, browse, browseCurrent, invalidateAfterMutation } =
|
||||
useFilesBrowse(fileFilter)
|
||||
|
||||
const permissions = useFilesPermissions({ browse })
|
||||
const {
|
||||
sshUserForFiles,
|
||||
filesAccessHint,
|
||||
dismissFilesAccessHint,
|
||||
setupFilesSudo,
|
||||
} = permissions
|
||||
|
||||
const nav = useFilesNavigation({
|
||||
browse,
|
||||
refreshFilesCapability: permissions.refreshFilesCapability,
|
||||
clearSelection,
|
||||
})
|
||||
|
||||
const actionLoading = ref(false)
|
||||
|
||||
const editor = useFilesEditor({
|
||||
browse,
|
||||
invalidateAfterMutation,
|
||||
actionLoading,
|
||||
})
|
||||
|
||||
const actions = useFilesActions({
|
||||
browse,
|
||||
invalidateAfterMutation,
|
||||
selection,
|
||||
openEntry: nav.openEntry,
|
||||
editFile: editor.editFile,
|
||||
previewFile: editor.previewFile,
|
||||
openInTerminal: nav.openInTerminal,
|
||||
editorWorkbench: editor.editorWorkbench,
|
||||
actionLoading,
|
||||
})
|
||||
|
||||
const { hasClipboard, clipboardSummary, canPasteOnServer } = useFilesClipboard()
|
||||
const canPasteHere = computed(() => canPasteOnServer(selectedServer.value))
|
||||
|
||||
const displayedFiles = computed(() => {
|
||||
let list = sortFileEntries(files.value, sortBy.value, 'asc')
|
||||
const ext = extFilter.value
|
||||
if (ext) list = list.filter((f) => matchesExtensionFilter(f.name, ext))
|
||||
const q = fileFilter.value.trim().toLowerCase()
|
||||
if (q) list = list.filter((f) => f.name.toLowerCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const dirs = files.value.filter((f) => f.type === 'directory').length
|
||||
const regular = files.value.filter((f) => f.type === 'file').length
|
||||
const links = files.value.filter((f) => f.type === 'symlink').length
|
||||
const preloadable = files.value.filter(isPreloadEligible).length
|
||||
const parts = [`${dirs} 个目录`, `${regular} 个文件`]
|
||||
if (links) parts.push(`${links} 个链接`)
|
||||
if (preloadable) parts.push(`${preloadable} 个 ≤300KB 可预加载`)
|
||||
return `${parts.join(',')} · ${currentPath.value}`
|
||||
})
|
||||
|
||||
function isArchiveName(name: string): boolean {
|
||||
return isArchiveFile(name)
|
||||
}
|
||||
|
||||
function entryIcon(item: FileEntry): string {
|
||||
if (item.type === 'directory') return 'mdi-folder'
|
||||
if (item.type === 'symlink') return 'mdi-link-variant'
|
||||
return 'mdi-file-outline'
|
||||
}
|
||||
|
||||
function entryIconColor(item: FileEntry): string {
|
||||
if (item.type === 'directory') return 'amber'
|
||||
if (item.type === 'symlink') return 'teal'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function isRegularFile(item: FileEntry): boolean {
|
||||
return item.type === 'file'
|
||||
}
|
||||
|
||||
const fileHeaders = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
||||
{ title: '大小', key: 'size', width: 120 },
|
||||
{ title: '修改时间', key: 'modified', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
|
||||
]
|
||||
|
||||
useFilesHotkeys(
|
||||
{
|
||||
refresh: browseCurrent,
|
||||
goUp: nav.goUp,
|
||||
renameSelection: () => {
|
||||
if (selectedFiles.value.length === 1) {
|
||||
actions.startRename(selectedFiles.value[0])
|
||||
}
|
||||
},
|
||||
deleteSelection: () => {
|
||||
if (selectedFiles.value.length > 0) void actions.batchDelete()
|
||||
},
|
||||
selectAll: () => {
|
||||
selectedFiles.value = [...displayedFiles.value]
|
||||
},
|
||||
copySelection: () => actions.copyToClipboard(),
|
||||
cutSelection: () => actions.cutToClipboard(),
|
||||
pasteClipboard: () => {
|
||||
void actions.pasteClipboard()
|
||||
},
|
||||
},
|
||||
selectedFiles,
|
||||
canPasteHere,
|
||||
)
|
||||
|
||||
return {
|
||||
FILE_EXT_FILTERS,
|
||||
validatePathSegment,
|
||||
required,
|
||||
fileFilter,
|
||||
extFilter,
|
||||
sortBy,
|
||||
sortOptions,
|
||||
selectedServer,
|
||||
currentPath,
|
||||
loading,
|
||||
browseError,
|
||||
pageReady,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
serverList: nav.serverList,
|
||||
onServerChange: nav.onServerChange,
|
||||
browseCurrent,
|
||||
filesAccessHint,
|
||||
dismissFilesAccessHint,
|
||||
setupFilesSudo,
|
||||
sshUserForFiles,
|
||||
statusSummary,
|
||||
hasClipboard,
|
||||
clipboardSummary,
|
||||
pasteDestLabel: actions.pasteDestLabel,
|
||||
canPasteHere,
|
||||
breadcrumbs: nav.breadcrumbs,
|
||||
onBreadcrumbClick: nav.onBreadcrumbClick,
|
||||
canGoUp: nav.canGoUp,
|
||||
goUp: nav.goUp,
|
||||
selectedFiles,
|
||||
selectedFileCount,
|
||||
displayedFiles,
|
||||
fileHeaders,
|
||||
fileRowProps: actions.fileRowProps,
|
||||
dropActive: actions.dropActive,
|
||||
onDropFiles: actions.onDropFiles,
|
||||
emptyHint,
|
||||
entryIcon,
|
||||
entryIconColor,
|
||||
isEntryLikelyUnreadable,
|
||||
formatOwnerChip,
|
||||
ownerChipColor,
|
||||
permissionTooltip,
|
||||
formatSize: editor.formatSize,
|
||||
isRegularFile,
|
||||
isArchiveName,
|
||||
showUpload: actions.showUpload,
|
||||
uploadFiles: actions.uploadFiles,
|
||||
uploading: actions.uploading,
|
||||
doUpload: actions.doUpload,
|
||||
showNewFile: actions.showNewFile,
|
||||
newFileName: actions.newFileName,
|
||||
doNewFile: actions.doNewFile,
|
||||
showMkdir: actions.showMkdir,
|
||||
mkdirName: actions.mkdirName,
|
||||
doMkdir: actions.doMkdir,
|
||||
showFileDelete: actions.showFileDelete,
|
||||
deletingFile: actions.deletingFile,
|
||||
doDeleteFile: actions.doDeleteFile,
|
||||
showRename: actions.showRename,
|
||||
newName: actions.newName,
|
||||
doRename: actions.doRename,
|
||||
showChmod: actions.showChmod,
|
||||
chmodFile: actions.chmodFile,
|
||||
chmodLoading: actions.chmodLoading,
|
||||
doChmod: actions.doChmod,
|
||||
showCompress: actions.showCompress,
|
||||
compressDest: actions.compressDest,
|
||||
compressFormat: actions.compressFormat,
|
||||
compressPaths: actions.compressPaths,
|
||||
doCompress: actions.doCompress,
|
||||
showDecompress: actions.showDecompress,
|
||||
decompressSource: actions.decompressSource,
|
||||
decompressDest: actions.decompressDest,
|
||||
doDecompress: actions.doDecompress,
|
||||
showContextMenu: actions.showContextMenu,
|
||||
contextX: actions.contextX,
|
||||
contextY: actions.contextY,
|
||||
contextFile: actions.contextFile,
|
||||
contextAction: actions.contextAction,
|
||||
actionLoading,
|
||||
batchDelete: actions.batchDelete,
|
||||
openCompressDialog: actions.openCompressDialog,
|
||||
showPreview: editor.showPreview,
|
||||
previewTitle: editor.previewTitle,
|
||||
previewContent: editor.previewContent,
|
||||
previewLoading: editor.previewLoading,
|
||||
editorWorkbench: editor.editorWorkbench,
|
||||
onEditorSaved: editor.onEditorSaved,
|
||||
navigateToPath: nav.navigateToPath,
|
||||
pasteClipboard: actions.pasteClipboard,
|
||||
editFile: editor.editFile,
|
||||
previewFile: editor.previewFile,
|
||||
downloadFile: actions.downloadFile,
|
||||
confirmDeleteFile: actions.confirmDeleteFile,
|
||||
openInTerminal: nav.openInTerminal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FilesCapabilityResult } from '@/types/api'
|
||||
import {
|
||||
buildFilesAccessHint,
|
||||
isEntryLikelyUnreadable,
|
||||
} from '@/utils/fileMode'
|
||||
import { FILES_HINT_DISMISSED_KEY } from './constants'
|
||||
import { isNotFoundError } from './useFilesBrowse'
|
||||
|
||||
export function useFilesPermissions(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
files,
|
||||
browseError,
|
||||
filesCapability,
|
||||
lastReadDeniedPath,
|
||||
accessHintDismissed,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null)
|
||||
|
||||
const unreadableFileCount = computed(() => {
|
||||
const user = sshUserForFiles.value
|
||||
if (!user) return 0
|
||||
return files.value.filter((f) => isEntryLikelyUnreadable(f, user)).length
|
||||
})
|
||||
|
||||
const filesAccessHint = computed(() => {
|
||||
if (accessHintDismissed.value || !selectedServer.value) return null
|
||||
if (browseError.value && isNotFoundError(browseError.value)) return null
|
||||
const cap = filesCapability.value
|
||||
return buildFilesAccessHint({
|
||||
sshUser: cap?.ssh_user,
|
||||
canSudo: cap?.can_sudo_nopasswd ?? false,
|
||||
whitelistOk: cap?.whitelist_ok ?? false,
|
||||
isRootSsh: cap?.is_root ?? false,
|
||||
filesElevation: cap?.files_elevation,
|
||||
capabilityMessage: cap?.message,
|
||||
lastReadDeniedPath: lastReadDeniedPath.value,
|
||||
unreadableCount: unreadableFileCount.value,
|
||||
})
|
||||
})
|
||||
|
||||
function dismissFilesAccessHint() {
|
||||
accessHintDismissed.value = true
|
||||
sessionStorage.setItem(FILES_HINT_DISMISSED_KEY, '1')
|
||||
}
|
||||
|
||||
async function refreshFilesCapability() {
|
||||
if (!selectedServer.value) {
|
||||
filesCapability.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
filesCapability.value = await http.get<FilesCapabilityResult>(
|
||||
`/servers/${selectedServer.value}/files-capability`,
|
||||
)
|
||||
} catch {
|
||||
filesCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function setupFilesSudo() {
|
||||
if (!selectedServer.value) return
|
||||
setupSudoLoading.value = true
|
||||
try {
|
||||
const res = await http.post<{ ok: boolean; message: string }>(
|
||||
`/servers/${selectedServer.value}/setup-files-sudo`,
|
||||
{},
|
||||
)
|
||||
setupSudoDone.value = true
|
||||
snackbar(res.message || 'sudoers 配置成功,文件管理器现在可自动提权', 'success')
|
||||
dismissFilesAccessHint()
|
||||
await refreshFilesCapability()
|
||||
await options.browse({ force: true })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '配置失败'
|
||||
snackbar(`一键配置失败:${msg}`, 'error')
|
||||
} finally {
|
||||
setupSudoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sshUserForFiles,
|
||||
filesAccessHint,
|
||||
dismissFilesAccessHint,
|
||||
refreshFilesCapability,
|
||||
setupFilesSudo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { joinRemotePath } from '@/utils/remotePath'
|
||||
|
||||
export function useFilesSelection() {
|
||||
const store = useFilesStore()
|
||||
const { currentPath } = storeToRefs(store)
|
||||
const selectedFiles = ref<FileEntry[]>([])
|
||||
const selectedFileCount = computed(() => selectedFiles.value.length)
|
||||
|
||||
function clearSelection() {
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
function selectedAbsolutePaths(extra?: FileEntry[]): string[] {
|
||||
const items = extra?.length ? extra : selectedFiles.value
|
||||
return items.map((f) => joinRemotePath(currentPath.value, f.name))
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
selectedFileCount,
|
||||
clearSelection,
|
||||
selectedAbsolutePaths,
|
||||
}
|
||||
}
|
||||
+102
-1058
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Pinia — 文件管理器共享状态(路径/列表/权限/目录缓存)
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { FileEntry, FilesCapabilityResult } from '@/types/api'
|
||||
import { DEFAULT_FILES_ROOT } from '@/utils/remotePath'
|
||||
import { FILES_CACHE_MAX_ENTRIES, FILES_CACHE_TTL_MS } from '@/composables/files/constants'
|
||||
|
||||
interface FilesCacheEntry {
|
||||
items: FileEntry[]
|
||||
at: number
|
||||
}
|
||||
|
||||
function cacheKey(serverId: number, path: string): string {
|
||||
return `${serverId}:${path}`
|
||||
}
|
||||
|
||||
export const useFilesStore = defineStore('files', () => {
|
||||
const selectedServer = ref<number | null>(null)
|
||||
const currentPath = ref(DEFAULT_FILES_ROOT)
|
||||
const files = ref<FileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const browseError = ref('')
|
||||
const filesCapability = ref<FilesCapabilityResult | null>(null)
|
||||
const lastReadDeniedPath = ref<string | null>(null)
|
||||
const accessHintDismissed = ref(false)
|
||||
const setupSudoLoading = ref(false)
|
||||
const setupSudoDone = ref(false)
|
||||
const pageReady = ref(false)
|
||||
/** 路由 watch 与 onMounted 协调 */
|
||||
const routeReady = ref(false)
|
||||
/** 阻止 v-select 初始化触发 onServerChange */
|
||||
const initing = ref(false)
|
||||
|
||||
const fileCache = new Map<string, FilesCacheEntry>()
|
||||
|
||||
function getCached(serverId: number, path: string): FileEntry[] | null {
|
||||
const entry = fileCache.get(cacheKey(serverId, path))
|
||||
if (!entry) return null
|
||||
if (Date.now() - entry.at > FILES_CACHE_TTL_MS) {
|
||||
fileCache.delete(cacheKey(serverId, path))
|
||||
return null
|
||||
}
|
||||
return entry.items
|
||||
}
|
||||
|
||||
function setCached(serverId: number, path: string, items: FileEntry[]) {
|
||||
const key = cacheKey(serverId, path)
|
||||
fileCache.set(key, { items, at: Date.now() })
|
||||
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
|
||||
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
|
||||
if (oldest) fileCache.delete(oldest[0])
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateCache(serverId?: number | null, path?: string) {
|
||||
if (serverId == null) {
|
||||
fileCache.clear()
|
||||
return
|
||||
}
|
||||
if (path == null) {
|
||||
const prefix = `${serverId}:`
|
||||
for (const key of [...fileCache.keys()]) {
|
||||
if (key.startsWith(prefix)) fileCache.delete(key)
|
||||
}
|
||||
return
|
||||
}
|
||||
fileCache.delete(cacheKey(serverId, path))
|
||||
}
|
||||
|
||||
function resetForServerChange() {
|
||||
lastReadDeniedPath.value = null
|
||||
accessHintDismissed.value = false
|
||||
setupSudoDone.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
browseError,
|
||||
filesCapability,
|
||||
lastReadDeniedPath,
|
||||
accessHintDismissed,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
pageReady,
|
||||
routeReady,
|
||||
initing,
|
||||
getCached,
|
||||
setCached,
|
||||
invalidateCache,
|
||||
resetForServerChange,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user