Files
orc-order-v2/docs/superpowers/plans/2026-05-05-logging-tasks-files.md
T

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:

  1. Import db_schema functions and LoggingMiddleware
  2. In lifespan: call init_db(), cleanup_old_records(), set db_pool on task_manager
  3. Add LoggingMiddleware after CORS
  4. Register logs_router and tasks_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.vue imports 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"