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:
+11
-7
@@ -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
|
||||||
|
|||||||
@@ -41,3 +41,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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="无效的认证凭据")
|
||||||
@@ -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])
|
||||||
@@ -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": "密码修改成功"}
|
||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
@@ -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}"}
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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": ""}
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
Generated
+1929
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'element-plus/dist/locale/zh-cn.mjs' {
|
||||||
|
const locale: any
|
||||||
|
export default locale
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user