feat: complete web application — FastAPI backend + Vue 3 SPA frontend

- Full FastAPI backend with JWT auth, file management, processing pipeline,
  memory CRUD, barcode mappings, config management, cloud sync
- Vue 3 + Element Plus frontend with dashboard, task history, HTTP logs,
  memory editor, barcode editor, config editor, sync page
- HTTP request logging middleware with SQLite persistence
- Task history tracking with progress and retry support
- File metadata recording for upload/download operations
- WebAuth section in config.ini for bcrypt password storage
- Bug fix: logs.py count query returns tuple not dict

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 11:59:07 +08:00
parent 79522d8356
commit dedc3b4183
46 changed files with 6971 additions and 9 deletions
+11 -7
View File
@@ -18,13 +18,8 @@ release/
logs/ logs/
data/temp/ data/temp/
# Runtime outputs # Runtime data (all runtime outputs, caches, databases)
data/output/ data/
data/result/
data/input/
data/product_cache.db
data/user_settings.json
*.db
# Claude Code / IDE # Claude Code / IDE
.claude/ .claude/
@@ -34,6 +29,15 @@ data/user_settings.json
# Old project # Old project
wework_xiaoai_bot/ wework_xiaoai_bot/
# Node.js
node_modules/
# Frontend build output
web/backend/static/
# Screenshots (from testing)
*.png
# OS/IDE # OS/IDE
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+6 -1
View File
@@ -40,4 +40,9 @@ version = 2026.05.05.0239
base_url = https://gitea.94kan.cn base_url = https://gitea.94kan.cn
owner = houhuan owner = houhuan
repo = yixuan-sync-data repo = yixuan-sync-data
token = 50b61e43a141d606ae2529cd1755bc666d800e08 token = 50b61e43a141d606ae2529cd1755bc666d800e08
[WebAuth]
username = admin
password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC
View File
View File
+58
View File
@@ -0,0 +1,58 @@
"""FastAPI auth dependencies"""
from fastapi import Depends, HTTPException, status, Query, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .jwt_handler import decode_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
try:
payload = decode_token(credentials.credentials)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": username}
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
async def get_current_user_ws(token: str = Query(...)) -> dict:
"""WebSocket auth via query parameter"""
try:
payload = decode_token(token)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": username}
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
async def get_current_user_flexible(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
token: str = Query(None),
) -> dict:
"""Auth from header OR query param (for file downloads in browser)."""
token_str = None
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
if not token_str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供认证凭据")
try:
payload = decode_token(token_str)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": username}
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
+19
View File
@@ -0,0 +1,19 @@
"""JWT token creation and validation"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from ..config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=JWT_EXPIRE_HOURS))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, get_or_generate_secret(), algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, get_or_generate_secret(), algorithms=[JWT_ALGORITHM])
+89
View File
@@ -0,0 +1,89 @@
"""Auth API endpoints"""
import os
import bcrypt
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from .jwt_handler import create_access_token
from .dependencies import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Default credentials (should be changed on first login)
DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "admin123"
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def _get_credentials() -> tuple[str, bytes]:
"""Get username and password hash from config or defaults"""
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
username = cfg.get('WebAuth', 'username', fallback=DEFAULT_USERNAME)
pw_hash = cfg.get('WebAuth', 'password_hash', fallback='')
if not pw_hash:
# First run: store default password hash
pw_hash = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode()
try:
cfg.update('WebAuth', 'username', DEFAULT_USERNAME)
cfg.update('WebAuth', 'password_hash', pw_hash)
cfg.save_config()
except Exception:
pass
return username, pw_hash.encode()
except Exception:
return DEFAULT_USERNAME, bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt())
@router.post("/login", response_model=LoginResponse)
async def login(req: LoginRequest):
stored_username, stored_hash = _get_credentials()
if req.username != stored_username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not bcrypt.checkpw(req.password.encode(), stored_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
token = create_access_token({"sub": req.username})
return LoginResponse(access_token=token)
@router.get("/me")
async def me(current_user: dict = Depends(get_current_user)):
return current_user
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/change-password")
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
_, stored_hash = _get_credentials()
if not bcrypt.checkpw(req.old_password.encode(), stored_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
new_hash = bcrypt.hashpw(req.new_password.encode(), bcrypt.gensalt()).decode()
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
cfg.update('WebAuth', 'password_hash', new_hash)
cfg.save_config()
except Exception as e:
raise HTTPException(status_code=500, detail=f"保存密码失败: {e}")
return {"message": "密码修改成功"}
+40
View File
@@ -0,0 +1,40 @@
"""Web-specific configuration"""
import os
import secrets
# JWT
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
# File upload
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp'}
ALLOWED_EXCEL_EXTENSIONS = {'.xlsx', '.xls'}
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | ALLOWED_EXCEL_EXTENSIONS
# CORS
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
# Auth rate limit
LOGIN_RATE_LIMIT = 5 # per minute
def get_or_generate_secret() -> str:
"""Get JWT secret from env or auto-generate on first run"""
global JWT_SECRET_KEY
if not JWT_SECRET_KEY:
secret_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', '.jwt_secret'
)
if os.path.exists(secret_file):
with open(secret_file, 'r') as f:
JWT_SECRET_KEY = f.read().strip()
if not JWT_SECRET_KEY:
JWT_SECRET_KEY = secrets.token_urlsafe(48)
os.makedirs(os.path.dirname(secret_file), exist_ok=True)
with open(secret_file, 'w') as f:
f.write(JWT_SECRET_KEY)
return JWT_SECRET_KEY
+109
View File
@@ -0,0 +1,109 @@
"""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"))
View File
+124
View File
@@ -0,0 +1,124 @@
"""Barcode mapping CRUD endpoints."""
import json
from pathlib import Path
from typing import Dict, Optional, List
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/barcodes", tags=["barcodes"])
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_mappings_file = _project_root / "config" / "barcode_mappings.json"
class BarcodeMapping(BaseModel):
barcode: str
target: str
description: Optional[str] = None
class BarcodeMappingUpdate(BaseModel):
target: Optional[str] = None
description: Optional[str] = None
def _load_mappings() -> Dict:
if not _mappings_file.is_file():
return {}
try:
return json.loads(_mappings_file.read_text(encoding="utf-8"))
except Exception:
return {}
def _save_mappings(data: Dict):
_mappings_file.parent.mkdir(parents=True, exist_ok=True)
_mappings_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
@router.get("")
async def list_barcodes(
search: str = "",
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
items = []
for barcode, info in mappings.items():
if isinstance(info, dict):
target = info.get("map_to", info.get("target", ""))
desc = info.get("description", "")
else:
target = str(info)
desc = ""
if search and search not in barcode and search not in target and search not in desc:
continue
items.append({"barcode": barcode, "target": target, "description": desc})
return {"items": items, "total": len(items)}
@router.get("/{barcode}")
async def get_barcode(
barcode: str,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}")
info = mappings[barcode]
if isinstance(info, dict):
return {"barcode": barcode, "target": info.get("map_to", info.get("target", "")), "description": info.get("description", "")}
return {"barcode": barcode, "target": str(info), "description": ""}
@router.post("")
async def create_barcode(
body: BarcodeMapping,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if body.barcode in mappings:
raise HTTPException(409, f"条码 {body.barcode} 已存在")
mappings[body.barcode] = {"map_to": body.target, "description": body.description or ""}
_save_mappings(mappings)
return {"message": f"已创建映射 {body.barcode}{body.target}"}
@router.put("/{barcode}")
async def update_barcode(
barcode: str,
body: BarcodeMappingUpdate,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}")
existing = mappings[barcode]
if not isinstance(existing, dict):
existing = {"map_to": str(existing), "description": ""}
if body.target is not None:
existing["map_to"] = body.target
if body.description is not None:
existing["description"] = body.description
mappings[barcode] = existing
_save_mappings(mappings)
return {"message": f"已更新映射 {barcode}"}
@router.delete("/{barcode}")
async def delete_barcode(
barcode: str,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}")
del mappings[barcode]
_save_mappings(mappings)
return {"message": f"已删除映射 {barcode}"}
+98
View File
@@ -0,0 +1,98 @@
"""Configuration read/write endpoints."""
from typing import Dict, Optional, Any
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/config", tags=["config"])
# Keys that should be masked in GET responses
_SENSITIVE_KEYS = {"api_key", "secret_key", "token", "password", "api_secret", "access_key"}
# Sections to expose (match actual config.ini)
_ALLOWED_SECTIONS = {"API", "Paths", "Performance", "File", "Templates", "Gitea", "WebAuth"}
class ConfigUpdate(BaseModel):
section: str
key: str
value: str
class ConfigBulkUpdate(BaseModel):
updates: list[ConfigUpdate]
def _get_config():
from app.config.settings import ConfigManager
return ConfigManager()
def _mask_value(key: str, value: str) -> str:
if any(s in key.lower() for s in _SENSITIVE_KEYS):
if len(value) > 4:
return value[:2] + "*" * (len(value) - 4) + value[-2:]
return "****"
return value
@router.get("")
async def get_config(
section: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
cfg = _get_config()
if section:
if section not in _ALLOWED_SECTIONS and section != "DEFAULT":
raise HTTPException(403, f"不允许访问配置节: {section}")
items = {}
for key, value in cfg.config.items(section):
items[key] = _mask_value(key, value)
return {"section": section, "items": items}
result = {}
for sec in _ALLOWED_SECTIONS:
try:
items = {}
for key, value in cfg.config.items(sec):
items[key] = _mask_value(key, value)
result[sec] = items
except Exception:
pass
return result
@router.put("")
async def update_config(
body: ConfigUpdate,
current_user: dict = Depends(get_current_user),
):
if body.section not in _ALLOWED_SECTIONS:
raise HTTPException(403, f"不允许修改配置节: {body.section}")
cfg = _get_config()
try:
cfg.update(body.section, body.key, body.value)
cfg.save_config()
return {"message": f"已更新 [{body.section}] {body.key}"}
except Exception as e:
raise HTTPException(500, f"保存失败: {e}")
@router.put("/bulk")
async def bulk_update_config(
body: ConfigBulkUpdate,
current_user: dict = Depends(get_current_user),
):
cfg = _get_config()
updated = []
for item in body.updates:
if item.section not in _ALLOWED_SECTIONS:
continue
cfg.update(item.section, item.key, item.value)
updated.append(f"[{item.section}] {item.key}")
cfg.save_config()
return {"message": f"已更新 {len(updated)}", "updated": updated}
+1 -1
View File
@@ -49,7 +49,7 @@ def _count_http_logs(
row = conn.execute( row = conn.execute(
f"SELECT COUNT(*) as cnt FROM http_logs{where}", params f"SELECT COUNT(*) as cnt FROM http_logs{where}", params
).fetchone() ).fetchone()
return row["cnt"] if row else 0 return row[0] if row else 0
finally: finally:
conn.close() conn.close()
+165
View File
@@ -0,0 +1,165 @@
"""Product memory CRUD endpoints."""
from typing import Optional, List, Dict
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/memory", tags=["memory"])
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_db_path = str(_project_root / "data" / "product_cache.db")
_excel_source = str(_project_root / "templates" / "商品资料.xlsx")
class MemoryItem(BaseModel):
barcode: str
name: str
spec: Optional[str] = None
unit: Optional[str] = None
price: Optional[float] = None
confidence: int = 0
source: str = "ocr"
last_used: Optional[str] = None
use_count: int = 0
class MemoryUpdate(BaseModel):
name: Optional[str] = None
spec: Optional[str] = None
unit: Optional[str] = None
price: Optional[float] = None
confidence: Optional[int] = None
class MemoryListResponse(BaseModel):
items: List[MemoryItem]
total: int
page: int
page_size: int
def _get_db():
from app.core.db.product_db import ProductDatabase
return ProductDatabase(_db_path, _excel_source)
def _row_to_item(row: Dict) -> MemoryItem:
return MemoryItem(
barcode=row.get("barcode", ""),
name=row.get("name", ""),
spec=row.get("spec"),
unit=row.get("unit"),
price=row.get("price"),
confidence=row.get("confidence", 0),
source=row.get("source", "ocr"),
last_used=row.get("last_used"),
use_count=row.get("use_count", 0),
)
@router.get("", response_model=MemoryListResponse)
async def list_memory(
search: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: dict = Depends(get_current_user),
):
db = _get_db()
results = db.get_all_memories()
if search:
s = search.lower()
results = [r for r in results if s in r.get("barcode", "").lower() or s in r.get("name", "").lower()]
total = len(results)
start = (page - 1) * page_size
page_items = results[start:start + page_size]
return MemoryListResponse(
items=[_row_to_item(r) for r in page_items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{barcode}")
async def get_memory(
barcode: str,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
product = db.get_memory(barcode)
if not product:
raise HTTPException(404, f"未找到条码 {barcode} 的记忆记录")
return product
@router.put("/{barcode}")
async def update_memory(
barcode: str,
body: MemoryUpdate,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
existing = db.get_memory(barcode)
if not existing:
raise HTTPException(404, f"未找到条码 {barcode}")
update_data = body.model_dump(exclude_none=True)
if not update_data:
raise HTTPException(400, "没有提供更新数据")
db.update_memory(barcode, update_data)
return {"message": f"已更新 {barcode}", "updated_fields": list(update_data.keys())}
@router.delete("/{barcode}")
async def delete_memory(
barcode: str,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
existing = db.get_memory(barcode)
if not existing:
raise HTTPException(404, f"未找到条码 {barcode}")
db.delete_memory(barcode)
return {"message": f"已删除 {barcode}"}
@router.post("/reimport")
async def reimport_memory(
current_user: dict = Depends(get_current_user),
):
db = _get_db()
try:
count = db.reimport()
return {"message": f"重新导入完成,共导入 {count} 条记录", "count": count}
except Exception as e:
raise HTTPException(500, f"导入失败: {e}")
@router.get("/export/sync")
async def export_memory(
current_user: dict = Depends(get_current_user),
):
db = _get_db()
data = db.export_for_sync()
return {"data": data, "count": len(data)}
@router.post("/import/sync")
async def import_memory(
data: dict,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
try:
count = db.import_from_sync(data.get("data", []))
return {"message": f"导入完成,共 {count}", "count": count}
except Exception as e:
raise HTTPException(500, f"导入失败: {e}")
+250
View File
@@ -0,0 +1,250 @@
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
import os
import sys
import traceback
from pathlib import Path
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from ..services.service_wrapper import ServiceWrapper
router = APIRouter(prefix="/api/processing", tags=["processing"])
_wrapper = ServiceWrapper(max_workers=3)
_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 PipelineRequest(BaseModel):
files: Optional[List[str]] = None # specific files, or None = all in input/
supplier: Optional[str] = None # force supplier type
class TaskResponse(BaseModel):
task_id: str
status: str
message: str
def _get_task_manager(request: Request):
return request.state.task_manager
def _list_input_files(filter_ext: Optional[List[str]] = None) -> List[Path]:
if not _input_dir.is_dir():
return []
files = []
for f in sorted(_input_dir.iterdir()):
if f.is_file():
if filter_ext is None or f.suffix.lower() in filter_ext:
files.append(f)
return files
@router.post("/ocr-batch", response_model=TaskResponse)
async def ocr_batch(
request: Request,
current_user: dict = Depends(get_current_user),
):
"""Run OCR on all images in input/."""
tm = _get_task_manager(request)
task = tm.create_task("批量OCR识别")
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
files = _list_input_files(filter_ext=list(image_exts))
if not files:
raise HTTPException(400, "input/ 目录中没有图片文件")
async def _run():
try:
from app.services.ocr_service import OCRService
svc = OCRService()
total = len(files)
for i, f in enumerate(files):
tm.update_progress(task.id, int((i / total) * 100), f"正在识别: {f.name}")
tm.add_log(task.id, f"[OCR] 处理 {f.name}")
try:
svc.process_single(str(f), str(_output_dir))
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
result_files = [f.name for f in _output_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成,共处理 {total} 个文件")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建")
@router.post("/excel", response_model=TaskResponse)
async def process_excel(
request: Request,
body: PipelineRequest = PipelineRequest(),
current_user: dict = Depends(get_current_user),
):
"""Convert OCR output Excel files to standardized format."""
tm = _get_task_manager(request)
task = tm.create_task("Excel标准化处理")
excel_exts = {'.xls', '.xlsx'}
if body.files:
files = [_output_dir / f for f in body.files if (_output_dir / f).is_file()]
else:
files = _list_input_files(filter_ext=list(excel_exts))
if not files:
files = _list_input_files_from(_output_dir, filter_ext=list(excel_exts))
if not files:
raise HTTPException(400, "没有找到Excel文件")
async def _run():
try:
from app.services.order_service import OrderService
svc = OrderService()
total = len(files)
for i, f in enumerate(files):
tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}")
tm.add_log(task.id, f"[Excel] 处理 {f.name}")
try:
svc.process_excel(str(f), str(_result_dir))
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成,共 {total} 个文件")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建")
@router.post("/merge", response_model=TaskResponse)
async def merge_orders(
request: Request,
current_user: dict = Depends(get_current_user),
):
"""Merge all processed Excel files into a single purchase order."""
tm = _get_task_manager(request)
task = tm.create_task("合并采购单")
async def _run():
try:
from app.services.order_service import OrderService
svc = OrderService()
tm.update_progress(task.id, 20, "正在合并采购单...")
tm.add_log(task.id, "[合并] 开始合并")
result = svc.merge_orders(str(_result_dir))
tm.add_log(task.id, f"[合并] 完成: {result}")
tm.set_completed(task.id, result_files=[result] if result else [], message="合并完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建")
@router.post("/pipeline", response_model=TaskResponse)
async def full_pipeline(
request: Request,
body: PipelineRequest = PipelineRequest(),
current_user: dict = Depends(get_current_user),
):
"""Run the full pipeline: OCR → Excel → Merge."""
tm = _get_task_manager(request)
task = tm.create_task("一键全流程处理")
async def _run():
try:
# Step 1: OCR
tm.update_progress(task.id, 0, "步骤 1/3: OCR识别")
tm.add_log(task.id, "[Pipeline] 开始OCR识别")
from app.services.ocr_service import OCRService
ocr_svc = OCRService()
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
images = _list_input_files(filter_ext=list(image_exts))
for i, f in enumerate(images):
pct = int((i / max(len(images), 1)) * 30)
tm.update_progress(task.id, pct, f"OCR: {f.name}")
try:
ocr_svc.process_single(str(f), str(_output_dir))
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
# Step 2: Excel conversion
tm.update_progress(task.id, 35, "步骤 2/3: Excel标准化")
tm.add_log(task.id, "[Pipeline] 开始Excel处理")
from app.services.order_service import OrderService
order_svc = OrderService()
excel_files = list(_output_dir.glob("*.xls")) + list(_output_dir.glob("*.xlsx"))
for i, f in enumerate(excel_files):
pct = 35 + int((i / max(len(excel_files), 1)) * 35)
tm.update_progress(task.id, pct, f"Excel: {f.name}")
try:
order_svc.process_excel(str(f), str(_result_dir))
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
# Step 3: Merge
tm.update_progress(task.id, 75, "步骤 3/3: 合并采购单")
tm.add_log(task.id, "[Pipeline] 开始合并")
try:
result = order_svc.merge_orders(str(_result_dir))
tm.add_log(task.id, f"[合并] 完成: {result}")
except Exception as e:
tm.add_log(task.id, f"[合并] 失败: {e}")
result = None
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message="全流程处理完成")
except Exception as e:
tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建")
@router.get("/status/{task_id}")
async def get_task_status(
task_id: str,
request: Request,
current_user: dict = Depends(get_current_user),
):
tm = _get_task_manager(request)
task = tm.get_task(task_id)
if not task:
raise HTTPException(404, "任务不存在")
return task.to_dict()
def _list_input_files_from(directory: Path, filter_ext: List[str] = None) -> List[Path]:
if not directory.is_dir():
return []
files = []
for f in sorted(directory.iterdir()):
if f.is_file():
if filter_ext is None or f.suffix.lower() in filter_ext:
files.append(f)
return files
+93
View File
@@ -0,0 +1,93 @@
"""Cloud sync endpoints (Gitea-based)."""
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from ..services.task_manager import TaskManager
router = APIRouter(prefix="/api/sync", tags=["sync"])
_project_root = Path(__file__).resolve().parent.parent.parent.parent
class SyncResponse(BaseModel):
task_id: str
status: str
message: str
def _get_sync():
from app.core.utils.cloud_sync import GiteaSync
from app.config.settings import ConfigManager
cfg = ConfigManager()
return GiteaSync(cfg)
@router.post("/push", response_model=SyncResponse)
async def sync_push(
request: Request,
current_user: dict = Depends(get_current_user),
):
tm = request.state.task_manager
task = tm.create_task("推送到云端")
async def _run():
try:
tm.update_progress(task.id, 10, "正在初始化同步...")
sync = _get_sync()
tm.update_progress(task.id, 30, "正在推送文件...")
tm.add_log(task.id, "[Push] 开始推送")
result = sync.push()
tm.add_log(task.id, f"[Push] 完成: {result}")
tm.set_completed(task.id, message="推送完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return SyncResponse(task_id=task.id, status="accepted", message="推送任务已创建")
@router.post("/pull", response_model=SyncResponse)
async def sync_pull(
request: Request,
current_user: dict = Depends(get_current_user),
):
tm = request.state.task_manager
task = tm.create_task("从云端拉取")
async def _run():
try:
tm.update_progress(task.id, 10, "正在初始化同步...")
sync = _get_sync()
tm.update_progress(task.id, 30, "正在拉取文件...")
tm.add_log(task.id, "[Pull] 开始拉取")
result = sync.pull()
tm.add_log(task.id, f"[Pull] 完成: {result}")
tm.set_completed(task.id, message="拉取完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return SyncResponse(task_id=task.id, status="accepted", message="拉取任务已创建")
@router.get("/status")
async def sync_status(
current_user: dict = Depends(get_current_user),
):
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
base_url = cfg.get("Gitea", "base_url", fallback="")
owner = cfg.get("Gitea", "owner", fallback="")
repo = cfg.get("Gitea", "repo", fallback="")
enabled = bool(base_url and owner and repo)
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
return {"enabled": enabled, "repo_url": repo_url}
except Exception:
return {"enabled": False, "repo_url": ""}
+47
View File
@@ -0,0 +1,47 @@
"""WebSocket endpoint for real-time task progress."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from ..auth.jwt_handler import decode_token
from jose import JWTError
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/task/{task_id}")
async def task_websocket(
websocket: WebSocket,
task_id: str,
token: str = Query(...),
):
"""WebSocket for real-time task progress updates."""
try:
payload = decode_token(token)
username = payload.get("sub")
if not username:
await websocket.close(code=4001, reason="Invalid token")
return
except (JWTError, Exception):
await websocket.close(code=4001, reason="Invalid token")
return
await websocket.accept()
tm = websocket.app.state.task_manager
task = tm.get_task(task_id)
if not task:
await websocket.send_json({"error": "任务不存在"})
await websocket.close()
return
tm.subscribe(task_id, websocket)
await websocket.send_json(task.to_dict())
try:
while True:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
tm.unsubscribe(task_id, websocket)
except Exception:
tm.unsubscribe(task_id, websocket)
View File
View File
+20
View File
@@ -0,0 +1,20 @@
"""SQLite write serialization for async context"""
import asyncio
from typing import Callable, Any
class DBPool:
"""Serializes SQLite writes via asyncio.Lock. Reads are concurrent."""
def __init__(self):
self._write_lock = asyncio.Lock()
async def execute_write(self, fn: Callable, *args, **kwargs) -> Any:
async with self._write_lock:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
async def execute_read(self, fn: Callable, *args, **kwargs) -> Any:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
+19
View File
@@ -0,0 +1,19 @@
"""Async wrapper for synchronous app/ services"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Any
class ServiceWrapper:
"""Wraps synchronous services for async FastAPI endpoints."""
def __init__(self, max_workers: int = 3):
self._executor = ThreadPoolExecutor(max_workers=max_workers)
async def run_sync(self, fn: Callable, *args, **kwargs) -> Any:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
self._executor,
lambda: fn(*args, **kwargs)
)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>益选 OCR 订单处理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1929
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "xiaoaitext-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"vue-tsc": "^2.0.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
// Request interceptor: attach JWT token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor: handle 401
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api
+4
View File
@@ -0,0 +1,4 @@
declare module 'element-plus/dist/locale/zh-cn.mjs' {
const locale: any
export default locale
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './styles/global.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+67
View File
@@ -0,0 +1,67 @@
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
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref('')
const isAuthenticated = computed(() => !!token.value)
async function login(user: string, password: string) {
const res = await api.post('/auth/login', { username: user, password })
token.value = res.data.access_token
localStorage.setItem('token', token.value)
username.value = user
}
function logout() {
token.value = ''
username.value = ''
localStorage.removeItem('token')
}
async function fetchUser() {
try {
const res = await api.get('/auth/me')
username.value = res.data.username
} catch {
logout()
}
}
return { token, username, isAuthenticated, login, logout, fetchUser }
})
+94
View File
@@ -0,0 +1,94 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api'
export interface TaskInfo {
task_id: string
name: string
status: string
progress: number
message: string
result_files: string[]
error: string | null
log_lines: string[]
}
export const useProcessingStore = defineStore('processing', () => {
const currentTask = ref<TaskInfo | null>(null)
const tasks = ref<TaskInfo[]>([])
const logs = ref<string[]>([])
let ws: WebSocket | null = null
function connectWebSocket(taskId: string) {
disconnectWebSocket()
const token = localStorage.getItem('token')
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
ws = new WebSocket(url)
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
currentTask.value = data
logs.value = data.log_lines || []
// Update in tasks list
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
if (idx >= 0) {
tasks.value[idx] = data
} else {
tasks.value.unshift(data)
}
// Auto-disconnect on completion
if (data.status === 'completed' || data.status === 'failed') {
setTimeout(() => disconnectWebSocket(), 2000)
}
} catch {}
}
ws.onerror = () => {
console.error('WebSocket error')
}
ws.onclose = () => {
ws = null
}
}
function disconnectWebSocket() {
if (ws) {
ws.close()
ws = null
}
}
async function startTask(endpoint: string, body?: any) {
const res = await api.post(endpoint, body || {})
const taskId = res.data.task_id
currentTask.value = {
task_id: taskId,
name: res.data.message || '',
status: 'pending',
progress: 0,
message: '',
result_files: [],
error: null,
log_lines: [],
}
logs.value = []
connectWebSocket(taskId)
return taskId
}
async function pollTaskStatus(taskId: string) {
const res = await api.get(`/processing/status/${taskId}`)
return res.data
}
return { currentTask, tasks, logs, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
})
+172
View File
@@ -0,0 +1,172 @@
/* ═══════════════════════════════════════════
益选 OCR — Industrial Command Center Theme
═══════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
/* Core palette */
--bg-dark: #0f1117;
--bg-sidebar: #161922;
--bg-card: #ffffff;
--bg-page: #f0f2f5;
--bg-hover: #f7f8fa;
/* Amber accent system */
--amber-50: #fff8e1;
--amber-100: #ffecb3;
--amber-400: #ffca28;
--amber-500: #ffc107;
--amber-600: #ffb300;
/* Semantic */
--primary: #2563eb;
--primary-hover: #1d4ed8;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #6366f1;
/* Text */
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--text-inverse: #e5e7eb;
--text-sidebar: #94a3b8;
--text-sidebar-active: #ffffff;
/* Borders & shadows */
--border-light: #e5e7eb;
--border-subtle: #f3f4f6;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.08);
--shadow-glow: 0 0 20px rgba(255,193,7,0.15);
/* Typography */
--font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Element Plus overrides */
.el-card {
border: 1px solid var(--border-light) !important;
border-radius: 12px !important;
box-shadow: var(--shadow-sm) !important;
transition: box-shadow 0.2s var(--ease-out) !important;
}
.el-card:hover {
box-shadow: var(--shadow-md) !important;
}
.el-button--primary {
background: var(--primary) !important;
border-color: var(--primary) !important;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s var(--ease-out);
}
.el-button--primary:hover {
background: var(--primary-hover) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
}
.el-table {
--el-table-border-color: var(--border-light);
--el-table-header-bg-color: #fafbfc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.el-progress-bar__outer {
border-radius: 6px;
}
.el-progress-bar__inner {
border-radius: 6px;
transition: width 0.4s var(--ease-out);
}
.el-tag {
border-radius: 6px;
font-weight: 500;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(255,193,7,0); }
50% { box-shadow: 0 0 16px 4px rgba(255,193,7,0.15); }
}
.animate-in {
animation: fadeInUp 0.4s var(--ease-out) both;
}
.animate-in-delay-1 { animation-delay: 0.05s; }
.animate-in-delay-2 { animation-delay: 0.1s; }
.animate-in-delay-3 { animation-delay: 0.15s; }
.animate-in-delay-4 { animation-delay: 0.2s; }
+274
View File
@@ -0,0 +1,274 @@
<template>
<div class="barcodes-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"><Connection /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ items.length }}</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-input
v-model="search"
placeholder="搜索条码..."
clearable
style="width: 200px"
@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>
<el-button size="small" type="primary" @click="openAdd" :icon="Plus">新增映射</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="600" size="small" class="barcode-table">
<el-table-column prop="barcode" label="原始条码" width="200">
<template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column label="映射" width="60" align="center">
<template #default>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</template>
</el-table-column>
<el-table-column prop="target" label="目标条码" width="200">
<template #default="{ row }">
<span class="barcode-cell target">{{ row.target }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Add/Edit dialog -->
<el-dialog v-model="showAdd" :title="isEdit ? '编辑映射' : '新增映射'" width="450px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px">
<el-form-item label="原始条码">
<el-input v-model="form.barcode" :disabled="isEdit" placeholder="输入原始条码" />
</el-form-item>
<el-form-item label="目标条码">
<el-input v-model="form.target" placeholder="输入目标条码" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" placeholder="映射说明(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdd = false">取消</el-button>
<el-button type="primary" @click="saveMapping">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Connection } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const items = ref<any[]>([])
const showAdd = ref(false)
const isEdit = ref(false)
const form = reactive({
barcode: '',
target: '',
description: '',
})
async function loadData() {
loading.value = true
try {
const res = await api.get('/barcodes', { params: { search: search.value } })
items.value = res.data.items
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function openAdd() {
resetForm()
showAdd.value = true
}
function editItem(row: any) {
isEdit.value = true
form.barcode = row.barcode
form.target = row.target
form.description = row.description || ''
showAdd.value = true
}
function resetForm() {
form.barcode = ''
form.target = ''
form.description = ''
isEdit.value = false
}
async function saveMapping() {
if (!form.barcode || !form.target) {
ElMessage.warning('请填写条码和目标')
return
}
try {
if (isEdit.value) {
await api.put(`/barcodes/${form.barcode}`, {
target: form.target,
description: form.description,
})
ElMessage.success('已更新')
} else {
await api.post('/barcodes', form)
ElMessage.success('已创建')
}
showAdd.value = false
resetForm()
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
async function deleteItem(row: any) {
try {
await ElMessageBox.confirm(`确定删除映射 ${row.barcode}${row.target}`, '确认')
await api.delete(`/barcodes/${row.barcode}`)
ElMessage.success('已删除')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.barcodes-page {
max-width: 1200px;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 16px;
margin-bottom: 20px;
max-width: 300px;
}
.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 ── */
.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;
}
/* ── Table ── */
.barcode-table {
border-radius: 10px;
overflow: hidden;
}
.barcode-cell {
font-family: var(--font-mono);
font-size: 13px;
color: var(--info);
}
.barcode-cell.target {
color: var(--success);
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<template>
<div class="config-page">
<!-- Header card -->
<div class="card animate-in">
<div class="card-head">
<h3>系统配置</h3>
<el-button type="primary" size="small" :loading="saving" @click="saveAll">
保存所有修改
</el-button>
</div>
<div class="config-layout">
<!-- Section sidebar -->
<div class="section-nav">
<button
v-for="(_, name) in config"
:key="name"
class="section-btn"
:class="{ active: activeTab === name }"
@click="activeTab = name"
>
<el-icon :size="16"><Setting /></el-icon>
<span>{{ sectionLabels[name] || name }}</span>
</button>
</div>
<!-- Config fields -->
<div class="config-fields">
<div v-if="!activeTab" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
<p>选择左侧配置分类</p>
</div>
<div v-else class="field-list">
<div
v-for="(value, key) in config[activeTab]"
:key="key"
class="field-row"
>
<label class="field-label">{{ key }}</label>
<el-input
:model-value="getEditedValue(activeTab, key, value)"
@update:model-value="setEditedValue(activeTab, key, $event)"
size="small"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Setting } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const saving = ref(false)
const activeTab = ref('')
const config = ref<Record<string, Record<string, string>>>({})
const edited: Record<string, Record<string, string>> = {}
const sectionLabels: Record<string, string> = {
API: 'API 配置',
Paths: '路径设置',
Performance: '性能参数',
File: '文件设置',
Templates: '模板配置',
Gitea: 'Gitea 同步',
WebAuth: 'Web 认证',
}
async function loadConfig() {
loading.value = true
try {
const res = await api.get('/config')
config.value = res.data
const keys = Object.keys(res.data)
if (keys.length > 0 && !activeTab.value) {
activeTab.value = keys[0]
}
} catch {
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
function getEditedValue(section: string, key: string, original: string): string {
return edited[section]?.[key] ?? original
}
function setEditedValue(section: string, key: string, value: string) {
if (!edited[section]) edited[section] = {}
edited[section][key] = value
}
async function saveAll() {
const updates: { section: string; key: string; value: string }[] = []
for (const [section, keys] of Object.entries(edited)) {
for (const [key, value] of Object.entries(keys)) {
updates.push({ section, key, value })
}
}
if (updates.length === 0) {
ElMessage.info('没有修改')
return
}
saving.value = true
try {
await api.put('/config/bulk', { updates })
ElMessage.success(`已保存 ${updates.length} 项配置`)
for (const key of Object.keys(edited)) {
delete edited[key]
}
loadConfig()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
onMounted(loadConfig)
</script>
<style scoped>
.config-page {
max-width: 1200px;
}
/* ── Card ── */
.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: 20px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Config layout ── */
.config-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
min-height: 400px;
}
/* ── Section nav ── */
.section-nav {
display: flex;
flex-direction: column;
gap: 4px;
border-right: 1px solid var(--border-subtle);
padding-right: 16px;
}
.section-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s var(--ease-out);
text-align: left;
}
.section-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.section-btn.active {
background: rgba(99,102,241,0.08);
color: var(--info);
}
/* ── Config fields ── */
.config-fields {
min-height: 300px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
}
.field-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-row {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
align-items: center;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
}
</style>
+719
View File
@@ -0,0 +1,719 @@
<template>
<div class="dashboard">
<!-- Top stats row -->
<div class="stats-row animate-in">
<div class="stat-card" v-for="stat in stats" :key="stat.label">
<div class="stat-icon" :style="{ background: stat.bg }">
<el-icon :size="20" :color="stat.color"><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</div>
</div>
<div class="main-grid">
<!-- Left column -->
<div class="col-left">
<!-- Upload zone -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>文件上传</h3>
<div class="card-actions">
<el-button size="small" @click="refreshFiles" :icon="Refresh">刷新</el-button>
<el-button size="small" type="danger" plain @click="clearInput" :icon="Delete">清空</el-button>
</div>
</div>
<div
class="drop-zone"
:class="{ dragover: isDragOver, 'has-files': inputFiles.length > 0 }"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@drop.prevent="handleDrop"
@click="triggerInput"
>
<div class="drop-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17,8 12,3 7,8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p class="drop-text">拖拽文件到此处或 <span class="drop-link">点击选择</span></p>
<p class="drop-hint">支持 JPG / PNG / BMP / XLS / XLSX</p>
<input ref="fileInput" type="file" multiple accept=".jpg,.jpeg,.png,.bmp,.xls,.xlsx" hidden @change="handleSelect" />
</div>
<!-- Upload progress -->
<div v-if="uploading" class="upload-bar">
<div class="upload-bar-fill" :style="{ width: uploadPct + '%' }"></div>
</div>
<!-- File list -->
<div v-if="inputFiles.length > 0" class="file-list">
<div v-for="f in inputFiles" :key="f.name" class="file-item">
<div class="file-icon">
<el-icon :size="16" :color="f.name.endsWith('.xls') || f.name.endsWith('.xlsx') ? '#10b981' : '#6366f1'">
<Document />
</el-icon>
</div>
<span class="file-name">{{ f.name }}</span>
<span class="file-size">{{ fmtSize(f.size) }}</span>
<el-button type="danger" link size="small" @click.stop="delFile(f)">
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card animate-in animate-in-delay-2">
<div class="card-head">
<h3>处理操作</h3>
</div>
<div class="action-grid">
<button class="action-btn primary" @click="runPipeline" :disabled="processing">
<div class="action-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
</div>
<div class="action-info">
<span class="action-name">一键全流程</span>
<span class="action-desc">OCR Excel 合并</span>
</div>
</button>
<button class="action-btn" @click="runOcr" :disabled="processing">
<div class="action-icon secondary">
<el-icon :size="20"><Document /></el-icon>
</div>
<div class="action-info">
<span class="action-name">批量 OCR</span>
<span class="action-desc">图片识别</span>
</div>
</button>
<button class="action-btn" @click="runExcel" :disabled="processing">
<div class="action-icon secondary">
<el-icon :size="20"><Grid /></el-icon>
</div>
<div class="action-info">
<span class="action-name">Excel 处理</span>
<span class="action-desc">标准化转换</span>
</div>
</button>
<button class="action-btn" @click="runMerge" :disabled="processing">
<div class="action-icon secondary">
<el-icon :size="20"><Files /></el-icon>
</div>
<div class="action-info">
<span class="action-name">合并采购单</span>
<span class="action-desc">汇总导出</span>
</div>
</button>
</div>
</div>
<!-- Result files -->
<div v-if="resultFiles.length > 0" class="card animate-in animate-in-delay-3">
<div class="card-head">
<h3>处理结果</h3>
<el-tag type="success" size="small">{{ resultFiles.length }} 个文件</el-tag>
</div>
<div class="file-list">
<div v-for="f in resultFiles" :key="f.name" class="file-item result">
<div class="file-icon success">
<el-icon :size="16"><Document /></el-icon>
</div>
<span class="file-name">{{ f.name }}</span>
<span class="file-size">{{ fmtSize(f.size) }}</span>
<el-button type="primary" link size="small" @click="downloadFile(f)">
<el-icon><Download /></el-icon> 下载
</el-button>
</div>
</div>
</div>
</div>
<!-- Right column: Progress + Logs -->
<div class="col-right">
<!-- Progress -->
<div class="card animate-in animate-in-delay-2">
<div class="card-head">
<h3>处理进度</h3>
<el-tag v-if="currentTask" :type="statusType" size="small" effect="dark">
{{ statusText }}
</el-tag>
</div>
<div v-if="currentTask" class="progress-area">
<div class="progress-ring">
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="#e5e7eb" stroke-width="6"/>
<circle
cx="50" cy="50" r="42" fill="none"
:stroke="statusColor"
stroke-width="6"
stroke-linecap="round"
:stroke-dasharray="264"
:stroke-dashoffset="264 - (264 * currentTask.progress / 100)"
transform="rotate(-90 50 50)"
style="transition: stroke-dashoffset 0.6s var(--ease-out)"
/>
</svg>
<div class="progress-pct">{{ currentTask.progress }}%</div>
</div>
<p class="progress-msg">{{ currentTask.message }}</p>
</div>
<div v-else class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
<p>等待任务启动</p>
</div>
</div>
<!-- Logs -->
<div class="card log-card animate-in animate-in-delay-3">
<div class="card-head">
<h3>处理日志</h3>
<el-button size="small" link @click="logs.length = 0">清空</el-button>
</div>
<div ref="logBox" class="log-box">
<div v-if="logs.length === 0" class="empty-state small">
<p>暂无日志</p>
</div>
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="logCls(line)">
<span class="log-time">{{ fmtTime(i) }}</span>
{{ line }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Delete, Document, Close, Download, Grid, Files } from '@element-plus/icons-vue'
import { useProcessingStore } from '../stores/processing'
import api from '../api'
const ps = useProcessingStore()
const isDragOver = ref(false)
const uploading = ref(false)
const uploadPct = ref(0)
const processing = ref(false)
const fileInput = ref<HTMLInputElement>()
const logBox = ref<HTMLElement>()
const inputFiles = ref<any[]>([])
const resultFiles = ref<any[]>([])
const fileStats = ref({ file_count: 0, total_size: 0 })
const currentTask = computed(() => ps.currentTask)
const logs = computed(() => ps.logs)
const statusType = computed(() => {
const m: Record<string, string> = { pending: 'info', running: 'warning', completed: 'success', failed: 'danger' }
return m[currentTask.value?.status || ''] || 'info'
})
const statusColor = computed(() => {
const m: Record<string, string> = { pending: '#6366f1', running: '#f59e0b', completed: '#10b981', failed: '#ef4444' }
return m[currentTask.value?.status || ''] || '#6366f1'
})
const statusText = computed(() => {
const m: Record<string, string> = { pending: '等待中', running: '运行中', completed: '已完成', failed: '已失败' }
return m[currentTask.value?.status || ''] || ''
})
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)' },
])
function fmtSize(b: number) {
if (b < 1024) return b + ' B'
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
return (b / 1048576).toFixed(1) + ' MB'
}
function fmtTime(i: number) {
const d = new Date()
d.setSeconds(d.getSeconds() - (logs.value.length - i))
return d.toTimeString().slice(0, 8)
}
function logCls(line: string) {
if (line.includes('失败') || line.includes('错误')) return 'err'
if (line.includes('完成')) return 'ok'
return ''
}
async function refreshFiles() {
try {
const [inp, res] = await Promise.all([
api.get('/files/list', { params: { directory: 'input' } }),
api.get('/files/list', { params: { directory: 'result' } }),
])
inputFiles.value = inp.data
resultFiles.value = res.data
} catch {}
loadFileStats()
}
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 {}
}
async function clearInput() {
try {
await ElMessageBox.confirm('确定清空所有待处理文件?', '确认')
await api.post('/files/clear/input')
ElMessage.success('已清空')
refreshFiles()
} catch {}
}
function triggerInput() { fileInput.value?.click() }
async function handleDrop(e: DragEvent) {
isDragOver.value = false
if (e.dataTransfer?.files) await upload(Array.from(e.dataTransfer.files))
}
async function handleSelect(e: Event) {
const el = e.target as HTMLInputElement
if (el.files) { await upload(Array.from(el.files)); el.value = '' }
}
async function upload(files: File[]) {
uploading.value = true
uploadPct.value = 0
for (let i = 0; i < files.length; i++) {
const fd = new FormData()
fd.append('file', files[i])
try {
await api.post('/files/upload', fd, {
onUploadProgress: (e) => { uploadPct.value = Math.round(((i + (e.loaded / (e.total || 1))) / files.length) * 100) },
})
} catch (err: any) {
ElMessage.error(`上传失败: ${files[i].name}`)
}
}
uploading.value = false
ElMessage.success(`上传完成,共 ${files.length} 个文件`)
refreshFiles()
}
async function delFile(f: any) {
try { await api.delete(`/files/${f.directory}/${f.name}`); refreshFiles() } catch {}
}
function downloadFile(f: any) {
const token = localStorage.getItem('token')
window.open(`/api/files/download/${f.directory || 'result'}/${encodeURIComponent(f.name)}?token=${token}`, '_blank')
}
async function doAction(endpoint: string) {
processing.value = true
try { await ps.startTask(endpoint) }
catch (err: any) { ElMessage.error(err.response?.data?.detail || '启动失败') }
finally { processing.value = false }
}
const runPipeline = () => doAction('/processing/pipeline')
const runOcr = () => doAction('/processing/ocr-batch')
const runExcel = () => doAction('/processing/excel')
const runMerge = () => doAction('/processing/merge')
watch(logs, async () => {
await nextTick()
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight
}, { deep: true })
onMounted(() => {
refreshFiles()
loadFileStats()
})
</script>
<style scoped>
.dashboard {
max-width: 1400px;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 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;
}
/* ── Main grid ── */
.main-grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: 20px;
}
/* ── Card ── */
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
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: 6px;
}
/* ── Drop zone ── */
.drop-zone {
border: 2px dashed var(--border-light);
border-radius: 12px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: all 0.25s var(--ease-out);
background: #fafbfc;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--amber-400);
background: var(--amber-50);
}
.drop-zone.dragover {
transform: scale(1.01);
box-shadow: 0 0 0 4px rgba(255,193,7,0.15);
}
.drop-icon {
color: #9ca3af;
margin-bottom: 12px;
transition: color 0.2s;
}
.drop-zone:hover .drop-icon { color: var(--amber-500); }
.drop-text {
font-size: 14px;
color: var(--text-secondary);
}
.drop-link {
color: var(--primary);
font-weight: 500;
}
.drop-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
/* ── Upload bar ── */
.upload-bar {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
margin-top: 12px;
overflow: hidden;
}
.upload-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--amber-400), var(--amber-600));
border-radius: 2px;
transition: width 0.3s var(--ease-out);
}
/* ── File list ── */
.file-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.file-item:hover {
background: var(--bg-hover);
}
.file-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(99,102,241,0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.file-icon.success {
background: rgba(16,185,129,0.08);
}
.file-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* ── Action buttons ── */
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.action-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--border-light);
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: all 0.2s var(--ease-out);
text-align: left;
}
.action-btn:hover:not(:disabled) {
border-color: var(--amber-400);
box-shadow: 0 0 0 3px rgba(255,193,7,0.08);
transform: translateY(-1px);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.primary {
grid-column: 1 / -1;
background: linear-gradient(135deg, #1e293b, #0f172a);
border-color: transparent;
color: #fff;
}
.action-btn.primary:hover:not(:disabled) {
box-shadow: 0 8px 24px rgba(15,23,42,0.3);
}
.action-btn.primary .action-icon {
background: rgba(255,193,7,0.15);
color: var(--amber-400);
}
.action-btn.primary .action-name { color: #fff; }
.action-btn.primary .action-desc { color: rgba(255,255,255,0.5); }
.action-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-icon.secondary {
background: rgba(99,102,241,0.08);
color: var(--info);
}
.action-name {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.action-desc {
font-size: 12px;
color: var(--text-muted);
}
/* ── Progress ── */
.progress-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
}
.progress-ring {
position: relative;
width: 120px;
height: 120px;
}
.progress-ring svg {
width: 100%;
height: 100%;
}
.progress-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
}
.progress-msg {
margin-top: 12px;
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
/* ── Empty state ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px 0;
color: var(--text-muted);
font-size: 14px;
}
.empty-state.small {
padding: 20px 0;
}
/* ── Logs ── */
.log-card {
max-height: 500px;
display: flex;
flex-direction: column;
}
.log-box {
flex: 1;
max-height: 400px;
overflow-y: auto;
background: #0f1117;
border-radius: 10px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
}
.log-line {
color: #94a3b8;
padding: 1px 0;
}
.log-line.err { color: #f87171; }
.log-line.ok { color: #34d399; }
.log-time {
color: #475569;
margin-right: 8px;
font-size: 11px;
}
</style>
+379
View File
@@ -0,0 +1,379 @@
<template>
<el-container class="layout">
<el-aside :width="isCollapse ? '72px' : '240px'" class="sidebar">
<!-- Logo -->
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
<div class="logo-mark">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 14l2 2 4-4"/>
</svg>
</div>
<transition name="fade">
<span v-if="!isCollapse" class="logo-text">益选 OCR</span>
</transition>
</div>
<!-- Navigation -->
<nav class="sidebar-nav">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path === item.path }"
>
<el-icon :size="20"><component :is="item.icon" /></el-icon>
<transition name="fade">
<span v-if="!isCollapse" class="nav-label">{{ item.label }}</span>
</transition>
<transition name="fade">
<span v-if="!isCollapse && item.badge" class="nav-badge">{{ item.badge }}</span>
</transition>
</router-link>
</nav>
<!-- Collapse toggle -->
<div class="sidebar-footer">
<button class="collapse-btn" @click="isCollapse = !isCollapse">
<el-icon :size="18">
<DArrowLeft v-if="!isCollapse" />
<DArrowRight v-else />
</el-icon>
</button>
</div>
</el-aside>
<el-container class="main-container">
<!-- Header -->
<header class="topbar">
<div class="topbar-left">
<h2 class="page-title">{{ pageTitle }}</h2>
</div>
<div class="topbar-right">
<el-dropdown @command="handleCommand" trigger="click">
<div class="user-chip">
<div class="user-avatar">{{ (authStore.username || 'U')[0].toUpperCase() }}</div>
<span class="user-name">{{ authStore.username || '用户' }}</span>
<el-icon :size="14"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">
<el-icon><Lock /></el-icon>修改密码
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- Content -->
<main class="content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</el-container>
</el-container>
<!-- Change password dialog -->
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
<el-form :model="pwdForm" label-width="70px">
<el-form-item label="旧密码">
<el-input v-model="pwdForm.old_password" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.new_password" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPwd = false">取消</el-button>
<el-button type="primary" @click="changePassword">确认修改</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook,
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
} from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
import api from '../api'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isCollapse = ref(false)
const showPwd = ref(false)
const pwdForm = reactive({ old_password: '', new_password: '' })
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 },
]
const pageTitle = computed(() => {
const item = navItems.find(n => n.path === route.path)
return item?.label || '处理中心'
})
function handleCommand(cmd: string) {
if (cmd === 'logout') {
authStore.logout()
router.push('/login')
} else if (cmd === 'password') {
pwdForm.old_password = ''
pwdForm.new_password = ''
showPwd.value = true
}
}
async function changePassword() {
if (!pwdForm.new_password) { ElMessage.warning('请输入新密码'); return }
try {
await api.post('/auth/change-password', pwdForm)
ElMessage.success('密码修改成功')
showPwd.value = false
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '修改失败')
}
}
</script>
<style scoped>
.layout {
height: 100vh;
}
/* ── Sidebar ── */
.sidebar {
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
transition: width 0.3s var(--ease-out);
overflow: hidden;
border-right: 1px solid rgba(255,255,255,0.06);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 20px 24px;
cursor: pointer;
user-select: none;
}
.logo-mark {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, rgba(255,193,7,0.15), rgba(255,193,7,0.05));
border: 1px solid rgba(255,193,7,0.2);
color: var(--amber-400);
flex-shrink: 0;
}
.logo-text {
font-size: 17px;
font-weight: 700;
color: #fff;
white-space: nowrap;
letter-spacing: -0.3px;
}
/* ── Nav items ── */
.sidebar-nav {
flex: 1;
padding: 0 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
color: var(--text-sidebar);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s var(--ease-out);
position: relative;
}
.nav-item:hover {
background: rgba(255,255,255,0.06);
color: var(--text-sidebar-active);
}
.nav-item.active {
background: rgba(255,193,7,0.1);
color: var(--amber-400);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 0 3px 3px 0;
background: var(--amber-400);
}
.nav-label {
white-space: nowrap;
}
.nav-badge {
margin-left: auto;
font-size: 11px;
background: var(--amber-400);
color: #000;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
/* ── Footer ── */
.sidebar-footer {
padding: 12px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px;
border: none;
background: rgba(255,255,255,0.04);
border-radius: 8px;
color: var(--text-sidebar);
cursor: pointer;
transition: all 0.2s;
}
.collapse-btn:hover {
background: rgba(255,255,255,0.08);
color: #fff;
}
/* ── Main container ── */
.main-container {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
/* ── Topbar ── */
.topbar {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: #fff;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
z-index: 10;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.user-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 6px;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.user-chip:hover {
background: var(--bg-hover);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary), #7c3aed);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
/* ── Content ── */
.content {
flex: 1;
padding: 24px 28px;
overflow-y: auto;
background: var(--bg-page);
}
/* ── Transitions ── */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.page-enter-active {
transition: opacity 0.25s var(--ease-out), transform 0.25s var(--ease-out);
}
.page-leave-active {
transition: opacity 0.15s;
}
.page-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-leave-to {
opacity: 0;
}
</style>
+241
View File
@@ -0,0 +1,241 @@
<template>
<div class="login-page">
<!-- Ambient background -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<div class="login-container animate-in">
<!-- Brand header -->
<div class="brand">
<div class="brand-icon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 14l2 2 4-4"/>
</svg>
</div>
<h1>益选 OCR</h1>
<p>采购单智能处理系统</p>
</div>
<!-- Login form -->
<el-form
ref="formRef"
:model="form"
:rules="rules"
@submit.prevent="handleLogin"
class="login-form"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
size="large"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
size="large"
show-password
:prefix-icon="Lock"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登 录' }}
</el-button>
</el-form>
<div class="hint">
<span class="hint-dot"></span>
默认账号 admin / admin123
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
try { await formRef.value?.validate() } catch { return }
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 48px 48px;
}
.bg-glow {
position: absolute;
top: 30%;
left: 50%;
width: 600px;
height: 600px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255,193,7,0.08) 0%, transparent 70%);
pointer-events: none;
}
.login-container {
position: relative;
width: 400px;
padding: 48px 40px;
background: rgba(22, 25, 34, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px;
box-shadow: 0 24px 48px rgba(0,0,0,0.4);
}
.brand {
text-align: center;
margin-bottom: 36px;
}
.brand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(255,193,7,0.15), rgba(255,193,7,0.05));
border: 1px solid rgba(255,193,7,0.2);
color: var(--amber-400);
margin-bottom: 16px;
}
.brand h1 {
font-size: 24px;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.5px;
}
.brand p {
font-size: 14px;
color: var(--text-sidebar);
margin-top: 4px;
}
.login-form {
margin-bottom: 24px;
}
.login-form :deep(.el-input__wrapper) {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
box-shadow: none;
transition: all 0.2s;
}
.login-form :deep(.el-input__wrapper:hover),
.login-form :deep(.el-input__wrapper.is-focus) {
border-color: var(--amber-400);
background: rgba(255,255,255,0.08);
box-shadow: 0 0 0 3px rgba(255,193,7,0.1);
}
.login-form :deep(.el-input__inner) {
color: #ffffff;
font-size: 15px;
}
.login-form :deep(.el-input__inner::placeholder) {
color: rgba(255,255,255,0.35);
}
.login-form :deep(.el-input__prefix .el-icon) {
color: rgba(255,255,255,0.4);
}
.login-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 10px !important;
background: linear-gradient(135deg, var(--amber-500), var(--amber-600)) !important;
border: none !important;
color: #000 !important;
transition: all 0.25s var(--ease-out);
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(255,193,7,0.3) !important;
}
.hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: rgba(255,255,255,0.3);
}
.hint-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--amber-400);
opacity: 0.6;
}
</style>
+302
View File
@@ -0,0 +1,302 @@
<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">
<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'
}
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>
+380
View File
@@ -0,0 +1,380 @@
<template>
<div class="memory-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"><Memo /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ 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">{{ highConfidence }}</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">{{ lowConfidence }}</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-input
v-model="search"
placeholder="搜索条码或名称..."
clearable
style="width: 240px"
@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>
<el-button size="small" type="warning" plain @click="reimport">重新导入</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
stripe
max-height="600"
size="small"
class="memory-table"
>
<el-table-column prop="barcode" label="条码" width="150" fixed>
<template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="120" />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="price" label="单价" width="100">
<template #default="{ row }">
<span class="price-cell">{{ row.price != null ? row.price.toFixed(4) : '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="100">
<template #default="{ row }">
<span class="confidence-badge" :class="confCls(row.confidence)">
{{ row.confidence }}
</span>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="80" />
<el-table-column prop="use_count" label="使用次数" width="90" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(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>
<!-- Edit dialog -->
<el-dialog v-model="showEdit" title="编辑记忆记录" width="480px" :close-on-click-modal="false">
<el-form :model="editForm" label-width="80px">
<el-form-item label="条码">
<el-input :model-value="editForm.barcode" disabled />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="editForm.spec" />
</el-form-item>
<el-form-item label="单位">
<el-input v-model="editForm.unit" />
</el-form-item>
<el-form-item label="单价">
<el-input-number v-model="editForm.price" :precision="4" :step="0.01" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="置信度">
<el-slider v-model="editForm.confidence" :max="100" show-input />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const items = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const highConfidence = computed(() => items.value.filter(i => i.confidence >= 80).length)
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 50).length)
const showEdit = ref(false)
const editForm = reactive({
barcode: '',
name: '',
spec: '',
unit: '',
price: 0,
confidence: 0,
})
function confCls(c: number) {
if (c >= 80) return 'high'
if (c >= 50) return 'mid'
return 'low'
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/memory', {
params: { search: search.value, page: page.value, page_size: pageSize.value },
})
items.value = res.data.items
total.value = res.data.total
} catch (err: any) {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function editItem(row: any) {
editForm.barcode = row.barcode
editForm.name = row.name || ''
editForm.spec = row.spec || ''
editForm.unit = row.unit || ''
editForm.price = row.price || 0
editForm.confidence = row.confidence || 0
showEdit.value = true
}
async function saveEdit() {
try {
await api.put(`/memory/${editForm.barcode}`, {
name: editForm.name,
spec: editForm.spec,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
ElMessage.success('保存成功')
showEdit.value = false
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '保存失败')
}
}
async function deleteItem(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.barcode} 的记忆记录?`, '确认')
await api.delete(`/memory/${row.barcode}`)
ElMessage.success('已删除')
loadData()
} catch {}
}
async function reimport() {
try {
await ElMessageBox.confirm('将从模板文件重新导入商品数据,确定继续?', '确认')
loading.value = true
const res = await api.post('/memory/reimport')
ElMessage.success(res.data.message)
loadData()
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.response?.data?.detail || '导入失败')
}
} finally {
loading.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.memory-page {
max-width: 1400px;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 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 ── */
.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;
}
/* ── Table ── */
.memory-table {
border-radius: 10px;
overflow: hidden;
}
.barcode-cell {
font-family: var(--font-mono);
font-size: 13px;
color: var(--info);
}
.price-cell {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.confidence-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-mono);
}
.confidence-badge.high {
background: rgba(16,185,129,0.1);
color: #10b981;
}
.confidence-badge.mid {
background: rgba(245,158,11,0.1);
color: #f59e0b;
}
.confidence-badge.low {
background: rgba(239,68,68,0.1);
color: #ef4444;
}
/* ── Pagination ── */
.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>
+371
View File
@@ -0,0 +1,371 @@
<template>
<div class="sync-page">
<!-- Status card -->
<div class="card animate-in">
<div class="card-head">
<h3>云端同步</h3>
<el-button size="small" @click="checkStatus" :icon="Refresh">刷新状态</el-button>
</div>
<!-- Enabled state -->
<div v-if="syncStatus.enabled" class="sync-enabled">
<!-- Connection info -->
<div class="connection-card">
<div class="connection-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="1.5">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="connection-info">
<span class="connection-status">已连接</span>
<span class="connection-url">{{ syncStatus.repo_url }}</span>
</div>
</div>
<!-- Action buttons -->
<div class="sync-actions">
<button class="sync-btn push" @click="doPush" :disabled="syncing">
<div class="sync-btn-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17,8 12,3 7,8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<div class="sync-btn-info">
<span class="sync-btn-name">推送到云端</span>
<span class="sync-btn-desc">上传本地数据到 Gitea</span>
</div>
</button>
<button class="sync-btn pull" @click="doPull" :disabled="syncing">
<div class="sync-btn-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<div class="sync-btn-info">
<span class="sync-btn-name">从云端拉取</span>
<span class="sync-btn-desc">下载远程数据到本地</span>
</div>
</button>
</div>
<!-- Sync progress -->
<div v-if="currentTask" class="progress-section animate-in">
<div class="progress-header">
<span class="progress-title">同步进度</span>
<el-tag :type="statusType" size="small" effect="dark">{{ statusText }}</el-tag>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" :style="{ width: currentTask.progress + '%' }"></div>
</div>
<p class="progress-msg">{{ currentTask.message }}</p>
<div v-if="logs.length > 0" class="sync-logs">
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="logCls(line)">{{ line }}</div>
</div>
</div>
</div>
<!-- Disabled state -->
<div v-else class="sync-disabled">
<div class="disabled-icon">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h4>云端同步未启用</h4>
<p>请在系统配置页面设置 Gitea 相关参数以启用同步功能</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { useProcessingStore } from '../stores/processing'
import api from '../api'
const processingStore = useProcessingStore()
const syncing = ref(false)
const syncStatus = ref({ enabled: false, repo_url: '' })
const currentTask = computed(() => processingStore.currentTask)
const logs = computed(() => processingStore.logs)
const statusType = computed(() => {
const m: Record<string, string> = { pending: 'info', running: 'warning', completed: 'success', failed: 'danger' }
return m[currentTask.value?.status || ''] || 'info'
})
const statusText = computed(() => {
const m: Record<string, string> = { pending: '等待中', running: '同步中', completed: '已完成', failed: '已失败' }
return m[currentTask.value?.status || ''] || ''
})
function logCls(line: string) {
if (line.includes('失败') || line.includes('错误')) return 'err'
if (line.includes('完成')) return 'ok'
return ''
}
async function checkStatus() {
try {
const res = await api.get('/sync/status')
syncStatus.value = res.data
} catch {}
}
async function doPush() {
syncing.value = true
try {
await processingStore.startTask('/sync/push')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '推送失败')
} finally {
syncing.value = false
}
}
async function doPull() {
syncing.value = true
try {
await processingStore.startTask('/sync/pull')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '拉取失败')
} finally {
syncing.value = false
}
}
onMounted(checkStatus)
</script>
<style scoped>
.sync-page {
max-width: 800px;
}
/* ── Card ── */
.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: 20px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Connection info ── */
.connection-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
background: rgba(16,185,129,0.05);
border: 1px solid rgba(16,185,129,0.15);
border-radius: 12px;
margin-bottom: 24px;
}
.connection-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(16,185,129,0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.connection-status {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--success);
}
.connection-url {
display: block;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-top: 2px;
}
/* ── Sync actions ── */
.sync-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.sync-btn {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border: 1px solid var(--border-light);
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: all 0.2s var(--ease-out);
text-align: left;
}
.sync-btn:hover:not(:disabled) {
border-color: var(--amber-400);
box-shadow: 0 0 0 3px rgba(255,193,7,0.08);
transform: translateY(-1px);
}
.sync-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sync-btn-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sync-btn.push .sync-btn-icon {
background: rgba(99,102,241,0.08);
color: var(--info);
}
.sync-btn.pull .sync-btn-icon {
background: rgba(16,185,129,0.08);
color: var(--success);
}
.sync-btn-name {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.sync-btn-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Progress ── */
.progress-section {
padding: 20px;
background: #fafbfc;
border-radius: 12px;
border: 1px solid var(--border-subtle);
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.progress-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.progress-bar-wrap {
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--amber-400), var(--amber-600));
border-radius: 3px;
transition: width 0.4s var(--ease-out);
}
.progress-msg {
margin-top: 8px;
font-size: 13px;
color: var(--text-secondary);
}
/* ── Logs ── */
.sync-logs {
margin-top: 16px;
background: #0f1117;
border-radius: 10px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
max-height: 200px;
overflow-y: auto;
}
.log-line {
color: #94a3b8;
padding: 1px 0;
}
.log-line.err { color: #f87171; }
.log-line.ok { color: #34d399; }
/* ── Disabled state ── */
.sync-disabled {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.disabled-icon {
opacity: 0.5;
margin-bottom: 4px;
}
.sync-disabled h4 {
font-size: 16px;
font-weight: 600;
color: var(--text-secondary);
}
.sync-disabled p {
font-size: 13px;
color: var(--text-muted);
}
</style>
+432
View File
@@ -0,0 +1,432 @@
<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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
build: {
outDir: '../backend/static',
emptyOutDir: true,
},
})
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.1.0
python-multipart>=0.0.6
aiofiles>=23.2.0
websockets>=12.0
pydantic>=2.5.0
werkzeug>=3.0.0