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
|
||||
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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user