From b575298a3ea6e94575592c7d9d2b0083d7e274c6 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Tue, 19 May 2026 15:04:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=A1=A8=E5=AF=BC=E5=87=BA=E9=80=89=E6=8B=A9=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routers/database.py | 64 ++++++- docs/project_context.md | 8 + frontend/src/api/client.ts | 2 +- frontend/src/components/DatabasePanel.vue | 206 ++++++++++++++++++---- 4 files changed, 234 insertions(+), 46 deletions(-) diff --git a/app/api/routers/database.py b/app/api/routers/database.py index d77f313..01add7d 100644 --- a/app/api/routers/database.py +++ b/app/api/routers/database.py @@ -1,3 +1,4 @@ +import re from contextlib import suppress from datetime import datetime from pathlib import Path @@ -14,6 +15,7 @@ from app.database import DatabaseManager router = APIRouter(tags=["database"]) +INVALID_SHEET_NAME_CHARS = re.compile(r"[:\\/?*\[\]]") def _remove_file(path: Path) -> None: @@ -21,6 +23,36 @@ def _remove_file(path: Path) -> None: path.unlink() +def _resolve_requested_tables( + table_name: Optional[str], + table_names: Optional[list[str]], +) -> list[str]: + names = table_names if table_names is not None else ([table_name] if table_name else []) + return [name.strip() for name in names if isinstance(name, str) and name.strip()] + + +def _make_sheet_name(table_name: str, used_names: set[str]) -> str: + base = INVALID_SHEET_NAME_CHARS.sub("_", table_name).strip("'").strip() or "Sheet" + base = base[:31] + sheet_name = base + index = 2 + + while sheet_name in used_names: + suffix = f"_{index}" + sheet_name = f"{base[:31 - len(suffix)]}{suffix}" or f"Sheet_{index}" + index += 1 + + used_names.add(sheet_name) + return sheet_name + + +def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame: + result = db.query_table(table_name, page=1, page_size=1000000) + table_info = db.get_table_info(table_name) + columns = [str(column["Field"]) for column in table_info["columns"]] + return pd.DataFrame(result["data"], columns=columns) + + @router.post("/api/database/test") async def test_database(): db = DatabaseManager(state.config) @@ -166,32 +198,52 @@ async def execute_sql(sql: str = Body(..., embed=True)): @router.post("/api/download") async def download_table( - table_name: str = Body(..., embed=True), + table_name: Optional[str] = Body(None, embed=True), + table_names: Optional[list[str]] = Body(None, embed=True), file_format: str = Body("csv", alias="format"), ): if file_format not in {"csv", "xlsx"}: raise HTTPException(status_code=400, detail="不支持的导出格式") + requested_tables = _resolve_requested_tables(table_name, table_names) + if not requested_tables: + raise HTTPException(status_code=400, detail="请选择要导出的数据表") + if file_format == "csv" and len(requested_tables) != 1: + raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表") + db = DatabaseManager(state.config) try: - result = db.query_table(table_name, page=1, page_size=1000000) + available_tables = set(db.get_tables()) + missing_tables = [name for name in requested_tables if name not in available_tables] + if missing_tables: + raise HTTPException(status_code=400, detail=f"数据表不存在: {', '.join(missing_tables)}") + + table_frames = { + name: _dataframe_from_table(db, name) + for name in requested_tables + } + except HTTPException: + raise except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc finally: db.dispose() - df = pd.DataFrame(result["data"]) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{table_name}_{timestamp}.{file_format}" + filename_prefix = requested_tables[0] if len(requested_tables) == 1 else "tables" + filename = f"{filename_prefix}_{timestamp}.{file_format}" filepath = CACHE_DIR / filename CACHE_DIR.mkdir(parents=True, exist_ok=True) try: if file_format == "csv": - df.to_csv(filepath, index=False, encoding="utf-8-sig") + table_frames[requested_tables[0]].to_csv(filepath, index=False, encoding="utf-8-sig") media_type = "text/csv" else: - df.to_excel(filepath, index=False) + used_sheet_names: set[str] = set() + with pd.ExcelWriter(filepath) as writer: + for name, df in table_frames.items(): + df.to_excel(writer, sheet_name=_make_sheet_name(name, used_sheet_names), index=False) media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" except Exception: _remove_file(filepath) diff --git a/docs/project_context.md b/docs/project_context.md index 49a9874..4514ed2 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -1,5 +1,13 @@ # 项目上下文记录 +## 2026-05-19:调整数据管理导出交互并支持多表 XLSX + +- `frontend/src/components/DatabasePanel.vue` 将导出入口移到页面顶部,并放在“删除全部表”按钮左侧;内容区工具栏只保留刷新、清空、删除当前表。 +- 点击“导出 CSV”会弹出全部表单选弹窗,用户选择一张表后下载 CSV;点击“导出 XLSX”会弹出全部表多选弹窗,用户可选择多张表并下载同一个 XLSX。 +- `app/api/routers/database.py` 的 `/api/download` 支持 `table_names`,XLSX 会按表名分 sheet 写入同一工作簿,sheet 名会兼容 Excel 的非法字符和 31 字符限制;CSV 仍限制单表导出。 +- `frontend/src/api/client.ts` 的 POST 下载会优先使用后端 `Content-Disposition` 文件名,便于多表导出使用服务端生成的文件名。 +- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;构建只保留 Vite 原有大 chunk 提示。 + ## 2026-05-19:优化数据表导出临时文件清理 - `app/api/routers/database.py` 的 `/api/download` 导出接口增加格式校验,只允许 `csv` 和 `xlsx`。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5471502..37b3cdc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -113,7 +113,7 @@ export async function download(url: string, body: unknown, filename: string): Pr throw new Error(await readError(response)); } - await saveBlobResponse(response, filename); + await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename); } export async function downloadGet(url: string, fallbackFilename: string): Promise { diff --git a/frontend/src/components/DatabasePanel.vue b/frontend/src/components/DatabasePanel.vue index 870cd2d..e2c911e 100644 --- a/frontend/src/components/DatabasePanel.vue +++ b/frontend/src/components/DatabasePanel.vue @@ -74,22 +74,6 @@ 刷新 - - - - - {{ downloadButtonText }} - - - - 清空 删除 @@ -161,15 +145,72 @@ + + +
+

选择要导出的数据表。

+ + + + + {{ table }} + + + + +
+ +
+ + +
+

选择一个或多个数据表,导出后会合并到同一个 XLSX 文件中。

+ + + + + {{ table }} + + + + +
+ +