71c0ba9c96
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2220 lines
64 KiB
Markdown
2220 lines
64 KiB
Markdown
# 日志系统 + 任务历史 + 文件管理 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`**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```python
|
|
```
|
|
|
|
(Empty init file to make it a package.)
|
|
|
|
- [ ] **Step 2: Create `web/backend/middleware/logging.py`**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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.
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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":
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
onMounted(() => {
|
|
refreshFiles()
|
|
loadFileStats()
|
|
})
|
|
```
|
|
|
|
Also add `loadFileStats()` call inside `refreshFiles()` after the data loads so stats stay current.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
# 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**
|
|
|
|
```bash
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: complete logging, task history, and file management system"
|
|
```
|