diff --git a/app/api/routers/history.py b/app/api/routers/history.py index 3653033..a0d6a09 100644 --- a/app/api/routers/history.py +++ b/app/api/routers/history.py @@ -2,6 +2,7 @@ import zipfile from contextlib import suppress from datetime import datetime from pathlib import Path +from typing import Any from fastapi import APIRouter, Body, HTTPException from fastapi.responses import FileResponse @@ -25,11 +26,11 @@ def _safe_filename_part(value: str) -> str: return safe.strip("_") or "history" -def _get_safe_work_dir(record_id: str) -> tuple[Path, str]: +def _get_safe_work_dir(record_id: str, *, require_finished: bool = True) -> tuple[Path, str]: record = state.history_manager.get(record_id) if not record: raise HTTPException(status_code=404, detail="记录不存在") - if record.status in {"pending", "processing"}: + if require_finished and record.status in {"pending", "processing"}: raise HTTPException(status_code=409, detail="任务尚未完成,暂不能下载历史数据") work_dir = Path(record.work_dir).resolve() @@ -45,6 +46,33 @@ def _get_safe_work_dir(record_id: str) -> tuple[Path, str]: return work_dir, record.id +def _get_safe_history_path(record_id: str, relative_path: str | None, *, require_finished: bool = False) -> tuple[Path, Path, str]: + work_dir, safe_record_id = _get_safe_work_dir(record_id, require_finished=require_finished) + clean_path = _normalize_relative_path(relative_path) + target = work_dir.joinpath(*clean_path.split("/")).resolve() if clean_path else work_dir + + try: + target.relative_to(work_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail="历史文件路径不合法") from exc + + if not target.exists(): + raise HTTPException(status_code=404, detail="历史文件不存在") + + return work_dir, target, safe_record_id + + +def _normalize_relative_path(value: str | None) -> str: + normalized = (value or "").replace("\\", "/").strip("/") + if not normalized: + return "" + + parts = [part for part in normalized.split("/") if part and part != "."] + if any(part == ".." for part in parts): + raise HTTPException(status_code=400, detail="历史文件路径不合法") + return "/".join(parts) + + def _zip_directory(source_dir: Path, archive_path: Path) -> None: with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: for item in source_dir.rglob("*"): @@ -55,6 +83,42 @@ def _zip_directory(source_dir: Path, archive_path: Path) -> None: archive.write(item, arcname) +def _zip_history_item(source_path: Path, archive_path: Path) -> None: + root_name = source_path.name or "history" + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + if source_path.is_file(): + archive.write(source_path, root_name) + return + + has_content = False + for item in source_path.rglob("*"): + has_content = True + arcname = Path(root_name, item.relative_to(source_path)).as_posix() + if item.is_dir(): + archive.writestr(f"{arcname}/", "") + elif item.is_file(): + archive.write(item, arcname) + + if not has_content: + archive.writestr(f"{root_name}/", "") + + +def _history_entry(item: Path, work_dir: Path) -> dict[str, Any]: + stat = item.stat() + is_dir = item.is_dir() + size = get_dir_size(item) if is_dir else stat.st_size + modified = datetime.fromtimestamp(stat.st_mtime) + return { + "name": item.name, + "path": item.relative_to(work_dir).as_posix(), + "type": "dir" if is_dir else "file", + "size": size, + "size_formatted": format_size(size), + "modified": modified.isoformat(), + "modified_formatted": modified.strftime("%Y-%m-%d %H:%M:%S"), + } + + @router.post("/api/history") async def get_history(limit: int = Body(50, embed=True)): return {"records": state.history_manager.list(limit)} @@ -98,6 +162,33 @@ async def get_history_size(record_id: str = Body(..., embed=True)): return {"success": True, "size": size, "size_formatted": format_size(size)} +@router.post("/api/history/files") +async def list_history_files( + record_id: str = Body(..., embed=True), + path: str | None = Body("", embed=True), +): + work_dir, current_path, _safe_record_id = _get_safe_history_path(record_id, path) + if not current_path.is_dir(): + raise HTTPException(status_code=400, detail="请选择目录") + + current_relative = current_path.relative_to(work_dir).as_posix() + current_relative = "" if current_relative == "." else current_relative + parent_relative = None + if current_path != work_dir: + parent_relative = current_path.parent.relative_to(work_dir).as_posix() + parent_relative = "" if parent_relative == "." else parent_relative + + entries = [_history_entry(item, work_dir) for item in current_path.iterdir()] + entries.sort(key=lambda item: (item["type"] != "dir", item["name"].lower())) + return { + "success": True, + "record_id": record_id, + "path": current_relative, + "parent_path": parent_relative, + "entries": entries, + } + + @router.post("/api/history/download") async def download_history(record_id: str = Body(..., embed=True)): work_dir, safe_record_id = _get_safe_work_dir(record_id) @@ -120,3 +211,39 @@ async def download_history(record_id: str = Body(..., embed=True)): media_type="application/zip", background=BackgroundTask(_remove_file, archive_path), ) + + +@router.post("/api/history/file/download") +async def download_history_file( + record_id: str = Body(..., embed=True), + path: str = Body(..., embed=True), +): + _work_dir, target_path, safe_record_id = _get_safe_history_path(record_id, path, require_finished=True) + if target_path.is_file(): + return FileResponse( + path=str(target_path), + filename=target_path.name, + media_type="application/octet-stream", + ) + + if not target_path.is_dir(): + raise HTTPException(status_code=400, detail="历史文件类型不支持下载") + + export_dir = CACHE_DIR / ".downloads" + export_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{_safe_filename_part(safe_record_id)}_{_safe_filename_part(target_path.name)}_{timestamp}.zip" + archive_path = export_dir / filename + + try: + _zip_history_item(target_path, archive_path) + except Exception: + _remove_file(archive_path) + raise + + return FileResponse( + path=str(archive_path), + filename=filename, + media_type="application/zip", + background=BackgroundTask(_remove_file, archive_path), + ) diff --git a/app/main.py b/app/main.py index d4ce670..aae7200 100644 --- a/app/main.py +++ b/app/main.py @@ -164,6 +164,16 @@ OPENAPI_OPERATION_DOCS = { "description": "将历史记录对应工作目录压缩为 ZIP 后下载,下载响应完成后自动清理临时压缩包。", "example": {"record_id": "20260519_172457"}, }, + ("post", "/api/history/files"): { + "summary": "浏览历史文件", + "description": "列出指定历史记录工作目录下某个相对目录的文件和子目录,返回类型、大小和修改时间。", + "example": {"record_id": "20260519_172457", "path": "4G/FDD"}, + }, + ("post", "/api/history/file/download"): { + "summary": "下载历史单个文件或目录", + "description": "下载历史工作目录内的单个文件;如果目标是目录,则先压缩该目录后下载,并在响应完成后清理临时压缩包。", + "example": {"record_id": "20260519_172457", "path": "4G/FDD/CapacityReportData4G_202605110000_202605120000.zip"}, + }, ("post", "/api/history/size"): { "summary": "查询历史目录大小", "description": "统计指定历史记录工作目录的文件数和占用空间。", diff --git a/docs/project_context.md b/docs/project_context.md index 7934d01..127402e 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -599,3 +599,10 @@ - `frontend/src/router.ts` now lazy-loads `FileWorkflow.vue`, matching the other main pages and keeping the data processing page out of the base entry chunk until the route is opened. - `frontend/src/AppShell.vue` now lazy-loads `LoginView.vue` and the hidden license activation dialog; the dialog UI and activation API flow moved to `frontend/src/components/LicenseActivationModal.vue`. - Build verification: `npm run build` passed. The base `index` chunk dropped from about `572 kB` to about `461 kB`; the remaining large chunks are still `ApiDocs` (`swagger-ui-dist`) and `ScriptPanel` (`monaco-editor`). + +## 2026-06-01: History detail file browser + +- `app/api/routers/history.py` adds `/api/history/files` for browsing a history work directory by safe relative path, and `/api/history/file/download` for downloading one file directly or one directory as a temporary ZIP. Both paths are constrained under the record's `cache/` work directory; download still rejects pending/processing records. +- `frontend/src/components/HistoryPanel.vue` keeps the processing log at the bottom of the task detail modal, and adds a `详情 / 文件` tab area above it. The file tab shows breadcrumb navigation, parent/refresh controls, directory/file type, size, modified time, and per-item download actions. +- `frontend/src/types.ts` defines `HistoryFileEntry` and `HistoryFilesResponse`; `app/main.py` adds Chinese OpenAPI descriptions and examples for the new history file APIs. +- Verification performed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, direct history file listing for `20260519_172434`, path traversal rejection, and single-file download of `log.txt` on a temporary local server all passed. Build output and Python caches were removed after verification. diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 1456c69..0542098 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -37,40 +37,116 @@ -
-
- 任务 ID - {{ detail.id }} -
-
- 状态 - {{ statusText(detail.status) }} -
-
- 文件数 - {{ detail.file_count }} -
-
- 耗时 - {{ formatElapsed(detail.elapsed_time) }} -
-
- 创建时间 - {{ formatDate(detail.timestamp) }} -
-
- 占用 - {{ recordSize }} -
-
- 工作目录 - {{ detail.work_dir }} -
-
- 错误 - {{ detail.error }} -
-
+ + +
+
+ 任务 ID + {{ detail.id }} +
+
+ 状态 + {{ statusText(detail.status) }} +
+
+ 文件数 + {{ detail.file_count }} +
+
+ 耗时 + {{ formatElapsed(detail.elapsed_time) }} +
+
+ 创建时间 + {{ formatDate(detail.timestamp) }} +
+
+ 占用 + {{ recordSize }} +
+
+ 工作目录 + {{ detail.work_dir }} +
+
+ 错误 + {{ detail.error }} +
+
+
+ +
+
+
+ + +
+
+ + + 上一级 + + + + 刷新 + +
+
+ + + +
+
+
+ + + + +
+ + + {{ entry.type === 'dir' ? '目录' : '文件' }} · {{ entry.size_formatted }} · {{ entry.modified_formatted }} + +
+
+
+ + 打开 + + + + 下载 + +
+
+
+
+
+
+