feat: 增加处理历史保留配置

This commit is contained in:
2026-05-19 12:01:26 +08:00
parent f1075695a1
commit a3cc9f347c
9 changed files with 208 additions and 35 deletions
+20 -6
View File
@@ -1,12 +1,13 @@
import json import json
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Body, HTTPException, UploadFile, File from fastapi import APIRouter, Body, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import Response
from app import state from app import state
from app.config import CONFIG_FILE, RemoteDataConfig from app.config import HistoryRetentionConfig, RemoteDataConfig
router = APIRouter(tags=["config"]) router = APIRouter(tags=["config"])
@@ -46,6 +47,13 @@ async def update_remote_config(config: dict[str, Any] = Body(...)):
return {"success": True, "message": "远程数据配置已更新", "update": state.config.update} return {"success": True, "message": "远程数据配置已更新", "update": state.config.update}
@router.post("/api/config/history-retention")
async def update_history_retention(config: dict[str, Any] = Body(...)):
state.config.history_retention = HistoryRetentionConfig.from_dict(config)
state.config.save()
return {"success": True, "message": "处理历史保留配置已更新", "update": state.config.update}
@router.post("/api/config/sheet-filter") @router.post("/api/config/sheet-filter")
async def update_sheet_filter(filters: list[str] = Body(...)): async def update_sheet_filter(filters: list[str] = Body(...)):
state.config.sheet_filter = filters state.config.sheet_filter = filters
@@ -62,12 +70,14 @@ async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
@router.get("/api/config/download") @router.get("/api/config/download")
async def download_config(): async def download_config():
if not CONFIG_FILE.exists():
raise HTTPException(status_code=404, detail="配置文件不存在")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"Configure_{timestamp}.json" filename = f"Configure_{timestamp}.json"
return FileResponse(path=str(CONFIG_FILE), filename=filename, media_type="application/json") content = json.dumps(state.config.to_file_dict(), ensure_ascii=False, indent=2)
return Response(
content=content,
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.post("/api/config/upload") @router.post("/api/config/upload")
@@ -107,3 +117,7 @@ def _apply_config_data(data: dict[str, Any]) -> None:
remote_data = data.get("RemoteData") remote_data = data.get("RemoteData")
if isinstance(remote_data, dict): if isinstance(remote_data, dict):
state.config.remote_data = RemoteDataConfig.from_dict(remote_data) state.config.remote_data = RemoteDataConfig.from_dict(remote_data)
history_retention = data.get("HistoryRetention")
if isinstance(history_retention, dict):
state.config.history_retention = HistoryRetentionConfig.from_dict(history_retention)
+4
View File
@@ -124,6 +124,10 @@ def _run_remote_processing(
"status": "failed", "status": "failed",
} }
finally: finally:
try:
state.apply_history_retention()
except Exception as exc:
print(f"自动清理处理历史失败: {exc}")
state.reset_task_lock() state.reset_task_lock()
+4 -1
View File
@@ -163,5 +163,8 @@ def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger) -> None
"status": "failed", "status": "failed",
} }
finally: finally:
try:
state.apply_history_retention()
except Exception as exc:
print(f"自动清理处理历史失败: {exc}")
state.reset_task_lock() state.reset_task_lock()
+49 -8
View File
@@ -2,10 +2,10 @@
配置管理模块 配置管理模块
""" """
import json import json
from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -98,11 +98,44 @@ class RemoteDataConfig:
).normalized() ).normalized()
@dataclass
class HistoryRetentionConfig:
enabled: bool = False
keep_count: int = 20
def normalized(self) -> "HistoryRetentionConfig":
try:
keep_count = int(self.keep_count)
except (TypeError, ValueError):
keep_count = 20
return HistoryRetentionConfig(
enabled=bool(self.enabled),
keep_count=max(keep_count, 0),
)
def to_dict(self) -> Dict[str, Any]:
normalized = self.normalized()
return {
"enabled": normalized.enabled,
"keep_count": normalized.keep_count,
}
@classmethod
def from_dict(cls, data: Dict[str, Any] | None) -> "HistoryRetentionConfig":
data = data or {}
return cls(
enabled=bool(data.get("enabled", False)),
keep_count=data.get("keep_count", 20),
).normalized()
@dataclass @dataclass
class AppConfig: class AppConfig:
update: str = "" update: str = ""
mysql: MySQLConfig = field(default_factory=MySQLConfig) mysql: MySQLConfig = field(default_factory=MySQLConfig)
remote_data: RemoteDataConfig = field(default_factory=RemoteDataConfig) remote_data: RemoteDataConfig = field(default_factory=RemoteDataConfig)
history_retention: HistoryRetentionConfig = field(default_factory=HistoryRetentionConfig)
sheet_filter: List[str] = field(default_factory=list) sheet_filter: List[str] = field(default_factory=list)
extract_fields: List[Dict[str, Any]] = field(default_factory=list) extract_fields: List[Dict[str, Any]] = field(default_factory=list)
@@ -124,20 +157,28 @@ class AppConfig:
dbname=mysql_data.get("dbname", "CapacityReport") dbname=mysql_data.get("dbname", "CapacityReport")
) )
remote_config = RemoteDataConfig.from_dict(data.get("RemoteData")) remote_config = RemoteDataConfig.from_dict(data.get("RemoteData"))
history_retention = HistoryRetentionConfig.from_dict(data.get("HistoryRetention"))
return cls( return cls(
update=data.get("Update", ""), update=data.get("Update", ""),
mysql=mysql_config, mysql=mysql_config,
remote_data=remote_config, remote_data=remote_config,
history_retention=history_retention,
sheet_filter=data.get("SheetFilter", []), sheet_filter=data.get("SheetFilter", []),
extract_fields=data.get("ExtractField", []) extract_fields=data.get("ExtractField", [])
) )
def save(self): def save(self):
"""保存配置到 Configure.json,并自动更新 Update 时间""" """保存配置到 Configure.json,并自动更新 Update 时间"""
self.update = datetime.now().strftime("%Y/%m/%d %H:%M:%S") self.update = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
data = self.to_file_dict()
data = {
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def to_file_dict(self) -> Dict[str, Any]:
"""转换为配置文件结构(包含敏感字段,用于保存和下载)"""
return {
"Update": self.update, "Update": self.update,
"MySQL_DBInfo": { "MySQL_DBInfo": {
"host": self.mysql.host, "host": self.mysql.host,
@@ -147,12 +188,10 @@ class AppConfig:
"dbname": self.mysql.dbname "dbname": self.mysql.dbname
}, },
"RemoteData": self.remote_data.normalized().to_dict(include_password=True), "RemoteData": self.remote_data.normalized().to_dict(include_password=True),
"HistoryRetention": self.history_retention.normalized().to_dict(),
"SheetFilter": self.sheet_filter, "SheetFilter": self.sheet_filter,
"ExtractField": self.extract_fields "ExtractField": self.extract_fields
} }
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""转换为字典(用于返回给前端,隐藏密码)""" """转换为字典(用于返回给前端,隐藏密码)"""
@@ -165,6 +204,7 @@ class AppConfig:
"dbname": self.mysql.dbname "dbname": self.mysql.dbname
}, },
"remote_data": self.remote_data.normalized().to_dict(), "remote_data": self.remote_data.normalized().to_dict(),
"history_retention": self.history_retention.normalized().to_dict(),
"sheet_filter": self.sheet_filter, "sheet_filter": self.sheet_filter,
"extract_fields": self.extract_fields "extract_fields": self.extract_fields
} }
@@ -181,6 +221,7 @@ class AppConfig:
"dbname": self.mysql.dbname "dbname": self.mysql.dbname
}, },
"remote_data": self.remote_data.normalized().to_dict(include_password=True), "remote_data": self.remote_data.normalized().to_dict(include_password=True),
"history_retention": self.history_retention.normalized().to_dict(),
"sheet_filter": self.sheet_filter, "sheet_filter": self.sheet_filter,
"extract_fields": self.extract_fields "extract_fields": self.extract_fields
} }
+47 -19
View File
@@ -55,6 +55,23 @@ class HistoryManager:
def _save(self, records: List[Dict[str, Any]]): def _save(self, records: List[Dict[str, Any]]):
"""保存历史记录""" """保存历史记录"""
HISTORY_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding='utf-8') HISTORY_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding='utf-8')
def _delete_work_dir(self, work_dir: str | None) -> None:
if not work_dir:
return
try:
work_path = Path(work_dir).resolve()
cache_path = CACHE_DIR.resolve()
if work_path.exists() and work_path.is_dir():
try:
work_path.relative_to(cache_path)
shutil.rmtree(work_path)
except ValueError:
print(f"警告: 尝试删除cache目录外的文件: {work_dir}")
except Exception as e:
print(f"删除文件目录失败: {work_dir}, 错误: {e}")
def create(self, work_dir: Path, file_count: int, record_id: Optional[str] = None) -> HistoryRecord: def create(self, work_dir: Path, file_count: int, record_id: Optional[str] = None) -> HistoryRecord:
"""创建新的历史记录""" """创建新的历史记录"""
@@ -162,28 +179,39 @@ class HistoryManager:
if len(new_records) < len(records): if len(new_records) < len(records):
self._save(new_records) self._save(new_records)
# 删除对应的文件目录(安全检查:确保路径在cache目录内) self._delete_work_dir(work_dir)
if work_dir:
try:
work_path = Path(work_dir).resolve()
cache_path = CACHE_DIR.resolve()
# 安全检查:确保要删除的目录在cache目录内
if work_path.exists() and work_path.is_dir():
# 检查路径是否在cache目录内
try:
work_path.relative_to(cache_path)
# 路径安全,可以删除
shutil.rmtree(work_path)
except ValueError:
# 路径不在cache目录内,跳过删除(安全保护)
print(f"警告: 尝试删除cache目录外的文件: {work_dir}")
except Exception as e:
# 记录错误但不影响删除历史记录的操作
print(f"删除文件目录失败: {work_dir}, 错误: {e}")
return True return True
return False return False
def prune_finished(self, keep_count: int) -> int:
"""按保留数量清理已结束的处理历史,处理中和等待中的记录不会被删除"""
keep_count = max(int(keep_count), 0)
records = self._load()
kept_finished = 0
pruned: List[Dict[str, Any]] = []
remaining: List[Dict[str, Any]] = []
for rec in records:
status = rec.get("status")
if status not in {"completed", "failed"}:
remaining.append(rec)
continue
if kept_finished < keep_count:
remaining.append(rec)
kept_finished += 1
else:
pruned.append(rec)
if not pruned:
return 0
self._save(remaining)
for rec in pruned:
self._delete_work_dir(rec.get("work_dir"))
return len(pruned)
def clear(self) -> int: def clear(self) -> int:
"""清空所有历史记录,同时清空cache目录(保留history.json)""" """清空所有历史记录,同时清空cache目录(保留history.json)"""
+6
View File
@@ -23,3 +23,9 @@ def reset_task_lock() -> None:
global_task_lock["stage"] = None global_task_lock["stage"] = None
global_task_lock["started_at"] = None global_task_lock["started_at"] = None
def apply_history_retention() -> int:
retention = config.history_retention.normalized()
if not retention.enabled:
return 0
return history_manager.prune_finished(retention.keep_count)
+9
View File
@@ -1,5 +1,14 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-19:新增处理历史保留配置并完善配置导入导出
- `app/config.py` 新增 `HistoryRetention` 配置块,包含 `enabled` 和 `keep_count`;`keep_count=0` 表示不保留已结束处理历史,关闭开关时不自动删除历史。
- `app/history.py` 新增按保留数量清理已结束历史的能力,只清理 `completed/failed` 记录及其 `cache/<task_id>` 工作目录,不删除 `pending/processing` 记录。
- `app/api/routers/tasks.py` 和 `app/api/routers/remote.py` 在本地上传处理、远程下载并处理任务结束后自动应用历史保留规则。
- `app/api/routers/config.py` 新增 `/api/config/history-retention` 保存接口;配置下载改为从当前内存配置生成完整 JSON,确保导出的配置始终包含 `RemoteData` 和 `HistoryRetention`;配置上传也会恢复这两个配置块。
- `frontend/src/components/SettingsPanel.vue` 在连接配置页新增“处理历史保留”卡片,可设置自动清理开关和保留最近次数。
- 已执行 `.venv\Scripts\python.exe -m compileall app`、临时目录历史清理验证、配置导入导出验证和 `npm run build`;已用本机 Chrome DevTools 验证设置页新增卡片可见。
## 2026-05-19:调整上传页远程下载入口 ## 2026-05-19:调整上传页远程下载入口
- `frontend/src/components/FileWorkflow.vue` 将“远程下载并处理”按钮移动到拖拽上传框内部,删除独立的“远程自动化”卡片,上传页首屏只保留一个主要操作区域。 - `frontend/src/components/FileWorkflow.vue` 将“远程下载并处理”按钮移动到拖拽上传框内部,删除独立的“远程自动化”卡片,上传页首屏只保留一个主要操作区域。
+63 -1
View File
@@ -124,6 +124,38 @@
</n-space> </n-space>
</template> </template>
</n-card> </n-card>
<n-card title="处理历史保留" size="small" class="work-card">
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="自动清理处理历史">
<n-switch v-model:value="historyRetentionForm.enabled" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="保留最近次数">
<n-input-number
v-model:value="historyRetentionForm.keep_count"
class="full-width"
:min="0"
:precision="0"
:disabled="!historyRetentionForm.enabled"
/>
</n-form-item>
</n-gi>
</n-grid>
<p class="form-hint">关闭后不自动删除处理历史;启用后会在任务结束时自动清理,设置为 0 表示不保留历史,否则只保留最近指定次数的处理历史。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingHistoryRetention" @click="saveHistoryRetention">
保存配置
</n-button>
</n-space>
</template>
</n-card>
</div> </div>
</n-tab-pane> </n-tab-pane>
@@ -282,7 +314,7 @@ import { useMessage, type SelectOption } from 'naive-ui';
import { CloseOutline, CloudDownloadOutline, CloudUploadOutline } from '@vicons/ionicons5'; import { CloseOutline, CloudDownloadOutline, CloudUploadOutline } from '@vicons/ionicons5';
import { apiGet, apiPost, downloadGet, upload } from '../api/client'; import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type { ApiMessage, AppConfig, RemoteDataConfig } from '../types'; import type { ApiMessage, AppConfig, HistoryRetentionConfig, RemoteDataConfig } from '../types';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
interface ExtractFieldConfig { interface ExtractFieldConfig {
@@ -300,6 +332,7 @@ const testingDb = ref(false);
const testingRemote = ref(false); const testingRemote = ref(false);
const savingMysql = ref(false); const savingMysql = ref(false);
const savingRemote = ref(false); const savingRemote = ref(false);
const savingHistoryRetention = ref(false);
const savingSheetFilter = ref(false); const savingSheetFilter = ref(false);
const savingExtractFields = ref(false); const savingExtractFields = ref(false);
const changingPassword = ref(false); const changingPassword = ref(false);
@@ -330,6 +363,11 @@ const remoteForm = reactive<RemoteDataConfig>({
auto_delete_source: false auto_delete_source: false
}); });
const historyRetentionForm = reactive<HistoryRetentionConfig>({
enabled: false,
keep_count: 20
});
const passwordForm = reactive({ const passwordForm = reactive({
current_password: '', current_password: '',
new_password: '', new_password: '',
@@ -384,6 +422,7 @@ async function loadConfig() {
mysqlForm.passwd = config.mysql.passwd || ''; mysqlForm.passwd = config.mysql.passwd || '';
mysqlForm.dbname = config.mysql.dbname; mysqlForm.dbname = config.mysql.dbname;
Object.assign(remoteForm, normalizeRemoteConfig(config.remote_data)); Object.assign(remoteForm, normalizeRemoteConfig(config.remote_data));
Object.assign(historyRetentionForm, normalizeHistoryRetentionConfig(config.history_retention));
sheetFilters.value = [...config.sheet_filter]; sheetFilters.value = [...config.sheet_filter];
extractFields.value = normalizeExtractFields(config.extract_fields); extractFields.value = normalizeExtractFields(config.extract_fields);
resetExtractInputs(); resetExtractInputs();
@@ -488,6 +527,22 @@ async function testDatabase() {
} }
} }
async function saveHistoryRetention() {
savingHistoryRetention.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/history-retention', {
enabled: historyRetentionForm.enabled,
keep_count: Math.max(Number(historyRetentionForm.keep_count) || 0, 0)
});
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '处理历史保留配置已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存处理历史保留配置失败');
} finally {
savingHistoryRetention.value = false;
}
}
function addSheetFilter() { function addSheetFilter() {
const value = newSheetFilter.value.trim(); const value = newSheetFilter.value.trim();
if (!value) return; if (!value) return;
@@ -643,6 +698,13 @@ function normalizeRemoteConfig(config: RemoteDataConfig | undefined): RemoteData
}; };
} }
function normalizeHistoryRetentionConfig(config: HistoryRetentionConfig | undefined): HistoryRetentionConfig {
return {
enabled: Boolean(config?.enabled),
keep_count: Math.max(Number(config?.keep_count ?? 20) || 0, 0)
};
}
function normalizeExtractFields(fields: Array<Record<string, unknown>>): ExtractFieldConfig[] { function normalizeExtractFields(fields: Array<Record<string, unknown>>): ExtractFieldConfig[] {
return fields.map(field => { return fields.map(field => {
const extract = Array.isArray(field.Extract) ? uniqueStrings(field.Extract) : []; const extract = Array.isArray(field.Extract) ? uniqueStrings(field.Extract) : [];
+6
View File
@@ -58,6 +58,7 @@ export interface AppConfig {
dbname: string; dbname: string;
}; };
remote_data: RemoteDataConfig; remote_data: RemoteDataConfig;
history_retention: HistoryRetentionConfig;
sheet_filter: string[]; sheet_filter: string[];
extract_fields: Array<Record<string, unknown>>; extract_fields: Array<Record<string, unknown>>;
} }
@@ -75,6 +76,11 @@ export interface RemoteDataConfig {
auto_delete_source: boolean; auto_delete_source: boolean;
} }
export interface HistoryRetentionConfig {
enabled: boolean;
keep_count: number;
}
export interface TableInfo { export interface TableInfo {
name: string; name: string;
columns: Array<{ columns: Array<{