refactor: 重构后端结构并迁移前端到 Vue3

This commit is contained in:
2026-05-15 11:46:21 +08:00
parent 9107378fcc
commit 9c5a5a54c6
165 changed files with 5057 additions and 62710 deletions
+2
View File
@@ -0,0 +1,2 @@
"""API router package."""
+36
View File
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from app.auth import create_jwt_token, get_auth_config, save_auth_password
router = APIRouter(tags=["auth"])
@router.post("/api/login")
async def login(
username: str = Body(..., embed=True),
password: str = Body(..., embed=True),
):
auth = get_auth_config()
if username != auth["username"] or password != auth["password"]:
return JSONResponse(status_code=401, content={"detail": "账号或密码错误"})
token = create_jwt_token({"user": username})
return {"success": True, "token": token}
@router.post("/api/change-password")
async def change_password(
current_password: str = Body(..., embed=True),
new_password: str = Body(..., embed=True),
):
auth = get_auth_config()
if current_password != auth["password"]:
return JSONResponse(status_code=400, content={"detail": "当前密码错误"})
if len(new_password) < 4:
return JSONResponse(status_code=400, content={"detail": "新密码长度不能少于 4 位"})
save_auth_password(new_password)
return {"success": True, "message": "密码修改成功"}
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter
from app.config import CACHE_DIR
from app.utils.files import format_size, get_dir_size
router = APIRouter(tags=["cache"])
@router.get("/api/cache/size")
async def get_cache_size():
if not CACHE_DIR.exists():
return {
"success": True,
"size_bytes": 0,
"size_formatted": "0 B",
"file_count": 0,
"dir_count": 0,
}
total_size = 0
file_count = 0
dir_count = 0
try:
for item in CACHE_DIR.iterdir():
if item.name == "history.json":
continue
if item.is_dir():
dir_count += 1
elif item.is_file():
file_count += 1
total_size += get_dir_size(item)
except (PermissionError, OSError) as exc:
return {"success": False, "error": str(exc), "size_formatted": "计算失败"}
return {
"success": True,
"size_bytes": total_size,
"size_formatted": format_size(total_size),
"file_count": file_count,
"dir_count": dir_count,
}
+99
View File
@@ -0,0 +1,99 @@
import json
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Body, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from app import state
from app.config import CONFIG_FILE
router = APIRouter(tags=["config"])
@router.get("/api/config")
async def get_config():
return state.config.to_dict()
@router.get("/api/config/full")
async def get_config_full():
return state.config.to_dict_full()
@router.post("/api/config/mysql")
async def update_mysql_config(
host: str = Body(...),
port: int = Body(...),
user: str = Body(...),
passwd: str = Body(...),
dbname: str = Body(...),
):
state.config.mysql.host = host
state.config.mysql.port = port
state.config.mysql.user = user
state.config.mysql.passwd = passwd
state.config.mysql.dbname = dbname
state.config.save()
return {"success": True, "message": "数据库配置已更新", "update": state.config.update}
@router.post("/api/config/sheet-filter")
async def update_sheet_filter(filters: list[str] = Body(...)):
state.config.sheet_filter = filters
state.config.save()
return {"success": True, "message": "Sheet 过滤规则已更新", "update": state.config.update}
@router.post("/api/config/extract-fields")
async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
state.config.extract_fields = fields
state.config.save()
return {"success": True, "message": "字段映射配置已更新", "update": state.config.update}
@router.get("/api/config/download")
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")
filename = f"Configure_{timestamp}.json"
return FileResponse(path=str(CONFIG_FILE), filename=filename, media_type="application/json")
@router.post("/api/config/upload")
async def upload_config(file: UploadFile = File(...)):
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail="只支持 JSON 格式的配置文件")
try:
data = json.loads((await file.read()).decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("配置文件格式错误:必须是 JSON 对象")
_apply_config_data(data)
state.config.save()
return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="配置文件格式错误:不是有效的 JSON 文件") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc
def _apply_config_data(data: dict[str, Any]) -> None:
mysql_data = data.get("MySQL_DBInfo")
if isinstance(mysql_data, dict):
for key in ("host", "port", "user", "passwd", "dbname"):
if key in mysql_data:
setattr(state.config.mysql, key, mysql_data[key])
if "SheetFilter" in data:
state.config.sheet_filter = data["SheetFilter"] if isinstance(data["SheetFilter"], list) else []
if "ExtractField" in data:
state.config.extract_fields = data["ExtractField"] if isinstance(data["ExtractField"], list) else []
+185
View File
@@ -0,0 +1,185 @@
from datetime import datetime
from typing import Optional
import pandas as pd
from fastapi import APIRouter, Body, HTTPException
from fastapi.responses import FileResponse
from app import state
from app.config import CACHE_DIR
from app.database import DatabaseManager
router = APIRouter(tags=["database"])
@router.post("/api/database/test")
async def test_database():
db = DatabaseManager(state.config)
try:
success, message = db.test_connection()
return {"success": success, "message": message}
finally:
db.dispose()
@router.get("/api/database/info")
async def get_database_info():
db = DatabaseManager(state.config)
try:
return {"success": True, **db.get_server_info()}
except Exception as exc:
return {"success": False, "error": str(exc)}
finally:
db.dispose()
@router.get("/api/database/tables")
@router.post("/api/database/tables")
async def get_tables():
db = DatabaseManager(state.config)
try:
return {"tables": db.get_tables()}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/table/info")
async def get_table_info(table_name: str = Body(..., embed=True)):
db = DatabaseManager(state.config)
try:
return db.get_table_info(table_name)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/table/data")
async def query_table_data(
table_name: str = Body(..., embed=True),
page: int = Body(1),
page_size: int = Body(50),
order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC"),
):
db = DatabaseManager(state.config)
try:
return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/table/query")
async def query_table_with_filter(
table_name: str = Body(..., embed=True),
page: int = Body(1),
page_size: int = Body(50),
filters: Optional[dict[str, str]] = Body(None),
order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC"),
):
db = DatabaseManager(state.config)
try:
return db.query_table(
table_name,
page,
page_size,
filters=filters or {},
order_by=order_by,
order_dir=order_dir,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/table/truncate")
async def truncate_table(table_name: str = Body(..., embed=True)):
db = DatabaseManager(state.config)
try:
db.truncate_table(table_name)
return {"success": True, "message": f"表 {table_name} 已清空"}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/table/drop")
async def drop_table(table_name: str = Body(..., embed=True)):
db = DatabaseManager(state.config)
try:
db.drop_table(table_name)
return {"success": True, "message": f"表 {table_name} 已删除"}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/table/drop-all")
async def drop_all_tables():
db = DatabaseManager(state.config)
try:
result = db.drop_all_tables()
return {
"success": True,
"message": f"已删除 {result['dropped_count']} 个表",
"dropped_count": result["dropped_count"],
"tables": result["tables"],
}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/database/execute")
async def execute_sql(sql: str = Body(..., embed=True)):
db = DatabaseManager(state.config)
try:
success, result = db.execute_sql(sql)
if success:
return {"success": True, "result": result}
raise HTTPException(status_code=400, detail=result)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
@router.post("/api/download")
async def download_table(
table_name: str = Body(..., embed=True),
file_format: str = Body("csv", alias="format"),
):
db = DatabaseManager(state.config)
try:
result = db.query_table(table_name, page=1, page_size=1000000)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
db.dispose()
df = pd.DataFrame(result["data"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{table_name}_{timestamp}.{file_format}"
filepath = CACHE_DIR / filename
if file_format == "csv":
df.to_csv(filepath, index=False, encoding="utf-8-sig")
media_type = "text/csv"
else:
df.to_excel(filepath, index=False)
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return FileResponse(path=str(filepath), filename=filename, media_type=media_type)
+42
View File
@@ -0,0 +1,42 @@
import os
from datetime import datetime
from fastapi import APIRouter
from app import state
from app.database import DatabaseManager
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check():
checks = {
"app": {"status": "ok"},
"database": {"status": "unknown"},
}
try:
db_manager = DatabaseManager(state.config)
try:
server_info = db_manager.get_server_info()
checks["database"] = {
"status": "ok",
"version": server_info.get("version", "unknown"),
"load_data_infile": server_info.get("load_data_infile", False),
}
finally:
db_manager.dispose()
except Exception as exc:
checks["database"] = {"status": "error", "message": str(exc)}
is_healthy = all(check.get("status") == "ok" for check in checks.values())
return {
"status": "healthy" if is_healthy else "unhealthy",
"timestamp": datetime.now().isoformat(),
"version": "2.0.2",
"uptime_pid": os.getpid(),
"checks": checks,
}
+53
View File
@@ -0,0 +1,53 @@
from pathlib import Path
from fastapi import APIRouter, Body, HTTPException
from app import state
from app.utils.files import format_size, get_dir_size
router = APIRouter(tags=["history"])
@router.post("/api/history")
async def get_history(limit: int = Body(50, embed=True)):
return {"records": state.history_manager.list(limit)}
@router.post("/api/history/delete")
async def delete_history(record_id: str = Body(..., embed=True)):
if state.history_manager.delete(record_id):
return {"success": True, "message": "删除成功"}
raise HTTPException(status_code=404, detail="记录不存在")
@router.post("/api/history/clear")
async def clear_history():
count = state.history_manager.clear()
return {"success": True, "deleted": count}
@router.post("/api/history/detail")
async def get_history_detail(record_id: str = Body(..., embed=True)):
record = state.history_manager.get(record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
result = record.to_dict()
result["logs"] = state.history_manager.get_logs(record_id)
return result
@router.post("/api/history/size")
async def get_history_size(record_id: str = Body(..., embed=True)):
record = state.history_manager.get(record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
work_dir = Path(record.work_dir)
if not work_dir.exists():
return {"success": True, "size": 0, "size_formatted": "0 B"}
size = get_dir_size(work_dir)
return {"success": True, "size": size, "size_formatted": format_size(size)}
+99
View File
@@ -0,0 +1,99 @@
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from threading import Thread
from fastapi import APIRouter, Body, HTTPException
from app import state
from app.config import CACHE_DIR, SQL_SCRIPT
from app.processor import DataProcessor, ProcessLogger
router = APIRouter(tags=["script"])
@router.get("/api/script/content")
async def get_script_content():
try:
if not SQL_SCRIPT.exists():
return {
"success": True,
"content": "# SQL 脚本文件不存在,请在此编写脚本\n",
"modified": None,
"path": str(SQL_SCRIPT),
}
modified = datetime.fromtimestamp(SQL_SCRIPT.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
return {
"success": True,
"content": SQL_SCRIPT.read_text(encoding="utf-8"),
"modified": modified,
"path": str(SQL_SCRIPT),
}
except Exception as exc:
return {"success": False, "error": str(exc)}
@router.post("/api/script/execute")
async def execute_script():
if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待完成")
task_id = f"script_{uuid.uuid4().hex[:8]}"
state.global_task_lock.update(
{
"locked": True,
"task_id": task_id,
"stage": "processing",
"started_at": datetime.now().isoformat(),
}
)
logs: list[str] = []
def log_callback(message: str) -> None:
logs.append(message)
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "processing"}
logger = ProcessLogger(log_file=None, callback=log_callback)
state.processing_tasks[task_id] = {"logs": [], "status": "processing"}
thread = Thread(target=_run_script, args=(task_id, logger, logs), daemon=True)
thread.start()
return {"success": True, "message": "脚本执行任务已启动", "task_id": task_id}
@router.post("/api/script/save")
async def save_script_content(content: str = Body(..., embed=True)):
try:
if SQL_SCRIPT.exists():
shutil.copy(SQL_SCRIPT, SQL_SCRIPT.with_suffix(".sql.bak"))
SQL_SCRIPT.write_text(content, encoding="utf-8")
modified = datetime.fromtimestamp(SQL_SCRIPT.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
return {"success": True, "message": "脚本保存成功", "modified": modified}
except Exception as exc:
return {"success": False, "error": str(exc)}
def _run_script(task_id: str, logger: ProcessLogger, logs: list[str]) -> None:
temp_work_dir: Path | None = None
try:
logger.info("开始执行 SQL 脚本...")
temp_work_dir = CACHE_DIR / task_id
temp_work_dir.mkdir(parents=True, exist_ok=True)
processor = DataProcessor(state.config, temp_work_dir, logger)
processor._execute_sql_script()
logger.success("SQL 脚本执行完成")
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "completed"}
except Exception as exc:
logger.error(f"SQL 脚本执行失败: {exc}")
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "failed"}
finally:
if temp_work_dir and temp_work_dir.exists():
shutil.rmtree(temp_work_dir, ignore_errors=True)
state.reset_task_lock()
+70
View File
@@ -0,0 +1,70 @@
import os
import platform
import subprocess
import time
from threading import Thread
from fastapi import APIRouter
from app.services.runtime import (
is_supervisor_running,
restart_via_supervisor,
terminate_current_process,
)
router = APIRouter(tags=["service"])
@router.post("/api/service/restart")
async def restart_service():
if is_supervisor_running():
success, message = restart_via_supervisor()
return {"success": success, "message": message, "method": "supervisor"}
if platform.system() != "Windows":
try:
result = subprocess.run(
["supervisorctl", "restart", "fastapi"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return {
"success": True,
"message": "服务正在通过 supervisor 重启...",
"method": "supervisor",
}
except FileNotFoundError:
pass
except subprocess.TimeoutExpired:
return {
"success": False,
"message": "重启操作超时,请检查 supervisor 状态",
"method": "supervisor",
}
except Exception:
pass
thread = Thread(target=_delayed_exit, daemon=True)
thread.start()
return {"success": True, "message": "服务正在重启,请稍后刷新页面...", "method": "signal"}
@router.get("/api/service/status")
async def get_service_status():
return {
"status": "running",
"version": "2.0.2",
"platform": platform.system(),
"supervisor": is_supervisor_running(),
"pid": os.getpid(),
"python_version": platform.python_version(),
}
def _delayed_exit() -> None:
time.sleep(1)
terminate_current_process()
+167
View File
@@ -0,0 +1,167 @@
from datetime import datetime
from pathlib import Path
from threading import Thread
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from app import state
from app.processor import DataProcessor, ProcessLogger
router = APIRouter(tags=["tasks"])
@router.get("/api/task/status")
async def get_global_task_status():
if state.global_task_lock["locked"]:
task_id = state.global_task_lock["task_id"]
if task_id and _task_finished(task_id):
state.reset_task_lock()
return {"has_active": False}
return {
"has_active": True,
"task_id": task_id,
"stage": state.global_task_lock["stage"],
"started_at": state.global_task_lock["started_at"],
"logs": [],
}
active_tasks = {
task_id: task
for task_id, task in state.processing_tasks.items()
if task.get("status") == "processing"
}
if active_tasks:
task_id = next(iter(active_tasks))
return {
"has_active": True,
"task_id": task_id,
"stage": "processing",
"logs": active_tasks[task_id].get("logs", []),
}
return {"has_active": False}
@router.post("/api/task/lock")
async def lock_task(task_id: str = Body(..., embed=True)):
if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行")
state.global_task_lock.update(
{
"locked": True,
"task_id": task_id,
"stage": "uploading",
"started_at": datetime.now().isoformat(),
}
)
return {"success": True, "message": "任务已锁定"}
@router.post("/api/task/unlock")
async def unlock_task(task_id: str | None = Body(None, embed=True)):
if task_id and state.global_task_lock["task_id"] != task_id:
raise HTTPException(status_code=403, detail="无权解锁此任务")
state.reset_task_lock()
return {"success": True, "message": "任务已解锁"}
@router.get("/api/process/active")
async def get_active_task():
return await get_global_task_status()
@router.post("/api/process/start")
async def start_processing(task_id: str = Body(..., embed=True)):
record = state.history_manager.get(task_id)
if not record:
raise HTTPException(status_code=404, detail="任务不存在")
if record.status == "processing":
raise HTTPException(status_code=400, detail="任务正在处理中")
work_dir = Path(record.work_dir)
if not work_dir.exists():
raise HTTPException(status_code=400, detail="工作目录不存在")
logs: list[str] = []
def log_callback(message: str) -> None:
logs.append(message)
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "processing"}
logger = ProcessLogger(log_file=work_dir / "log.txt", callback=log_callback)
state.history_manager.update(task_id, status="processing")
state.processing_tasks[task_id] = {"logs": [], "status": "processing"}
state.global_task_lock.update(
{
"locked": True,
"task_id": task_id,
"stage": "processing",
"started_at": datetime.now().isoformat(),
}
)
thread = Thread(target=_run_processing, args=(task_id, work_dir, logger), daemon=True)
thread.start()
return {"success": True, "message": "处理任务已启动", "task_id": task_id}
@router.post("/api/process/status")
async def get_processing_status(task_id: str = Body(..., embed=True)):
if task_id in state.processing_tasks:
task_info = state.processing_tasks[task_id]
logs = task_info.get("logs") or state.history_manager.get_logs(task_id)
return {"task_id": task_id, "status": task_info["status"], "logs": logs}
record = state.history_manager.get(task_id)
if not record:
raise HTTPException(status_code=404, detail="任务不存在")
return {
"task_id": task_id,
"status": record.status,
"logs": state.history_manager.get_logs(task_id),
"elapsed_time": record.elapsed_time,
"error": record.error,
}
def _task_finished(task_id: str) -> bool:
record = state.history_manager.get(task_id)
if record and record.status in {"completed", "failed"}:
return True
task_info: dict[str, Any] | None = state.processing_tasks.get(task_id)
return bool(task_info and task_info.get("status") in {"completed", "failed"})
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger) -> None:
try:
processor = DataProcessor(state.config, work_dir, logger)
result = processor.process()
status = "completed" if result.get("success") else "failed"
state.history_manager.update(
task_id,
status=status,
elapsed_time=result.get("elapsed_time", 0),
error=result.get("error"),
result_tables=["4G_结果表", "5G_结果表"],
)
state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id),
"status": status,
}
except Exception as exc:
state.history_manager.update(task_id, status="failed", error=str(exc))
state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id),
"status": "failed",
}
finally:
state.reset_task_lock()
+110
View File
@@ -0,0 +1,110 @@
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, UploadFile
from app import state
from app.config import CACHE_DIR
router = APIRouter(tags=["upload"])
@router.post("/api/upload/create")
async def create_upload_session():
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
work_dir = CACHE_DIR / session_id
work_dir.mkdir(parents=True, exist_ok=True)
state.upload_sessions[session_id] = {
"work_dir": work_dir,
"files": [],
"created_at": datetime.now().isoformat(),
}
return {"success": True, "session_id": session_id, "work_dir": str(work_dir)}
@router.post("/api/upload")
async def upload_files(
files: list[UploadFile] = File(...),
session_id: Optional[str] = None,
):
if not files:
raise HTTPException(status_code=400, detail="没有上传文件")
is_new_session = False
if not session_id or session_id not in state.upload_sessions:
if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
work_dir = CACHE_DIR / session_id
work_dir.mkdir(parents=True, exist_ok=True)
state.global_task_lock.update(
{
"locked": True,
"task_id": session_id,
"stage": "uploading",
"started_at": datetime.now().isoformat(),
}
)
is_new_session = True
state.upload_sessions[session_id] = {
"work_dir": work_dir,
"files": [],
"created_at": datetime.now().isoformat(),
}
else:
if state.global_task_lock["locked"] and state.global_task_lock["task_id"] != session_id:
raise HTTPException(status_code=409, detail="已有其他任务在运行")
session: dict[str, Any] = state.upload_sessions[session_id]
work_dir = session["work_dir"]
try:
saved_files: list[str] = []
for file in files:
if not file.filename:
continue
file_path = work_dir / file.filename
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(await file.read())
saved_files.append(file.filename)
session["files"].append(file.filename)
record = state.history_manager.get(session_id)
if record:
state.history_manager.update(session_id, file_count=len(session["files"]))
else:
state.history_manager.create(work_dir, len(session["files"]), record_id=session_id)
return {
"success": True,
"task_id": session_id,
"session_id": session_id,
"work_dir": str(work_dir),
"file_count": len(saved_files),
"total_files": len(session["files"]),
"files": saved_files,
}
except Exception as exc:
if is_new_session:
state.reset_task_lock()
raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc
@router.post("/api/upload/complete/{session_id}")
async def complete_upload_session(session_id: str):
if session_id not in state.upload_sessions:
raise HTTPException(status_code=404, detail="上传会话不存在")
session = state.upload_sessions[session_id]
state.history_manager.update(session_id, file_count=len(session["files"]))
return {"success": True, "session_id": session_id, "total_files": len(session["files"])}