refactor(files): P2 split UI into components with page context inject
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user