Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
64 KiB
日志系统 + 任务历史 + 文件管理 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add persistent logging, task history, and file operation tracking to the web backend, with two new frontend pages for viewing logs and task history.
Architecture: Single SQLite DB (data/web_data.db) with three tables. FastAPI middleware captures HTTP logs. TaskManager modified to persist task lifecycle to DB. File operations record metadata. Frontend adds two sidebar pages (Tasks, Logs) and enhances Dashboard.
Tech Stack: FastAPI middleware, SQLite (via existing DBPool), Vue 3 + Element Plus, Pinia
File Structure
New files
| File | Responsibility |
|---|---|
web/backend/services/db_schema.py |
SQLite table creation, auto-cleanup of 30-day-old records |
web/backend/middleware/__init__.py |
Package init |
web/backend/middleware/logging.py |
HTTP request logging middleware |
web/backend/routers/logs.py |
Log query API endpoints |
web/backend/routers/tasks.py |
Task history query + retry API endpoints |
web/frontend/src/views/Tasks.vue |
Task history page |
web/frontend/src/views/Logs.vue |
Log viewer page |
Modified files
| File | Changes |
|---|---|
web/backend/main.py |
Import and mount middleware + new routers, call init_db() in lifespan |
web/backend/services/task_manager.py |
Add db_pool reference, persist task lifecycle to task_history table |
web/backend/routers/files.py |
Record upload/delete/clear operations to file_metadata table |
web/frontend/src/router/index.ts |
Add /tasks and /logs routes |
web/frontend/src/views/Layout.vue |
Add Timer and Notebook icons, add two nav items |
web/frontend/src/views/Dashboard.vue |
Dynamic storage stats, file history button |
Task 1: Database Schema — db_schema.py
Files:
-
Create:
web/backend/services/db_schema.py -
Step 1: Create
web/backend/services/db_schema.py
"""SQLite schema initialization and cleanup for web_data.db"""
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_db_path = str(_project_root / "data" / "web_data.db")
CREATE_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS http_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER,
duration_ms REAL,
user TEXT,
ip TEXT,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_http_logs_timestamp ON http_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_http_logs_status ON http_logs(status_code);
CREATE TABLE IF NOT EXISTS task_history (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER DEFAULT 0,
message TEXT,
result_files TEXT,
error TEXT,
log_lines TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_task_history_status ON task_history(status);
CREATE INDEX IF NOT EXISTS idx_task_history_created ON task_history(created_at);
CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
directory TEXT NOT NULL,
size INTEGER,
action TEXT NOT NULL,
user TEXT,
timestamp TEXT NOT NULL,
task_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_file_metadata_timestamp ON file_metadata(timestamp);
"""
def init_db():
"""Create tables if they don't exist. Called once on startup."""
conn = sqlite3.connect(_db_path)
try:
conn.executescript(CREATE_TABLES_SQL)
conn.commit()
finally:
conn.close()
def cleanup_old_records():
"""Delete records older than 30 days from all tables."""
cutoff = (datetime.now() - timedelta(days=30)).isoformat()
conn = sqlite3.connect(_db_path)
try:
conn.execute("DELETE FROM http_logs WHERE timestamp < ?", (cutoff,))
conn.execute("DELETE FROM task_history WHERE created_at < ?", (cutoff,))
conn.execute("DELETE FROM file_metadata WHERE timestamp < ?", (cutoff,))
conn.commit()
finally:
conn.close()
def insert_http_log(method: str, path: str, status_code: int, duration_ms: float,
user: str = None, ip: str = None, detail: str = None):
"""Insert an HTTP log record."""
conn = sqlite3.connect(_db_path)
try:
conn.execute(
"INSERT INTO http_logs (timestamp, method, path, status_code, duration_ms, user, ip, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(datetime.now().isoformat(), method, path, status_code, duration_ms, user, ip, detail),
)
conn.commit()
finally:
conn.close()
def insert_task(task_id: str, name: str, status: str, created_at: str):
"""Insert a new task record."""
conn = sqlite3.connect(_db_path)
try:
conn.execute(
"INSERT INTO task_history (id, name, status, progress, message, result_files, error, log_lines, created_at, updated_at) VALUES (?, ?, ?, 0, '', '[]', '[]', '[]', ?, ?)",
(task_id, name, status, created_at, created_at),
)
conn.commit()
finally:
conn.close()
def update_task(task_id: str, **fields):
"""Update specific fields of a task record."""
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
vals = list(fields.values())
vals.append(task_id)
conn = sqlite3.connect(_db_path)
try:
conn.execute(f"UPDATE task_history SET {sets} WHERE id=?", vals)
conn.commit()
finally:
conn.close()
def insert_file_metadata(filename: str, directory: str, size: int, action: str,
user: str = None, task_id: str = None):
"""Insert a file operation record."""
conn = sqlite3.connect(_db_path)
try:
conn.execute(
"INSERT INTO file_metadata (filename, directory, size, action, user, timestamp, task_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
(filename, directory, size, action, user, datetime.now().isoformat(), task_id),
)
conn.commit()
finally:
conn.close()
def query_http_logs(page: int = 1, page_size: int = 50, method: str = None,
status_code: int = None, path: str = None,
start_date: str = None, end_date: str = None):
"""Query HTTP logs with filters and pagination."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
where = []
params = []
if method:
where.append("method = ?")
params.append(method)
if status_code:
where.append("status_code = ?")
params.append(status_code)
if path:
where.append("path LIKE ?")
params.append(f"%{path}%")
if start_date:
where.append("timestamp >= ?")
params.append(start_date)
if end_date:
where.append("timestamp <= ?")
params.append(end_date)
where_clause = " WHERE " + " AND ".join(where) if where else ""
count = conn.execute(f"SELECT COUNT(*) FROM http_logs{where_clause}", params).fetchone()[0]
offset = (page - 1) * page_size
rows = conn.execute(
f"SELECT * FROM http_logs{where_clause} ORDER BY id DESC LIMIT ? OFFSET ?",
params + [page_size, offset],
).fetchall()
return {"items": [dict(r) for r in rows], "total": count}
finally:
conn.close()
def query_http_log_stats():
"""Get HTTP log statistics for today."""
conn = sqlite3.connect(_db_path)
try:
today = datetime.now().strftime("%Y-%m-%d")
row = conn.execute(
"SELECT COUNT(*) as total, SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as errors, AVG(duration_ms) as avg_ms FROM http_logs WHERE timestamp >= ?",
(today,),
).fetchone()
total = row[0] or 0
errors = row[1] or 0
avg_ms = round(row[2] or 0, 1)
error_rate = round((errors / total * 100) if total > 0 else 0, 1)
return {"today_count": total, "error_count": errors, "avg_duration_ms": avg_ms, "error_rate": error_rate}
finally:
conn.close()
def query_task_history(page: int = 1, page_size: int = 50, status: str = None,
name: str = None, search: str = None):
"""Query task history with filters and pagination."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
where = []
params = []
if status:
where.append("status = ?")
params.append(status)
if name:
where.append("name = ?")
params.append(name)
if search:
where.append("(name LIKE ? OR id LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
where_clause = " WHERE " + " AND ".join(where) if where else ""
count = conn.execute(f"SELECT COUNT(*) FROM task_history{where_clause}", params).fetchone()[0]
offset = (page - 1) * page_size
rows = conn.execute(
f"SELECT * FROM task_history{where_clause} ORDER BY created_at DESC LIMIT ? OFFSET ?",
params + [page_size, offset],
).fetchall()
items = []
for r in rows:
d = dict(r)
import json
d["result_files"] = json.loads(d.get("result_files") or "[]")
d["log_lines"] = json.loads(d.get("log_lines") or "[]")
items.append(d)
return {"items": items, "total": count}
finally:
conn.close()
def query_task_by_id(task_id: str):
"""Get a single task by ID."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
row = conn.execute("SELECT * FROM task_history WHERE id=?", (task_id,)).fetchone()
if not row:
return None
d = dict(row)
import json
d["result_files"] = json.loads(d.get("result_files") or "[]")
d["log_lines"] = json.loads(d.get("log_lines") or "[]")
return d
finally:
conn.close()
def query_task_stats():
"""Get task statistics."""
conn = sqlite3.connect(_db_path)
try:
rows = conn.execute("SELECT status, COUNT(*) FROM task_history GROUP BY status").fetchall()
stats = {r[0]: r[1] for r in rows}
return {
"total": sum(stats.values()),
"completed": stats.get("completed", 0),
"failed": stats.get("failed", 0),
"running": stats.get("running", 0),
}
finally:
conn.close()
def query_file_history(page: int = 1, page_size: int = 50, directory: str = None,
action: str = None):
"""Query file operation history with filters and pagination."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
where = []
params = []
if directory:
where.append("directory = ?")
params.append(directory)
if action:
where.append("action = ?")
params.append(action)
where_clause = " WHERE " + " AND ".join(where) if where else ""
count = conn.execute(f"SELECT COUNT(*) FROM file_metadata{where_clause}", params).fetchone()[0]
offset = (page - 1) * page_size
rows = conn.execute(
f"SELECT * FROM file_metadata{where_clause} ORDER BY id DESC LIMIT ? OFFSET ?",
params + [page_size, offset],
).fetchall()
return {"items": [dict(r) for r in rows], "total": count}
finally:
conn.close()
def query_file_stats():
"""Get file storage statistics per directory."""
dir_map = {
"input": _project_root / "data" / "input",
"output": _project_root / "data" / "output",
"result": _project_root / "data" / "result",
}
stats = []
for name, dir_path in dir_map.items():
if dir_path.is_dir():
files = [f for f in dir_path.iterdir() if f.is_file()]
total_size = sum(f.stat().st_size for f in files)
stats.append({"name": name, "file_count": len(files), "total_size": total_size})
else:
stats.append({"name": name, "file_count": 0, "total_size": 0})
return {"directories": stats}
- Step 2: Verify the module imports cleanly
Run: cd "f:\vebing coding\xiaoaitext" && python -c "from web.backend.services.db_schema import init_db; print('OK')"
Expected: OK
- Step 3: Commit
git add web/backend/services/db_schema.py
git commit -m "feat: add db_schema for http_logs, task_history, file_metadata tables"
Task 2: HTTP Logging Middleware
Files:
-
Create:
web/backend/middleware/__init__.py -
Create:
web/backend/middleware/logging.py -
Step 1: Create
web/backend/middleware/__init__.py
(Empty init file to make it a package.)
- Step 2: Create
web/backend/middleware/logging.py
"""HTTP request logging middleware"""
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Skip static assets and WebSocket
path = request.url.path
if path.startswith("/assets") or path.startswith("/ws") or path == "/favicon.ico":
return await call_next(request)
start = time.time()
response = await call_next(request)
duration_ms = (time.time() - start) * 1000
# Extract username from request state (set by auth middleware if available)
user = None
if hasattr(request.state, "user"):
user = request.state.user.get("username")
# Async write — don't block the response
try:
from ..services.db_schema import insert_http_log
import asyncio
asyncio.create_task(asyncio.get_event_loop().run_in_executor(
None,
lambda: insert_http_log(
method=request.method,
path=path,
status_code=response.status_code,
duration_ms=round(duration_ms, 2),
user=user,
ip=request.client.host if request.client else None,
),
))
except Exception:
pass # Never let logging failures break requests
return response
- Step 3: Verify imports
Run: cd "f:\vebing coding\xiaoaitext" && python -c "from web.backend.middleware.logging import LoggingMiddleware; print('OK')"
Expected: OK
- Step 4: Commit
git add web/backend/middleware/__init__.py web/backend/middleware/logging.py
git commit -m "feat: add HTTP request logging middleware"
Task 3: TaskManager DB Persistence
Files:
-
Modify:
web/backend/services/task_manager.py -
Step 1: Rewrite
web/backend/services/task_manager.py
The full file with DB persistence added. Key changes: __init__ accepts db_pool, create_task writes to DB, update_progress/add_log/set_completed/set_failed update DB.
"""Background task tracking + WebSocket broadcast + DB persistence"""
import uuid
import json
import asyncio
from enum import Enum
from datetime import datetime
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, field
class TaskStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class Task:
id: str
name: str
status: TaskStatus = TaskStatus.PENDING
progress: int = 0
message: str = ""
result_files: List[str] = field(default_factory=list)
error: Optional[str] = None
log_lines: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"task_id": self.id,
"name": self.name,
"status": self.status.value,
"progress": self.progress,
"message": self.message,
"result_files": self.result_files,
"error": self.error,
"log_lines": self.log_lines[-100:],
}
class TaskManager:
def __init__(self, db_pool=None):
self._tasks: Dict[str, Task] = {}
self._connections: Dict[str, Set] = {}
self._db = db_pool
def set_db_pool(self, db_pool):
"""Set the DB pool reference (called during startup)."""
self._db = db_pool
def create_task(self, name: str) -> Task:
task_id = str(uuid.uuid4())[:8]
task = Task(id=task_id, name=name)
self._tasks[task_id] = task
self._connections[task_id] = set()
# Persist to DB
if self._db:
from .db_schema import insert_task
now = datetime.now().isoformat()
asyncio.create_task(self._db.execute_write(
insert_task, task_id, name, TaskStatus.PENDING.value, now
))
return task
def get_task(self, task_id: str) -> Optional[Task]:
return self._tasks.get(task_id)
def update_progress(self, task_id: str, progress: int, message: str = ""):
task = self._tasks.get(task_id)
if not task:
return
task.progress = progress
task.message = message
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.RUNNING
# Persist to DB
if self._db:
from .db_schema import update_task
asyncio.create_task(self._db.execute_write(
update_task, task_id,
status=task.status.value,
progress=progress,
message=message,
updated_at=datetime.now().isoformat(),
))
asyncio.create_task(self._broadcast(task_id))
def add_log(self, task_id: str, line: str):
task = self._tasks.get(task_id)
if not task:
return
task.log_lines.append(line)
# Persist to DB
if self._db:
from .db_schema import update_task
asyncio.create_task(self._db.execute_write(
update_task, task_id,
log_lines=json.dumps(task.log_lines[-200:]),
updated_at=datetime.now().isoformat(),
))
asyncio.create_task(self._broadcast(task_id))
def set_completed(self, task_id: str, result_files: List[str] = None, message: str = ""):
task = self._tasks.get(task_id)
if not task:
return
task.status = TaskStatus.COMPLETED
task.progress = 100
task.message = message or "处理完成"
if result_files:
task.result_files = result_files
# Persist to DB
if self._db:
from .db_schema import update_task
now = datetime.now().isoformat()
asyncio.create_task(self._db.execute_write(
update_task, task_id,
status=TaskStatus.COMPLETED.value,
progress=100,
message=task.message,
result_files=json.dumps(result_files or []),
completed_at=now,
updated_at=now,
))
asyncio.create_task(self._broadcast(task_id))
def set_failed(self, task_id: str, error: str):
task = self._tasks.get(task_id)
if not task:
return
task.status = TaskStatus.FAILED
task.error = error
task.message = f"处理失败: {error}"
# Persist to DB
if self._db:
from .db_schema import update_task
now = datetime.now().isoformat()
asyncio.create_task(self._db.execute_write(
update_task, task_id,
status=TaskStatus.FAILED.value,
error=error,
message=task.message,
completed_at=now,
updated_at=now,
))
asyncio.create_task(self._broadcast(task_id))
def subscribe(self, task_id: str, websocket):
if task_id in self._connections:
self._connections[task_id].add(websocket)
def unsubscribe(self, task_id: str, websocket):
if task_id in self._connections:
self._connections[task_id].discard(websocket)
async def _broadcast(self, task_id: str):
task = self._tasks.get(task_id)
if not task:
return
data = task.to_dict()
dead = set()
for ws in self._connections.get(task_id, set()):
try:
await ws.send_json(data)
except Exception:
dead.add(ws)
for ws in dead:
self._connections[task_id].discard(ws)
- Step 2: Verify the module imports cleanly
Run: cd "f:\vebing coding\xiaoaitext" && python -c "from web.backend.services.task_manager import TaskManager; print('OK')"
Expected: OK
- Step 3: Commit
git add web/backend/services/task_manager.py
git commit -m "feat: persist task lifecycle to SQLite via TaskManager"
Task 4: File Metadata Tracking
Files:
-
Modify:
web/backend/routers/files.py -
Step 1: Add file metadata recording to
files.py
Add insert_file_metadata import and call it in upload, delete, and clear endpoints. The full modified file:
"""File upload, download, and listing endpoints."""
import os
import shutil
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from ..auth.dependencies import get_current_user, get_current_user_flexible
from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
router = APIRouter(prefix="/api/files", tags=["files"])
# Resolve data directories relative to project root
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_input_dir = _project_root / "data" / "input"
_output_dir = _project_root / "data" / "output"
_result_dir = _project_root / "data" / "result"
class FileItem(BaseModel):
name: str
size: int
modified: float
directory: str
class UploadResponse(BaseModel):
filename: str
size: int
path: str
def _ensure_dirs():
for d in [_input_dir, _output_dir, _result_dir]:
d.mkdir(parents=True, exist_ok=True)
def _record_file_action(filename: str, directory: str, size: int, action: str, user: str = None):
"""Record file operation to metadata DB (best-effort, non-blocking)."""
try:
from ..services.db_schema import insert_file_metadata
insert_file_metadata(filename=filename, directory=directory, size=size, action=action, user=user)
except Exception:
pass
@router.post("/upload", response_model=UploadResponse)
async def upload_file(
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
):
_ensure_dirs()
# Validate extension
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"不支持的文件类型: {ext}")
# Validate size
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(400, f"文件过大,最大 {MAX_UPLOAD_SIZE // 1024 // 1024}MB")
# Save with secure name
from werkzeug.utils import secure_filename
safe_name = secure_filename(file.filename) or file.filename
dest = _input_dir / safe_name
# Avoid overwrite: add suffix if exists
counter = 0
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix
while dest.exists():
counter += 1
dest = _input_dir / f"{stem}_{counter}{suffix}"
dest.write_bytes(content)
# Record metadata
_record_file_action(dest.name, "input", len(content), "upload", current_user.get("username"))
return UploadResponse(
filename=dest.name,
size=len(content),
path=str(dest.relative_to(_project_root)),
)
@router.get("/list")
async def list_files(
directory: str = "input",
current_user: dict = Depends(get_current_user),
) -> List[FileItem]:
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir or not target_dir.is_dir():
return []
files = []
for f in sorted(target_dir.iterdir()):
if f.is_file():
stat = f.stat()
files.append(FileItem(
name=f.name,
size=stat.st_size,
modified=stat.st_mtime,
directory=directory,
))
return files
@router.get("/download/{directory}/{filename}")
async def download_file(
directory: str,
filename: str,
current_user: dict = Depends(get_current_user_flexible),
):
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir:
raise HTTPException(404, "目录不存在")
file_path = target_dir / filename
if not file_path.is_file():
raise HTTPException(404, "文件不存在")
return FileResponse(
str(file_path),
filename=filename,
media_type="application/octet-stream",
)
@router.delete("/{directory}/{filename}")
async def delete_file(
directory: str,
filename: str,
current_user: dict = Depends(get_current_user),
):
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir:
raise HTTPException(404, "目录不存在")
file_path = target_dir / filename
if not file_path.is_file():
raise HTTPException(404, "文件不存在")
size = file_path.stat().st_size
file_path.unlink()
# Record metadata
_record_file_action(filename, directory, size, "delete", current_user.get("username"))
return {"message": f"已删除 {filename}"}
@router.post("/clear/{directory}")
async def clear_directory(
directory: str,
current_user: dict = Depends(get_current_user),
):
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir:
raise HTTPException(404, "目录不存在")
count = 0
for f in target_dir.iterdir():
if f.is_file():
f.unlink()
count += 1
# Record metadata
_record_file_action("*", directory, 0, "clear", current_user.get("username"))
return {"message": f"已清除 {count} 个文件", "count": count}
@router.get("/history")
async def file_history(
page: int = 1,
page_size: int = 50,
directory: str = None,
action: str = None,
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_file_history
return query_file_history(page=page, page_size=page_size, directory=directory, action=action)
@router.get("/stats")
async def file_stats(
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_file_stats
return query_file_stats()
- Step 2: Commit
git add web/backend/routers/files.py
git commit -m "feat: record file upload/delete/clear operations to metadata table"
Task 5: Logs API Router
Files:
-
Create:
web/backend/routers/logs.py -
Step 1: Create
web/backend/routers/logs.py
"""HTTP log query endpoints."""
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from typing import Optional
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/logs", tags=["logs"])
class LogItem(BaseModel):
id: int
timestamp: str
method: str
path: str
status_code: Optional[int] = None
duration_ms: Optional[float] = None
user: Optional[str] = None
ip: Optional[str] = None
detail: Optional[str] = None
class LogListResponse(BaseModel):
items: list
total: int
class LogStatsResponse(BaseModel):
today_count: int
error_count: int
avg_duration_ms: float
error_rate: float
@router.get("", response_model=LogListResponse)
async def list_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
method: Optional[str] = None,
status_code: Optional[int] = None,
path: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_http_logs
return query_http_logs(
page=page,
page_size=page_size,
method=method,
status_code=status_code,
path=path,
start_date=start_date,
end_date=end_date,
)
@router.get("/stats", response_model=LogStatsResponse)
async def get_log_stats(
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_http_log_stats
return query_http_log_stats()
- Step 2: Commit
git add web/backend/routers/logs.py
git commit -m "feat: add HTTP log query API endpoints"
Task 6: Tasks API Router
Files:
-
Create:
web/backend/routers/tasks.py -
Step 1: Create
web/backend/routers/tasks.py
"""Task history query and retry endpoints."""
import asyncio
from fastapi import APIRouter, HTTPException, Depends, Query, Request
from pydantic import BaseModel
from typing import Optional
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
class TaskListResponse(BaseModel):
items: list
total: int
class TaskStatsResponse(BaseModel):
total: int
completed: int
failed: int
running: int
@router.get("", response_model=TaskListResponse)
async def list_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
status: Optional[str] = None,
name: Optional[str] = None,
search: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_task_history
return query_task_history(
page=page,
page_size=page_size,
status=status,
name=name,
search=search,
)
@router.get("/stats", response_model=TaskStatsResponse)
async def get_task_stats(
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_task_stats
return query_task_stats()
@router.get("/{task_id}")
async def get_task_detail(
task_id: str,
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_task_by_id
task = query_task_by_id(task_id)
if not task:
raise HTTPException(404, "任务不存在")
return task
@router.post("/{task_id}/retry")
async def retry_task(
task_id: str,
request: Request,
current_user: dict = Depends(get_current_user),
):
from ..services.db_schema import query_task_by_id
task = query_task_by_id(task_id)
if not task:
raise HTTPException(404, "任务不存在")
if task["status"] != "failed":
raise HTTPException(400, "只能重试失败的任务")
# Map task name to processing endpoint
name = task["name"]
endpoint_map = {
"批量OCR识别": "/api/processing/ocr-batch",
"Excel标准化处理": "/api/processing/excel",
"合并采购单": "/api/processing/merge",
"一键全流程处理": "/api/processing/pipeline",
}
endpoint = endpoint_map.get(name)
if not endpoint:
raise HTTPException(400, f"未知的任务类型: {name}")
# Call the processing endpoint internally
from httpx import AsyncClient
async with AsyncClient() as client:
# Forward auth header
auth_header = request.headers.get("authorization", "")
resp = await client.post(
f"http://127.0.0.1:8000{endpoint}",
headers={"authorization": auth_header},
json={},
)
if resp.status_code >= 400:
raise HTTPException(resp.status_code, resp.json().get("detail", "重试失败"))
return resp.json()
- Step 2: Commit
git add web/backend/routers/tasks.py
git commit -m "feat: add task history query and retry API endpoints"
Task 7: Register Everything in main.py
Files:
-
Modify:
web/backend/main.py -
Step 1: Update
web/backend/main.py
Changes:
- Import
db_schemafunctions andLoggingMiddleware - In lifespan: call
init_db(),cleanup_old_records(), setdb_poolontask_manager - Add
LoggingMiddlewareafter CORS - Register
logs_routerandtasks_router
Full file:
"""FastAPI application entry point for the web-based OCR order processing system."""
import sys
import os
from contextlib import asynccontextmanager
from pathlib import Path
# Ensure app/ is importable
_web_dir = Path(__file__).resolve().parent.parent # web/
_project_root = _web_dir.parent # project root
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .config import get_or_generate_secret # noqa: trigger secret generation
from .services.task_manager import TaskManager
from .services.db_pool import DBPool
from .auth.router import router as auth_router
from .routers.files import router as files_router
from .routers.processing import router as processing_router
from .routers.memory import router as memory_router
from .routers.config_api import router as config_router
from .routers.barcodes import router as barcodes_router
from .routers.sync import router as sync_router
from .routers.websocket import router as ws_router
from .routers.logs import router as logs_router
from .routers.tasks import router as tasks_router
from .middleware.logging import LoggingMiddleware
# Shared singletons
task_manager = TaskManager()
db_pool = DBPool()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize shared resources on startup."""
from app.config.settings import ConfigManager
ConfigManager()
# Initialize DB and cleanup old records
from .services.db_schema import init_db, cleanup_old_records
init_db()
cleanup_old_records()
# Wire up DB pool to task manager
task_manager.set_db_pool(db_pool)
app.state.task_manager = task_manager
app.state.db_pool = db_pool
yield
app = FastAPI(
title="益选 OCR 订单处理系统",
version="1.0.0",
lifespan=lifespan,
)
# CORS
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# HTTP logging middleware (after CORS, before routes)
app.add_middleware(LoggingMiddleware)
# Make task_manager and db_pool accessible via request.state
@app.middleware("http")
async def inject_services(request, call_next):
request.state.task_manager = task_manager
request.state.db_pool = db_pool
return await call_next(request)
# Mount routers
app.include_router(auth_router)
app.include_router(files_router)
app.include_router(processing_router)
app.include_router(memory_router)
app.include_router(config_router)
app.include_router(barcodes_router)
app.include_router(sync_router)
app.include_router(ws_router)
app.include_router(logs_router)
app.include_router(tasks_router)
# Serve Vue SPA static files
_static_dir = Path(__file__).resolve().parent / "static"
if _static_dir.is_dir():
app.mount("/assets", StaticFiles(directory=str(_static_dir / "assets")), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Catch-all: serve index.html for Vue Router history mode."""
file_path = _static_dir / full_path
if file_path.is_file():
return FileResponse(str(file_path))
return FileResponse(str(_static_dir / "index.html"))
- Step 2: Test server startup
Run: cd "f:\vebing coding\xiaoaitext" && python -c "from web.backend.main import app; print('OK')"
Expected: OK
- Step 3: Commit
git add web/backend/main.py
git commit -m "feat: register logging middleware, logs/tasks routers, init DB on startup"
Task 8: Frontend — Tasks.vue (Task History Page)
Files:
-
Create:
web/frontend/src/views/Tasks.vue -
Step 1: Create
web/frontend/src/views/Tasks.vue
<template>
<div class="tasks-page">
<!-- Stats row -->
<div class="stats-row animate-in">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
<el-icon :size="20" color="#6366f1"><Timer /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.total }}</span>
<span class="stat-label">总任务数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><CircleCheck /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.completed }}</span>
<span class="stat-label">成功</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
<el-icon :size="20" color="#ef4444"><CircleClose /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.failed }}</span>
<span class="stat-label">失败</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Loading /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.running }}</span>
<span class="stat-label">运行中</span>
</div>
</div>
</div>
<!-- Main table card -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>任务历史</h3>
<div class="card-actions">
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width: 120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="成功" value="completed" />
<el-option label="失败" value="failed" />
<el-option label="运行中" value="running" />
</el-select>
<el-select v-model="filterName" placeholder="类型" clearable size="small" style="width: 150px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="一键全流程" value="一键全流程处理" />
<el-option label="批量OCR" value="批量OCR识别" />
<el-option label="Excel处理" value="Excel标准化处理" />
<el-option label="合并采购单" value="合并采购单" />
</el-select>
<el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @keyup.enter="loadData" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="500" size="small" class="task-table">
<el-table-column prop="id" label="ID" width="100">
<template #default="{ row }">
<span class="task-id">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="类型" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<span class="status-tag" :class="row.status">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="进度" width="140">
<template #default="{ row }">
<el-progress :percentage="row.progress" :stroke-width="6" :status="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'exception' : ''" />
</template>
</el-table-column>
<el-table-column prop="message" label="消息" min-width="200" show-overflow-tooltip />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
<el-button v-if="row.status === 'failed'" type="warning" link size="small" @click="retryTask(row)">重试</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<span class="pagination-info">共 {{ total }} 条记录</span>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadData" />
</div>
</div>
<!-- Detail dialog -->
<el-dialog v-model="showDetailDialog" title="任务详情" width="700px" :close-on-click-modal="false">
<div v-if="detailTask" class="task-detail">
<div class="detail-meta">
<div class="meta-item"><span class="meta-label">任务ID</span><span class="meta-value">{{ detailTask.id }}</span></div>
<div class="meta-item"><span class="meta-label">类型</span><span class="meta-value">{{ detailTask.name }}</span></div>
<div class="meta-item"><span class="meta-label">状态</span><span class="status-tag" :class="detailTask.status">{{ statusLabel(detailTask.status) }}</span></div>
<div class="meta-item"><span class="meta-label">进度</span><span class="meta-value">{{ detailTask.progress }}%</span></div>
</div>
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
<h4>结果文件</h4>
<div v-for="f in detailTask.result_files" :key="f" class="file-chip">{{ f }}</div>
</div>
<div class="detail-logs">
<h4>执行日志</h4>
<div class="log-box">
<div v-if="detailTask.log_lines.length === 0" class="log-empty">暂无日志</div>
<div v-for="(line, i) in detailTask.log_lines" :key="i" class="log-line" :class="logCls(line)">{{ line }}</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="showDetailDialog = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const filterStatus = ref('')
const filterName = ref('')
const items = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 0 })
const showDetailDialog = ref(false)
const detailTask = ref<any>(null)
function statusLabel(s: string) {
const m: Record<string, string> = { pending: '等待中', running: '运行中', completed: '成功', failed: '失败' }
return m[s] || s
}
function formatTime(iso: string) {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function logCls(line: string) {
if (line.includes('失败') || line.includes('错误') || line.includes('Error')) return 'err'
if (line.includes('完成')) return 'ok'
return ''
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/tasks', {
params: { page: page.value, page_size: pageSize.value, status: filterStatus.value, name: filterName.value, search: search.value },
})
items.value = res.data.items
total.value = res.data.total
} catch {
ElMessage.error('加载任务历史失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await api.get('/tasks/stats')
Object.assign(taskStats, res.data)
} catch {}
}
function showDetail(row: any) {
detailTask.value = row
showDetailDialog.value = true
}
async function retryTask(row: any) {
try {
await api.post(`/tasks/${row.id}/retry`)
ElMessage.success('重试任务已创建')
loadData()
loadStats()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '重试失败')
}
}
onMounted(() => {
loadData()
loadStats()
})
</script>
<style scoped>
.tasks-page {
max-width: 1400px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border-light);
transition: all 0.2s var(--ease-out);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
display: flex;
gap: 8px;
align-items: center;
}
.task-table {
border-radius: 10px;
overflow: hidden;
}
.task-id {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.status-tag.completed {
background: rgba(16,185,129,0.1);
color: #10b981;
}
.status-tag.failed {
background: rgba(239,68,68,0.1);
color: #ef4444;
}
.status-tag.running {
background: rgba(245,158,11,0.1);
color: #f59e0b;
}
.status-tag.pending {
background: rgba(99,102,241,0.1);
color: #6366f1;
}
.time-cell {
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.pagination-info {
font-size: 13px;
color: var(--text-muted);
}
/* Detail dialog */
.task-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.detail-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
}
.meta-label {
font-size: 13px;
color: var(--text-muted);
min-width: 60px;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.detail-files h4,
.detail-logs h4 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.file-chip {
display: inline-block;
padding: 4px 10px;
background: rgba(16,185,129,0.08);
border-radius: 6px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--success);
margin: 0 4px 4px 0;
}
.log-box {
background: #0f1117;
border-radius: 10px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
max-height: 300px;
overflow-y: auto;
}
.log-empty {
color: #475569;
text-align: center;
padding: 20px 0;
}
.log-line {
color: #94a3b8;
padding: 1px 0;
}
.log-line.err { color: #f87171; }
.log-line.ok { color: #34d399; }
</style>
- Step 2: Commit
git add web/frontend/src/views/Tasks.vue
git commit -m "feat: add Tasks page with stats, filters, detail dialog, retry"
Task 9: Frontend — Logs.vue (Log Viewer Page)
Files:
-
Create:
web/frontend/src/views/Logs.vue -
Step 1: Create
web/frontend/src/views/Logs.vue
<template>
<div class="logs-page">
<!-- Stats row -->
<div class="stats-row animate-in">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
<el-icon :size="20" color="#6366f1"><Notebook /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.today_count }}</span>
<span class="stat-label">今日请求</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
<el-icon :size="20" color="#ef4444"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.error_count }}</span>
<span class="stat-label">错误数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><Timer /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.avg_duration_ms }}ms</span>
<span class="stat-label">平均耗时</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.error_rate }}%</span>
<span class="stat-label">错误率</span>
</div>
</div>
</div>
<!-- Main table card -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>请求日志</h3>
<div class="card-actions">
<el-select v-model="filterMethod" placeholder="方法" clearable size="small" style="width: 100px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态码" clearable size="small" style="width: 100px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="2xx" value="200" />
<el-option label="4xx" value="400" />
<el-option label="5xx" value="500" />
</el-select>
<el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @keyup.enter="loadData" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="500" size="small" class="log-table" @row-click="toggleExpand">
<el-table-column prop="timestamp" label="时间" width="170">
<template #default="{ row }">
<span class="time-cell">{{ formatTime(row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="method" label="方法" width="80">
<template #default="{ row }">
<span class="method-tag" :class="row.method.toLowerCase()">{{ row.method }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="250" show-overflow-tooltip />
<el-table-column prop="status_code" label="状态码" width="80">
<template #default="{ row }">
<span class="status-code" :class="statusCls(row.status_code)">{{ row.status_code }}</span>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="90">
<template #default="{ row }">
<span class="duration-cell" :class="{ slow: row.duration_ms > 1000 }">{{ row.duration_ms?.toFixed(0) }}ms</span>
</template>
</el-table-column>
<el-table-column prop="user" label="用户" width="80" />
</el-table>
<div class="pagination-bar">
<span class="pagination-info">共 {{ total }} 条记录</span>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadData" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const searchPath = ref('')
const filterMethod = ref('')
const filterStatus = ref('')
const items = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const logStats = reactive({ today_count: 0, error_count: 0, avg_duration_ms: 0, error_rate: 0 })
function formatTime(iso: string) {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function statusCls(code: number) {
if (code >= 500) return 's5xx'
if (code >= 400) return 's4xx'
return 's2xx'
}
function toggleExpand(row: any) {
// Could expand detail panel on click
}
async function loadData() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterMethod.value) params.method = filterMethod.value
if (filterStatus.value) params.status_code = parseInt(filterStatus.value)
if (searchPath.value) params.path = searchPath.value
const res = await api.get('/logs', { params })
items.value = res.data.items
total.value = res.data.total
} catch {
ElMessage.error('加载日志失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await api.get('/logs/stats')
Object.assign(logStats, res.data)
} catch {}
}
onMounted(() => {
loadData()
loadStats()
})
</script>
<style scoped>
.logs-page {
max-width: 1400px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border-light);
transition: all 0.2s var(--ease-out);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
display: flex;
gap: 8px;
align-items: center;
}
.log-table {
border-radius: 10px;
overflow: hidden;
}
.time-cell {
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.method-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
}
.method-tag.get { background: rgba(16,185,129,0.1); color: #10b981; }
.method-tag.post { background: rgba(99,102,241,0.1); color: #6366f1; }
.method-tag.put { background: rgba(245,158,11,0.1); color: #f59e0b; }
.method-tag.delete { background: rgba(239,68,68,0.1); color: #ef4444; }
.status-code {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.status-code.s2xx { color: #10b981; }
.status-code.s4xx { color: #f59e0b; }
.status-code.s5xx { color: #ef4444; }
.duration-cell {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.duration-cell.slow {
color: #ef4444;
font-weight: 600;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.pagination-info {
font-size: 13px;
color: var(--text-muted);
}
</style>
- Step 2: Commit
git add web/frontend/src/views/Logs.vue
git commit -m "feat: add Logs page with stats, filters, color-coded table"
Task 10: Frontend — Router + Layout Registration
Files:
-
Modify:
web/frontend/src/router/index.ts -
Modify:
web/frontend/src/views/Layout.vue -
Step 1: Update
web/frontend/src/router/index.ts
Add two child routes:
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: () => import('../views/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
},
{
path: 'memory',
name: 'Memory',
component: () => import('../views/Memory.vue'),
},
{
path: 'barcodes',
name: 'Barcodes',
component: () => import('../views/Barcodes.vue'),
},
{
path: 'config',
name: 'Config',
component: () => import('../views/Config.vue'),
},
{
path: 'sync',
name: 'Sync',
component: () => import('../views/Sync.vue'),
},
{
path: 'tasks',
name: 'Tasks',
component: () => import('../views/Tasks.vue'),
},
{
path: 'logs',
name: 'Logs',
component: () => import('../views/Logs.vue'),
},
],
},
],
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth !== false && !authStore.isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router
- Step 2: Update
web/frontend/src/views/Layout.vueimports and navItems
In the <script setup> section, update the import to include Timer and Notebook:
import {
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook,
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
} from '@element-plus/icons-vue'
Update the navItems array to include the two new pages:
const navItems: { path: string; label: string; icon: any; badge?: string }[] = [
{ path: '/', label: '处理中心', icon: HomeFilled },
{ path: '/tasks', label: '任务历史', icon: Timer },
{ path: '/logs', label: '日志中心', icon: Notebook },
{ path: '/memory', label: '记忆库', icon: Memo },
{ path: '/barcodes', label: '条码映射', icon: Connection },
{ path: '/config', label: '系统配置', icon: Setting },
{ path: '/sync', label: '云端同步', icon: Cloudy },
]
- Step 3: Commit
git add web/frontend/src/router/index.ts web/frontend/src/views/Layout.vue
git commit -m "feat: add Tasks and Logs routes and sidebar navigation"
Task 11: Frontend — Dashboard Enhancement
Files:
-
Modify:
web/frontend/src/views/Dashboard.vue -
Step 1: Update Dashboard stats to use dynamic file stats
In the <script setup> section, add a fileStats ref and loadFileStats function:
const fileStats = ref({ file_count: 0, total_size: 0 })
async function loadFileStats() {
try {
const res = await api.get('/files/stats')
const dirs = res.data.directories || []
const totalFiles = dirs.reduce((s: number, d: any) => s + d.file_count, 0)
const totalSize = dirs.reduce((s: number, d: any) => s + d.total_size, 0)
fileStats.value = { file_count: totalFiles, total_size: totalSize }
} catch {}
}
Update the stats computed to use dynamic data instead of hardcoded "5591":
const stats = computed(() => [
{ label: '待处理', value: inputFiles.value.length, icon: Document, color: '#6366f1', bg: 'rgba(99,102,241,0.1)' },
{ label: '已输出', value: resultFiles.value.length, icon: Files, color: '#10b981', bg: 'rgba(16,185,129,0.1)' },
{ label: '存储文件', value: fileStats.value.file_count, icon: Grid, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
])
Update onMounted to also call loadFileStats:
onMounted(() => {
refreshFiles()
loadFileStats()
})
Also add loadFileStats() call inside refreshFiles() after the data loads so stats stay current.
- Step 2: Commit
git add web/frontend/src/views/Dashboard.vue
git commit -m "feat: Dashboard stats now show dynamic file count instead of hardcoded value"
Task 12: Build and End-to-End Test
Files: None (verification only)
- Step 1: Build the frontend
Run: cd "f:\vebing coding\xiaoaitext\web\frontend" && npm run build
Expected: Build succeeds, files output to web/backend/static/
- Step 2: Start the server and verify DB creation
Run: cd "f:\vebing coding\xiaoaitext" && python -c "from web.backend.services.db_schema import init_db; init_db(); print('DB initialized')"
Then check: dir data\web_data.db
Expected: File exists
- Step 3: Test log recording via curl
# Login first
TOKEN=$(curl -s http://localhost:8000/api/auth/login -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"admin123"}' | python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# Make a request (should be logged)
curl -s http://localhost:8000/api/files/stats -H "Authorization: Bearer $TOKEN"
# Check logs
curl -s http://localhost:8000/api/logs -H "Authorization: Bearer $TOKEN" | python -c "import sys,json;d=json.load(sys.stdin);print(f'Logs: {d[\"total\"]} items')"
Expected: Logs: N items where N > 0
- Step 4: Test task history
# Check task stats
curl -s http://localhost:8000/api/tasks/stats -H "Authorization: Bearer $TOKEN"
Expected: JSON with total/completed/failed/running counts
- Step 5: Test file stats
curl -s http://localhost:8000/api/files/stats -H "Authorization: Bearer $TOKEN"
Expected: JSON with directories array
- Step 6: Test frontend pages in browser
Open http://localhost:8000/tasks — verify task history page loads with stats and table
Open http://localhost:8000/logs — verify log page loads with stats and table
- Step 7: Commit all changes
git add -A
git commit -m "feat: complete logging, task history, and file management system"