feat(frontend): 跨服务器传输目标机 B 文件浏览
选 B 后展示目录列表,浏览时同步目标路径。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user