feat(frontend): 跨服务器传输目标机 B 文件浏览

选 B 后展示目录列表,浏览时同步目标路径。
This commit is contained in:
Nexus Agent
2026-06-21 13:15:01 +08:00
parent 113718b553
commit 71f0f53e6a
3 changed files with 188 additions and 0 deletions
@@ -0,0 +1,20 @@
# 跨服务器传输:目标机 B 目录浏览
**日期**2026-06-21
## 摘要
「目标与方式」区域增加 B 服务器文件列表,可浏览目录;进入目录后自动同步下方目标路径(直传为目录,打包为目录下归档名)。
## 动机
仅文本框填路径不直观;需在目标机侧像源机一样点选目录确认落点。
## 涉及文件
- `frontend/src/composables/useServerFileTransfer.ts``browseDest``destCurrentPath``syncDestPathFromBrowse`
- `frontend/src/pages/ServerTransferPage.vue` — 目标机文件表
## 验证
选 B 后出现文件列表;点目录进入并更新目标路径字段;`vite build` 通过。
@@ -34,6 +34,10 @@ export function useServerFileTransfer() {
const files = ref<FileEntry[]>([])
const browseLoading = ref(false)
const browseError = ref('')
const destCurrentPath = ref(DEFAULT_FILES_ROOT)
const destFiles = ref<FileEntry[]>([])
const destBrowseLoading = ref(false)
const destBrowseError = ref('')
const selectedPaths = ref<string[]>([])
const manualPaths = ref('')
@@ -152,10 +156,32 @@ export function useServerFileTransfer() {
const stored = loadStoredDest(transferMode.value, destId)
if (stored) {
destPath.value = stored
destCurrentPath.value = destBrowseDirFromPath(stored)
return
}
}
destPath.value = defaultDestPath(transferMode.value, destId)
destCurrentPath.value = serverTargetPath(destId)
}
function destBrowseDirFromPath(path: string): string {
const normalized = normalizeRemotePath(path)
if (transferMode.value === 'relay') return normalized
const parent = parentRemotePath(normalized)
return parent || normalized
}
function syncDestPathFromBrowse() {
if (!destServerId.value) return
const dir = destCurrentPath.value.replace(/\/$/, '') || '/'
if (transferMode.value === 'relay') {
destPath.value = dir
return
}
const first = effectivePaths.value[0]
const name = first ? (first.split('/').pop() || 'transfer') : 'transfer'
const safe = name.replace(/[^\w.\-]/g, '_') || 'transfer'
destPath.value = `${dir}/${safe}.tar.gz`
}
function recommendMode(paths: string[]): ServerTransferMode {
@@ -191,17 +217,27 @@ export function useServerFileTransfer() {
}
applyDestPathForServer(destServerId.value, false)
void browse()
if (destServerId.value) void browseDest()
}
function onDestServerChange(id: number | null) {
destServerId.value = id
destServerSearchQuery.value = ''
if (!id) {
destFiles.value = []
destCurrentPath.value = DEFAULT_FILES_ROOT
destPath.value = ''
return
}
applyDestPathForServer(id, false)
void browseDest()
}
function onModeChange(mode: ServerTransferMode) {
transferMode.value = mode
applyDestPathForServer(destServerId.value, true)
syncDestPathFromBrowse()
if (destServerId.value) void browseDest()
}
async function browse(path?: string) {
@@ -225,6 +261,28 @@ export function useServerFileTransfer() {
}
}
async function browseDest(path?: string) {
if (!destServerId.value) return
if (path !== undefined) destCurrentPath.value = normalizeRemotePath(path)
destBrowseLoading.value = true
destBrowseError.value = ''
try {
const data = await http.post('/sync/browse', {
server_id: destServerId.value,
path: destCurrentPath.value,
})
const { items, error } = parseBrowseResponse(data ?? { items: [] })
destBrowseError.value = error || ''
destFiles.value = items
syncDestPathFromBrowse()
} catch (e: unknown) {
destFiles.value = []
destBrowseError.value = e instanceof Error ? e.message : '浏览失败'
} finally {
destBrowseLoading.value = false
}
}
function goUp() {
const parent = parentRemotePath(currentPath.value)
if (parent) void browse(parent)
@@ -236,6 +294,17 @@ export function useServerFileTransfer() {
}
}
function goUpDest() {
const parent = parentRemotePath(destCurrentPath.value)
if (parent) void browseDest(parent)
}
function openDestEntry(item: FileEntry) {
if (item.type === 'directory' || item.type === 'symlink') {
void browseDest(joinRemotePath(destCurrentPath.value, item.name))
}
}
function togglePath(item: FileEntry) {
const abs = joinRemotePath(currentPath.value, item.name)
const idx = selectedPaths.value.indexOf(abs)
@@ -245,6 +314,9 @@ export function useServerFileTransfer() {
selectedPaths.value = [...selectedPaths.value, abs]
}
transferMode.value = recommendMode(selectedPaths.value)
if (transferMode.value === 'deliver' && destServerId.value) {
syncDestPathFromBrowse()
}
}
function isSelected(item: FileEntry): boolean {
@@ -255,6 +327,9 @@ export function useServerFileTransfer() {
const paths = files.value.map((f) => joinRemotePath(currentPath.value, f.name))
selectedPaths.value = [...new Set([...selectedPaths.value, ...paths])]
transferMode.value = recommendMode(selectedPaths.value)
if (transferMode.value === 'deliver' && destServerId.value) {
syncDestPathFromBrowse()
}
}
function clearSelection() {
@@ -284,6 +359,7 @@ export function useServerFileTransfer() {
transferMode.value = recommendMode(selectedPaths.value)
applyDestPathForServer(destServerId.value, false)
void browse()
void browseDest()
}
}
@@ -387,6 +463,10 @@ export function useServerFileTransfer() {
files,
browseLoading,
browseError,
destCurrentPath,
destFiles,
destBrowseLoading,
destBrowseError,
selectedPaths,
manualPaths,
effectivePaths,
@@ -403,8 +483,11 @@ export function useServerFileTransfer() {
onDestServerChange,
onModeChange,
browse,
browseDest,
goUp,
goUpDest,
openEntry,
openDestEntry,
togglePath,
isSelected,
selectAllVisible,
+85
View File
@@ -235,6 +235,81 @@
含目录时建议打包投递;直传将递归复制,可能较慢或超时。
</v-alert>
<div class="text-caption text-medium-emphasis mb-2">目标机目录浏览</div>
<div class="d-flex ga-2 mb-3 flex-wrap align-center">
<v-text-field
:model-value="destCurrentPath"
label="当前路径"
variant="outlined"
density="compact"
hide-details
prepend-inner-icon="mdi-folder"
class="flex-grow-1"
style="min-width: 160px"
:disabled="!destServerId"
@update:model-value="(v: string) => browseDest(v)"
@keydown.enter="browseDest(destCurrentPath)"
/>
<v-btn variant="tonal" size="small" :disabled="!destServerId" @click="goUpDest">
上级
</v-btn>
<v-btn
variant="tonal"
size="small"
:loading="destBrowseLoading"
:disabled="!destServerId"
@click="browseDest()"
>
刷新
</v-btn>
</div>
<v-alert
v-if="destBrowseError"
type="warning"
density="compact"
variant="tonal"
class="mb-3"
>
{{ destBrowseError }}
</v-alert>
<v-table density="compact" class="transfer-file-table transfer-file-table--dest mb-3" hover>
<thead>
<tr>
<th>名称</th>
<th style="width: 90px">类型</th>
<th style="width: 100px" class="text-end">大小</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in destFiles"
:key="item.name"
class="cursor-pointer"
@click="item.type === 'directory' || item.type === 'symlink' ? openDestEntry(item) : undefined"
>
<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="!destBrowseLoading && !destFiles.length">
<td colspan="3" class="text-center text-medium-emphasis py-4">
{{ destServerId ? '目录为空' : '请先选择目标服务器' }}
</td>
</tr>
</tbody>
</v-table>
<v-text-field
v-model="destPath"
:label="transferMode === 'relay' ? '目标目录' : '目标归档路径'"
@@ -311,6 +386,10 @@ const {
files,
browseLoading,
browseError,
destCurrentPath,
destFiles,
destBrowseLoading,
destBrowseError,
selectedPaths,
manualPaths,
effectivePaths,
@@ -326,8 +405,11 @@ const {
onDestServerChange,
onModeChange,
browse,
browseDest,
goUp,
goUpDest,
openEntry,
openDestEntry,
togglePath,
isSelected,
selectAllVisible,
@@ -350,6 +432,9 @@ function formatSize(bytes: number): string {
max-height: 420px;
overflow: auto;
}
.transfer-file-table--dest {
max-height: 280px;
}
.transfer-result-json {
white-space: pre-wrap;
word-break: break-all;