feat: 添加历史文件浏览下载
This commit is contained in:
+129
-2
@@ -2,6 +2,7 @@ import zipfile
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException
|
from fastapi import APIRouter, Body, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -25,11 +26,11 @@ def _safe_filename_part(value: str) -> str:
|
|||||||
return safe.strip("_") or "history"
|
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)
|
record = state.history_manager.get(record_id)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
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="任务尚未完成,暂不能下载历史数据")
|
raise HTTPException(status_code=409, detail="任务尚未完成,暂不能下载历史数据")
|
||||||
|
|
||||||
work_dir = Path(record.work_dir).resolve()
|
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
|
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:
|
def _zip_directory(source_dir: Path, archive_path: Path) -> None:
|
||||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||||
for item in source_dir.rglob("*"):
|
for item in source_dir.rglob("*"):
|
||||||
@@ -55,6 +83,42 @@ def _zip_directory(source_dir: Path, archive_path: Path) -> None:
|
|||||||
archive.write(item, arcname)
|
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")
|
@router.post("/api/history")
|
||||||
async def get_history(limit: int = Body(50, embed=True)):
|
async def get_history(limit: int = Body(50, embed=True)):
|
||||||
return {"records": state.history_manager.list(limit)}
|
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)}
|
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")
|
@router.post("/api/history/download")
|
||||||
async def download_history(record_id: str = Body(..., embed=True)):
|
async def download_history(record_id: str = Body(..., embed=True)):
|
||||||
work_dir, safe_record_id = _get_safe_work_dir(record_id)
|
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",
|
media_type="application/zip",
|
||||||
background=BackgroundTask(_remove_file, archive_path),
|
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),
|
||||||
|
)
|
||||||
|
|||||||
+10
@@ -164,6 +164,16 @@ OPENAPI_OPERATION_DOCS = {
|
|||||||
"description": "将历史记录对应工作目录压缩为 ZIP 后下载,下载响应完成后自动清理临时压缩包。",
|
"description": "将历史记录对应工作目录压缩为 ZIP 后下载,下载响应完成后自动清理临时压缩包。",
|
||||||
"example": {"record_id": "20260519_172457"},
|
"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"): {
|
("post", "/api/history/size"): {
|
||||||
"summary": "查询历史目录大小",
|
"summary": "查询历史目录大小",
|
||||||
"description": "统计指定历史记录工作目录的文件数和占用空间。",
|
"description": "统计指定历史记录工作目录的文件数和占用空间。",
|
||||||
|
|||||||
@@ -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/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`.
|
- `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`).
|
- 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.
|
||||||
|
|||||||
@@ -37,40 +37,116 @@
|
|||||||
<n-modal v-model:show="detailVisible" preset="card" class="history-detail-modal" title="任务详情">
|
<n-modal v-model:show="detailVisible" preset="card" class="history-detail-modal" title="任务详情">
|
||||||
<n-empty v-if="!detail" description="暂无详情" />
|
<n-empty v-if="!detail" description="暂无详情" />
|
||||||
<n-space v-else vertical size="large">
|
<n-space v-else vertical size="large">
|
||||||
<div class="detail-info-list">
|
<n-tabs v-model:value="detailTab" type="segment" animated>
|
||||||
<div class="detail-info-row">
|
<n-tab-pane name="detail" tab="详情">
|
||||||
<span class="detail-info-label">任务 ID</span>
|
<div class="detail-info-list">
|
||||||
<span class="detail-info-value monospace">{{ detail.id }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">任务 ID</span>
|
||||||
<div class="detail-info-row">
|
<span class="detail-info-value monospace">{{ detail.id }}</span>
|
||||||
<span class="detail-info-label">状态</span>
|
</div>
|
||||||
<span class="detail-info-value">{{ statusText(detail.status) }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">状态</span>
|
||||||
<div class="detail-info-row">
|
<span class="detail-info-value">{{ statusText(detail.status) }}</span>
|
||||||
<span class="detail-info-label">文件数</span>
|
</div>
|
||||||
<span class="detail-info-value">{{ detail.file_count }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">文件数</span>
|
||||||
<div class="detail-info-row">
|
<span class="detail-info-value">{{ detail.file_count }}</span>
|
||||||
<span class="detail-info-label">耗时</span>
|
</div>
|
||||||
<span class="detail-info-value">{{ formatElapsed(detail.elapsed_time) }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">耗时</span>
|
||||||
<div class="detail-info-row">
|
<span class="detail-info-value">{{ formatElapsed(detail.elapsed_time) }}</span>
|
||||||
<span class="detail-info-label">创建时间</span>
|
</div>
|
||||||
<span class="detail-info-value monospace">{{ formatDate(detail.timestamp) }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">创建时间</span>
|
||||||
<div class="detail-info-row">
|
<span class="detail-info-value monospace">{{ formatDate(detail.timestamp) }}</span>
|
||||||
<span class="detail-info-label">占用</span>
|
</div>
|
||||||
<span class="detail-info-value" :class="{ muted: recordSizeLoading }">{{ recordSize }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">占用</span>
|
||||||
<div class="detail-info-row">
|
<span class="detail-info-value" :class="{ muted: recordSizeLoading }">{{ recordSize }}</span>
|
||||||
<span class="detail-info-label">工作目录</span>
|
</div>
|
||||||
<span class="detail-info-value path-value">{{ detail.work_dir }}</span>
|
<div class="detail-info-row">
|
||||||
</div>
|
<span class="detail-info-label">工作目录</span>
|
||||||
<div v-if="detail.error" class="detail-info-row error-row">
|
<span class="detail-info-value path-value">{{ detail.work_dir }}</span>
|
||||||
<span class="detail-info-label">错误</span>
|
</div>
|
||||||
<span class="detail-info-value">{{ detail.error }}</span>
|
<div v-if="detail.error" class="detail-info-row error-row">
|
||||||
</div>
|
<span class="detail-info-label">错误</span>
|
||||||
</div>
|
<span class="detail-info-value">{{ detail.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="files" tab="文件">
|
||||||
|
<div class="history-file-browser">
|
||||||
|
<div class="history-file-toolbar">
|
||||||
|
<div class="history-file-breadcrumb">
|
||||||
|
<button type="button" class="breadcrumb-link" @click="loadHistoryFiles('')">根目录</button>
|
||||||
|
<template v-for="crumb in fileBreadcrumbs" :key="crumb.path">
|
||||||
|
<n-icon class="breadcrumb-separator"><ChevronForwardOutline /></n-icon>
|
||||||
|
<button type="button" class="breadcrumb-link" @click="loadHistoryFiles(crumb.path)">
|
||||||
|
{{ crumb.name }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="history-file-toolbar-actions">
|
||||||
|
<n-button size="small" tertiary :disabled="fileParentPath === null || fileLoading" @click="loadHistoryFiles(fileParentPath || '')">
|
||||||
|
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||||
|
上一级
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" tertiary :loading="fileLoading" @click="reloadHistoryFiles">
|
||||||
|
<template #icon><n-icon><RefreshOutline /></n-icon></template>
|
||||||
|
刷新
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-spin :show="fileLoading">
|
||||||
|
<n-empty v-if="!fileEntries.length && !fileLoading" description="当前目录为空" />
|
||||||
|
<div v-else class="history-file-list">
|
||||||
|
<div v-for="entry in fileEntries" :key="entry.path" class="history-file-row">
|
||||||
|
<div class="history-file-main">
|
||||||
|
<n-icon class="history-file-icon" :class="entry.type">
|
||||||
|
<FolderOpenOutline v-if="entry.type === 'dir'" />
|
||||||
|
<DocumentOutline v-else />
|
||||||
|
</n-icon>
|
||||||
|
<div class="history-file-text">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="history-file-name"
|
||||||
|
:disabled="entry.type !== 'dir'"
|
||||||
|
@click="entry.type === 'dir' && loadHistoryFiles(entry.path)"
|
||||||
|
>
|
||||||
|
{{ entry.name }}
|
||||||
|
</button>
|
||||||
|
<span class="history-file-meta">
|
||||||
|
{{ entry.type === 'dir' ? '目录' : '文件' }} · {{ entry.size_formatted }} · {{ entry.modified_formatted }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-file-actions">
|
||||||
|
<n-button
|
||||||
|
v-if="entry.type === 'dir'"
|
||||||
|
size="small"
|
||||||
|
tertiary
|
||||||
|
@click="loadHistoryFiles(entry.path)"
|
||||||
|
>
|
||||||
|
打开
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
tertiary
|
||||||
|
:loading="downloadingFilePath === entry.path"
|
||||||
|
:disabled="detailDownloadDisabled"
|
||||||
|
@click="downloadHistoryItem(entry)"
|
||||||
|
>
|
||||||
|
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||||
|
下载
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="section-label log-section-header">
|
<div class="section-label log-section-header">
|
||||||
@@ -94,12 +170,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useDialog, useMessage } from 'naive-ui';
|
import { useDialog, useMessage } from 'naive-ui';
|
||||||
import { CopyOutline, DownloadOutline, FileTrayOutline, RefreshOutline, TrashOutline } from '@vicons/ionicons5';
|
import {
|
||||||
|
ArrowBackOutline,
|
||||||
|
ChevronForwardOutline,
|
||||||
|
CopyOutline,
|
||||||
|
DocumentOutline,
|
||||||
|
DownloadOutline,
|
||||||
|
FileTrayOutline,
|
||||||
|
FolderOpenOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
TrashOutline
|
||||||
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
import { apiGet, apiPost, download as downloadFile } from '../api/client';
|
import { apiGet, apiPost, download as downloadFile } from '../api/client';
|
||||||
import type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
|
import type { CacheSize, HistoryDetail, HistoryFileEntry, HistoryFilesResponse, HistoryRecord } from '../types';
|
||||||
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
|
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
|
||||||
import { toColoredLogLines } from '../composables/logLines';
|
import { toColoredLogLines } from '../composables/logLines';
|
||||||
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||||
@@ -114,12 +200,33 @@ const recordSize = ref('-');
|
|||||||
const recordSizeLoading = ref(false);
|
const recordSizeLoading = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const downloadingRecordId = ref<string | null>(null);
|
const downloadingRecordId = ref<string | null>(null);
|
||||||
|
const detailTab = ref<'detail' | 'files'>('detail');
|
||||||
|
const fileEntries = ref<HistoryFileEntry[]>([]);
|
||||||
|
const fileCurrentPath = ref('');
|
||||||
|
const fileParentPath = ref<string | null>(null);
|
||||||
|
const fileLoading = ref(false);
|
||||||
|
const downloadingFilePath = ref<string | null>(null);
|
||||||
let recordSizeToken = 0;
|
let recordSizeToken = 0;
|
||||||
|
let fileListToken = 0;
|
||||||
|
|
||||||
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
|
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
|
||||||
const hasRecords = computed(() => records.value.length > 0);
|
const hasRecords = computed(() => records.value.length > 0);
|
||||||
const detailLogText = computed(() => detail.value?.logs?.join('\n') || '');
|
const detailLogText = computed(() => detail.value?.logs?.join('\n') || '');
|
||||||
const detailLogLines = computed(() => toColoredLogLines(detailLogText.value));
|
const detailLogLines = computed(() => toColoredLogLines(detailLogText.value));
|
||||||
|
const detailDownloadDisabled = computed(() => {
|
||||||
|
const status = detail.value?.status;
|
||||||
|
return status === 'pending' || status === 'processing';
|
||||||
|
});
|
||||||
|
const fileBreadcrumbs = computed(() => {
|
||||||
|
let currentPath = '';
|
||||||
|
return fileCurrentPath.value
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(name => {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${name}` : name;
|
||||||
|
return { name, path: currentPath };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setPageHeader({
|
setPageHeader({
|
||||||
@@ -144,6 +251,12 @@ onBeforeUnmount(() => {
|
|||||||
resetPageHeader();
|
resetPageHeader();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(detailTab, tab => {
|
||||||
|
if (tab === 'files' && detail.value && fileEntries.value.length === 0 && !fileLoading.value) {
|
||||||
|
void loadHistoryFiles(fileCurrentPath.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -163,6 +276,8 @@ async function loadHistory() {
|
|||||||
async function openDetail(recordId: string) {
|
async function openDetail(recordId: string) {
|
||||||
try {
|
try {
|
||||||
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
|
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
|
||||||
|
detailTab.value = 'detail';
|
||||||
|
resetFileBrowser();
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
void loadRecordSize(recordId);
|
void loadRecordSize(recordId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -170,6 +285,14 @@ async function openDetail(recordId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetFileBrowser() {
|
||||||
|
fileEntries.value = [];
|
||||||
|
fileCurrentPath.value = '';
|
||||||
|
fileParentPath.value = null;
|
||||||
|
downloadingFilePath.value = null;
|
||||||
|
fileListToken += 1;
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadHistory(record: HistoryRecord) {
|
async function downloadHistory(record: HistoryRecord) {
|
||||||
if (record.status === 'pending' || record.status === 'processing') {
|
if (record.status === 'pending' || record.status === 'processing') {
|
||||||
message.warning('任务尚未完成,暂不能下载历史数据');
|
message.warning('任务尚未完成,暂不能下载历史数据');
|
||||||
@@ -190,6 +313,59 @@ async function downloadHistory(record: HistoryRecord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadHistoryFiles(path: string) {
|
||||||
|
if (!detail.value) return;
|
||||||
|
|
||||||
|
const currentToken = ++fileListToken;
|
||||||
|
fileLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await apiPost<HistoryFilesResponse>('/api/history/files', {
|
||||||
|
record_id: detail.value.id,
|
||||||
|
path
|
||||||
|
});
|
||||||
|
if (currentToken !== fileListToken) return;
|
||||||
|
fileEntries.value = result.entries;
|
||||||
|
fileCurrentPath.value = result.path;
|
||||||
|
fileParentPath.value = result.parent_path;
|
||||||
|
} catch (error) {
|
||||||
|
if (currentToken !== fileListToken) return;
|
||||||
|
message.error(error instanceof Error ? error.message : '加载历史文件失败');
|
||||||
|
} finally {
|
||||||
|
if (currentToken === fileListToken) {
|
||||||
|
fileLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadHistoryFiles() {
|
||||||
|
void loadHistoryFiles(fileCurrentPath.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadHistoryItem(entry: HistoryFileEntry) {
|
||||||
|
if (!detail.value) return;
|
||||||
|
if (detailDownloadDisabled.value) {
|
||||||
|
message.warning('任务尚未完成,暂不能下载历史数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadingFilePath.value = entry.path;
|
||||||
|
try {
|
||||||
|
const fallbackFilename = entry.type === 'dir' ? `${entry.name}.zip` : entry.name;
|
||||||
|
const result = await downloadFile(
|
||||||
|
'/api/history/file/download',
|
||||||
|
{ record_id: detail.value.id, path: entry.path },
|
||||||
|
fallbackFilename
|
||||||
|
);
|
||||||
|
if (result.saved) {
|
||||||
|
showDownloadCompleteDialog(dialog, fallbackFilename, result.path);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '下载历史文件失败');
|
||||||
|
} finally {
|
||||||
|
downloadingFilePath.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRecordSize(recordId: string) {
|
async function loadRecordSize(recordId: string) {
|
||||||
const currentToken = ++recordSizeToken;
|
const currentToken = ++recordSizeToken;
|
||||||
recordSize.value = '计算中...';
|
recordSize.value = '计算中...';
|
||||||
|
|||||||
@@ -1089,6 +1089,144 @@ select {
|
|||||||
color: var(--td-error-color);
|
color: var(--td-error-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-file-browser {
|
||||||
|
min-height: 280px;
|
||||||
|
border: 1px solid var(--td-border-color-light);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
border-bottom: 1px solid var(--td-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
max-width: 180px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
font: inherit;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
color: var(--td-brand-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--td-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-icon.dir {
|
||||||
|
color: var(--td-warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-name {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-name:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-name:not(:disabled):hover {
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-name:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.log-section-header {
|
.log-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1837,6 +1975,17 @@ select {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-file-toolbar,
|
||||||
|
.history-file-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-toolbar-actions,
|
||||||
|
.history-file-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.card-actions,
|
.card-actions,
|
||||||
.field-card-title-row,
|
.field-card-title-row,
|
||||||
.extract-list-header {
|
.extract-list-header {
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ export interface HistoryDetail extends HistoryRecord {
|
|||||||
logs: string[];
|
logs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HistoryFileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'dir' | 'file';
|
||||||
|
size: number;
|
||||||
|
size_formatted: string;
|
||||||
|
modified: string;
|
||||||
|
modified_formatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryFilesResponse {
|
||||||
|
success: boolean;
|
||||||
|
record_id: string;
|
||||||
|
path: string;
|
||||||
|
parent_path: string | null;
|
||||||
|
entries: HistoryFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CacheSize {
|
export interface CacheSize {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user