fix(files): 修复下载流式 SFTP 并收紧列表行高

下载在独立 SFTP 会话中流式传输;文件/终端列表与服务器页一致的紧凑操作与行高。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 05:50:09 +08:00
parent 80aaf855a3
commit a51a5c5341
6 changed files with 84 additions and 65 deletions
@@ -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 测试;文件页点击下载应保存文件;终端/文件列表行高与服务器页接近。
+16 -34
View File
@@ -37,13 +37,13 @@ const p = useFilesPageContext()
show-select
return-object
hover
density="comfortable"
density="compact"
item-value="name"
:row-props="p.fileRowProps"
>
<template #item.name="{ item }">
<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) }}
</v-icon>
<v-icon
@@ -96,41 +96,38 @@ const p = useFilesPageContext()
<div class="files-row-actions">
<v-btn
v-if="p.isRegularFile(item)"
variant="outlined"
size="small"
variant="text"
size="x-small"
color="primary"
class="files-action-btn"
prepend-icon="mdi-pencil-outline"
density="compact"
@click.stop="p.editFile(item)"
>
编辑
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="outlined"
size="small"
class="files-action-btn"
prepend-icon="mdi-eye-outline"
variant="text"
size="x-small"
density="compact"
@click.stop="p.previewFile(item)"
>
查看
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="outlined"
size="small"
class="files-action-btn"
prepend-icon="mdi-download-outline"
variant="text"
size="x-small"
density="compact"
:loading="p.actionLoading"
@click.stop="p.downloadFile(item)"
>
下载
</v-btn>
<v-btn
variant="outlined"
size="small"
variant="text"
size="x-small"
color="error"
class="files-action-btn"
prepend-icon="mdi-delete-outline"
density="compact"
@click.stop="p.confirmDeleteFile(item)"
>
删除
@@ -260,26 +257,11 @@ const p = useFilesPageContext()
outline-offset: -2px;
}
/* 行内操作:独立小按钮、字号略大、横向可换行 */
.files-row-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 6px;
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;
gap: 2px;
}
</style>
@@ -2,16 +2,20 @@
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
density="compact"
class="terminal-server-item"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</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-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>
</template>
@@ -27,3 +31,13 @@ defineEmits<{
select: [serverId: number]
}>()
</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) {
if (!selectedServer.value) return
actionLoading.value = true
try {
const remotePath = joinRemotePath(currentPath.value, item.name)
const { blob, filename } = await http.download('/sync/download', {
@@ -427,6 +428,8 @@ export function useFilesActions(options: {
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '下载失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
@@ -140,7 +140,7 @@ export function useFilesPage() {
{ title: '归属', key: 'owner', width: 110, sortable: false },
{ title: '大小', key: 'size', width: 120 },
{ 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(
+23 -27
View File
@@ -1448,12 +1448,10 @@ async def download_file(
raise HTTPException(status_code=400, detail=str(exc)) from exc
conn = await ssh_pool.acquire(server)
# NOTE: connection is released INSIDE file_generator (after stream finishes),
# not in a finally block here — releasing before the generator runs would close
# the SFTP channel mid-download.
# Pre-check in a short SFTP session; stream opens its own session so the
# context manager is not exited before StreamingResponse consumes the body.
try:
async with sftp_session(conn) as sftp:
# Check file exists and size
try:
stat = await sftp.stat(remote_path)
except FileNotFoundError:
@@ -1467,42 +1465,40 @@ async def download_file(
await ssh_pool.release(server.id)
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
# H-15: Download file size limit (500MB)
MAX_DOWNLOAD_SIZE = 524_288_000
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
await ssh_pool.release(server.id)
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
import re as _re
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:
raise
except Exception as e:
await ssh_pool.release(server.id)
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")
async def read_file(