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 { useFilesSelection } from './useFilesSelection'
export { useFilesPage } from './useFilesPage'
export { provideFilesPage, useFilesPageContext } from './filesPageContext'
export type { FilesPageContext } from './filesPageContext'
+22 -622
View File
@@ -1,639 +1,39 @@
<template>
<v-container fluid class="pa-6">
<!-- Toolbar -->
<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="selectedServer"
: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 编辑器目录树在编辑器内可拖动/缩放 -->
<FilesToolbar />
<FilesPermissionAlert />
<FilesStatusBar />
<FilesBreadcrumb />
<FilesList />
<FilesDialogs />
<FileEditorWorkbench
ref="editorWorkbench"
:server-id="selectedServer ?? 0"
:current-path="currentPath"
@saved="onEditorSaved($event)"
@path-change="navigateToPath"
@paste-target="pasteClipboard"
:server-id="page.selectedServer ?? 0"
:current-path="page.currentPath"
@saved="page.onEditorSaved($event)"
@path-change="page.navigateToPath"
@paste-target="page.pasteClipboard"
/>
</v-container>
</template>
<script setup lang="ts">
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'
const FileEditorWorkbench = defineAsyncComponent(
() => import('@/components/FileEditorWorkbench.vue'),
)
const {
FILE_EXT_FILTERS,
validatePathSegment,
required,
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()
const page = useFilesPage()
provideFilesPage(page)
const { editorWorkbench } = page
</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>