feat(frontend): 跨服务器传输独立页与侧栏菜单调整

将传输功能从文件页拆到 #/server-transfer,文件页改为跳转预填;监测历史移入系统分组。
This commit is contained in:
Nexus Agent
2026-06-21 12:59:45 +08:00
parent 73278667e0
commit d770b07609
12 changed files with 833 additions and 313 deletions
@@ -0,0 +1,41 @@
# 审计 — 2026-06-21 跨服务器传输独立页 + 侧栏调整
**Changelog**: `docs/changelog/2026-06-21-server-transfer-page.md``docs/changelog/2026-06-21-nav-watch-metrics-under-system.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `useServerFileTransfer.ts` | 路径 normalize、relay 500MB 禁用、localStorage 分模式缓存 | PASS |
| `useFilesActions.ts` | 移除内嵌传输;跳转 query 预填 | PASS |
| `ServerTransferPage.vue` | 顶层解构 ref,模板类型正确 | PASS |
| `App.vue` | 仅菜单分组调整,路由不变 | PASS |
## Closure
| 项 | 状态 | 说明 |
|----|------|------|
| 文件页内嵌传输对话框 | REMOVED | 改独立页 |
| 监测历史菜单位置 | MOVED | 运维 → 系统 |
## 文件清单
- `frontend/src/pages/ServerTransferPage.vue`
- `frontend/src/composables/useServerFileTransfer.ts`
- `frontend/src/router/index.ts`
- `frontend/src/App.vue`
- `frontend/src/composables/files/useFilesActions.ts`
- `frontend/src/composables/files/useFilesPage.ts`
- `frontend/src/components/files/FilesDialogs.vue`
- `frontend/src/components/files/FilesToolbar.vue`
- `docs/project/nexus-functional-development-guide.md`
- `docs/changelog/2026-06-21-server-transfer-page.md`
- `docs/changelog/2026-06-21-nav-watch-metrics-under-system.md`
## DoD
- [x] 独立页 `#/server-transfer` + 侧栏入口
- [x] 文件页跳转预填 query
- [x] 监测历史归入系统分组
- [x] `npx vite build` 通过
- [x] changelog ≥10 行
@@ -0,0 +1,23 @@
# 侧栏:监测历史归入系统分组
**日期**2026-06-21
## 摘要
将「监测历史」从侧栏「运维」分组移至「系统」分组(位于告警中心与审计日志之间)。
## 动机
监测历史属于系统观测/留存类功能,与命令日志、告警、审计同属系统侧栏更合理。
## 涉及文件
- `frontend/src/App.vue``opsItems` / `sysItems` 调整
## 迁移 / 重启
仅前端静态资源;路由 `#/watch-metrics` 不变。
## 验证
刷新 SPA,确认侧栏「系统」下可见「监测历史」,「运维」下已无该项。
@@ -0,0 +1,44 @@
# 跨服务器传输独立页面
**日期**2026-06-21
## 摘要
将「跨服务器文件直传 / 打包投递」从文件管理页内嵌对话框拆分为独立 SPA 页面 `#/server-transfer`,侧栏新增入口;文件页工具栏与右键改为跳转并预填源机与路径。
## 动机
- 传输流程涉及源机浏览、多选、目标机与模式选择,独立页比对话框更易操作与扩展。
- 与文件管理职责分离:文件页专注浏览编辑,传输页专注 A→B 编排。
## 涉及文件
| 路径 | 变更 |
|------|------|
| `frontend/src/pages/ServerTransferPage.vue` | 新建完整页(源浏览 + 目标配置 + 执行) |
| `frontend/src/composables/useServerFileTransfer.ts` | 新建页 composable;支持 query `source`/`path`/`paths` |
| `frontend/src/router/index.ts` | 路由 `/server-transfer` |
| `frontend/src/App.vue` | 侧栏「跨服务器传输」 |
| `frontend/src/composables/files/useFilesActions.ts` | 移除内嵌传输逻辑,改为 `goToServerTransfer` |
| `frontend/src/composables/files/useFilesPage.ts` | 导出 `goToServerTransfer` |
| `frontend/src/components/files/FilesToolbar.vue` | 按钮跳转独立页 |
| `frontend/src/components/files/FilesDialogs.vue` | 移除传输对话框 |
| `web/app/assets/ServerTransferPage-*` | Vite 构建产物 |
## 行为说明
- 文件页选中项后点「发送到其他服务器」→ `#/server-transfer?source=&path=&paths=`(路径 `|` 分隔)。
- 独立页可手动输入绝对路径、浏览源机目录、直传或打包投递;目标路径仍用 `localStorage``nexus:file-transfer-dest:{mode}:{serverId}`
- 后端 API 不变:`POST /sync/server-transfer``POST /sync/server-transfer/deliver`
## 迁移 / 重启
- 仅前端静态资源更新,无需 DB 迁移。
- 部署:重新 `vite build` 并同步 `web/app/`
## 验证
```bash
cd frontend && npx vite build
# 浏览器:侧栏「跨服务器传输」;文件页选中后跳转预填;直传/投递 smoke
```
@@ -569,6 +569,16 @@ main.ts → App.vue(侧栏/搜索)→ router-view
| Editor | Monaco`POST /sync/read-file|write-file` |
| 权限 | files-capability 检测、sudo 提示 |
| 热键 | 复制/粘贴/删除(`useFilesHotkeys` |
| 跨机传输 | 工具栏/右键跳转 `#/server-transfer`(见 §12.4.1 |
### 12.4.1 ServerTransferPage `/server-transfer`
| 能力 | 说明 |
|------|------|
| 源机浏览 | `POST /sync/browse`,多选 + 手动绝对路径 |
| 直传 | `POST /sync/server-transfer`(单文件 ≤500MB |
| 打包投递 | `POST /sync/server-transfer/deliver` + 可选解压 |
| 深链 | `?source=&path=&paths=` 自文件页预填 |
### 12.5 PushPage `/push`
+2 -1
View File
@@ -340,9 +340,9 @@ const mainContentStyle = computed(() => {
const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/watch-metrics', title: '监测历史', icon: 'mdi-chart-line' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/server-transfer', title: '跨服务器传输', icon: 'mdi-server-network' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
{ to: '/executions', title: '执行记录', icon: 'mdi-clipboard-play-outline' },
@@ -353,6 +353,7 @@ const opsItems = [
const sysItems = [
{ to: '/commands', title: '命令日志', icon: 'mdi-console' },
{ to: '/alerts', title: '告警中心', icon: 'mdi-bell-outline' },
{ to: '/watch-metrics', title: '监测历史', icon: 'mdi-chart-line' },
{ to: '/audit', title: '审计日志', icon: 'mdi-clipboard-text-outline' },
]
@@ -150,107 +150,6 @@ const p = useFilesPageContext()
</v-card>
</v-dialog>
<v-dialog v-model="p.showTransfer" max-width="520" persistent>
<v-card border>
<v-card-title>发送到其他服务器</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
从当前服务器传输 {{ p.transferPaths.length }}
</p>
<v-autocomplete
:model-value="p.transferDestServerId"
:items="p.transferDestServers"
item-title="name"
item-value="id"
label="目标服务器"
variant="outlined"
density="compact"
class="mb-3"
no-data-text="无其他服务器可选"
@update:model-value="p.onTransferDestServerChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :subtitle="item.domain || item.target_path" />
</template>
</v-autocomplete>
<v-btn-toggle
:model-value="p.transferMode"
mandatory
density="compact"
color="primary"
class="mb-3"
@update:model-value="p.onTransferModeChange"
>
<v-btn value="relay" :disabled="p.transferRelayDisabled">直传</v-btn>
<v-btn value="deliver">打包投递</v-btn>
</v-btn-toggle>
<v-alert
v-if="p.transferRelayDisabled"
type="warning"
density="compact"
variant="tonal"
class="mb-3"
>
所选文件超过 500MB 直传上限请使用打包投递
</v-alert>
<v-alert
v-else-if="p.transferHasDirectory && p.transferMode === 'relay'"
type="info"
density="compact"
variant="tonal"
class="mb-3"
>
含目录时建议使用打包投递直传将递归复制可能较慢
</v-alert>
<v-text-field
v-model="p.transferDestPath"
:label="p.transferMode === 'relay' ? '目标目录' : '目标归档路径'"
variant="outlined"
density="compact"
class="mb-2"
:hint="p.transferMode === 'relay' ? '文件将复制到此目录下' : '例如 /www/backup/site.tar.gz'"
persistent-hint
/>
<v-checkbox
v-if="p.transferMode === 'deliver'"
v-model="p.transferExtract"
label="下载后在目标服务器解压"
density="compact"
hide-details
/>
<v-expand-transition>
<div v-if="p.transferDownloadUrl" class="mt-3">
<v-text-field
:model-value="p.transferDownloadUrl"
label="下载链接(排错用)"
variant="outlined"
density="compact"
readonly
append-inner-icon="mdi-content-copy"
@click:append-inner="p.copyTransferDownloadUrl()"
/>
</div>
</v-expand-transition>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showTransfer = false">
{{ p.transferDownloadUrl ? '关闭' : '取消' }}
</v-btn>
<v-btn
v-if="!p.transferDownloadUrl"
color="primary"
variant="flat"
:loading="p.actionLoading"
:disabled="!p.transferDestServerId"
@click="p.doTransfer"
>
{{ p.transferMode === 'relay' ? '开始直传' : '打包并投递' }}
</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">
@@ -118,7 +118,7 @@ const p = useFilesPageContext()
variant="tonal"
prepend-icon="mdi-server-network"
:disabled="!p.selectedServer || !p.selectedFileCount"
@click="p.openTransferDialog()"
@click="p.goToServerTransfer()"
>
发送到其他服务器
</v-btn>
+16 -193
View File
@@ -1,8 +1,8 @@
import { computed, nextTick, ref, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { http } from '@/api'
import { useFilesClipboard } from '@/composables/useFilesClipboard'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
@@ -18,11 +18,6 @@ import type { useFilesSelection } from './useFilesSelection'
type SelectionApi = ReturnType<typeof useFilesSelection>
const MAX_RELAY_FILE_BYTES = 524_288_000
const TRANSFER_DEST_STORAGE_PREFIX = 'nexus:file-transfer-dest'
export type ServerTransferMode = 'relay' | 'deliver'
export function useFilesActions(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
invalidateAfterMutation: (parentPath?: string) => void
@@ -36,9 +31,9 @@ export function useFilesActions(options: {
actionLoading?: Ref<boolean>
}) {
const snackbar = useSnackbar()
const router = useRouter()
const store = useFilesStore()
const { selectedServer, currentPath } = storeToRefs(store)
const { servers: serverList } = useServerList()
const { selectedFiles, selectedAbsolutePaths, clearSelection } = options.selection
const {
setFromSelection,
@@ -75,180 +70,24 @@ export function useFilesActions(options: {
const contextY = ref(0)
const contextFile = ref<FileEntry | null>(null)
const showTransfer = ref(false)
const transferMode = ref<ServerTransferMode>('relay')
const transferDestServerId = ref<number | null>(null)
const transferDestPath = ref('')
const transferExtract = ref(true)
const transferPaths = ref<string[]>([])
const transferItems = ref<FileEntry[]>([])
const transferDownloadUrl = ref('')
const transferDestServers = computed(() => {
const cur = selectedServer.value
return serverList.value.filter((s) => s.id !== cur)
})
const transferHasDirectory = computed(() =>
transferItems.value.some((f) => f.type === 'directory'),
)
const transferHasOversizedFile = computed(() =>
transferItems.value.some(
(f) => f.type === 'file' && typeof f.size === 'number' && f.size > MAX_RELAY_FILE_BYTES,
),
)
const transferRelayDisabled = computed(() => transferHasOversizedFile.value)
const transferRecommendedMode = computed<ServerTransferMode>(() => {
if (transferHasOversizedFile.value || transferHasDirectory.value) return 'deliver'
return 'relay'
})
function defaultTransferDestPath(mode: ServerTransferMode, destServerId: number | null): string {
const destSrv = destServerId
? serverList.value.find((s) => s.id === destServerId)
: undefined
const base = destSrv?.target_path?.trim() || '/tmp'
if (mode === 'relay') {
return base.endsWith('/') ? base.slice(0, -1) : base
}
const first = transferPaths.value[0]
const name = first ? (first.split('/').pop() || 'transfer') : 'transfer'
const safe = name.replace(/[^\w.\-]/g, '_') || 'transfer'
return `${base.replace(/\/$/, '')}/${safe}.tar.gz`
}
function transferDestStorageKey(mode: ServerTransferMode, serverId: number): string {
return `${TRANSFER_DEST_STORAGE_PREFIX}:${mode}:${serverId}`
}
function loadStoredTransferDest(mode: ServerTransferMode, serverId: number): string | null {
try {
return localStorage.getItem(transferDestStorageKey(mode, serverId))
} catch {
return null
}
}
function storeTransferDest(mode: ServerTransferMode, serverId: number, path: string) {
try {
localStorage.setItem(transferDestStorageKey(mode, serverId), path)
} catch {
/* ignore */
}
}
function openTransferDialog(items?: FileEntry[]) {
function goToServerTransfer(items?: FileEntry[]) {
if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
snackbar('请先选择服务器', 'warning')
return
}
const entries = items?.length ? items : selectedFiles.value
if (!entries.length) {
const paths = selectedAbsolutePaths(items)
if (!paths.length) {
snackbar('请先选择文件或目录', 'warning')
return
}
transferItems.value = entries
transferPaths.value = entries.map((f) => joinRemotePath(currentPath.value, f.name))
transferDownloadUrl.value = ''
const destCandidates = transferDestServers.value
transferDestServerId.value = destCandidates[0]?.id ?? null
const recommended: ServerTransferMode =
entries.some((f) => f.type === 'directory')
|| entries.some(
(f) => f.type === 'file' && typeof f.size === 'number' && f.size > MAX_RELAY_FILE_BYTES,
)
? 'deliver'
: 'relay'
transferMode.value = recommended
const stored = transferDestServerId.value
? loadStoredTransferDest(recommended, transferDestServerId.value)
: null
transferDestPath.value =
stored || defaultTransferDestPath(recommended, transferDestServerId.value)
showTransfer.value = true
}
function onTransferModeChange(mode: ServerTransferMode) {
transferMode.value = mode
const stored = transferDestServerId.value
? loadStoredTransferDest(mode, transferDestServerId.value)
: null
transferDestPath.value =
stored || defaultTransferDestPath(mode, transferDestServerId.value)
}
function onTransferDestServerChange(serverId: number | null) {
transferDestServerId.value = serverId
const stored = serverId ? loadStoredTransferDest(transferMode.value, serverId) : null
transferDestPath.value =
stored || defaultTransferDestPath(transferMode.value, serverId)
}
async function doTransfer() {
if (!selectedServer.value || !transferDestServerId.value || !transferPaths.value.length) {
snackbar('请完整填写传输信息', 'warning')
return
}
if (transferMode.value === 'relay' && transferRelayDisabled.value) {
snackbar('所选文件超过直传上限,请使用打包投递', 'warning')
return
}
const destPath = normalizeRemotePath(transferDestPath.value)
actionLoading.value = true
transferDownloadUrl.value = ''
try {
if (transferMode.value === 'relay') {
await http.post('/sync/server-transfer', {
source_server_id: selectedServer.value,
dest_server_id: transferDestServerId.value,
sources: transferPaths.value,
dest_dir: destPath,
})
snackbar(`已直传 ${transferPaths.value.length} 项到目标服务器`)
storeTransferDest('relay', transferDestServerId.value, destPath)
showTransfer.value = false
clearSelection()
} else {
const res = await http.post<{
download_url?: string
dest_path?: string
}>('/sync/server-transfer/deliver', {
source_server_id: selectedServer.value,
dest_server_id: transferDestServerId.value,
paths: transferPaths.value,
dest_path: destPath,
extract: transferExtract.value,
format: 'tar.gz',
})
transferDownloadUrl.value = res.download_url || ''
storeTransferDest('deliver', transferDestServerId.value, destPath)
snackbar(
transferExtract.value
? '打包投递完成,已解压到目标机'
: `打包投递完成:${res.dest_path || destPath}`,
)
// 保留对话框以展示 download_url(排错);用户点取消关闭
clearSelection()
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '跨服务器传输失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function copyTransferDownloadUrl() {
if (!transferDownloadUrl.value) return
try {
await navigator.clipboard.writeText(transferDownloadUrl.value)
snackbar('下载链接已复制')
} catch {
snackbar('复制失败', 'error')
}
router.push({
path: '/server-transfer',
query: {
source: String(selectedServer.value),
path: currentPath.value,
paths: paths.join('|'),
},
})
}
function resolvePasteDestination(explicitDest?: string): string {
@@ -368,7 +207,7 @@ export function useFilesActions(options: {
openDecompressDialog(item)
break
case 'sendToServer':
openTransferDialog([item])
goToServerTransfer([item])
break
case 'rename':
startRename(item)
@@ -775,23 +614,7 @@ export function useFilesActions(options: {
contextX,
contextY,
contextFile,
showTransfer,
transferMode,
transferDestServerId,
transferDestPath,
transferExtract,
transferPaths,
transferDownloadUrl,
transferDestServers,
transferHasDirectory,
transferHasOversizedFile,
transferRelayDisabled,
transferRecommendedMode,
openTransferDialog,
onTransferModeChange,
onTransferDestServerChange,
doTransfer,
copyTransferDownloadUrl,
goToServerTransfer,
copyToClipboard,
cutToClipboard,
pasteClipboard,
+1 -17
View File
@@ -260,23 +260,7 @@ export function useFilesPage() {
actionLoading,
batchDelete: actions.batchDelete,
openCompressDialog: actions.openCompressDialog,
openTransferDialog: actions.openTransferDialog,
showTransfer: actions.showTransfer,
transferMode: actions.transferMode,
transferDestServerId: actions.transferDestServerId,
transferDestPath: actions.transferDestPath,
transferExtract: actions.transferExtract,
transferPaths: actions.transferPaths,
transferDownloadUrl: actions.transferDownloadUrl,
transferDestServers: actions.transferDestServers,
transferHasDirectory: actions.transferHasDirectory,
transferHasOversizedFile: actions.transferHasOversizedFile,
transferRelayDisabled: actions.transferRelayDisabled,
transferRecommendedMode: actions.transferRecommendedMode,
onTransferModeChange: actions.onTransferModeChange,
onTransferDestServerChange: actions.onTransferDestServerChange,
doTransfer: actions.doTransfer,
copyTransferDownloadUrl: actions.copyTransferDownloadUrl,
goToServerTransfer: actions.goToServerTransfer,
showPreview: editor.showPreview,
previewTitle: editor.previewTitle,
previewContent: editor.previewContent,
@@ -0,0 +1,361 @@
/**
* 跨服务器文件传输 — 独立页 composable(源机浏览 + 直传 / 打包投递)
*/
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerList } from '@/composables/useServerList'
import type { FileEntry } from '@/types/api'
import { parseBrowseResponse } from '@/utils/fileBrowse'
import {
DEFAULT_FILES_ROOT,
joinRemotePath,
normalizeRemotePath,
parentRemotePath,
} from '@/utils/remotePath'
export type ServerTransferMode = 'relay' | 'deliver'
const MAX_RELAY_FILE_BYTES = 524_288_000
const TRANSFER_DEST_STORAGE_PREFIX = 'nexus:file-transfer-dest'
export function useServerFileTransfer() {
const snackbar = useSnackbar()
const route = useRoute()
const router = useRouter()
const { servers: serverList, loading: serversLoading, loadServers } = useServerList()
const sourceServerId = ref<number | null>(null)
const destServerId = ref<number | null>(null)
const currentPath = ref(DEFAULT_FILES_ROOT)
const files = ref<FileEntry[]>([])
const browseLoading = ref(false)
const browseError = ref('')
const selectedPaths = ref<string[]>([])
const manualPaths = ref('')
const transferMode = ref<ServerTransferMode>('relay')
const destPath = ref('')
const extract = ref(true)
const actionLoading = ref(false)
const downloadUrl = ref('')
const lastResult = ref<Record<string, unknown> | null>(null)
const destServers = computed(() =>
serverList.value.filter((s) => s.id !== sourceServerId.value),
)
const selectedItems = computed(() => {
const set = new Set(selectedPaths.value)
return files.value.filter((f) => set.has(joinRemotePath(currentPath.value, f.name)))
})
const hasDirectory = computed(() =>
selectedItems.value.some((f) => f.type === 'directory'),
)
const hasOversizedFile = computed(() =>
selectedItems.value.some(
(f) => f.type === 'file' && typeof f.size === 'number' && f.size > MAX_RELAY_FILE_BYTES,
),
)
const relayDisabled = computed(() => hasOversizedFile.value)
const effectivePaths = computed(() => {
const fromBrowse = [...selectedPaths.value]
const manual = manualPaths.value
.split(/[\n,]+/)
.map((p) => p.trim())
.filter(Boolean)
.map((p) => normalizeRemotePath(p))
return [...new Set([...fromBrowse, ...manual])]
})
function destStorageKey(mode: ServerTransferMode, serverId: number): string {
return `${TRANSFER_DEST_STORAGE_PREFIX}:${mode}:${serverId}`
}
function loadStoredDest(mode: ServerTransferMode, serverId: number): string | null {
try {
return localStorage.getItem(destStorageKey(mode, serverId))
} catch {
return null
}
}
function storeDest(mode: ServerTransferMode, serverId: number, path: string) {
try {
localStorage.setItem(destStorageKey(mode, serverId), path)
} catch {
/* ignore */
}
}
function defaultDestPath(mode: ServerTransferMode, destId: number | null): string {
const destSrv = destId ? serverList.value.find((s) => s.id === destId) : undefined
const base = destSrv?.target_path?.trim() || '/tmp'
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
if (mode === 'relay') return normalizedBase
const first = effectivePaths.value[0]
const name = first ? (first.split('/').pop() || 'transfer') : 'transfer'
const safe = name.replace(/[^\w.\-]/g, '_') || 'transfer'
return `${normalizedBase}/${safe}.tar.gz`
}
function recommendMode(paths: string[]): ServerTransferMode {
for (const p of paths) {
const item = files.value.find(
(f) => joinRemotePath(currentPath.value, f.name) === p,
)
if (item?.type === 'directory') return 'deliver'
if (
item?.type === 'file'
&& typeof item.size === 'number'
&& item.size > MAX_RELAY_FILE_BYTES
) {
return 'deliver'
}
}
if (hasDirectory.value || hasOversizedFile.value) return 'deliver'
return 'relay'
}
function syncDestPathDefaults() {
if (!destServerId.value) return
const stored = loadStoredDest(transferMode.value, destServerId.value)
destPath.value = stored || defaultDestPath(transferMode.value, destServerId.value)
}
function onSourceServerChange(id: number | null) {
sourceServerId.value = id
selectedPaths.value = []
currentPath.value = DEFAULT_FILES_ROOT
if (id) {
const srv = serverList.value.find((s) => s.id === id)
if (srv?.target_path?.trim()) {
currentPath.value = normalizeRemotePath(srv.target_path)
}
}
const candidates = destServers.value
if (!destServerId.value || destServerId.value === id) {
destServerId.value = candidates[0]?.id ?? null
}
syncDestPathDefaults()
void browse()
}
function onDestServerChange(id: number | null) {
destServerId.value = id
syncDestPathDefaults()
}
function onModeChange(mode: ServerTransferMode) {
transferMode.value = mode
syncDestPathDefaults()
}
async function browse(path?: string) {
if (!sourceServerId.value) return
if (path !== undefined) currentPath.value = normalizeRemotePath(path)
browseLoading.value = true
browseError.value = ''
try {
const data = await http.post('/sync/browse', {
server_id: sourceServerId.value,
path: currentPath.value,
})
const { items, error } = parseBrowseResponse(data ?? { items: [] })
browseError.value = error || ''
files.value = items
} catch (e: unknown) {
files.value = []
browseError.value = e instanceof Error ? e.message : '浏览失败'
} finally {
browseLoading.value = false
}
}
function goUp() {
const parent = parentRemotePath(currentPath.value)
if (parent) void browse(parent)
}
function openEntry(item: FileEntry) {
if (item.type === 'directory' || item.type === 'symlink') {
void browse(joinRemotePath(currentPath.value, item.name))
}
}
function togglePath(item: FileEntry) {
const abs = joinRemotePath(currentPath.value, item.name)
const idx = selectedPaths.value.indexOf(abs)
if (idx >= 0) {
selectedPaths.value = selectedPaths.value.filter((p) => p !== abs)
} else {
selectedPaths.value = [...selectedPaths.value, abs]
}
transferMode.value = recommendMode(selectedPaths.value)
}
function isSelected(item: FileEntry): boolean {
return selectedPaths.value.includes(joinRemotePath(currentPath.value, item.name))
}
function selectAllVisible() {
const paths = files.value.map((f) => joinRemotePath(currentPath.value, f.name))
selectedPaths.value = [...new Set([...selectedPaths.value, ...paths])]
transferMode.value = recommendMode(selectedPaths.value)
}
function clearSelection() {
selectedPaths.value = []
}
function applyRouteQuery() {
const q = route.query
const src = Number(q.source ?? q.server_id)
if (Number.isFinite(src) && src > 0) {
sourceServerId.value = src
const pathQ = typeof q.path === 'string' ? q.path : ''
if (pathQ) currentPath.value = normalizeRemotePath(pathQ)
const pathsQ = typeof q.paths === 'string' ? q.paths : ''
if (pathsQ) {
selectedPaths.value = pathsQ.split('|').map((p) => normalizeRemotePath(p))
manualPaths.value = ''
}
}
if (sourceServerId.value) {
const candidates = serverList.value.filter((s) => s.id !== sourceServerId.value)
destServerId.value = candidates[0]?.id ?? null
transferMode.value = recommendMode(selectedPaths.value)
syncDestPathDefaults()
void browse()
}
}
async function runTransfer() {
if (!sourceServerId.value || !destServerId.value) {
snackbar('请选择源服务器与目标服务器', 'warning')
return
}
const paths = effectivePaths.value
if (!paths.length) {
snackbar('请选择或填写至少一个源路径', 'warning')
return
}
if (transferMode.value === 'relay' && relayDisabled.value) {
snackbar('所选文件超过直传上限,请使用打包投递', 'warning')
return
}
const target = normalizeRemotePath(destPath.value)
actionLoading.value = true
downloadUrl.value = ''
lastResult.value = null
try {
if (transferMode.value === 'relay') {
const res = await http.post('/sync/server-transfer', {
source_server_id: sourceServerId.value,
dest_server_id: destServerId.value,
sources: paths,
dest_dir: target,
})
lastResult.value = res as Record<string, unknown>
snackbar(`已直传 ${paths.length} 项到目标服务器`)
storeDest('relay', destServerId.value, target)
} else {
const res = await http.post<{
download_url?: string
dest_path?: string
}>('/sync/server-transfer/deliver', {
source_server_id: sourceServerId.value,
dest_server_id: destServerId.value,
paths,
dest_path: target,
extract: extract.value,
format: 'tar.gz',
})
lastResult.value = res as Record<string, unknown>
downloadUrl.value = res.download_url || ''
storeDest('deliver', destServerId.value, target)
snackbar(
extract.value
? '打包投递完成,已在目标服务器解压'
: `打包投递完成:${res.dest_path || target}`,
)
}
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '传输失败', 'error')
} finally {
actionLoading.value = false
}
}
async function copyDownloadUrl() {
if (!downloadUrl.value) return
try {
await navigator.clipboard.writeText(downloadUrl.value)
snackbar('下载链接已复制')
} catch {
snackbar('复制失败', 'error')
}
}
function openInFiles(serverId: number, path: string) {
router.push({
path: '/files',
query: { server_id: String(serverId), path: path === DEFAULT_FILES_ROOT ? undefined : path },
})
}
onMounted(async () => {
await loadServers({ all: true })
applyRouteQuery()
})
watch(
() => route.query,
() => {
if (serverList.value.length) applyRouteQuery()
},
)
return {
serverList,
serversLoading,
sourceServerId,
destServerId,
destServers,
currentPath,
files,
browseLoading,
browseError,
selectedPaths,
manualPaths,
effectivePaths,
transferMode,
destPath,
extract,
actionLoading,
downloadUrl,
lastResult,
hasDirectory,
hasOversizedFile,
relayDisabled,
onSourceServerChange,
onDestServerChange,
onModeChange,
browse,
goUp,
openEntry,
togglePath,
isSelected,
selectAllVisible,
clearSelection,
runTransfer,
copyDownloadUrl,
openInFiles,
MAX_RELAY_FILE_BYTES,
}
}
+333
View File
@@ -0,0 +1,333 @@
<template>
<v-container fluid class="pa-6">
<div class="d-flex align-center flex-wrap ga-3 mb-4">
<div>
<h1 class="text-h5 font-weight-medium">跨服务器传输</h1>
<p class="text-body-2 text-medium-emphasis mb-0">
从源服务器选择文件或目录直传或打包投递到另一台服务器无需经本地下载
</p>
</div>
<v-spacer />
<v-btn
variant="tonal"
prepend-icon="mdi-folder-outline"
:disabled="!sourceServerId"
@click="openInFiles(sourceServerId!, currentPath)"
>
在文件管理器中打开
</v-btn>
</div>
<v-row dense>
<v-col cols="12" md="7">
<v-card border rounded="lg" class="mb-4">
<v-card-title class="text-subtitle-1">源服务器与文件</v-card-title>
<v-card-text>
<v-autocomplete
:model-value="sourceServerId"
:items="serverList"
:loading="serversLoading"
item-title="name"
item-value="id"
label="源服务器 (A)"
variant="outlined"
density="compact"
class="mb-3"
prepend-inner-icon="mdi-server"
@update:model-value="onSourceServerChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item
v-bind="itemProps"
:subtitle="item.raw.domain || item.raw.target_path"
/>
</template>
</v-autocomplete>
<div class="d-flex ga-2 mb-3 flex-wrap align-center">
<v-text-field
:model-value="currentPath"
label="当前路径"
variant="outlined"
density="compact"
hide-details
prepend-inner-icon="mdi-folder"
class="flex-grow-1"
style="min-width: 200px"
@update:model-value="(v: string) => browse(v)"
@keydown.enter="browse(currentPath)"
/>
<v-btn variant="tonal" size="small" :disabled="!sourceServerId" @click="goUp">
上级
</v-btn>
<v-btn
variant="tonal"
size="small"
:loading="browseLoading"
:disabled="!sourceServerId"
@click="browse()"
>
刷新
</v-btn>
</div>
<v-alert
v-if="browseError"
type="warning"
density="compact"
variant="tonal"
class="mb-3"
>
{{ browseError }}
</v-alert>
<div class="d-flex ga-2 mb-2">
<v-btn size="small" variant="text" :disabled="!files.length" @click="selectAllVisible">
全选当前页
</v-btn>
<v-btn
size="small"
variant="text"
:disabled="!selectedPaths.length"
@click="clearSelection"
>
清空选择 ({{ selectedPaths.length }})
</v-btn>
</div>
<v-table density="compact" class="transfer-file-table" hover>
<thead>
<tr>
<th style="width: 48px" />
<th>名称</th>
<th style="width: 90px">类型</th>
<th style="width: 100px" class="text-end">大小</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in files"
:key="item.name"
class="cursor-pointer"
@click="item.type === 'directory' ? openEntry(item) : togglePath(item)"
>
<td @click.stop>
<v-checkbox-btn
:model-value="isSelected(item)"
density="compact"
@update:model-value="togglePath(item)"
/>
</td>
<td>
<v-icon
:icon="item.type === 'directory' ? 'mdi-folder' : 'mdi-file-outline'"
size="small"
class="mr-1"
/>
{{ item.name }}
</td>
<td>{{ item.type === 'directory' ? '目录' : '文件' }}</td>
<td class="text-end text-caption">
{{ item.type === 'file' && item.size != null ? formatSize(item.size) : '—' }}
</td>
</tr>
<tr v-if="!browseLoading && !files.length">
<td colspan="4" class="text-center text-medium-emphasis py-6">
{{ sourceServerId ? '目录为空' : '请先选择源服务器' }}
</td>
</tr>
</tbody>
</v-table>
<v-textarea
v-model="manualPaths"
label="或手动输入绝对路径每行一个可与上表勾选合并"
variant="outlined"
density="compact"
rows="2"
class="mt-4"
auto-grow
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="5">
<v-card border rounded="lg" class="mb-4">
<v-card-title class="text-subtitle-1">目标与方式</v-card-title>
<v-card-text>
<v-autocomplete
:model-value="destServerId"
:items="destServers"
:disabled="!sourceServerId"
item-title="name"
item-value="id"
label="目标服务器 (B)"
variant="outlined"
density="compact"
class="mb-3"
no-data-text="无其他服务器可选"
@update:model-value="onDestServerChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item
v-bind="itemProps"
:subtitle="item.raw.domain || item.raw.target_path"
/>
</template>
</v-autocomplete>
<v-btn-toggle
:model-value="transferMode"
mandatory
density="compact"
color="primary"
class="mb-3"
@update:model-value="onModeChange"
>
<v-btn value="relay" :disabled="relayDisabled">直传</v-btn>
<v-btn value="deliver">打包投递</v-btn>
</v-btn-toggle>
<v-alert
v-if="relayDisabled"
type="warning"
density="compact"
variant="tonal"
class="mb-3"
>
所选含超过 500MB 的文件,请使用打包投递。
</v-alert>
<v-alert
v-else-if="hasDirectory && transferMode === 'relay'"
type="info"
density="compact"
variant="tonal"
class="mb-3"
>
含目录时建议打包投递;直传将递归复制,可能较慢或超时。
</v-alert>
<v-text-field
v-model="destPath"
:label="transferMode === 'relay' ? '目标目录' : '目标归档路径'"
variant="outlined"
density="compact"
class="mb-2"
:hint="transferMode === 'relay' ? '文件将复制到此目录下' : '例如 /www/backup/site.tar.gz'"
persistent-hint
/>
<v-checkbox
v-if="transferMode === 'deliver'"
v-model="extract"
label="下载后在目标服务器解压"
density="compact"
hide-details
class="mb-3"
/>
<p class="text-body-2 mb-3">
将传输 <strong>{{ effectivePaths.length }}</strong> 项
</p>
<v-btn
color="primary"
block
size="large"
:loading="actionLoading"
:disabled="!sourceServerId || !destServerId || !effectivePaths.length"
@click="runTransfer"
>
{{ transferMode === 'relay' ? '开始直传' : '打包并投递' }}
</v-btn>
</v-card-text>
</v-card>
<v-card v-if="downloadUrl || lastResult" border rounded="lg" variant="outlined">
<v-card-title class="text-subtitle-2">最近结果</v-card-title>
<v-card-text>
<v-text-field
v-if="downloadUrl"
:model-value="downloadUrl"
label="下载链接排错用"
variant="outlined"
density="compact"
readonly
append-inner-icon="mdi-content-copy"
class="mb-2"
@click:append-inner="copyDownloadUrl"
/>
<pre class="text-caption transfer-result-json">{{ JSON.stringify(lastResult, null, 2) }}</pre>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { useServerFileTransfer } from '@/composables/useServerFileTransfer'
defineOptions({ name: 'ServerTransferPage' })
const {
serverList,
serversLoading,
sourceServerId,
destServerId,
destServers,
currentPath,
files,
browseLoading,
browseError,
selectedPaths,
manualPaths,
effectivePaths,
transferMode,
destPath,
extract,
actionLoading,
downloadUrl,
lastResult,
hasDirectory,
relayDisabled,
onSourceServerChange,
onDestServerChange,
onModeChange,
browse,
goUp,
openEntry,
togglePath,
isSelected,
selectAllVisible,
clearSelection,
runTransfer,
copyDownloadUrl,
openInFiles,
} = useServerFileTransfer()
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
</script>
<style scoped>
.transfer-file-table {
max-height: 420px;
overflow: auto;
}
.transfer-result-json {
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow: auto;
margin: 0;
}
.cursor-pointer {
cursor: pointer;
}
</style>
+1
View File
@@ -7,6 +7,7 @@ const routes = [
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/server-transfer', name: 'ServerTransfer', component: () => import('@/pages/ServerTransferPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
{ path: '/script-runs', name: 'ScriptRuns', component: () => import('@/pages/ScriptRunsPage.vue') },