feat(frontend): 跨服务器传输独立页与侧栏菜单调整
将传输功能从文件页拆到 #/server-transfer,文件页改为跳转预填;监测历史移入系统分组。
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
Reference in New Issue
Block a user