refactor(files): P2 split UI into components with page context inject
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-02 05:14:13 +08:00
parent 7cbddf5d48
commit 6cb591212d
11 changed files with 740 additions and 622 deletions
@@ -0,0 +1,48 @@
# Changelog: 文件管理器 P2 — 组件拆分
**日期**: 2026-06-02
**类型**: 重构 (Refactor)
**影响范围**: `frontend/src/pages/FilesPage.vue``frontend/src/components/files/*`
---
## 变更摘要
将文件管理页模板拆分为 6 个子组件;`FilesPage.vue` 仅负责布局装配与 `provideFilesPage` 注入上下文。
---
## 动机
P1 已将逻辑迁入 composable,但单文件模板仍超 500 行,不利于维护与 P5/P6 局部优化。
---
## 新增文件
- `frontend/src/composables/files/filesPageContext.ts` — provide/inject 上下文
- `frontend/src/components/files/FilesToolbar.vue`
- `frontend/src/components/files/FilesPermissionAlert.vue`
- `frontend/src/components/files/FilesStatusBar.vue`
- `frontend/src/components/files/FilesBreadcrumb.vue`
- `frontend/src/components/files/FilesList.vue`
- `frontend/src/components/files/FilesDialogs.vue`
---
## 涉及文件
- `frontend/src/pages/FilesPage.vue` — 由 ~640 行降至 ~45 行
---
## 是否需迁移 / 重启
- 仅前端重新构建部署
---
## 验证方式
1. `cd frontend && npx vite build`
2. 文件管理:工具栏、列表、对话框、编辑器、右键菜单、快捷键
@@ -0,0 +1,27 @@
# 文件管理器重构 P2 — 拆分 Vue 组件
**日期**: 2026-06-02
**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P2
**前置**: P1 composable + Pinia store
## 目标
- 模板迁入 `frontend/src/components/files/*`
- `FilesPage.vue` ≤ 250 行(实际 ~45 行模板 + ~25 行 script
- 子组件通过 `provideFilesPage` / `useFilesPageContext` 共享状态,避免 props 爆炸
## 组件清单
| 组件 | 职责 |
|------|------|
| `FilesToolbar.vue` | 服务器、路径、筛选、操作按钮 |
| `FilesPermissionAlert.vue` | 权限横幅 + 一键 sudo |
| `FilesStatusBar.vue` | 目录统计、剪贴板提示 |
| `FilesBreadcrumb.vue` | 面包屑 + 批量操作条 |
| `FilesList.vue` | 表格、拖拽上传、右键菜单 |
| `FilesDialogs.vue` | 上传/新建/删除/重命名/压缩/预览等对话框 |
## 验证
- `npx vite build`
- 文件管理全功能回归(与 P1 相同)
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-breadcrumbs :items="p.breadcrumbs" class="pa-0 mb-2">
<template #item="{ item, index }">
<v-breadcrumbs-item
:disabled="item.disabled"
:class="{ 'text-primary': !item.disabled, 'cursor-pointer': !item.disabled }"
@click="p.onBreadcrumbClick(index)"
>
{{ item.title }}
</v-breadcrumbs-item>
</template>
<template #divider>/</template>
</v-breadcrumbs>
<v-card v-if="p.selectedFileCount > 0" class="mb-4" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ p.selectedFileCount }} 个文件</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="p.batchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="p.selectedFiles = []">取消选择</v-btn>
</v-card-text>
</v-card>
</template>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import FilePermissionDialog from '@/components/FilePermissionDialog.vue'
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-dialog v-model="p.showUpload" max-width="500">
<v-card border>
<v-card-title>上传文件</v-card-title>
<v-card-text>
<v-file-input v-model="p.uploadFiles" label="选择文件" variant="outlined" multiple show-size />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showUpload = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.uploading" @click="p.doUpload">上传</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showNewFile" max-width="400">
<v-card border>
<v-card-title>新建文件</v-card-title>
<v-card-text>
<v-text-field
v-model="p.newFileName"
label="文件名"
variant="outlined"
density="compact"
:rules="[p.validatePathSegment]"
placeholder="example.conf"
autofocus
@keydown.enter="p.doNewFile"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showNewFile = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doNewFile">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showMkdir" max-width="400">
<v-card border>
<v-card-title>新建目录</v-card-title>
<v-card-text>
<v-text-field
v-model="p.mkdirName"
label="目录名"
variant="outlined"
density="compact"
:rules="[p.validatePathSegment]"
@keydown.enter="p.doMkdir"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showMkdir = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="p.doMkdir">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showFileDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>
确定要删除 <strong>{{ p.deletingFile?.name }}</strong>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showFileDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="p.doDeleteFile">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showRename" max-width="400">
<v-card border>
<v-card-title>重命名</v-card-title>
<v-card-text>
<v-text-field
v-model="p.newName"
label="新名称"
variant="outlined"
density="compact"
:rules="[p.required('名称')]"
autofocus
@keydown.enter="p.doRename"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showRename = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="p.doRename">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<FilePermissionDialog
v-model="p.showChmod"
:file="p.chmodFile"
:server-id="p.selectedServer"
:loading="p.chmodLoading"
@apply="p.doChmod"
/>
<v-dialog v-model="p.showCompress" max-width="440">
<v-card border>
<v-card-title>压缩</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">将压缩 {{ p.compressPaths.length }} 项到当前目录</p>
<v-text-field v-model="p.compressDest" label="压缩包路径" variant="outlined" density="compact" />
<v-select
v-model="p.compressFormat"
:items="[
{ title: 'tar.gz', value: 'tar.gz' },
{ title: 'zip', value: 'zip' },
]"
item-title="title"
item-value="value"
label="格式"
variant="outlined"
density="compact"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showCompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doCompress">压缩</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showDecompress" max-width="440">
<v-card border>
<v-card-title>解压</v-card-title>
<v-card-text>
<p class="text-body-2 mb-2 text-truncate">{{ p.decompressSource }}</p>
<v-text-field v-model="p.decompressDest" label="解压到目录" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showDecompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doDecompress">解压</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showPreview" max-width="900" scrollable>
<v-card border>
<v-card-title class="d-flex align-center">
<span class="text-truncate">{{ p.previewTitle }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="p.showPreview = false" />
</v-card-title>
<v-divider />
<v-card-text style="max-height: 70vh">
<v-progress-linear v-if="p.previewLoading" indeterminate class="mb-2" />
<pre v-else class="files-preview-body text-body-2">{{ p.previewContent }}</pre>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showPreview = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.files-preview-body {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
margin: 0;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-card
elevation="0"
border
rounded="lg"
:class="{ 'files-drop-active': p.dropActive }"
@dragenter.prevent="p.dropActive = true"
@dragover.prevent="p.dropActive = true"
@dragleave.prevent="p.dropActive = false"
@drop.prevent="p.onDropFiles"
>
<v-data-table
v-model="p.selectedFiles"
:items="p.displayedFiles"
:headers="p.fileHeaders"
:loading="p.loading"
show-select
return-object
hover
density="comfortable"
item-value="name"
:row-props="p.fileRowProps"
>
<template #item.name="{ item }">
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="p.entryIconColor(item)" size="20">
{{ p.entryIcon(item) }}
</v-icon>
<v-icon
v-if="p.isEntryLikelyUnreadable(item, p.sshUserForFiles)"
size="18"
color="warning"
title="当前 SSH 用户可能无法读取"
>
mdi-shield-lock
</v-icon>
<div class="min-w-0">
<span :class="{ 'font-weight-medium': item.type === 'directory' }">{{ item.name }}</span>
<div
v-if="item.type === 'symlink' && item.link_target"
class="text-caption text-medium-emphasis text-truncate"
>
{{ item.link_target }}
</div>
</div>
</div>
</template>
<template #item.owner="{ item }">
<v-tooltip :text="p.permissionTooltip(item)" location="top">
<template #activator="{ props: tipProps }">
<v-chip
v-bind="tipProps"
size="small"
variant="tonal"
:color="p.ownerChipColor(item.owner || '')"
class="text-none"
>
{{ p.formatOwnerChip(item) }}
</v-chip>
</template>
</v-tooltip>
</template>
<template #item.size="{ item }">
<span class="text-medium-emphasis">
{{ item.type === 'directory' ? '—' : p.formatSize(item.size) }}
</span>
</template>
<template #item.modified="{ item }">
<span class="text-medium-emphasis">{{ item.modified || '—' }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
color="primary"
@click.stop="p.editFile(item)"
>
编辑
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
@click.stop="p.previewFile(item)"
>
查看
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
@click.stop="p.downloadFile(item)"
>
下载
</v-btn>
<v-btn
variant="text"
size="x-small"
color="error"
density="compact"
@click.stop="p.confirmDeleteFile(item)"
>
删除
</v-btn>
</div>
</template>
<template #no-data>
<div v-if="p.pageReady && !p.loading" class="text-center text-medium-emphasis py-8">
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>{{ p.emptyHint }}</div>
<div v-if="p.selectedServer" class="text-caption mt-2 text-disabled">
单击目录进入 · 双击文件编辑 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
</div>
</div>
</template>
</v-data-table>
</v-card>
<v-menu v-model="p.showContextMenu" :target="[p.contextX, p.contextY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item
v-if="p.contextFile && p.contextFile.type === 'directory'"
prepend-icon="mdi-folder-open"
@click="p.contextAction('open')"
>
<v-list-item-title>打开</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-pencil"
@click="p.contextAction('edit')"
>
<v-list-item-title>编辑</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-eye"
@click="p.contextAction('preview')"
>
<v-list-item-title>查看</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-download"
@click="p.contextAction('download')"
>
<v-list-item-title>下载</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-content-copy"
@click="p.contextAction('copy')"
>
<v-list-item-title>复制 (Ctrl+C)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-content-cut"
@click="p.contextAction('cut')"
>
<v-list-item-title>剪切 (Ctrl+X)</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-content-paste"
:disabled="!p.canPasteHere"
@click="p.contextAction('paste')"
>
<v-list-item-title>粘贴到当前目录 (Ctrl+V)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile?.type === 'directory' && p.canPasteHere"
prepend-icon="mdi-folder-arrow-down"
@click="p.contextAction('pasteInto')"
>
<v-list-item-title>粘贴到{{ p.contextFile.name }}</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-link-variant" @click="p.contextAction('copyPath')">
<v-list-item-title>复制路径</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-console" @click="p.contextAction('terminal')">
<v-list-item-title>终端</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
v-if="p.contextFile && (p.isRegularFile(p.contextFile) || p.contextFile.type === 'directory')"
prepend-icon="mdi-zip-box"
@click="p.contextAction('compress')"
>
<v-list-item-title>压缩</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isArchiveName(p.contextFile.name)"
prepend-icon="mdi-package-up"
@click="p.contextAction('decompress')"
>
<v-list-item-title>解压</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-file-rename-box" @click="p.contextAction('rename')">
<v-list-item-title>重命名</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-lock" @click="p.contextAction('chmod')">
<v-list-item-title>修改权限</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete" @click="p.contextAction('delete')">
<v-list-item-title class="text-error">删除</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<style scoped>
.files-drop-active {
outline: 2px dashed rgb(var(--v-theme-primary));
outline-offset: -2px;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-alert
v-if="p.filesAccessHint"
:type="p.filesAccessHint.type"
variant="tonal"
closable
prominent
density="comfortable"
class="mb-4"
icon="mdi-shield-lock-outline"
@click:close="p.dismissFilesAccessHint"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
<span>{{ p.filesAccessHint.text.split('目标机安装示例')[0].trim() }}</span>
<v-btn
v-if="p.sshUserForFiles && p.sshUserForFiles !== 'root'"
size="small"
variant="flat"
color="primary"
prepend-icon="mdi-auto-fix"
:loading="p.setupSudoLoading"
:disabled="p.setupSudoDone"
@click.stop="p.setupFilesSudo"
>
{{ p.setupSudoDone ? '已配置' : '一键配置 sudo 权限' }}
</v-btn>
</div>
</v-alert>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<div class="text-caption text-medium-emphasis mb-2 d-flex flex-wrap align-center ga-2">
<span>{{ p.statusSummary }}</span>
<v-chip v-if="p.browseError" size="x-small" color="warning" variant="tonal">
{{ p.browseError }}
</v-chip>
<v-chip
v-if="p.hasClipboard"
size="x-small"
color="info"
variant="tonal"
class="cursor-pointer"
@click="() => p.pasteClipboard()"
>
{{ p.clipboardSummary }} {{ p.pasteDestLabel }} · Ctrl+V 粘贴
</v-chip>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
<v-row align="center" dense>
<v-col cols="12" sm="4">
<v-select
v-model="p.selectedServer"
:items="p.serverList"
item-title="name"
item-value="id"
label="选择服务器"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-server"
@update:model-value="p.onServerChange"
/>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="p.currentPath"
label="路径"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-folder"
append-inner-icon="mdi-arrow-right"
@click:append-inner="p.browseCurrent"
@keydown.enter="p.browseCurrent"
/>
</v-col>
<v-col cols="12" sm="3">
<v-text-field
v-model="p.fileFilter"
label="筛选文件名"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="p.extFilter"
:items="p.FILE_EXT_FILTERS"
item-title="title"
item-value="value"
label="类型"
variant="outlined"
density="compact"
hide-details
clearable
/>
</v-col>
<v-col cols="12" sm="2">
<v-select
v-model="p.sortBy"
:items="p.sortOptions"
item-title="title"
item-value="value"
label="排序"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" class="d-flex ga-2 flex-wrap justify-end">
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="p.showUpload = true">
上传
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-file-plus"
:disabled="!p.selectedServer"
@click="p.showNewFile = true"
>
新建文件
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="p.showMkdir = true">
新建目录
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-zip-box"
:disabled="!p.selectedServer || !p.selectedFileCount"
@click="p.openCompressDialog()"
>
压缩
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-paste"
:disabled="!p.canPasteHere"
@click="() => p.pasteClipboard()"
>
粘贴
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-arrow-up" :disabled="!p.canGoUp" @click="p.goUp">
上级
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="p.browseCurrent">刷新</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-console"
:disabled="!p.selectedServer"
@click="p.openInTerminal"
>
终端
</v-btn>
</v-col>
</v-row>
</v-card>
</template>
@@ -0,0 +1,18 @@
import { inject, provide, type InjectionKey } from 'vue'
import type { useFilesPage } from './useFilesPage'
export type FilesPageContext = ReturnType<typeof useFilesPage>
export const filesPageKey: InjectionKey<FilesPageContext> = Symbol('filesPage')
export function provideFilesPage(ctx: FilesPageContext): void {
provide(filesPageKey, ctx)
}
export function useFilesPageContext(): FilesPageContext {
const ctx = inject(filesPageKey)
if (!ctx) {
throw new Error('useFilesPageContext() must be used inside FilesPage')
}
return ctx
}
+2
View File
@@ -5,3 +5,5 @@ export { useFilesEditor } from './useFilesEditor'
export { useFilesActions } from './useFilesActions' export { useFilesActions } from './useFilesActions'
export { useFilesSelection } from './useFilesSelection' export { useFilesSelection } from './useFilesSelection'
export { useFilesPage } from './useFilesPage' export { useFilesPage } from './useFilesPage'
export { provideFilesPage, useFilesPageContext } from './filesPageContext'
export type { FilesPageContext } from './filesPageContext'
+22 -622
View File
@@ -1,639 +1,39 @@
<template> <template>
<v-container fluid class="pa-6"> <v-container fluid class="pa-6">
<!-- Toolbar --> <FilesToolbar />
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4"> <FilesPermissionAlert />
<v-row align="center" dense> <FilesStatusBar />
<v-col cols="12" sm="4"> <FilesBreadcrumb />
<v-select <FilesList />
v-model="selectedServer" <FilesDialogs />
:items="serverList"
item-title="name"
item-value="id"
label="选择服务器"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-server"
@update:model-value="onServerChange"
/>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="currentPath"
label="路径"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-folder"
append-inner-icon="mdi-arrow-right"
@click:append-inner="browseCurrent"
@keydown.enter="browseCurrent"
/>
</v-col>
<v-col cols="12" sm="3">
<v-text-field
v-model="fileFilter"
label="筛选文件名"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="extFilter"
:items="FILE_EXT_FILTERS"
item-title="title"
item-value="value"
label="类型"
variant="outlined"
density="compact"
hide-details
clearable
/>
</v-col>
<v-col cols="12" sm="2">
<v-select
v-model="sortBy"
:items="sortOptions"
item-title="title"
item-value="value"
label="排序"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" class="d-flex ga-2 flex-wrap justify-end">
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="showUpload = true">上传</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-file-plus" :disabled="!selectedServer" @click="showNewFile = true">新建文件</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="showMkdir = true">新建目录</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-zip-box"
:disabled="!selectedServer || !selectedFileCount"
@click="openCompressDialog()"
>
压缩
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-paste"
:disabled="!canPasteHere"
@click="() => pasteClipboard()"
>
粘贴
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-arrow-up"
:disabled="!canGoUp"
@click="goUp"
>
上级
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="browseCurrent">刷新</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-console"
:disabled="!selectedServer"
@click="openInTerminal"
>
终端
</v-btn>
</v-col>
</v-row>
</v-card>
<v-alert
v-if="filesAccessHint"
:type="filesAccessHint.type"
variant="tonal"
closable
prominent
density="comfortable"
class="mb-4"
icon="mdi-shield-lock-outline"
@click:close="dismissFilesAccessHint"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
<span>{{ filesAccessHint.text.split('目标机安装示例')[0].trim() }}</span>
<v-btn
v-if="sshUserForFiles && sshUserForFiles !== 'root'"
size="small"
variant="flat"
color="primary"
prepend-icon="mdi-auto-fix"
:loading="setupSudoLoading"
:disabled="setupSudoDone"
@click.stop="setupFilesSudo"
>
{{ setupSudoDone ? '已配置' : '一键配置 sudo 权限' }}
</v-btn>
</div>
</v-alert>
<!-- Status -->
<div class="text-caption text-medium-emphasis mb-2 d-flex flex-wrap align-center ga-2">
<span>{{ statusSummary }}</span>
<v-chip v-if="browseError" size="x-small" color="warning" variant="tonal">{{ browseError }}</v-chip>
<v-chip
v-if="hasClipboard"
size="x-small"
color="info"
variant="tonal"
class="cursor-pointer"
@click="() => pasteClipboard()"
>
{{ clipboardSummary }} {{ pasteDestLabel }} · Ctrl+V 粘贴
</v-chip>
</div>
<!-- Breadcrumb -->
<v-breadcrumbs :items="breadcrumbs" class="pa-0 mb-2">
<template #item="{ item, index }">
<v-breadcrumbs-item
:disabled="item.disabled"
:class="{ 'text-primary': !item.disabled, 'cursor-pointer': !item.disabled }"
@click="onBreadcrumbClick(index)"
>
{{ item.title }}
</v-breadcrumbs-item>
</template>
<template #divider>/</template>
</v-breadcrumbs>
<!-- Batch action bar -->
<v-card v-if="selectedFileCount > 0" class="mb-4" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ selectedFileCount }} 个文件</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="batchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="selectedFiles = []">取消选择</v-btn>
</v-card-text>
</v-card>
<!-- File list (drag-drop upload) -->
<v-card
elevation="0"
border
rounded="lg"
:class="{ 'files-drop-active': dropActive }"
@dragenter.prevent="dropActive = true"
@dragover.prevent="dropActive = true"
@dragleave.prevent="dropActive = false"
@drop.prevent="onDropFiles"
>
<v-data-table
v-model="selectedFiles"
:items="displayedFiles"
:headers="fileHeaders"
:loading="loading"
show-select
return-object
hover
density="comfortable"
item-value="name"
:row-props="fileRowProps"
>
<template #item.name="{ item }">
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="entryIconColor(item)" size="20">
{{ entryIcon(item) }}
</v-icon>
<v-icon
v-if="isEntryLikelyUnreadable(item, sshUserForFiles)"
size="18"
color="warning"
title="当前 SSH 用户可能无法读取"
>
mdi-shield-lock
</v-icon>
<div class="min-w-0">
<span :class="{ 'font-weight-medium': item.type === 'directory' }">{{ item.name }}</span>
<div v-if="item.type === 'symlink' && item.link_target" class="text-caption text-medium-emphasis text-truncate">
{{ item.link_target }}
</div>
</div>
</div>
</template>
<template #item.owner="{ item }">
<v-tooltip :text="permissionTooltip(item)" location="top">
<template #activator="{ props: tipProps }">
<v-chip
v-bind="tipProps"
size="small"
variant="tonal"
:color="ownerChipColor(item.owner || '')"
class="text-none"
>
{{ formatOwnerChip(item) }}
</v-chip>
</template>
</v-tooltip>
</template>
<template #item.size="{ item }">
<span class="text-medium-emphasis">{{ item.type === 'directory' ? '—' : formatSize(item.size) }}</span>
</template>
<template #item.modified="{ item }">
<span class="text-medium-emphasis">{{ item.modified || '—' }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn v-if="isRegularFile(item)" variant="text" size="x-small" density="compact" color="primary" @click.stop="editFile(item)">编辑</v-btn>
<v-btn v-if="isRegularFile(item)" variant="text" size="x-small" density="compact" @click.stop="previewFile(item)">查看</v-btn>
<v-btn v-if="isRegularFile(item)" variant="text" size="x-small" density="compact" @click.stop="downloadFile(item)">下载</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="confirmDeleteFile(item)">删除</v-btn>
</div>
</template>
<template #no-data>
<div v-if="pageReady && !loading" class="text-center text-medium-emphasis py-8">
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>{{ emptyHint }}</div>
<div v-if="selectedServer" class="text-caption mt-2 text-disabled">
单击目录进入 · 双击文件编辑 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
</div>
</div>
</template>
</v-data-table>
</v-card>
<!-- Upload dialog -->
<v-dialog v-model="showUpload" max-width="500">
<v-card border>
<v-card-title>上传文件</v-card-title>
<v-card-text>
<v-file-input v-model="uploadFiles" label="选择文件" variant="outlined" multiple show-size />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showUpload = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doUpload" :loading="uploading">上传</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- New file dialog -->
<v-dialog v-model="showNewFile" max-width="400">
<v-card border>
<v-card-title>新建文件</v-card-title>
<v-card-text>
<v-text-field
v-model="newFileName"
label="文件名"
variant="outlined"
density="compact"
:rules="[validatePathSegment]"
placeholder="example.conf"
autofocus
@keydown.enter="doNewFile"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showNewFile = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="actionLoading" @click="doNewFile">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Mkdir dialog -->
<v-dialog v-model="showMkdir" max-width="400">
<v-card border>
<v-card-title>新建目录</v-card-title>
<v-card-text>
<v-text-field
v-model="mkdirName"
label="目录名"
variant="outlined"
density="compact"
:rules="[validatePathSegment]"
@keydown.enter="doMkdir"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showMkdir = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doMkdir">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirm -->
<v-dialog v-model="showFileDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除 <strong>{{ deletingFile?.name }}</strong> </v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showFileDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDeleteFile">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Rename dialog -->
<v-dialog v-model="showRename" max-width="400">
<v-card border>
<v-card-title>重命名</v-card-title>
<v-card-text>
<v-text-field v-model="newName" label="新名称" variant="outlined" density="compact" :rules="[required('名称')]" autofocus @keydown.enter="doRename" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRename = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doRename">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<FilePermissionDialog
v-model="showChmod"
:file="chmodFile"
:server-id="selectedServer"
:loading="chmodLoading"
@apply="doChmod"
/>
<!-- Compress dialog -->
<v-dialog v-model="showCompress" max-width="440">
<v-card border>
<v-card-title>压缩</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">将压缩 {{ compressPaths.length }} 项到当前目录</p>
<v-text-field v-model="compressDest" label="压缩包路径" variant="outlined" density="compact" />
<v-select
v-model="compressFormat"
:items="[{ title: 'tar.gz', value: 'tar.gz' }, { title: 'zip', value: 'zip' }]"
item-title="title"
item-value="value"
label="格式"
variant="outlined"
density="compact"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="actionLoading" @click="doCompress">压缩</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Decompress dialog -->
<v-dialog v-model="showDecompress" max-width="440">
<v-card border>
<v-card-title>解压</v-card-title>
<v-card-text>
<p class="text-body-2 mb-2 text-truncate">{{ decompressSource }}</p>
<v-text-field v-model="decompressDest" label="解压到目录" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDecompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="actionLoading" @click="doDecompress">解压</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Context Menu -->
<v-menu v-model="showContextMenu" :target="[contextX, contextY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item
v-if="contextFile && contextFile.type === 'directory'"
prepend-icon="mdi-folder-open"
@click="contextAction('open')"
>
<v-list-item-title>打开</v-list-item-title>
</v-list-item>
<v-list-item v-if="contextFile && isRegularFile(contextFile)" prepend-icon="mdi-pencil" @click="contextAction('edit')">
<v-list-item-title>编辑</v-list-item-title>
</v-list-item>
<v-list-item v-if="contextFile && isRegularFile(contextFile)" prepend-icon="mdi-eye" @click="contextAction('preview')">
<v-list-item-title>查看</v-list-item-title>
</v-list-item>
<v-list-item v-if="contextFile && isRegularFile(contextFile)" prepend-icon="mdi-download" @click="contextAction('download')">
<v-list-item-title>下载</v-list-item-title>
</v-list-item>
<v-list-item
v-if="contextFile"
prepend-icon="mdi-content-copy"
@click="contextAction('copy')"
>
<v-list-item-title>复制 (Ctrl+C)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="contextFile"
prepend-icon="mdi-content-cut"
@click="contextAction('cut')"
>
<v-list-item-title>剪切 (Ctrl+X)</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-content-paste" :disabled="!canPasteHere" @click="contextAction('paste')">
<v-list-item-title>粘贴到当前目录 (Ctrl+V)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="contextFile?.type === 'directory' && canPasteHere"
prepend-icon="mdi-folder-arrow-down"
@click="contextAction('pasteInto')"
>
<v-list-item-title>粘贴到{{ contextFile.name }}</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-link-variant" @click="contextAction('copyPath')">
<v-list-item-title>复制路径</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-console" @click="contextAction('terminal')">
<v-list-item-title>终端</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
v-if="contextFile && (isRegularFile(contextFile) || contextFile.type === 'directory')"
prepend-icon="mdi-zip-box"
@click="contextAction('compress')"
>
<v-list-item-title>压缩</v-list-item-title>
</v-list-item>
<v-list-item
v-if="contextFile && isArchiveName(contextFile.name)"
prepend-icon="mdi-package-up"
@click="contextAction('decompress')"
>
<v-list-item-title>解压</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-file-rename-box" @click="contextAction('rename')">
<v-list-item-title>重命名</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-lock" @click="contextAction('chmod')">
<v-list-item-title>修改权限</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete" @click="contextAction('delete')">
<v-list-item-title class="text-error">删除</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- Preview dialog -->
<v-dialog v-model="showPreview" max-width="900" scrollable>
<v-card border>
<v-card-title class="d-flex align-center">
<span class="text-truncate">{{ previewTitle }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="showPreview = false" />
</v-card-title>
<v-divider />
<v-card-text style="max-height: 70vh;">
<v-progress-linear v-if="previewLoading" indeterminate class="mb-2" />
<pre v-else class="files-preview-body text-body-2">{{ previewContent }}</pre>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showPreview = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 浮动 IDE 编辑器目录树在编辑器内可拖动/缩放 -->
<FileEditorWorkbench <FileEditorWorkbench
ref="editorWorkbench" ref="editorWorkbench"
:server-id="selectedServer ?? 0" :server-id="page.selectedServer ?? 0"
:current-path="currentPath" :current-path="page.currentPath"
@saved="onEditorSaved($event)" @saved="page.onEditorSaved($event)"
@path-change="navigateToPath" @path-change="page.navigateToPath"
@paste-target="pasteClipboard" @paste-target="page.pasteClipboard"
/> />
</v-container> </v-container>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import FilePermissionDialog from '@/components/FilePermissionDialog.vue' import FilesBreadcrumb from '@/components/files/FilesBreadcrumb.vue'
import FilesDialogs from '@/components/files/FilesDialogs.vue'
import FilesList from '@/components/files/FilesList.vue'
import FilesPermissionAlert from '@/components/files/FilesPermissionAlert.vue'
import FilesStatusBar from '@/components/files/FilesStatusBar.vue'
import FilesToolbar from '@/components/files/FilesToolbar.vue'
import { provideFilesPage } from '@/composables/files/filesPageContext'
import { useFilesPage } from '@/composables/files/useFilesPage' import { useFilesPage } from '@/composables/files/useFilesPage'
const FileEditorWorkbench = defineAsyncComponent( const FileEditorWorkbench = defineAsyncComponent(
() => import('@/components/FileEditorWorkbench.vue'), () => import('@/components/FileEditorWorkbench.vue'),
) )
const { const page = useFilesPage()
FILE_EXT_FILTERS, provideFilesPage(page)
validatePathSegment,
required, const { editorWorkbench } = page
fileFilter,
extFilter,
sortBy,
sortOptions,
selectedServer,
currentPath,
loading,
browseError,
pageReady,
setupSudoLoading,
setupSudoDone,
serverList,
onServerChange,
browseCurrent,
filesAccessHint,
dismissFilesAccessHint,
setupFilesSudo,
sshUserForFiles,
statusSummary,
hasClipboard,
clipboardSummary,
pasteDestLabel,
canPasteHere,
breadcrumbs,
onBreadcrumbClick,
canGoUp,
goUp,
selectedFiles,
selectedFileCount,
displayedFiles,
fileHeaders,
fileRowProps,
dropActive,
onDropFiles,
emptyHint,
entryIcon,
entryIconColor,
isEntryLikelyUnreadable,
formatOwnerChip,
ownerChipColor,
permissionTooltip,
formatSize,
isRegularFile,
isArchiveName,
showUpload,
uploadFiles,
uploading,
doUpload,
showNewFile,
newFileName,
doNewFile,
showMkdir,
mkdirName,
doMkdir,
showFileDelete,
deletingFile,
doDeleteFile,
showRename,
newName,
doRename,
showChmod,
chmodFile,
chmodLoading,
doChmod,
showCompress,
compressDest,
compressFormat,
compressPaths,
doCompress,
showDecompress,
decompressSource,
decompressDest,
doDecompress,
showContextMenu,
contextX,
contextY,
contextFile,
contextAction,
actionLoading,
batchDelete,
openCompressDialog,
showPreview,
previewTitle,
previewContent,
previewLoading,
editorWorkbench,
onEditorSaved,
navigateToPath,
pasteClipboard,
editFile,
previewFile,
downloadFile,
confirmDeleteFile,
openInTerminal,
} = useFilesPage()
</script> </script>
<style scoped>
.files-drop-active {
outline: 2px dashed rgb(var(--v-theme-primary));
outline-offset: -2px;
}
.files-preview-body {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
margin: 0;
}
</style>