feat: 添加历史文件浏览下载

This commit is contained in:
2026-06-01 12:25:32 +08:00
parent 2e9d8e5767
commit bdc6d5d677
6 changed files with 526 additions and 39 deletions
+129 -2
View File
@@ -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),
)
+10
View File
@@ -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": "统计指定历史记录工作目录的文件数和占用空间。",
+7
View File
@@ -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.
+213 -37
View File
@@ -37,40 +37,116 @@
<n-modal v-model:show="detailVisible" preset="card" class="history-detail-modal" title="任务详情">
<n-empty v-if="!detail" description="暂无详情" />
<n-space v-else vertical size="large">
<div class="detail-info-list">
<div class="detail-info-row">
<span class="detail-info-label">任务 ID</span>
<span class="detail-info-value monospace">{{ detail.id }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">状态</span>
<span class="detail-info-value">{{ statusText(detail.status) }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">文件数</span>
<span class="detail-info-value">{{ detail.file_count }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">耗时</span>
<span class="detail-info-value">{{ formatElapsed(detail.elapsed_time) }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">创建时间</span>
<span class="detail-info-value monospace">{{ formatDate(detail.timestamp) }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">占用</span>
<span class="detail-info-value" :class="{ muted: recordSizeLoading }">{{ recordSize }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">工作目录</span>
<span class="detail-info-value path-value">{{ detail.work_dir }}</span>
</div>
<div v-if="detail.error" class="detail-info-row error-row">
<span class="detail-info-label">错误</span>
<span class="detail-info-value">{{ detail.error }}</span>
</div>
</div>
<n-tabs v-model:value="detailTab" type="segment" animated>
<n-tab-pane name="detail" tab="详情">
<div class="detail-info-list">
<div class="detail-info-row">
<span class="detail-info-label">任务 ID</span>
<span class="detail-info-value monospace">{{ detail.id }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">状态</span>
<span class="detail-info-value">{{ statusText(detail.status) }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">文件数</span>
<span class="detail-info-value">{{ detail.file_count }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">耗时</span>
<span class="detail-info-value">{{ formatElapsed(detail.elapsed_time) }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">创建时间</span>
<span class="detail-info-value monospace">{{ formatDate(detail.timestamp) }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">占用</span>
<span class="detail-info-value" :class="{ muted: recordSizeLoading }">{{ recordSize }}</span>
</div>
<div class="detail-info-row">
<span class="detail-info-label">工作目录</span>
<span class="detail-info-value path-value">{{ detail.work_dir }}</span>
</div>
<div v-if="detail.error" class="detail-info-row error-row">
<span class="detail-info-label">错误</span>
<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 class="section-label log-section-header">
@@ -94,12 +170,22 @@
</template>
<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 { 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 type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
import type { CacheSize, HistoryDetail, HistoryFileEntry, HistoryFilesResponse, HistoryRecord } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { toColoredLogLines } from '../composables/logLines';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
@@ -114,12 +200,33 @@ const recordSize = ref('-');
const recordSizeLoading = ref(false);
const loading = ref(false);
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 fileListToken = 0;
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
const hasRecords = computed(() => records.value.length > 0);
const detailLogText = computed(() => detail.value?.logs?.join('\n') || '');
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(() => {
setPageHeader({
@@ -144,6 +251,12 @@ onBeforeUnmount(() => {
resetPageHeader();
});
watch(detailTab, tab => {
if (tab === 'files' && detail.value && fileEntries.value.length === 0 && !fileLoading.value) {
void loadHistoryFiles(fileCurrentPath.value);
}
});
async function loadHistory() {
loading.value = true;
try {
@@ -163,6 +276,8 @@ async function loadHistory() {
async function openDetail(recordId: string) {
try {
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
detailTab.value = 'detail';
resetFileBrowser();
detailVisible.value = true;
void loadRecordSize(recordId);
} 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) {
if (record.status === 'pending' || record.status === 'processing') {
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) {
const currentToken = ++recordSizeToken;
recordSize.value = '计算中...';
+149
View File
@@ -1089,6 +1089,144 @@ select {
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 {
display: flex;
align-items: center;
@@ -1837,6 +1975,17 @@ select {
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,
.field-card-title-row,
.extract-list-header {
+18
View File
@@ -60,6 +60,24 @@ export interface HistoryDetail extends HistoryRecord {
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 {
success: boolean;
size_bytes: number;