diff --git a/docs/changelog/2026-06-12-files-download-terminal-list-ui.md b/docs/changelog/2026-06-12-files-download-terminal-list-ui.md new file mode 100644 index 00000000..8bbe8f3e --- /dev/null +++ b/docs/changelog/2026-06-12-files-download-terminal-list-ui.md @@ -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 测试;文件页点击下载应保存文件;终端/文件列表行高与服务器页接近。 diff --git a/frontend/src/components/files/FilesList.vue b/frontend/src/components/files/FilesList.vue index 11915534..f529dc34 100644 --- a/frontend/src/components/files/FilesList.vue +++ b/frontend/src/components/files/FilesList.vue @@ -37,13 +37,13 @@ const p = useFilesPageContext() show-select return-object hover - density="comfortable" + density="compact" item-value="name" :row-props="p.fileRowProps" > @@ -27,3 +31,13 @@ defineEmits<{ select: [serverId: number] }>() + + diff --git a/frontend/src/composables/files/useFilesActions.ts b/frontend/src/composables/files/useFilesActions.ts index c449f6a4..8958f9f3 100644 --- a/frontend/src/composables/files/useFilesActions.ts +++ b/frontend/src/composables/files/useFilesActions.ts @@ -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 } } diff --git a/frontend/src/composables/files/useFilesPage.ts b/frontend/src/composables/files/useFilesPage.ts index 713b241b..7c0d264a 100644 --- a/frontend/src/composables/files/useFilesPage.ts +++ b/frontend/src/composables/files/useFilesPage.ts @@ -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( diff --git a/server/api/sync_v2.py b/server/api/sync_v2.py index a51d6f8b..b3eab215 100644 --- a/server/api/sync_v2.py +++ b/server/api/sync_v2.py @@ -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(