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 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
View File
@@ -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": "统计指定历史记录工作目录的文件数和占用空间。",
+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/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.
+213 -37
View File
@@ -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 = '计算中...';
+149
View File
@@ -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 {
+18
View File
@@ -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;