fix(files): 修复下载流式 SFTP 并收紧列表行高
下载在独立 SFTP 会话中流式传输;文件/终端列表与服务器页一致的紧凑操作与行高。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
# 文件下载修复 + 文件/终端列表紧凑样式
|
||||||
|
|
||||||
|
**日期**: 2026-06-12
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
- **下载**:修复 SFTP 会话在流式响应开始前关闭导致下载失败;下载时显示 loading
|
||||||
|
- **文件列表**:表格 `compact` + 操作按钮与服务器页一致的 `text/x-small`
|
||||||
|
- **终端右侧列表**:行高与字号收紧,与服务器列表密度接近
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `server/api/sync_v2.py`
|
||||||
|
- `frontend/src/components/files/FilesList.vue`
|
||||||
|
- `frontend/src/composables/files/useFilesActions.ts`
|
||||||
|
- `frontend/src/components/terminal/TerminalServerPickerList.vue`
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 需 API 重启(下载逻辑在后端)
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
`pytest` 相关 sync 测试;文件页点击下载应保存文件;终端/文件列表行高与服务器页接近。
|
||||||
@@ -37,13 +37,13 @@ const p = useFilesPageContext()
|
|||||||
show-select
|
show-select
|
||||||
return-object
|
return-object
|
||||||
hover
|
hover
|
||||||
density="comfortable"
|
density="compact"
|
||||||
item-value="name"
|
item-value="name"
|
||||||
:row-props="p.fileRowProps"
|
:row-props="p.fileRowProps"
|
||||||
>
|
>
|
||||||
<template #item.name="{ item }">
|
<template #item.name="{ item }">
|
||||||
<div class="d-flex align-center ga-2 min-w-0">
|
<div class="d-flex align-center ga-2 min-w-0">
|
||||||
<v-icon :color="p.entryIconColor(item)" size="20">
|
<v-icon :color="p.entryIconColor(item)" size="18">
|
||||||
{{ p.entryIcon(item) }}
|
{{ p.entryIcon(item) }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-icon
|
<v-icon
|
||||||
@@ -96,41 +96,38 @@ const p = useFilesPageContext()
|
|||||||
<div class="files-row-actions">
|
<div class="files-row-actions">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="p.isRegularFile(item)"
|
v-if="p.isRegularFile(item)"
|
||||||
variant="outlined"
|
variant="text"
|
||||||
size="small"
|
size="x-small"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-pencil-outline"
|
|
||||||
@click.stop="p.editFile(item)"
|
@click.stop="p.editFile(item)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="p.isRegularFile(item)"
|
v-if="p.isRegularFile(item)"
|
||||||
variant="outlined"
|
variant="text"
|
||||||
size="small"
|
size="x-small"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-eye-outline"
|
|
||||||
@click.stop="p.previewFile(item)"
|
@click.stop="p.previewFile(item)"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="p.isRegularFile(item)"
|
v-if="p.isRegularFile(item)"
|
||||||
variant="outlined"
|
variant="text"
|
||||||
size="small"
|
size="x-small"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-download-outline"
|
:loading="p.actionLoading"
|
||||||
@click.stop="p.downloadFile(item)"
|
@click.stop="p.downloadFile(item)"
|
||||||
>
|
>
|
||||||
下载
|
下载
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="outlined"
|
variant="text"
|
||||||
size="small"
|
size="x-small"
|
||||||
color="error"
|
color="error"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-delete-outline"
|
|
||||||
@click.stop="p.confirmDeleteFile(item)"
|
@click.stop="p.confirmDeleteFile(item)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -260,26 +257,11 @@ const p = useFilesPageContext()
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行内操作:独立小按钮、字号略大、横向可换行 */
|
|
||||||
.files-row-actions {
|
.files-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-action-btn {
|
|
||||||
text-transform: none;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
min-height: 32px;
|
|
||||||
padding-inline: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-action-btn :deep(.v-btn__prepend) {
|
|
||||||
margin-inline-end: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="s in servers"
|
v-for="s in servers"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
:title="s.name"
|
|
||||||
:subtitle="s.domain"
|
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
|
density="compact"
|
||||||
|
class="terminal-server-item"
|
||||||
@click="$emit('select', s.id)"
|
@click="$emit('select', s.id)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||||
</template>
|
</template>
|
||||||
|
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-caption text-truncate text-medium-emphasis" :title="s.domain">
|
||||||
|
{{ s.domain }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
<v-list-item v-if="servers.length === 0" density="compact" class="text-medium-emphasis terminal-server-item">
|
||||||
暂无匹配的服务器
|
暂无匹配的服务器
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,3 +31,13 @@ defineEmits<{
|
|||||||
select: [serverId: number]
|
select: [serverId: number]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terminal-server-item {
|
||||||
|
min-height: 40px !important;
|
||||||
|
padding-block: 2px;
|
||||||
|
}
|
||||||
|
.terminal-server-item :deep(.v-list-item__prepend) {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ export function useFilesActions(options: {
|
|||||||
|
|
||||||
async function downloadFile(item: FileEntry) {
|
async function downloadFile(item: FileEntry) {
|
||||||
if (!selectedServer.value) return
|
if (!selectedServer.value) return
|
||||||
|
actionLoading.value = true
|
||||||
try {
|
try {
|
||||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
const remotePath = joinRemotePath(currentPath.value, item.name)
|
||||||
const { blob, filename } = await http.download('/sync/download', {
|
const { blob, filename } = await http.download('/sync/download', {
|
||||||
@@ -427,6 +428,8 @@ export function useFilesActions(options: {
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : '下载失败'
|
const msg = e instanceof Error ? e.message : '下载失败'
|
||||||
snackbar(msg, 'error')
|
snackbar(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function useFilesPage() {
|
|||||||
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
||||||
{ title: '大小', key: 'size', width: 120 },
|
{ title: '大小', key: 'size', width: 120 },
|
||||||
{ title: '修改时间', key: 'modified', width: 180 },
|
{ title: '修改时间', key: 'modified', width: 180 },
|
||||||
{ title: '操作', key: 'actions', width: 300, sortable: false, align: 'end' as const },
|
{ title: '操作', key: 'actions', width: 200, sortable: false, align: 'end' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
useFilesHotkeys(
|
useFilesHotkeys(
|
||||||
|
|||||||
+23
-27
@@ -1448,12 +1448,10 @@ async def download_file(
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
conn = await ssh_pool.acquire(server)
|
conn = await ssh_pool.acquire(server)
|
||||||
# NOTE: connection is released INSIDE file_generator (after stream finishes),
|
# Pre-check in a short SFTP session; stream opens its own session so the
|
||||||
# not in a finally block here — releasing before the generator runs would close
|
# context manager is not exited before StreamingResponse consumes the body.
|
||||||
# the SFTP channel mid-download.
|
|
||||||
try:
|
try:
|
||||||
async with sftp_session(conn) as sftp:
|
async with sftp_session(conn) as sftp:
|
||||||
# Check file exists and size
|
|
||||||
try:
|
try:
|
||||||
stat = await sftp.stat(remote_path)
|
stat = await sftp.stat(remote_path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -1467,42 +1465,40 @@ async def download_file(
|
|||||||
await ssh_pool.release(server.id)
|
await ssh_pool.release(server.id)
|
||||||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||||||
|
|
||||||
# H-15: Download file size limit (500MB)
|
|
||||||
MAX_DOWNLOAD_SIZE = 524_288_000
|
MAX_DOWNLOAD_SIZE = 524_288_000
|
||||||
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
||||||
await ssh_pool.release(server.id)
|
await ssh_pool.release(server.id)
|
||||||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||||||
|
|
||||||
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
|
|
||||||
import re as _re
|
import re as _re
|
||||||
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
||||||
|
|
||||||
async def file_generator():
|
|
||||||
try:
|
|
||||||
async with sftp.open(remote_path, "rb") as f:
|
|
||||||
while chunk := await f.read(65536):
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
# Release after stream is fully consumed (or on client disconnect)
|
|
||||||
await ssh_pool.release(server.id)
|
|
||||||
|
|
||||||
await _audit_sync(
|
|
||||||
"file_download", "server", payload.server_id,
|
|
||||||
f"下载文件: {remote_path}",
|
|
||||||
admin.username, request,
|
|
||||||
)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
file_generator(),
|
|
||||||
media_type="application/octet-stream",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await ssh_pool.release(server.id)
|
await ssh_pool.release(server.id)
|
||||||
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
||||||
|
|
||||||
|
async def file_generator():
|
||||||
|
try:
|
||||||
|
async with sftp_session(conn) as sftp:
|
||||||
|
async with sftp.open(remote_path, "rb") as f:
|
||||||
|
while chunk := await f.read(65536):
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await ssh_pool.release(server.id)
|
||||||
|
|
||||||
|
await _audit_sync(
|
||||||
|
"file_download", "server", payload.server_id,
|
||||||
|
f"下载文件: {remote_path}",
|
||||||
|
admin.username, request,
|
||||||
|
)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
file_generator(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/read-file")
|
@router.post("/read-file")
|
||||||
async def read_file(
|
async def read_file(
|
||||||
|
|||||||
Reference in New Issue
Block a user