Compare commits

...

11 Commits

Author SHA1 Message Date
houhuan c18039f790 feat: 重构仪表盘布局并优化交互 2026-05-05 16:05:59 +08:00
houhuan 0721ed099c @
feat: shadcn主题 + 文件关系追踪 + 处理流程修复

前端:
- 全站应用 shadcn/ui 主题 (zinc灰调, Inter字体, 1px细边框, 无硬阴影)
- 重写 global.css / Dashboard.vue / Login.vue / Layout.vue 样式
- 新增文件处理子页面: 采购单(Orders), 表格处理(Tables), 图片处理(Images)
- 侧边栏使用 el-sub-menu 组织文件处理导航

后端:
- 新增 file_relations 表追踪 input→output→result 链路
- 新增 /files/relations, /files/stats/detailed 等关系查询API
- 新增 ocr-single, excel-single, pipeline-single, merge-batch 端点
- 处理流程增加跳过逻辑 (已处理文件自动跳过)
- 全流程不再自动合并, 合并仅在采购单页面手动触发

Bug修复:
- TaskManager: asyncio.create_task 在线程池中无事件循环 → 改用 _schedule() 调度
- PurchaseOrderMerger 缺少 config 参数 → 传入 ConfigManager()
- FastAPI regex= 弃用 → 改为 pattern=
- merger.process() 接收 Path 对象 → 转为字符串
@
2026-05-05 14:16:30 +08:00
houhuan dedc3b4183 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>
2026-05-05 11:59:07 +08:00
houhuan 79522d8356 feat: add task history query and retry API endpoints 2026-05-05 11:33:40 +08:00
houhuan c49105a678 feat: add HTTP log query API endpoints
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 11:33:23 +08:00
houhuan 5de8694eec feat: persist task lifecycle to SQLite via TaskManager 2026-05-05 11:32:07 +08:00
houhuan 205e18563d feat: record file operations to metadata table + add history/stats endpoints
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 11:32:04 +08:00
houhuan 56561068ea feat: add HTTP request logging middleware 2026-05-05 11:31:47 +08:00
houhuan 280b94ae1d feat: add db_schema for http_logs, task_history, file_metadata tables 2026-05-05 11:29:41 +08:00
houhuan 71c0ba9c96 docs: add implementation plan for logging, task history, and file management
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 11:25:02 +08:00
houhuan c1826918aa docs: add design spec for logging, task history, and file management
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 11:19:14 +08:00
57 changed files with 12335 additions and 8 deletions
+11 -7
View File
@@ -18,13 +18,8 @@ release/
logs/
data/temp/
# Runtime outputs
data/output/
data/result/
data/input/
data/product_cache.db
data/user_settings.json
*.db
# Runtime data (all runtime outputs, caches, databases)
data/
# Claude Code / IDE
.claude/
@@ -34,6 +29,15 @@ data/user_settings.json
# Old project
wework_xiaoai_bot/
# Node.js
node_modules/
# Frontend build output
web/backend/static/
# Screenshots (from testing)
*.png
# OS/IDE
.DS_Store
Thumbs.db
+6 -1
View File
@@ -40,4 +40,9 @@ version = 2026.05.05.0239
base_url = https://gitea.94kan.cn
owner = houhuan
repo = yixuan-sync-data
token = 50b61e43a141d606ae2529cd1755bc666d800e08
token = 50b61e43a141d606ae2529cd1755bc666d800e08
[WebAuth]
username = admin
password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
# 日志系统 + 任务历史 + 文件管理 设计文档
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:writing-plans to create an implementation plan from this spec.
**Goal:** 为益选 OCR Web 系统添加持久化日志、任务历史和增强文件管理,提升生产环境可观测性和用户体验。
**Architecture:** 单一 SQLite 数据库 (`data/web_data.db`) 存储三类数据,FastAPI 中间件自动采集 HTTP 日志,TaskManager 改造为写入 DB,前端新增两个独立页面。
**Tech Stack:** FastAPI middleware, SQLite (via existing DBPool), Vue 3 + Element Plus, Pinia
---
## 1. 数据库设计
数据库文件: `data/web_data.db`,通过现有 `DBPool` 管理。
### 1.1 `http_logs` 表
```sql
CREATE TABLE IF NOT EXISTS http_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL, -- ISO 8601
method TEXT NOT NULL, -- GET/POST/PUT/DELETE
path TEXT NOT NULL, -- /api/memory
status_code INTEGER, -- 200, 404, 500
duration_ms REAL, -- 请求耗时(ms)
user TEXT, -- 当前用户名
ip TEXT, -- 客户端 IP
detail TEXT -- 错误详情/备注
);
CREATE INDEX IF NOT EXISTS idx_http_logs_timestamp ON http_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_http_logs_status ON http_logs(status_code);
```
### 1.2 `task_history` 表
```sql
CREATE TABLE IF NOT EXISTS task_history (
id TEXT PRIMARY KEY, -- 8-char UUID
name TEXT NOT NULL, -- pipeline/ocr-batch/excel/merge/sync-push/sync-pull
status TEXT NOT NULL, -- pending/running/completed/failed
progress INTEGER DEFAULT 0,
message TEXT,
result_files TEXT, -- JSON array of filenames
error TEXT,
log_lines TEXT, -- JSON array of log strings
created_at TEXT NOT NULL, -- ISO 8601
updated_at TEXT NOT NULL, -- ISO 8601
completed_at TEXT -- ISO 8601, null if not done
);
CREATE INDEX IF NOT EXISTS idx_task_history_status ON task_history(status);
CREATE INDEX IF NOT EXISTS idx_task_history_created ON task_history(created_at);
```
### 1.3 `file_metadata` 表
```sql
CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
directory TEXT NOT NULL, -- input/output/result
size INTEGER,
action TEXT NOT NULL, -- upload/delete/clear
user TEXT,
timestamp TEXT NOT NULL, -- ISO 8601
task_id TEXT -- 关联的任务 ID (可选)
);
CREATE INDEX IF NOT EXISTS idx_file_metadata_timestamp ON file_metadata(timestamp);
```
### 1.4 自动清理
30 天过期清理,在服务器启动时执行,之后每天通过 `asyncio` 定时任务执行一次:
```python
async def cleanup_old_records():
cutoff = (datetime.now() - timedelta(days=30)).isoformat()
await db_pool.execute_write("DELETE FROM http_logs WHERE timestamp < ?", cutoff)
await db_pool.execute_write("DELETE FROM task_history WHERE created_at < ?", cutoff)
await db_pool.execute_write("DELETE FROM file_metadata WHERE timestamp < ?", cutoff)
```
---
## 2. 后端架构
### 2.1 新增文件
| 文件 | 职责 |
|------|------|
| `web/backend/services/db_schema.py` | 建表 SQL + `init_db()` + `cleanup_old_records()` |
| `web/backend/middleware/logging.py` | HTTP 请求日志中间件 |
| `web/backend/routers/logs.py` | 日志查询 API |
| `web/backend/routers/tasks.py` | 任务历史 API |
### 2.2 修改文件
| 文件 | 改动 |
|------|------|
| `web/backend/main.py` | lifespan 中调用 `init_db()`,挂载 logging 中间件,注册 logs/tasks 路由 |
| `web/backend/services/task_manager.py` | `update_progress()``_finish()` 写入 task_history 表 |
| `web/backend/routers/files.py` | upload/delete/clear 操作写入 file_metadata 表 |
### 2.3 API 端点
**日志 (`/api/logs`)**
- `GET /api/logs` — 分页查询
- 参数: `page`, `page_size`, `method`, `status_code`, `path`(搜索), `start_date`, `end_date`
- 返回: `{ items: [...], total: number }`
- `GET /api/logs/stats` — 统计概览
- 返回: `{ today_count, error_count, avg_duration_ms, error_rate }`
**任务历史 (`/api/tasks`)**
- `GET /api/tasks` — 分页查询
- 参数: `page`, `page_size`, `status`, `name`(类型筛选), `search`
- 返回: `{ items: [...], total: number }`
- `GET /api/tasks/{task_id}` — 任务详情(含完整 log_lines)
- `POST /api/tasks/{task_id}/retry` — 重试失败任务
- 根据 `name` 字段重新调用对应处理端点
**文件历史 (`/api/files`)**
- `GET /api/files/history` — 文件操作记录
- 参数: `page`, `page_size`, `directory`, `action`
- 返回: `{ items: [...], total: number }`
- `GET /api/files/stats` — 存储统计
- 返回: `{ directories: [{ name, file_count, total_size }] }`
### 2.4 中间件设计
```python
async def logging_middleware(request: Request, call_next):
# 跳过静态资源和 WebSocket
if request.url.path.startswith("/assets") or request.url.path.startswith("/ws"):
return await call_next(request)
start = time.time()
response = await call_next(request)
duration_ms = (time.time() - start) * 1000
# 异步写入日志(不阻塞响应)
asyncio.create_task(write_log(
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
user=get_current_user_from_request(request),
ip=request.client.host,
))
return response
```
### 2.5 TaskManager 改造
现有 `TaskManager.update_progress()``_finish()` 方法中增加 DB 写入:
```python
async def update_progress(self, task_id: str, progress: int, message: str):
task = self._tasks[task_id]
task.progress = progress
task.message = message
task.log_lines.append(message)
# 新增:写入 DB
await self._db.execute_write(
"UPDATE task_history SET progress=?, message=?, log_lines=?, updated_at=? WHERE id=?",
progress, message, json.dumps(task.log_lines), datetime.now().isoformat(), task_id
)
await self._broadcast(task)
```
---
## 3. 前端设计
### 3.1 新增页面
**侧边栏导航新增 2 项:**
| 页面 | 路由 | 图标 | 标签 |
|------|------|------|------|
| 任务历史 | `/tasks` | `Timer` | - |
| 日志中心 | `/logs` | `Notebook` | - |
### 3.2 任务历史页面 (`Tasks.vue`)
**布局:**
- 顶部统计卡片行(4 卡片):总任务数 / 成功 / 失败 / 运行中
- 筛选栏:状态下拉(全部/成功/失败/运行中)+ 类型下拉(全部/pipeline/ocr/excel/merge+ 搜索框
- 表格列:任务ID、类型、状态(彩色标签)、进度条、耗时、创建时间、操作
- 操作:查看详情(弹窗显示完整日志流)、重试(仅失败任务)
**详情弹窗:**
- 任务基本信息(类型/状态/耗时/结果文件)
- 终端风格日志流(复用 Dashboard 的 log-box 样式)
- 结果文件列表(可下载)
### 3.3 日志中心页面 (`Logs.vue`)
**布局:**
- 顶部统计卡片行(4 卡片):今日请求 / 错误数 / 平均耗时 / 错误率
- 筛选栏:时间范围选择器(今天/7天/30天)+ 方法筛选(GET/POST/PUT/DELETE+ 状态码筛选(2xx/4xx/5xx+ 路径搜索
- 表格列:时间、方法(彩色标签)、路径、状态码(颜色区分)、耗时、用户
- 点击行展开详情面板(IP 地址、错误信息)
### 3.4 Dashboard 增强
- stats-row 第三列从硬编码 "记忆库 5591" 改为动态存储统计(磁盘用量)
- 文件列表区新增「操作历史」按钮,弹窗显示该目录的 file_metadata 记录
### 3.5 新增文件
| 文件 | 职责 |
|------|------|
| `web/frontend/src/views/Tasks.vue` | 任务历史页面 |
| `web/frontend/src/views/Logs.vue` | 日志中心页面 |
| `web/frontend/src/stores/tasks.ts` | 任务历史状态管理(可选,可用 api 直接调用) |
### 3.6 修改文件
| 文件 | 改动 |
|------|------|
| `web/frontend/src/views/Layout.vue` | navItems 新增 2 项 |
| `web/frontend/src/router/index.ts` | 新增 2 个路由 |
| `web/frontend/src/views/Dashboard.vue` | stats-row 动态化 + 文件历史弹窗 |
---
## 4. 安全与性能
- 日志查询 API 仅限认证用户
- HTTP 日志不记录请求体(避免泄露敏感数据)
- 中间件使用 `asyncio.create_task()` 异步写入,不阻塞响应
- 日志表索引:`timestamp``status_code``path`
- 任务表索引:`status``created_at`
- 自动清理 30 天前的记录,防止数据库无限增长
- 分页查询默认 page_size=50,最大 200
---
## 5. 实施顺序
1. **Phase 1: 数据库 + 后端**
- db_schema.py(建表 + 清理)
- logging 中间件
- task_manager 改造
- files.py 改造
- logs.py + tasks.py 路由
2. **Phase 2: 前端页面**
- Tasks.vue
- Logs.vue
- Layout.vue 路由注册
- Dashboard.vue 增强
3. **Phase 3: 集成测试**
- npm run build
- 端到端验证:操作 → 日志记录 → 任务历史 → 文件历史
View File
View File
+58
View File
@@ -0,0 +1,58 @@
"""FastAPI auth dependencies"""
from fastapi import Depends, HTTPException, status, Query, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .jwt_handler import decode_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
try:
payload = decode_token(credentials.credentials)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": username}
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
async def get_current_user_ws(token: str = Query(...)) -> dict:
"""WebSocket auth via query parameter"""
try:
payload = decode_token(token)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": username}
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
async def get_current_user_flexible(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
token: str = Query(None),
) -> dict:
"""Auth from header OR query param (for file downloads in browser)."""
token_str = None
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
if not token_str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供认证凭据")
try:
payload = decode_token(token_str)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": username}
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
+19
View File
@@ -0,0 +1,19 @@
"""JWT token creation and validation"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from ..config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=JWT_EXPIRE_HOURS))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, get_or_generate_secret(), algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, get_or_generate_secret(), algorithms=[JWT_ALGORITHM])
+89
View File
@@ -0,0 +1,89 @@
"""Auth API endpoints"""
import os
import bcrypt
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from .jwt_handler import create_access_token
from .dependencies import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Default credentials (should be changed on first login)
DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "admin123"
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def _get_credentials() -> tuple[str, bytes]:
"""Get username and password hash from config or defaults"""
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
username = cfg.get('WebAuth', 'username', fallback=DEFAULT_USERNAME)
pw_hash = cfg.get('WebAuth', 'password_hash', fallback='')
if not pw_hash:
# First run: store default password hash
pw_hash = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode()
try:
cfg.update('WebAuth', 'username', DEFAULT_USERNAME)
cfg.update('WebAuth', 'password_hash', pw_hash)
cfg.save_config()
except Exception:
pass
return username, pw_hash.encode()
except Exception:
return DEFAULT_USERNAME, bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt())
@router.post("/login", response_model=LoginResponse)
async def login(req: LoginRequest):
stored_username, stored_hash = _get_credentials()
if req.username != stored_username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not bcrypt.checkpw(req.password.encode(), stored_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
token = create_access_token({"sub": req.username})
return LoginResponse(access_token=token)
@router.get("/me")
async def me(current_user: dict = Depends(get_current_user)):
return current_user
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/change-password")
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
_, stored_hash = _get_credentials()
if not bcrypt.checkpw(req.old_password.encode(), stored_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
new_hash = bcrypt.hashpw(req.new_password.encode(), bcrypt.gensalt()).decode()
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
cfg.update('WebAuth', 'password_hash', new_hash)
cfg.save_config()
except Exception as e:
raise HTTPException(status_code=500, detail=f"保存密码失败: {e}")
return {"message": "密码修改成功"}
+40
View File
@@ -0,0 +1,40 @@
"""Web-specific configuration"""
import os
import secrets
# JWT
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
# File upload
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp'}
ALLOWED_EXCEL_EXTENSIONS = {'.xlsx', '.xls'}
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | ALLOWED_EXCEL_EXTENSIONS
# CORS
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
# Auth rate limit
LOGIN_RATE_LIMIT = 5 # per minute
def get_or_generate_secret() -> str:
"""Get JWT secret from env or auto-generate on first run"""
global JWT_SECRET_KEY
if not JWT_SECRET_KEY:
secret_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', '.jwt_secret'
)
if os.path.exists(secret_file):
with open(secret_file, 'r') as f:
JWT_SECRET_KEY = f.read().strip()
if not JWT_SECRET_KEY:
JWT_SECRET_KEY = secrets.token_urlsafe(48)
os.makedirs(os.path.dirname(secret_file), exist_ok=True)
with open(secret_file, 'w') as f:
f.write(JWT_SECRET_KEY)
return JWT_SECRET_KEY
+112
View File
@@ -0,0 +1,112 @@
"""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, sync_file_relations
init_db()
cleanup_old_records()
# Sync file relations from existing files
sync_file_relations()
# Wire up DB pool to task manager
task_manager.set_db_pool(db_pool)
app.state.task_manager = task_manager
app.state.db_pool = db_pool
yield
app = FastAPI(
title="益选 OCR 订单处理系统",
version="1.0.0",
lifespan=lifespan,
)
# CORS
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# HTTP logging middleware (after CORS, before routes)
app.add_middleware(LoggingMiddleware)
# Make task_manager and db_pool accessible via request.state
@app.middleware("http")
async def inject_services(request, call_next):
request.state.task_manager = task_manager
request.state.db_pool = db_pool
return await call_next(request)
# Mount routers
app.include_router(auth_router)
app.include_router(files_router)
app.include_router(processing_router)
app.include_router(memory_router)
app.include_router(config_router)
app.include_router(barcodes_router)
app.include_router(sync_router)
app.include_router(ws_router)
app.include_router(logs_router)
app.include_router(tasks_router)
# Serve Vue SPA static files
_static_dir = Path(__file__).resolve().parent / "static"
if _static_dir.is_dir():
app.mount("/assets", StaticFiles(directory=str(_static_dir / "assets")), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Catch-all: serve index.html for Vue Router history mode."""
file_path = _static_dir / full_path
if file_path.is_file():
return FileResponse(str(file_path))
return FileResponse(str(_static_dir / "index.html"))
View File
+93
View File
@@ -0,0 +1,93 @@
"""HTTP request logging middleware."""
import asyncio
import logging
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
# Paths that should not be logged
_SKIP_PREFIXES = ("/assets", "/ws")
_SKIP_PATHS = ("/favicon.ico",)
class LoggingMiddleware(BaseHTTPMiddleware):
"""Logs every HTTP request to the database via db_schema.insert_http_log.
- Skips static assets, WebSocket, and favicon paths.
- Measures request duration in milliseconds.
- Extracts username from request.state.user when available.
- Writes logs asynchronously (non-blocking).
- Never lets logging failures break a request.
"""
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
# Skip paths that should not be logged
if path in _SKIP_PATHS or any(path.startswith(p) for p in _SKIP_PREFIXES):
return await call_next(request)
start = time.perf_counter()
status_code = 500 # default if call_next raises
try:
response = await call_next(request)
status_code = response.status_code
return response
finally:
duration_ms = (time.perf_counter() - start) * 1000
method = request.method
user = getattr(request.state, "user", None)
ip = request.client.host if request.client else None
# Fire-and-forget: never block the response
asyncio.create_task(
self._write_log(method, path, status_code, duration_ms, user, ip)
)
@staticmethod
async def _write_log(
method: str,
path: str,
status_code: int,
duration_ms: float,
user: str | None,
ip: str | None,
) -> None:
"""Write the log entry in a thread executor to avoid blocking."""
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: _db_insert(method, path, status_code, duration_ms, user, ip),
)
except Exception:
# Logging must never break the request
logger.debug("Failed to write HTTP log", exc_info=True)
def _db_insert(
method: str,
path: str,
status_code: int,
duration_ms: float,
user: str | None,
ip: str | None,
) -> None:
"""Synchronous DB insert — called inside run_in_executor."""
try:
from web.backend.services.db_schema import insert_http_log
insert_http_log(
method=method,
path=path,
status_code=status_code,
duration_ms=duration_ms,
user=user,
ip=ip,
)
except Exception:
logger.debug("DB insert_http_log failed", exc_info=True)
View File
+124
View File
@@ -0,0 +1,124 @@
"""Barcode mapping CRUD endpoints."""
import json
from pathlib import Path
from typing import Dict, Optional, List
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/barcodes", tags=["barcodes"])
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_mappings_file = _project_root / "config" / "barcode_mappings.json"
class BarcodeMapping(BaseModel):
barcode: str
target: str
description: Optional[str] = None
class BarcodeMappingUpdate(BaseModel):
target: Optional[str] = None
description: Optional[str] = None
def _load_mappings() -> Dict:
if not _mappings_file.is_file():
return {}
try:
return json.loads(_mappings_file.read_text(encoding="utf-8"))
except Exception:
return {}
def _save_mappings(data: Dict):
_mappings_file.parent.mkdir(parents=True, exist_ok=True)
_mappings_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
@router.get("")
async def list_barcodes(
search: str = "",
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
items = []
for barcode, info in mappings.items():
if isinstance(info, dict):
target = info.get("map_to", info.get("target", ""))
desc = info.get("description", "")
else:
target = str(info)
desc = ""
if search and search not in barcode and search not in target and search not in desc:
continue
items.append({"barcode": barcode, "target": target, "description": desc})
return {"items": items, "total": len(items)}
@router.get("/{barcode}")
async def get_barcode(
barcode: str,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}")
info = mappings[barcode]
if isinstance(info, dict):
return {"barcode": barcode, "target": info.get("map_to", info.get("target", "")), "description": info.get("description", "")}
return {"barcode": barcode, "target": str(info), "description": ""}
@router.post("")
async def create_barcode(
body: BarcodeMapping,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if body.barcode in mappings:
raise HTTPException(409, f"条码 {body.barcode} 已存在")
mappings[body.barcode] = {"map_to": body.target, "description": body.description or ""}
_save_mappings(mappings)
return {"message": f"已创建映射 {body.barcode}{body.target}"}
@router.put("/{barcode}")
async def update_barcode(
barcode: str,
body: BarcodeMappingUpdate,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}")
existing = mappings[barcode]
if not isinstance(existing, dict):
existing = {"map_to": str(existing), "description": ""}
if body.target is not None:
existing["map_to"] = body.target
if body.description is not None:
existing["description"] = body.description
mappings[barcode] = existing
_save_mappings(mappings)
return {"message": f"已更新映射 {barcode}"}
@router.delete("/{barcode}")
async def delete_barcode(
barcode: str,
current_user: dict = Depends(get_current_user),
):
mappings = _load_mappings()
if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}")
del mappings[barcode]
_save_mappings(mappings)
return {"message": f"已删除映射 {barcode}"}
+98
View File
@@ -0,0 +1,98 @@
"""Configuration read/write endpoints."""
from typing import Dict, Optional, Any
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/config", tags=["config"])
# Keys that should be masked in GET responses
_SENSITIVE_KEYS = {"api_key", "secret_key", "token", "password", "api_secret", "access_key"}
# Sections to expose (match actual config.ini)
_ALLOWED_SECTIONS = {"API", "Paths", "Performance", "File", "Templates", "Gitea", "WebAuth"}
class ConfigUpdate(BaseModel):
section: str
key: str
value: str
class ConfigBulkUpdate(BaseModel):
updates: list[ConfigUpdate]
def _get_config():
from app.config.settings import ConfigManager
return ConfigManager()
def _mask_value(key: str, value: str) -> str:
if any(s in key.lower() for s in _SENSITIVE_KEYS):
if len(value) > 4:
return value[:2] + "*" * (len(value) - 4) + value[-2:]
return "****"
return value
@router.get("")
async def get_config(
section: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
cfg = _get_config()
if section:
if section not in _ALLOWED_SECTIONS and section != "DEFAULT":
raise HTTPException(403, f"不允许访问配置节: {section}")
items = {}
for key, value in cfg.config.items(section):
items[key] = _mask_value(key, value)
return {"section": section, "items": items}
result = {}
for sec in _ALLOWED_SECTIONS:
try:
items = {}
for key, value in cfg.config.items(sec):
items[key] = _mask_value(key, value)
result[sec] = items
except Exception:
pass
return result
@router.put("")
async def update_config(
body: ConfigUpdate,
current_user: dict = Depends(get_current_user),
):
if body.section not in _ALLOWED_SECTIONS:
raise HTTPException(403, f"不允许修改配置节: {body.section}")
cfg = _get_config()
try:
cfg.update(body.section, body.key, body.value)
cfg.save_config()
return {"message": f"已更新 [{body.section}] {body.key}"}
except Exception as e:
raise HTTPException(500, f"保存失败: {e}")
@router.put("/bulk")
async def bulk_update_config(
body: ConfigBulkUpdate,
current_user: dict = Depends(get_current_user),
):
cfg = _get_config()
updated = []
for item in body.updates:
if item.section not in _ALLOWED_SECTIONS:
continue
cfg.update(item.section, item.key, item.value)
updated.append(f"[{item.section}] {item.key}")
cfg.save_config()
return {"message": f"已更新 {len(updated)}", "updated": updated}
+301
View File
@@ -0,0 +1,301 @@
"""File upload, download, and listing endpoints."""
import logging
import os
import shutil
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from ..auth.dependencies import get_current_user, get_current_user_flexible
from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
from ..services.db_schema import (
insert_file_metadata, query_file_history, query_file_stats,
query_file_relations, delete_file_relations, sync_file_relations,
query_file_relations_stats,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"])
# Resolve data directories relative to project root
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_input_dir = _project_root / "data" / "input"
_output_dir = _project_root / "data" / "output"
_result_dir = _project_root / "data" / "result"
class FileItem(BaseModel):
name: str
size: int
modified: float
directory: str
class UploadResponse(BaseModel):
filename: str
size: int
path: str
def _ensure_dirs():
for d in [_input_dir, _output_dir, _result_dir]:
d.mkdir(parents=True, exist_ok=True)
def _record_file_action(filename: str, directory: str, size: int, action: str, user: str = None):
"""Record a file operation to the metadata table. Best-effort, non-blocking."""
try:
insert_file_metadata(filename=filename, directory=directory, size=size, action=action, user=user)
except Exception:
logger.debug("Failed to record file metadata for %s/%s action=%s", directory, filename, action, exc_info=True)
@router.post("/upload", response_model=UploadResponse)
async def upload_file(
file: UploadFile = File(...),
target: str = Query("input", pattern="^(input|output)$"),
current_user: dict = Depends(get_current_user),
):
_ensure_dirs()
# Validate extension
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"不支持的文件类型: {ext}")
# Validate size
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(400, f"文件过大,最大 {MAX_UPLOAD_SIZE // 1024 // 1024}MB")
# Choose target directory
target_dir = _output_dir if target == "output" else _input_dir
# Save with secure name
from werkzeug.utils import secure_filename
safe_name = secure_filename(file.filename) or file.filename
dest = target_dir / safe_name
# Avoid overwrite: add suffix if exists
counter = 0
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix
while dest.exists():
counter += 1
dest = target_dir / f"{stem}_{counter}{suffix}"
dest.write_bytes(content)
_record_file_action(dest.name, target, len(content), "upload", current_user.get("username"))
return UploadResponse(
filename=dest.name,
size=len(content),
path=str(dest.relative_to(_project_root)),
)
@router.get("/list")
async def list_files(
directory: str = "input",
current_user: dict = Depends(get_current_user),
) -> List[FileItem]:
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir or not target_dir.is_dir():
return []
files = []
for f in sorted(target_dir.iterdir()):
if f.is_file():
stat = f.stat()
files.append(FileItem(
name=f.name,
size=stat.st_size,
modified=stat.st_mtime,
directory=directory,
))
return files
@router.get("/download/{directory}/{filename}")
async def download_file(
directory: str,
filename: str,
current_user: dict = Depends(get_current_user_flexible),
):
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir:
raise HTTPException(404, "目录不存在")
file_path = target_dir / filename
if not file_path.is_file():
raise HTTPException(404, "文件不存在")
return FileResponse(
str(file_path),
filename=filename,
media_type="application/octet-stream",
)
@router.delete("/{directory}/{filename}")
async def delete_file(
directory: str,
filename: str,
current_user: dict = Depends(get_current_user),
):
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir:
raise HTTPException(404, "目录不存在")
file_path = target_dir / filename
if not file_path.is_file():
raise HTTPException(404, "文件不存在")
size = file_path.stat().st_size
file_path.unlink()
_record_file_action(filename, directory, size, "delete", current_user.get("username"))
# Cascade: clean up relation table
_cleanup_relation_for_deleted_file(directory, filename)
return {"message": f"已删除 {filename}"}
@router.post("/clear/{directory}")
async def clear_directory(
directory: str,
current_user: dict = Depends(get_current_user),
):
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
target_dir = dir_map.get(directory)
if not target_dir:
raise HTTPException(404, "目录不存在")
count = 0
for f in target_dir.iterdir():
if f.is_file():
f.unlink()
count += 1
_record_file_action("*", directory, 0, "clear", current_user.get("username"))
return {"message": f"已清除 {count} 个文件", "count": count}
@router.get("/history")
async def get_file_history(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
directory: Optional[str] = None,
action: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""Query file operation history with pagination and optional filters."""
offset = (page - 1) * page_size
rows = query_file_history(
directory=directory,
action=action,
limit=page_size,
offset=offset,
)
return {"page": page, "page_size": page_size, "items": rows}
@router.get("/stats")
async def get_file_stats(
current_user: dict = Depends(get_current_user),
):
"""Return file storage statistics per directory."""
return {"directories": query_file_stats()}
# ---------------------------------------------------------------------------
# File relations
# ---------------------------------------------------------------------------
class RelationDeleteRequest(BaseModel):
ids: List[int]
def _cleanup_relation_for_deleted_file(directory: str, filename: str):
"""Clean up relation table when a file is deleted."""
import sqlite3
from ..services.db_schema import _db_path
try:
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
if directory == "input":
row = conn.execute("SELECT id FROM file_relations WHERE input_image = ?", (filename,)).fetchone()
if row:
conn.execute("UPDATE file_relations SET input_image = NULL, updated_at = datetime('now') WHERE id = ?", (row['id'],))
# Delete if no other fields
check = conn.execute("SELECT * FROM file_relations WHERE id = ?", (row['id'],)).fetchone()
if check and not check['output_excel'] and not check['result_purchase']:
conn.execute("DELETE FROM file_relations WHERE id = ?", (row['id'],))
elif directory == "output":
row = conn.execute("SELECT id FROM file_relations WHERE output_excel = ?", (filename,)).fetchone()
if row:
conn.execute("UPDATE file_relations SET output_excel = NULL, updated_at = datetime('now') WHERE id = ?", (row['id'],))
check = conn.execute("SELECT * FROM file_relations WHERE id = ?", (row['id'],)).fetchone()
if check and not check['input_image'] and not check['result_purchase']:
conn.execute("DELETE FROM file_relations WHERE id = ?", (row['id'],))
elif directory == "result":
row = conn.execute("SELECT id FROM file_relations WHERE result_purchase = ?", (filename,)).fetchone()
if row:
conn.execute("UPDATE file_relations SET result_purchase = NULL, updated_at = datetime('now') WHERE id = ?", (row['id'],))
check = conn.execute("SELECT * FROM file_relations WHERE id = ?", (row['id'],)).fetchone()
if check and not check['input_image'] and not check['output_excel']:
conn.execute("DELETE FROM file_relations WHERE id = ?", (row['id'],))
conn.commit()
finally:
conn.close()
except Exception:
logger.debug("Failed to cleanup relation for %s/%s", directory, filename, exc_info=True)
@router.get("/relations")
async def get_file_relations(
view: Optional[str] = Query(None, pattern="^(orders|tables|images)$"),
status: Optional[str] = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: dict = Depends(get_current_user),
):
"""Query file relations with optional view filter."""
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size)
return {"items": items, "total": total}
@router.get("/stats/detailed")
async def get_detailed_stats(
current_user: dict = Depends(get_current_user),
):
"""Get detailed file statistics for Dashboard."""
return query_file_relations_stats()
@router.post("/relations/sync")
async def sync_relations(
current_user: dict = Depends(get_current_user),
):
"""Scan directories and rebuild file_relations table."""
sync_file_relations()
return {"message": "文件关系表已重建"}
@router.delete("/relations")
async def delete_relations(
body: RelationDeleteRequest,
current_user: dict = Depends(get_current_user),
):
"""Delete file relation records by IDs."""
delete_file_relations(body.ids)
return {"message": f"已删除 {len(body.ids)} 条关系记录"}
+103
View File
@@ -0,0 +1,103 @@
"""HTTP log query endpoints."""
import logging
import sqlite3
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, Query
from ..auth.dependencies import get_current_user
from ..services.db_schema import query_http_logs, query_http_log_stats
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/logs", tags=["logs"])
_db_path = Path(__file__).resolve().parent.parent.parent.parent / "data" / "web_data.db"
def _count_http_logs(
method: str = None,
path: str = None,
status_code: int = None,
start_time: str = None,
end_time: str = None,
) -> int:
"""Count total matching HTTP log rows for pagination."""
conn = sqlite3.connect(_db_path)
try:
clauses = []
params = []
if method:
clauses.append("method = ?")
params.append(method)
if path:
clauses.append("path LIKE ?")
params.append(f"%{path}%")
if status_code is not None:
clauses.append("status_code = ?")
params.append(status_code)
if start_time:
clauses.append("timestamp >= ?")
params.append(start_time)
if end_time:
clauses.append("timestamp <= ?")
params.append(end_time)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
row = conn.execute(
f"SELECT COUNT(*) as cnt FROM http_logs{where}", params
).fetchone()
return row[0] if row else 0
finally:
conn.close()
@router.get("")
async def list_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
method: Optional[str] = None,
status_code: Optional[int] = None,
path: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""List HTTP logs with filters and pagination."""
offset = (page - 1) * page_size
items = query_http_logs(
method=method,
path=path,
status_code=status_code,
start_time=start_date,
end_time=end_date,
limit=page_size,
offset=offset,
)
total = _count_http_logs(
method=method,
path=path,
status_code=status_code,
start_time=start_date,
end_time=end_date,
)
return {"items": items, "total": total}
@router.get("/stats")
async def log_stats(
current_user: dict = Depends(get_current_user),
):
"""Get today's HTTP log statistics."""
raw = query_http_log_stats()
total = raw.get("total", 0)
errors = raw.get("errors", 0)
avg_duration = raw.get("avg_duration")
return {
"today_count": total,
"error_count": errors,
"avg_duration_ms": round(avg_duration, 2) if avg_duration else 0.0,
"error_rate": round(errors / total, 4) if total > 0 else 0.0,
}
+165
View File
@@ -0,0 +1,165 @@
"""Product memory CRUD endpoints."""
from typing import Optional, List, Dict
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
router = APIRouter(prefix="/api/memory", tags=["memory"])
_project_root = Path(__file__).resolve().parent.parent.parent.parent
_db_path = str(_project_root / "data" / "product_cache.db")
_excel_source = str(_project_root / "templates" / "商品资料.xlsx")
class MemoryItem(BaseModel):
barcode: str
name: str
spec: Optional[str] = None
unit: Optional[str] = None
price: Optional[float] = None
confidence: int = 0
source: str = "ocr"
last_used: Optional[str] = None
use_count: int = 0
class MemoryUpdate(BaseModel):
name: Optional[str] = None
spec: Optional[str] = None
unit: Optional[str] = None
price: Optional[float] = None
confidence: Optional[int] = None
class MemoryListResponse(BaseModel):
items: List[MemoryItem]
total: int
page: int
page_size: int
def _get_db():
from app.core.db.product_db import ProductDatabase
return ProductDatabase(_db_path, _excel_source)
def _row_to_item(row: Dict) -> MemoryItem:
return MemoryItem(
barcode=row.get("barcode", ""),
name=row.get("name", ""),
spec=row.get("spec"),
unit=row.get("unit"),
price=row.get("price"),
confidence=row.get("confidence", 0),
source=row.get("source", "ocr"),
last_used=row.get("last_used"),
use_count=row.get("use_count", 0),
)
@router.get("", response_model=MemoryListResponse)
async def list_memory(
search: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: dict = Depends(get_current_user),
):
db = _get_db()
results = db.get_all_memories()
if search:
s = search.lower()
results = [r for r in results if s in r.get("barcode", "").lower() or s in r.get("name", "").lower()]
total = len(results)
start = (page - 1) * page_size
page_items = results[start:start + page_size]
return MemoryListResponse(
items=[_row_to_item(r) for r in page_items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{barcode}")
async def get_memory(
barcode: str,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
product = db.get_memory(barcode)
if not product:
raise HTTPException(404, f"未找到条码 {barcode} 的记忆记录")
return product
@router.put("/{barcode}")
async def update_memory(
barcode: str,
body: MemoryUpdate,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
existing = db.get_memory(barcode)
if not existing:
raise HTTPException(404, f"未找到条码 {barcode}")
update_data = body.model_dump(exclude_none=True)
if not update_data:
raise HTTPException(400, "没有提供更新数据")
db.update_memory(barcode, update_data)
return {"message": f"已更新 {barcode}", "updated_fields": list(update_data.keys())}
@router.delete("/{barcode}")
async def delete_memory(
barcode: str,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
existing = db.get_memory(barcode)
if not existing:
raise HTTPException(404, f"未找到条码 {barcode}")
db.delete_memory(barcode)
return {"message": f"已删除 {barcode}"}
@router.post("/reimport")
async def reimport_memory(
current_user: dict = Depends(get_current_user),
):
db = _get_db()
try:
count = db.reimport()
return {"message": f"重新导入完成,共导入 {count} 条记录", "count": count}
except Exception as e:
raise HTTPException(500, f"导入失败: {e}")
@router.get("/export/sync")
async def export_memory(
current_user: dict = Depends(get_current_user),
):
db = _get_db()
data = db.export_for_sync()
return {"data": data, "count": len(data)}
@router.post("/import/sync")
async def import_memory(
data: dict,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
try:
count = db.import_from_sync(data.get("data", []))
return {"message": f"导入完成,共 {count}", "count": count}
except Exception as e:
raise HTTPException(500, f"导入失败: {e}")
+534
View File
@@ -0,0 +1,534 @@
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
import asyncio
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
from ..services.db_schema import upsert_file_relation
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
supplier: Optional[str] = None
class SingleFileRequest(BaseModel):
filename: str
class MergeBatchRequest(BaseModel):
filenames: List[str]
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
def _list_files_in(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
def _run_background(coro):
"""Schedule a coroutine as a background task."""
asyncio.ensure_future(coro)
# ---------------------------------------------------------------------------
# Batch endpoints
# ---------------------------------------------------------------------------
@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 _bg():
def do_work():
from app.services.ocr_service import OCRService
svc = OCRService()
total = len(files)
for i, f in enumerate(files):
# Skip check
out_stem = f.stem
# OCR output could be .xlsx or .xls
out_xlsx = _output_dir / f"{out_stem}.xlsx"
out_xls = _output_dir / f"{out_stem}.xls"
if out_xlsx.exists() or out_xls.exists():
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] {f.name} 已OCR过 → {out_name}")
upsert_file_relation(input_image=f.name, output_excel=out_name, status='ocr_done')
continue
tm.update_progress(task.id, int((i / total) * 100), f"正在识别: {f.name}")
tm.add_log(task.id, f"[OCR] 处理 {f.name}")
try:
svc.process_image(str(f))
# Find the output file
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{out_stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
break
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} 个文件")
await _wrapper.run_sync(do_work)
_run_background(_bg())
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 purchase orders."""
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_files_in(_output_dir, filter_ext=list(excel_exts))
if not files:
raise HTTPException(400, "output/ 目录中没有Excel文件")
async def _bg():
def do_work():
from app.services.order_service import OrderService
svc = OrderService()
total = len(files)
for i, f in enumerate(files):
# Skip check
result_name = f"采购单_{f.stem}.xls"
result_path = _result_dir / result_name
if result_path.exists():
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
continue
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))
# Find result file
if result_path.exists():
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
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} 个文件")
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建")
@router.post("/merge", response_model=TaskResponse)
async def merge_orders(
request: Request,
body: MergeBatchRequest = MergeBatchRequest(filenames=[]),
current_user: dict = Depends(get_current_user),
):
"""Merge selected purchase order files into one PosPal template."""
tm = _get_task_manager(request)
task = tm.create_task("合并采购单")
# If specific files provided, use them; otherwise merge all
if body.filenames:
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
else:
file_paths = list(_result_dir.glob("采购单_*.xls"))
if not file_paths:
raise HTTPException(400, "没有找到可合并的采购单文件")
async def _bg():
def do_work():
from app.core.excel.merger import PurchaseOrderMerger
tm.update_progress(task.id, 20, "正在合并采购单...")
tm.add_log(task.id, f"[合并] 合并 {len(file_paths)} 个文件")
try:
from app.config.settings import ConfigManager
merger = PurchaseOrderMerger(ConfigManager())
result = merger.process([str(f) for f in file_paths])
if result:
merged_name = Path(result).name
upsert_file_relation(result_purchase=merged_name, status='merged')
tm.add_log(task.id, f"[合并] 完成: {merged_name}")
tm.set_completed(task.id, result_files=[merged_name], message="合并完成")
else:
tm.set_failed(task.id, "合并返回空结果")
except Exception as e:
tm.add_log(task.id, f"[合并] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
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 -> Result (NO merge)."""
tm = _get_task_manager(request)
task = tm.create_task("一键全流程处理")
async def _bg():
def do_work():
try:
# Step 1: OCR
tm.update_progress(task.id, 0, "步骤 1/2: 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)) * 40)
# Skip check
out_stem = f.stem
out_xlsx = _output_dir / f"{out_stem}.xlsx"
out_xls = _output_dir / f"{out_stem}.xls"
if out_xlsx.exists() or out_xls.exists():
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] {f.name} 已OCR过 → {out_name}")
upsert_file_relation(input_image=f.name, output_excel=out_name, status='ocr_done')
tm.update_progress(task.id, pct, f"跳过: {f.name}")
continue
tm.update_progress(task.id, pct, f"OCR: {f.name}")
try:
ocr_svc.process_image(str(f))
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{out_stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
break
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, 45, "步骤 2/2: 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 = 45 + int((i / max(len(excel_files), 1)) * 55)
# Skip check
result_name = f"采购单_{f.stem}.xls"
result_path = _result_dir / result_name
if result_path.exists():
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
tm.update_progress(task.id, pct, f"跳过: {f.name}")
continue
tm.update_progress(task.id, pct, f"Excel: {f.name}")
try:
order_svc.process_excel(str(f))
if result_path.exists():
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
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="全流程处理完成(不含合并)")
except Exception as e:
tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建")
# ---------------------------------------------------------------------------
# Single-file endpoints
# ---------------------------------------------------------------------------
@router.post("/ocr-single", response_model=TaskResponse)
async def ocr_single(
request: Request,
body: SingleFileRequest,
current_user: dict = Depends(get_current_user),
):
"""OCR a single image file."""
tm = _get_task_manager(request)
task = tm.create_task(f"OCR: {body.filename}")
file_path = _input_dir / body.filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {body.filename}")
async def _bg():
def do_work():
from app.services.ocr_service import OCRService
svc = OCRService()
tm.update_progress(task.id, 10, f"正在识别: {body.filename}")
tm.add_log(task.id, f"[OCR] 处理 {body.filename}")
try:
svc.process_image(str(file_path))
# Find output
stem = file_path.stem
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
break
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
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完成: {body.filename}")
except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"OCR任务已创建: {body.filename}")
@router.post("/excel-single", response_model=TaskResponse)
async def excel_single(
request: Request,
body: SingleFileRequest,
current_user: dict = Depends(get_current_user),
):
"""Process a single Excel file to purchase order."""
tm = _get_task_manager(request)
task = tm.create_task(f"Excel处理: {body.filename}")
file_path = _output_dir / body.filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {body.filename}")
async def _bg():
def do_work():
from app.services.order_service import OrderService
svc = OrderService()
tm.update_progress(task.id, 10, f"正在处理: {body.filename}")
tm.add_log(task.id, f"[Excel] 处理 {body.filename}")
try:
svc.process_excel(str(file_path))
result_name = f"采购单_{file_path.stem}.xls"
if (_result_dir / result_name).exists():
upsert_file_relation(output_excel=body.filename, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成: {body.filename}")
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处理完成: {body.filename}")
except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"Excel处理任务已创建: {body.filename}")
@router.post("/pipeline-single", response_model=TaskResponse)
async def pipeline_single(
request: Request,
body: SingleFileRequest,
current_user: dict = Depends(get_current_user),
):
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
tm = _get_task_manager(request)
task = tm.create_task(f"全流程: {body.filename}")
file_path = _input_dir / body.filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {body.filename}")
async def _bg():
def do_work():
try:
stem = file_path.stem
# Step 1: OCR
tm.update_progress(task.id, 10, "步骤 1/2: OCR识别")
tm.add_log(task.id, f"[Pipeline] OCR: {body.filename}")
from app.services.ocr_service import OCRService
ocr_svc = OCRService()
out_xlsx = _output_dir / f"{stem}.xlsx"
out_xls = _output_dir / f"{stem}.xls"
if out_xlsx.exists() or out_xls.exists():
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}")
upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done')
else:
ocr_svc.process_image(str(file_path))
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
break
tm.add_log(task.id, f"[OCR] 完成")
# Step 2: Excel
tm.update_progress(task.id, 50, "步骤 2/2: Excel处理")
tm.add_log(task.id, f"[Pipeline] Excel处理")
from app.services.order_service import OrderService
order_svc = OrderService()
result_name = f"采购单_{stem}.xls"
result_path = _result_dir / result_name
if result_path.exists():
tm.add_log(task.id, f"[跳过] 已处理过 → {result_name}")
upsert_file_relation(output_excel=f"{stem}.xlsx", result_purchase=result_name, status='done')
else:
# Find the output excel
excel_file = out_xlsx if out_xlsx.exists() else (out_xls if out_xls.exists() else None)
if excel_file:
order_svc.process_excel(str(excel_file))
if result_path.exists():
upsert_file_relation(output_excel=excel_file.name, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成")
else:
tm.add_log(task.id, f"[错误] OCR未生成Excel文件")
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"全流程完成: {body.filename}")
except Exception as e:
tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"全流程任务已创建: {body.filename}")
@router.post("/merge-batch", response_model=TaskResponse)
async def merge_batch(
request: Request,
body: MergeBatchRequest,
current_user: dict = Depends(get_current_user),
):
"""Merge selected purchase order files into one PosPal template."""
tm = _get_task_manager(request)
task = tm.create_task("批量合并采购单")
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
if not file_paths:
raise HTTPException(400, "没有找到可合并的采购单文件")
async def _bg():
def do_work():
from app.core.excel.merger import PurchaseOrderMerger
tm.update_progress(task.id, 20, f"正在合并 {len(file_paths)} 个采购单...")
tm.add_log(task.id, f"[合并] 合并文件: {', '.join(f.name for f in file_paths)}")
try:
from app.config.settings import ConfigManager
merger = PurchaseOrderMerger(ConfigManager())
result = merger.process([str(f) for f in file_paths])
if result:
merged_name = Path(result).name
upsert_file_relation(result_purchase=merged_name, status='merged')
tm.add_log(task.id, f"[合并] 完成: {merged_name}")
tm.set_completed(task.id, result_files=[merged_name], message="批量合并完成")
else:
tm.set_failed(task.id, "合并返回空结果")
except Exception as e:
tm.add_log(task.id, f"[合并] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="批量合并任务已创建")
# ---------------------------------------------------------------------------
# Status endpoint
# ---------------------------------------------------------------------------
@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()
+93
View File
@@ -0,0 +1,93 @@
"""Cloud sync endpoints (Gitea-based)."""
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from ..services.task_manager import TaskManager
router = APIRouter(prefix="/api/sync", tags=["sync"])
_project_root = Path(__file__).resolve().parent.parent.parent.parent
class SyncResponse(BaseModel):
task_id: str
status: str
message: str
def _get_sync():
from app.core.utils.cloud_sync import GiteaSync
from app.config.settings import ConfigManager
cfg = ConfigManager()
return GiteaSync(cfg)
@router.post("/push", response_model=SyncResponse)
async def sync_push(
request: Request,
current_user: dict = Depends(get_current_user),
):
tm = request.state.task_manager
task = tm.create_task("推送到云端")
async def _run():
try:
tm.update_progress(task.id, 10, "正在初始化同步...")
sync = _get_sync()
tm.update_progress(task.id, 30, "正在推送文件...")
tm.add_log(task.id, "[Push] 开始推送")
result = sync.push()
tm.add_log(task.id, f"[Push] 完成: {result}")
tm.set_completed(task.id, message="推送完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return SyncResponse(task_id=task.id, status="accepted", message="推送任务已创建")
@router.post("/pull", response_model=SyncResponse)
async def sync_pull(
request: Request,
current_user: dict = Depends(get_current_user),
):
tm = request.state.task_manager
task = tm.create_task("从云端拉取")
async def _run():
try:
tm.update_progress(task.id, 10, "正在初始化同步...")
sync = _get_sync()
tm.update_progress(task.id, 30, "正在拉取文件...")
tm.add_log(task.id, "[Pull] 开始拉取")
result = sync.pull()
tm.add_log(task.id, f"[Pull] 完成: {result}")
tm.set_completed(task.id, message="拉取完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return SyncResponse(task_id=task.id, status="accepted", message="拉取任务已创建")
@router.get("/status")
async def sync_status(
current_user: dict = Depends(get_current_user),
):
try:
from app.config.settings import ConfigManager
cfg = ConfigManager()
base_url = cfg.get("Gitea", "base_url", fallback="")
owner = cfg.get("Gitea", "owner", fallback="")
repo = cfg.get("Gitea", "repo", fallback="")
enabled = bool(base_url and owner and repo)
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
return {"enabled": enabled, "repo_url": repo_url}
except Exception:
return {"enabled": False, "repo_url": ""}
+159
View File
@@ -0,0 +1,159 @@
"""Tasks API router: history query, stats, detail, and retry."""
import asyncio
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from ..auth.dependencies import get_current_user
from ..services import db_schema
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
# Mapping from task name to the processing endpoint that retries it.
_RETRY_ROUTE_MAP = {
"批量OCR识别": "/api/processing/ocr-batch",
"Excel标准化处理": "/api/processing/excel",
"合并采购单": "/api/processing/merge",
"一键全流程处理": "/api/processing/pipeline",
}
@router.get("/stats")
async def task_stats(
current_user: dict = Depends(get_current_user),
):
"""Return aggregate task statistics."""
loop = asyncio.get_event_loop()
stats = await loop.run_in_executor(None, db_schema.query_task_stats)
# Ensure all expected keys are present.
return {
"total": stats.get("total", 0),
"completed": stats.get("completed", 0),
"failed": stats.get("failed", 0),
"running": stats.get("running", 0),
}
@router.get("")
async def list_tasks(
page: int = 1,
page_size: int = 50,
status: Optional[str] = None,
name: Optional[str] = None,
search: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""List tasks with optional filters and pagination.
``search`` is applied as a general text filter (matches name).
"""
page_size = min(page_size, 200)
page = max(page, 1)
offset = (page - 1) * page_size
# ``search`` maps to the ``name`` filter in the DB layer.
effective_name = search or name
loop = asyncio.get_event_loop()
items = await loop.run_in_executor(
None,
lambda: db_schema.query_task_history(
status=status,
name=effective_name,
limit=page_size,
offset=offset,
),
)
# Obtain total count for pagination. Re-run a lightweight count query.
def _count():
import sqlite3
from pathlib import Path
db_path = Path(__file__).resolve().parent.parent.parent.parent / "data" / "web_data.db"
conn = sqlite3.connect(db_path)
try:
clauses: list[str] = []
params: list = []
if status:
clauses.append("status = ?")
params.append(status)
if effective_name:
clauses.append("name LIKE ?")
params.append(f"%{effective_name}%")
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
row = conn.execute(
f"SELECT COUNT(*) as cnt FROM task_history{where}",
params,
).fetchone()
return row[0] if row else 0
finally:
conn.close()
total = await loop.run_in_executor(None, _count)
return {"items": items, "total": total}
@router.get("/{task_id}")
async def get_task(
task_id: str,
current_user: dict = Depends(get_current_user),
):
"""Get full task detail including log_lines and result_files."""
loop = asyncio.get_event_loop()
task = await loop.run_in_executor(
None, lambda: db_schema.query_task_by_id(task_id),
)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
return task
@router.post("/{task_id}/retry")
async def retry_task(
task_id: str,
request: Request,
current_user: dict = Depends(get_current_user),
):
"""Retry a failed task by re-invoking its processing endpoint.
Only tasks with status ``failed`` may be retried.
"""
loop = asyncio.get_event_loop()
task = await loop.run_in_executor(
None, lambda: db_schema.query_task_by_id(task_id),
)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task.get("status") != "failed":
raise HTTPException(
status_code=400,
detail="只有失败的任务才能重试",
)
task_name = task.get("name", "")
endpoint = _RETRY_ROUTE_MAP.get(task_name)
if endpoint is None:
raise HTTPException(
status_code=400,
detail=f"未知的任务类型: {task_name}",
)
# Build the internal URL to the processing endpoint.
base_url = f"http://{request.url.hostname}:{request.url.port}"
url = f"{base_url}{endpoint}"
# Forward the Authorization header so the processing endpoint can
# authenticate the request.
auth_header = request.headers.get("authorization")
headers: dict[str, str] = {}
if auth_header:
headers["authorization"] = auth_header
async with httpx.AsyncClient() as client:
resp = await client.post(url, headers=headers)
return resp.json()
+47
View File
@@ -0,0 +1,47 @@
"""WebSocket endpoint for real-time task progress."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from ..auth.jwt_handler import decode_token
from jose import JWTError
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/task/{task_id}")
async def task_websocket(
websocket: WebSocket,
task_id: str,
token: str = Query(...),
):
"""WebSocket for real-time task progress updates."""
try:
payload = decode_token(token)
username = payload.get("sub")
if not username:
await websocket.close(code=4001, reason="Invalid token")
return
except (JWTError, Exception):
await websocket.close(code=4001, reason="Invalid token")
return
await websocket.accept()
tm = websocket.app.state.task_manager
task = tm.get_task(task_id)
if not task:
await websocket.send_json({"error": "任务不存在"})
await websocket.close()
return
tm.subscribe(task_id, websocket)
await websocket.send_json(task.to_dict())
try:
while True:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
tm.unsubscribe(task_id, websocket)
except Exception:
tm.unsubscribe(task_id, websocket)
View File
View File
+20
View File
@@ -0,0 +1,20 @@
"""SQLite write serialization for async context"""
import asyncio
from typing import Callable, Any
class DBPool:
"""Serializes SQLite writes via asyncio.Lock. Reads are concurrent."""
def __init__(self):
self._write_lock = asyncio.Lock()
async def execute_write(self, fn: Callable, *args, **kwargs) -> Any:
async with self._write_lock:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
async def execute_read(self, fn: Callable, *args, **kwargs) -> Any:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
+716
View File
@@ -0,0 +1,716 @@
"""SQLite schema management and query functions for web backend.
Tables:
- http_logs: HTTP request/response logging
- task_history: Background task tracking
- file_metadata: File operation records
- file_relations: Input→Output→Result file chain tracking
All functions are synchronous; the async db_pool.DBPool wraps them via run_in_executor.
"""
import json
import os
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
_db_path = Path(__file__).resolve().parent.parent.parent.parent / "data" / "web_data.db"
def _ensure_db_dir():
"""Create the parent directory for the database if it doesn't exist."""
_db_path.parent.mkdir(parents=True, exist_ok=True)
def init_db():
"""Create tables and indexes if they don't exist."""
_ensure_db_dir()
conn = sqlite3.connect(_db_path)
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS http_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER,
duration_ms REAL,
user TEXT,
ip TEXT,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_http_logs_timestamp ON http_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_http_logs_status ON http_logs(status_code);
CREATE TABLE IF NOT EXISTS task_history (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER DEFAULT 0,
message TEXT,
result_files TEXT,
error TEXT,
log_lines TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_task_history_status ON task_history(status);
CREATE INDEX IF NOT EXISTS idx_task_history_created ON task_history(created_at);
CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
directory TEXT NOT NULL,
size INTEGER,
action TEXT NOT NULL,
user TEXT,
timestamp TEXT NOT NULL,
task_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_file_metadata_timestamp ON file_metadata(timestamp);
CREATE TABLE IF NOT EXISTS file_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
input_image TEXT,
output_excel TEXT,
result_purchase TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_file_relations_input ON file_relations(input_image);
CREATE INDEX IF NOT EXISTS idx_file_relations_output ON file_relations(output_excel);
CREATE INDEX IF NOT EXISTS idx_file_relations_result ON file_relations(result_purchase);
""")
conn.commit()
finally:
conn.close()
def cleanup_old_records():
"""Delete records older than 30 days from all tables."""
cutoff = (datetime.now() - timedelta(days=30)).isoformat()
conn = sqlite3.connect(_db_path)
try:
conn.execute("DELETE FROM http_logs WHERE timestamp < ?", (cutoff,))
conn.execute("DELETE FROM task_history WHERE created_at < ?", (cutoff,))
conn.execute("DELETE FROM file_metadata WHERE timestamp < ?", (cutoff,))
conn.execute("DELETE FROM file_relations WHERE updated_at < ?", (cutoff,))
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# Insert functions
# ---------------------------------------------------------------------------
def insert_http_log(method: str, path: str, status_code: int = None,
duration_ms: float = None, user: str = None,
ip: str = None, detail: str = None):
"""Insert an HTTP log record."""
conn = sqlite3.connect(_db_path)
try:
conn.execute(
"INSERT INTO http_logs (timestamp, method, path, status_code, duration_ms, user, ip, detail) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(datetime.now().isoformat(), method, path, status_code,
duration_ms, user, ip, detail),
)
conn.commit()
finally:
conn.close()
def insert_task(task_id: str, name: str, status: str = "pending",
progress: int = 0, message: str = None,
result_files: str = None, error: str = None,
log_lines: str = None):
"""Insert a new task record."""
now = datetime.now().isoformat()
conn = sqlite3.connect(_db_path)
try:
conn.execute(
"INSERT INTO task_history (id, name, status, progress, message, "
"result_files, error, log_lines, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, name, status, progress, message,
result_files, error, log_lines, now, now),
)
conn.commit()
finally:
conn.close()
def update_task(task_id: str, **kwargs):
"""Update specific fields of a task record.
Allowed fields: name, status, progress, message, result_files,
error, log_lines, completed_at.
"""
allowed = {
"name", "status", "progress", "message",
"result_files", "error", "log_lines", "completed_at",
}
fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields:
return
fields["updated_at"] = datetime.now().isoformat()
set_clause = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [task_id]
conn = sqlite3.connect(_db_path)
try:
conn.execute(
f"UPDATE task_history SET {set_clause} WHERE id = ?",
values,
)
conn.commit()
finally:
conn.close()
def insert_file_metadata(filename: str, directory: str, action: str,
size: int = None, user: str = None,
task_id: str = None):
"""Insert a file operation record."""
conn = sqlite3.connect(_db_path)
try:
conn.execute(
"INSERT INTO file_metadata (filename, directory, size, action, user, timestamp, task_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(filename, directory, size, action, user,
datetime.now().isoformat(), task_id),
)
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# Query functions — HTTP logs
# ---------------------------------------------------------------------------
def query_http_logs(method: str = None, path: str = None,
status_code: int = None,
start_time: str = None, end_time: str = None,
limit: int = 50, offset: int = 0) -> list[dict]:
"""Query HTTP logs with optional filters and pagination."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
clauses = []
params = []
if method:
clauses.append("method = ?")
params.append(method)
if path:
clauses.append("path LIKE ?")
params.append(f"%{path}%")
if status_code is not None:
clauses.append("status_code = ?")
params.append(status_code)
if start_time:
clauses.append("timestamp >= ?")
params.append(start_time)
if end_time:
clauses.append("timestamp <= ?")
params.append(end_time)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
params.extend([limit, offset])
rows = conn.execute(
f"SELECT * FROM http_logs{where} ORDER BY id DESC LIMIT ? OFFSET ?",
params,
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def query_http_log_stats() -> dict:
"""Get HTTP log statistics for today."""
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT "
" COUNT(*) as total, "
" SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as errors, "
" AVG(duration_ms) as avg_duration "
"FROM http_logs WHERE timestamp >= ?",
(today_start,),
).fetchone()
return dict(row) if row else {"total": 0, "errors": 0, "avg_duration": 0}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Query functions — Task history
# ---------------------------------------------------------------------------
def query_task_history(status: str = None, name: str = None,
start_time: str = None, end_time: str = None,
limit: int = 50, offset: int = 0) -> list[dict]:
"""Query task history with optional filters and pagination.
Returns list of dicts with result_files and log_lines parsed from JSON.
"""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
clauses = []
params = []
if status:
clauses.append("status = ?")
params.append(status)
if name:
clauses.append("name LIKE ?")
params.append(f"%{name}%")
if start_time:
clauses.append("created_at >= ?")
params.append(start_time)
if end_time:
clauses.append("created_at <= ?")
params.append(end_time)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
params.extend([limit, offset])
rows = conn.execute(
f"SELECT * FROM task_history{where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
params,
).fetchall()
results = []
for r in rows:
d = dict(r)
d["result_files"] = _parse_json_field(d.get("result_files"))
d["log_lines"] = _parse_json_field(d.get("log_lines"))
results.append(d)
return results
finally:
conn.close()
def query_task_by_id(task_id: str) -> dict | None:
"""Get a single task by ID, with JSON fields parsed."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT * FROM task_history WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return None
d = dict(row)
d["result_files"] = _parse_json_field(d.get("result_files"))
d["log_lines"] = _parse_json_field(d.get("log_lines"))
return d
finally:
conn.close()
def query_task_stats() -> dict:
"""Get task statistics: counts by status and total."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT status, COUNT(*) as count FROM task_history GROUP BY status"
).fetchall()
stats = {r["status"]: r["count"] for r in rows}
stats["total"] = sum(stats.values())
return stats
finally:
conn.close()
# ---------------------------------------------------------------------------
# Query functions — File metadata
# ---------------------------------------------------------------------------
def query_file_history(filename: str = None, directory: str = None,
action: str = None, task_id: str = None,
start_time: str = None, end_time: str = None,
limit: int = 50, offset: int = 0) -> list[dict]:
"""Query file operation history with optional filters and pagination."""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
clauses = []
params = []
if filename:
clauses.append("filename LIKE ?")
params.append(f"%{filename}%")
if directory:
clauses.append("directory = ?")
params.append(directory)
if action:
clauses.append("action = ?")
params.append(action)
if task_id:
clauses.append("task_id = ?")
params.append(task_id)
if start_time:
clauses.append("timestamp >= ?")
params.append(start_time)
if end_time:
clauses.append("timestamp <= ?")
params.append(end_time)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
params.extend([limit, offset])
rows = conn.execute(
f"SELECT * FROM file_metadata{where} ORDER BY id DESC LIMIT ? OFFSET ?",
params,
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def query_file_stats() -> list[dict]:
"""Get file storage statistics by scanning actual directories.
Returns a list of dicts with keys: directory, file_count, total_size.
Scans data/input/, data/output/, data/result/ relative to project root.
"""
project_root = Path(__file__).resolve().parent.parent.parent.parent
dirs_to_scan = ["data/input", "data/output", "data/result"]
stats = []
for rel_dir in dirs_to_scan:
dir_path = project_root / rel_dir
if not dir_path.exists():
stats.append({"directory": rel_dir, "file_count": 0, "total_size": 0})
continue
file_count = 0
total_size = 0
for f in dir_path.rglob("*"):
if f.is_file():
file_count += 1
total_size += f.stat().st_size
stats.append({
"directory": rel_dir,
"file_count": file_count,
"total_size": total_size,
})
return stats
# ---------------------------------------------------------------------------
# File relations — CRUD
# ---------------------------------------------------------------------------
def upsert_file_relation(input_image: str = None, output_excel: str = None,
result_purchase: str = None, status: str = 'pending'):
"""Insert or update a file relation.
Match strategy:
- If input_image provided, try to find existing row by input_image
- Else if output_excel provided, try to find by output_excel
- Otherwise insert new row.
"""
now = datetime.now().isoformat()
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
existing = None
if input_image:
existing = conn.execute(
"SELECT * FROM file_relations WHERE input_image = ?", (input_image,)
).fetchone()
if not existing and output_excel:
existing = conn.execute(
"SELECT * FROM file_relations WHERE output_excel = ?", (output_excel,)
).fetchone()
if not existing and result_purchase:
existing = conn.execute(
"SELECT * FROM file_relations WHERE result_purchase = ?", (result_purchase,)
).fetchone()
if existing:
updates = []
params = []
if input_image and not existing['input_image']:
updates.append("input_image = ?")
params.append(input_image)
if output_excel and not existing['output_excel']:
updates.append("output_excel = ?")
params.append(output_excel)
if result_purchase and not existing['result_purchase']:
updates.append("result_purchase = ?")
params.append(result_purchase)
if status:
updates.append("status = ?")
params.append(status)
updates.append("updated_at = ?")
params.append(now)
params.append(existing['id'])
conn.execute(
f"UPDATE file_relations SET {', '.join(updates)} WHERE id = ?",
params,
)
else:
conn.execute(
"INSERT INTO file_relations (input_image, output_excel, result_purchase, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(input_image, output_excel, result_purchase, status, now, now),
)
conn.commit()
finally:
conn.close()
def query_file_relations(view: str = None, status: str = None,
page: int = 1, page_size: int = 50) -> tuple[list[dict], int]:
"""Query file relations with optional view filter and pagination.
view='orders': only rows with result_purchase, sorted by result_purchase
view='tables': only rows with output_excel, sorted by output_excel
view='images': only rows with input_image, sorted by input_image
view=None: all rows
Returns (items, total).
"""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
clauses = []
params = []
if view == 'orders':
clauses.append("result_purchase IS NOT NULL")
order_by = "result_purchase DESC"
elif view == 'tables':
clauses.append("output_excel IS NOT NULL")
order_by = "output_excel DESC"
elif view == 'images':
clauses.append("input_image IS NOT NULL")
order_by = "input_image DESC"
else:
order_by = "id DESC"
if status:
clauses.append("status = ?")
params.append(status)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
# Count
row = conn.execute(
f"SELECT COUNT(*) as cnt FROM file_relations{where}", params
).fetchone()
total = row[0] if row else 0
# Page
offset = (page - 1) * page_size
params.extend([page_size, offset])
rows = conn.execute(
f"SELECT * FROM file_relations{where} ORDER BY {order_by} LIMIT ? OFFSET ?",
params,
).fetchall()
items = []
project_root = Path(__file__).resolve().parent.parent.parent.parent
for r in rows:
d = dict(r)
# Check file existence
if d.get('input_image'):
d['input_exists'] = (project_root / 'data' / 'input' / d['input_image']).exists()
else:
d['input_exists'] = False
if d.get('output_excel'):
d['output_exists'] = (project_root / 'data' / 'output' / d['output_excel']).exists()
else:
d['output_exists'] = False
if d.get('result_purchase'):
d['result_exists'] = (project_root / 'data' / 'result' / d['result_purchase']).exists()
else:
d['result_exists'] = False
items.append(d)
return items, total
finally:
conn.close()
def delete_file_relations(ids: list[int]):
"""Delete file relation records by IDs."""
if not ids:
return
conn = sqlite3.connect(_db_path)
try:
placeholders = ','.join('?' * len(ids))
conn.execute(f"DELETE FROM file_relations WHERE id IN ({placeholders})", ids)
conn.commit()
finally:
conn.close()
def sync_file_relations():
"""Scan input/output/result directories and rebuild file_relations table.
Matches files by stem:
- input: {stem}.jpg/.png/.bmp
- output: {stem}.xlsx or {stem}.xls
- result: 采购单_{stem}.xls
"""
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'
image_exts = {'.jpg', '.jpeg', '.png', '.bmp'}
excel_exts = {'.xls', '.xlsx'}
# Collect files by stem
input_files = {} # stem -> filename
if input_dir.exists():
for f in input_dir.iterdir():
if f.is_file() and f.suffix.lower() in image_exts:
input_files[f.stem] = f.name
output_files = {}
if output_dir.exists():
for f in output_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
output_files[f.stem] = f.name
result_files = {}
if result_dir.exists():
for f in result_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
name = f.name
# Strip 采购单_ prefix for matching
if name.startswith('采购单_'):
stem = name[len('采购单_'):-len(f.suffix)]
elif name.startswith('合并采购单_'):
continue # Skip merged files
else:
stem = f.stem
result_files[stem] = name
# Build relations
all_stems = set(input_files.keys()) | set(output_files.keys()) | set(result_files.keys())
now = datetime.now().isoformat()
conn = sqlite3.connect(_db_path)
try:
# Clear existing and rebuild
conn.execute("DELETE FROM file_relations")
for stem in sorted(all_stems):
inp = input_files.get(stem)
out = output_files.get(stem)
res = result_files.get(stem)
if res:
status = 'done'
elif out:
status = 'ocr_done'
else:
status = 'pending'
conn.execute(
"INSERT INTO file_relations (input_image, output_excel, result_purchase, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(inp, out, res, status, now, now),
)
conn.commit()
finally:
conn.close()
def query_file_relations_stats() -> dict:
"""Get detailed file statistics for Dashboard.
Returns dict with:
- input_images: count of image files in input/
- output_excel: count of excel files in output/
- unprocessed_images: images without corresponding output
- unprocessed_excel: excel without corresponding result
- completed_results: purchase order files in result/
- total_processed: relations with status done/merged
"""
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'
image_exts = {'.jpg', '.jpeg', '.png', '.bmp'}
excel_exts = {'.xls', '.xlsx'}
# Count files
input_images = 0
input_stems = set()
if input_dir.exists():
for f in input_dir.iterdir():
if f.is_file() and f.suffix.lower() in image_exts:
input_images += 1
input_stems.add(f.stem)
output_excel = 0
output_stems = set()
if output_dir.exists():
for f in output_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
output_excel += 1
output_stems.add(f.stem)
completed_results = 0
result_stems = set()
if result_dir.exists():
for f in result_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
if f.name.startswith('采购单_'):
completed_results += 1
stem = f.name[len('采购单_'):-len(f.suffix)]
result_stems.add(stem)
unprocessed_images = len(input_stems - output_stems)
unprocessed_excel = len(output_stems - result_stems)
# Count from relations table
conn = sqlite3.connect(_db_path)
try:
row = conn.execute(
"SELECT COUNT(*) FROM file_relations WHERE status IN ('done', 'merged')"
).fetchone()
total_processed = row[0] if row else 0
finally:
conn.close()
return {
'input_images': input_images,
'output_excel': output_excel,
'unprocessed_images': unprocessed_images,
'unprocessed_excel': unprocessed_excel,
'completed_results': completed_results,
'total_processed': total_processed,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_json_field(value):
"""Parse a JSON string to a Python list; return empty list on failure."""
if not value:
return []
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return []
+19
View File
@@ -0,0 +1,19 @@
"""Async wrapper for synchronous app/ services"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Any
class ServiceWrapper:
"""Wraps synchronous services for async FastAPI endpoints."""
def __init__(self, max_workers: int = 3):
self._executor = ThreadPoolExecutor(max_workers=max_workers)
async def run_sync(self, fn: Callable, *args, **kwargs) -> Any:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
self._executor,
lambda: fn(*args, **kwargs)
)
+176
View File
@@ -0,0 +1,176 @@
"""Background task tracking + WebSocket broadcast"""
import json
import uuid
import asyncio
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, field
from web.backend.services.db_schema import insert_task, update_task
class TaskStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class Task:
id: str
name: str
status: TaskStatus = TaskStatus.PENDING
progress: int = 0
message: str = ""
result_files: List[str] = field(default_factory=list)
error: Optional[str] = None
log_lines: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"task_id": self.id,
"name": self.name,
"status": self.status.value,
"progress": self.progress,
"message": self.message,
"result_files": self.result_files,
"error": self.error,
"log_lines": self.log_lines[-100:],
}
class TaskManager:
def __init__(self):
self._tasks: Dict[str, Task] = {}
self._connections: Dict[str, Set] = {}
self._db = None # type: ignore
self._loop = None # captured event loop
def set_db_pool(self, db_pool):
"""Set the DBPool reference for database persistence."""
self._db = db_pool
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
def _schedule(self, coro):
"""Schedule a coroutine from either async or thread context."""
try:
loop = asyncio.get_running_loop()
asyncio.ensure_future(coro, loop=loop)
except RuntimeError:
# No running loop — we're in a thread; schedule onto the main loop
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(coro, self._loop)
def create_task(self, name: str) -> Task:
task_id = str(uuid.uuid4())[:8]
task = Task(id=task_id, name=name)
self._tasks[task_id] = task
self._connections[task_id] = set()
if self._db:
self._schedule(
self._db.execute_write(insert_task, task_id, name, TaskStatus.PENDING.value)
)
return task
def get_task(self, task_id: str) -> Optional[Task]:
return self._tasks.get(task_id)
def update_progress(self, task_id: str, progress: int, message: str = ""):
task = self._tasks.get(task_id)
if not task:
return
# Auto-transition from PENDING to RUNNING on first progress update
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.RUNNING
task.progress = progress
task.message = message
if self._db:
self._schedule(
self._db.execute_write(
update_task, task_id,
status=task.status.value, progress=progress, message=message,
)
)
self._schedule(self._broadcast(task_id))
def add_log(self, task_id: str, line: str):
task = self._tasks.get(task_id)
if not task:
return
task.log_lines.append(line)
if self._db:
self._schedule(
self._db.execute_write(
update_task, task_id,
log_lines=json.dumps(task.log_lines[-200:]),
)
)
self._schedule(self._broadcast(task_id))
def set_completed(self, task_id: str, result_files: List[str] = None, message: str = ""):
task = self._tasks.get(task_id)
if not task:
return
task.status = TaskStatus.COMPLETED
task.progress = 100
task.message = message or "处理完成"
if result_files:
task.result_files = result_files
now = datetime.now().isoformat()
if self._db:
self._schedule(
self._db.execute_write(
update_task, task_id,
status=TaskStatus.COMPLETED.value, progress=100,
message=task.message,
result_files=json.dumps(task.result_files),
completed_at=now,
)
)
self._schedule(self._broadcast(task_id))
def set_failed(self, task_id: str, error: str):
task = self._tasks.get(task_id)
if not task:
return
task.status = TaskStatus.FAILED
task.error = error
task.message = f"处理失败: {error}"
now = datetime.now().isoformat()
if self._db:
self._schedule(
self._db.execute_write(
update_task, task_id,
status=TaskStatus.FAILED.value, error=error,
message=task.message, completed_at=now,
)
)
self._schedule(self._broadcast(task_id))
def subscribe(self, task_id: str, websocket):
if task_id in self._connections:
self._connections[task_id].add(websocket)
def unsubscribe(self, task_id: str, websocket):
if task_id in self._connections:
self._connections[task_id].discard(websocket)
async def _broadcast(self, task_id: str):
task = self._tasks.get(task_id)
if not task:
return
data = task.to_dict()
dead = set()
for ws in self._connections.get(task_id, set()):
try:
await ws.send_json(data)
except Exception:
dead.add(ws)
for ws in dead:
self._connections[task_id].discard(ws)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>益选 OCR 订单处理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1929
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "xiaoaitext-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"vue-tsc": "^2.0.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
// Request interceptor: attach JWT token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor: handle 401
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api
+4
View File
@@ -0,0 +1,4 @@
declare module 'element-plus/dist/locale/zh-cn.mjs' {
const locale: any
export default locale
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './styles/global.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+86
View File
@@ -0,0 +1,86 @@
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'),
},
{
path: 'files',
redirect: '/files/orders',
},
{
path: 'files/orders',
name: 'FilesOrders',
component: () => import('../views/files/Orders.vue'),
},
{
path: 'files/tables',
name: 'FilesTables',
component: () => import('../views/files/Tables.vue'),
},
{
path: 'files/images',
name: 'FilesImages',
component: () => import('../views/files/Images.vue'),
},
],
},
],
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth !== false && !authStore.isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref('')
const isAuthenticated = computed(() => !!token.value)
async function login(user: string, password: string) {
const res = await api.post('/auth/login', { username: user, password })
token.value = res.data.access_token
localStorage.setItem('token', token.value)
username.value = user
}
function logout() {
token.value = ''
username.value = ''
localStorage.removeItem('token')
}
async function fetchUser() {
try {
const res = await api.get('/auth/me')
username.value = res.data.username
} catch {
logout()
}
}
return { token, username, isAuthenticated, login, logout, fetchUser }
})
+94
View File
@@ -0,0 +1,94 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api'
export interface TaskInfo {
task_id: string
name: string
status: string
progress: number
message: string
result_files: string[]
error: string | null
log_lines: string[]
}
export const useProcessingStore = defineStore('processing', () => {
const currentTask = ref<TaskInfo | null>(null)
const tasks = ref<TaskInfo[]>([])
const logs = ref<string[]>([])
let ws: WebSocket | null = null
function connectWebSocket(taskId: string) {
disconnectWebSocket()
const token = localStorage.getItem('token')
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
ws = new WebSocket(url)
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
currentTask.value = data
logs.value = data.log_lines || []
// Update in tasks list
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
if (idx >= 0) {
tasks.value[idx] = data
} else {
tasks.value.unshift(data)
}
// Auto-disconnect on completion
if (data.status === 'completed' || data.status === 'failed') {
setTimeout(() => disconnectWebSocket(), 2000)
}
} catch {}
}
ws.onerror = () => {
console.error('WebSocket error')
}
ws.onclose = () => {
ws = null
}
}
function disconnectWebSocket() {
if (ws) {
ws.close()
ws = null
}
}
async function startTask(endpoint: string, body?: any) {
const res = await api.post(endpoint, body || {})
const taskId = res.data.task_id
currentTask.value = {
task_id: taskId,
name: res.data.message || '',
status: 'pending',
progress: 0,
message: '',
result_files: [],
error: null,
log_lines: [],
}
logs.value = []
connectWebSocket(taskId)
return taskId
}
async function pollTaskStatus(taskId: string) {
const res = await api.get(`/processing/status/${taskId}`)
return res.data
}
return { currentTask, tasks, logs, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
})
+395
View File
@@ -0,0 +1,395 @@
/* ═══════════════════════════════════════════
益选 OCR — shadcn/ui Theme
Clean · Minimal · Zinc palette
═══════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
/* ── Backgrounds ── */
--bg-page: #fafafa;
--bg-card: #ffffff;
--bg-sidebar: #09090b;
--bg-hover: #f4f4f5;
--bg-dark: #09090b;
/* ── Semantic colors (shadcn zinc) ── */
--primary: #18181b;
--primary-hover: #27272a;
--primary-active: #09090b;
--primary-light: #f5f5f5;
--success: #22c55e;
--success-light: #f0fdf4;
--warning: #f97316;
--warning-light: #fff7ed;
--danger: #ef4444;
--danger-light: #fef2f2;
--info: #18181b;
/* ── Text ── */
--text-primary: #18181b;
--text-secondary: #525252;
--text-muted: #a1a1aa;
--text-inverse: #ffffff;
--text-sidebar: #a1a1aa;
--text-sidebar-active: #fafafa;
/* ── Borders ── */
--border-light: #e4e4e7;
--border-subtle: #f4f4f5;
--border-focus: #18181b;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
--shadow-md: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
/* ── Radius ── */
--radius: 10px;
--radius-lg: 14px;
--radius-sm: 6px;
/* ── Typography ── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 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 — shadcn Overrides
═══════════════════════════════════════════ */
/* ── Card ── */
.el-card {
border: 1px solid var(--border-light) !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-sm) !important;
background: var(--bg-card) !important;
transition: box-shadow 0.2s, border-color 0.2s !important;
}
.el-card:hover {
box-shadow: var(--shadow-md) !important;
border-color: #d4d4d8 !important;
}
/* ── Buttons ── */
.el-button {
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
letter-spacing: 0;
transition: all 0.15s ease !important;
box-shadow: none !important;
}
.el-button--default {
border: 1px solid #e4e4e7 !important;
box-shadow: none !important;
background: #ffffff !important;
color: #18181b !important;
}
.el-button--default:hover {
background: #f4f4f5 !important;
border-color: #d4d4d8 !important;
color: #18181b !important;
}
.el-button--default:active {
background: #e4e4e7 !important;
}
.el-button--primary {
background: #18181b !important;
border: 1px solid #18181b !important;
box-shadow: none !important;
color: #fff !important;
}
.el-button--primary:hover {
background: #27272a !important;
border-color: #27272a !important;
}
.el-button--primary:active {
background: #09090b !important;
}
.el-button--danger {
background: #ef4444 !important;
border: 1px solid #ef4444 !important;
box-shadow: none !important;
color: #fff !important;
}
.el-button--danger:hover {
background: #dc2626 !important;
border-color: #dc2626 !important;
}
.el-button--warning {
border: 1px solid #f97316 !important;
box-shadow: none !important;
}
.el-button--success {
border: 1px solid #22c55e !important;
box-shadow: none !important;
}
/* Link buttons — no border/shadow */
.el-button--primary.is-link,
.el-button--danger.is-link,
.el-button--default.is-link {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
.el-button--primary.is-link {
color: #18181b !important;
}
.el-button--primary.is-link:hover {
background: transparent !important;
color: #27272a !important;
}
.el-button--danger.is-link {
color: #ef4444 !important;
}
.el-button--danger.is-link:hover {
color: #dc2626 !important;
}
/* Small buttons */
.el-button--small {
border-radius: var(--radius-sm) !important;
}
/* ── Input / Select ── */
.el-input__wrapper,
.el-select .el-input__wrapper {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-sm) !important;
box-shadow: none !important;
background: #ffffff !important;
transition: border-color 0.15s ease !important;
}
.el-input__wrapper:hover,
.el-select .el-input__wrapper:hover {
border-color: #a1a1aa !important;
}
.el-input__wrapper.is-focus,
.el-select .el-input__wrapper.is-focus {
border-color: #18181b !important;
box-shadow: 0 0 0 1px #18181b !important;
}
/* ── Table ── */
.el-table {
--el-table-border-color: #e4e4e7;
--el-table-header-bg-color: #fafafa;
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius) !important;
overflow: hidden;
box-shadow: none !important;
}
.el-table th.el-table__cell {
font-weight: 600 !important;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #525252 !important;
background: #fafafa !important;
}
.el-table td.el-table__cell {
border-bottom: 1px solid #f4f4f5 !important;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: #fafafa !important;
}
/* ── Dialog ── */
.el-dialog {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg) !important;
overflow: hidden;
}
.el-dialog__header {
border-bottom: 1px solid #e4e4e7;
padding: 16px 20px !important;
}
.el-dialog__footer {
border-top: 1px solid #e4e4e7;
padding: 12px 20px !important;
}
/* ── Tag ── */
.el-tag {
border: 1px solid transparent !important;
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
font-size: 11px;
letter-spacing: 0.2px;
}
.el-tag--success {
background: #f0fdf4 !important;
color: #16a34a !important;
border-color: #bbf7d0 !important;
}
.el-tag--warning {
background: #fff7ed !important;
color: #ea580c !important;
border-color: #fed7aa !important;
}
.el-tag--danger {
background: #fef2f2 !important;
color: #dc2626 !important;
border-color: #fecaca !important;
}
.el-tag--info {
background: #f5f5f5 !important;
color: #525252 !important;
border-color: #e4e4e7 !important;
}
/* ── Progress ── */
.el-progress-bar__outer {
border: none !important;
border-radius: 999px !important;
box-shadow: none !important;
background: #f4f4f5 !important;
height: 8px !important;
}
.el-progress-bar__inner {
border-radius: 999px !important;
transition: width 0.4s ease;
border: none !important;
}
/* ── Dropdown / Popover ── */
.el-dropdown-menu,
.el-popover.el-popper {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
/* ── Menu (sidebar sub-menu) ── */
.el-menu {
border-right: none !important;
}
.el-sub-menu .el-menu {
background: rgba(255,255,255,0.03) !important;
}
.el-sub-menu .el-menu-item {
padding-left: 52px !important;
font-size: 13px;
}
.el-menu-item.is-active {
color: var(--primary) !important;
}
/* ── Breadcrumb ── */
.el-breadcrumb__inner {
font-weight: 500;
}
/* ── Message / Notification ── */
.el-message {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
/* ── Tooltip ── */
.el-tooltip__popper.is-dark {
border: none !important;
border-radius: var(--radius-sm) !important;
}
/* ═══════════════════════════════════════════
Scrollbar
═══════════════════════════════════════════ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d4d4d8;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1aa;
}
/* ═══════════════════════════════════════════
Animations
═══════════════════════════════════════════ */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-in {
animation: fadeInUp 0.3s var(--ease-out) both;
}
.animate-in-delay-1 { animation-delay: 0.05s; }
.animate-in-delay-2 { animation-delay: 0.1s; }
.animate-in-delay-3 { animation-delay: 0.15s; }
.animate-in-delay-4 { animation-delay: 0.2s; }
+274
View File
@@ -0,0 +1,274 @@
<template>
<div class="barcodes-page">
<!-- Stats row -->
<div class="stats-row animate-in">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
<el-icon :size="20" color="#6366f1"><Connection /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ items.length }}</span>
<span class="stat-label">映射规则</span>
</div>
</div>
</div>
<!-- Main table card -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>条码映射管理</h3>
<div class="card-actions">
<el-input
v-model="search"
placeholder="搜索条码..."
clearable
style="width: 200px"
@keyup.enter="loadData"
@clear="loadData"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="primary" @click="openAdd" :icon="Plus">新增映射</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="600" size="small" class="barcode-table">
<el-table-column prop="barcode" label="原始条码" width="200">
<template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column label="映射" width="60" align="center">
<template #default>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</template>
</el-table-column>
<el-table-column prop="target" label="目标条码" width="200">
<template #default="{ row }">
<span class="barcode-cell target">{{ row.target }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Add/Edit dialog -->
<el-dialog v-model="showAdd" :title="isEdit ? '编辑映射' : '新增映射'" width="450px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px">
<el-form-item label="原始条码">
<el-input v-model="form.barcode" :disabled="isEdit" placeholder="输入原始条码" />
</el-form-item>
<el-form-item label="目标条码">
<el-input v-model="form.target" placeholder="输入目标条码" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" placeholder="映射说明(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdd = false">取消</el-button>
<el-button type="primary" @click="saveMapping">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Connection } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const items = ref<any[]>([])
const showAdd = ref(false)
const isEdit = ref(false)
const form = reactive({
barcode: '',
target: '',
description: '',
})
async function loadData() {
loading.value = true
try {
const res = await api.get('/barcodes', { params: { search: search.value } })
items.value = res.data.items
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function openAdd() {
resetForm()
showAdd.value = true
}
function editItem(row: any) {
isEdit.value = true
form.barcode = row.barcode
form.target = row.target
form.description = row.description || ''
showAdd.value = true
}
function resetForm() {
form.barcode = ''
form.target = ''
form.description = ''
isEdit.value = false
}
async function saveMapping() {
if (!form.barcode || !form.target) {
ElMessage.warning('请填写条码和目标')
return
}
try {
if (isEdit.value) {
await api.put(`/barcodes/${form.barcode}`, {
target: form.target,
description: form.description,
})
ElMessage.success('已更新')
} else {
await api.post('/barcodes', form)
ElMessage.success('已创建')
}
showAdd.value = false
resetForm()
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
async function deleteItem(row: any) {
try {
await ElMessageBox.confirm(`确定删除映射 ${row.barcode}${row.target}`, '确认')
await api.delete(`/barcodes/${row.barcode}`)
ElMessage.success('已删除')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.barcodes-page {
max-width: 1200px;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 16px;
margin-bottom: 20px;
max-width: 300px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border-light);
transition: all 0.2s var(--ease-out);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* ── Card ── */
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* ── Table ── */
.barcode-table {
border-radius: 10px;
overflow: hidden;
}
.barcode-cell {
font-family: var(--font-mono);
font-size: 13px;
color: var(--info);
}
.barcode-cell.target {
color: var(--success);
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<template>
<div class="config-page">
<!-- Header card -->
<div class="card animate-in">
<div class="card-head">
<h3>系统配置</h3>
<el-button type="primary" size="small" :loading="saving" @click="saveAll">
保存所有修改
</el-button>
</div>
<div class="config-layout">
<!-- Section sidebar -->
<div class="section-nav">
<button
v-for="(_, name) in config"
:key="name"
class="section-btn"
:class="{ active: activeTab === name }"
@click="activeTab = name"
>
<el-icon :size="16"><Setting /></el-icon>
<span>{{ sectionLabels[name] || name }}</span>
</button>
</div>
<!-- Config fields -->
<div class="config-fields">
<div v-if="!activeTab" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
<p>选择左侧配置分类</p>
</div>
<div v-else class="field-list">
<div
v-for="(value, key) in config[activeTab]"
:key="key"
class="field-row"
>
<label class="field-label">{{ key }}</label>
<el-input
:model-value="getEditedValue(activeTab, key, value)"
@update:model-value="setEditedValue(activeTab, key, $event)"
size="small"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Setting } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const saving = ref(false)
const activeTab = ref('')
const config = ref<Record<string, Record<string, string>>>({})
const edited: Record<string, Record<string, string>> = {}
const sectionLabels: Record<string, string> = {
API: 'API 配置',
Paths: '路径设置',
Performance: '性能参数',
File: '文件设置',
Templates: '模板配置',
Gitea: 'Gitea 同步',
WebAuth: 'Web 认证',
}
async function loadConfig() {
loading.value = true
try {
const res = await api.get('/config')
config.value = res.data
const keys = Object.keys(res.data)
if (keys.length > 0 && !activeTab.value) {
activeTab.value = keys[0]
}
} catch {
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
function getEditedValue(section: string, key: string, original: string): string {
return edited[section]?.[key] ?? original
}
function setEditedValue(section: string, key: string, value: string) {
if (!edited[section]) edited[section] = {}
edited[section][key] = value
}
async function saveAll() {
const updates: { section: string; key: string; value: string }[] = []
for (const [section, keys] of Object.entries(edited)) {
for (const [key, value] of Object.entries(keys)) {
updates.push({ section, key, value })
}
}
if (updates.length === 0) {
ElMessage.info('没有修改')
return
}
saving.value = true
try {
await api.put('/config/bulk', { updates })
ElMessage.success(`已保存 ${updates.length} 项配置`)
for (const key of Object.keys(edited)) {
delete edited[key]
}
loadConfig()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
onMounted(loadConfig)
</script>
<style scoped>
.config-page {
max-width: 1200px;
}
/* ── Card ── */
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Config layout ── */
.config-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
min-height: 400px;
}
/* ── Section nav ── */
.section-nav {
display: flex;
flex-direction: column;
gap: 4px;
border-right: 1px solid var(--border-subtle);
padding-right: 16px;
}
.section-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s var(--ease-out);
text-align: left;
}
.section-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.section-btn.active {
background: rgba(99,102,241,0.08);
color: var(--info);
}
/* ── Config fields ── */
.config-fields {
min-height: 300px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
}
.field-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-row {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
align-items: center;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
}
</style>
+802
View File
@@ -0,0 +1,802 @@
<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"
:class="{ clickable: stat.route }"
@click="stat.route && $router.push(stat.route)"
>
<div class="stat-icon" :style="{ background: stat.bg }">
<span class="stat-emoji">{{ stat.emoji }}</span>
</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: Progress + Logs -->
<div class="col-left">
<!-- Progress -->
<div class="card progress-card animate-in animate-in-delay-1">
<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-bar-wrapper">
<div class="progress-bar-track">
<div
class="progress-bar-fill"
:style="{ width: currentTask.progress + '%', background: statusColor }"
></div>
</div>
</div>
<div class="progress-meta">
<span class="progress-pct" :style="{ color: statusColor }">{{ currentTask.progress }}%</span>
<span class="progress-msg">{{ currentTask.message }}</span>
</div>
</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-2">
<div class="card-head">
<h3>处理日志</h3>
<el-button size="small" link @click="clearLogs">清空</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>
<!-- Right column: Upload + Actions -->
<div class="col-right">
<!-- Upload zone -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>文件上传</h3>
<el-button size="small" @click="refreshStats" :icon="Refresh">刷新</el-button>
</div>
<div
class="drop-zone"
:class="{ dragover: isDragOver }"
@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 (自动OCR) / 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-section">
<div class="upload-info">
<span class="upload-filename">{{ uploadingName }}</span>
<span class="upload-pct">{{ uploadPct }}%</span>
</div>
<div class="upload-bar">
<div class="upload-bar-fill" :style="{ width: uploadPct + '%' }"></div>
</div>
</div>
</div>
<!-- Quick actions -->
<div class="card animate-in animate-in-delay-2">
<div class="card-head">
<h3>快捷操作</h3>
</div>
<div class="action-grid">
<button class="action-btn" @click="runPipeline" :disabled="processing">
<div class="action-icon secondary">
<svg width="20" height="20" 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">处理Excel生成采购单</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Document, Grid } 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 uploadingName = ref('')
const processing = ref(false)
const fileInput = ref<HTMLInputElement>()
const logBox = ref<HTMLElement>()
const detailedStats = ref({
input_images: 0,
output_excel: 0,
unprocessed_images: 0,
unprocessed_excel: 0,
completed_results: 0,
total_processed: 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: '#a1a1aa',
running: '#f97316',
completed: '#22c55e',
failed: '#ef4444',
}
return m[currentTask.value?.status || ''] || '#a1a1aa'
})
const statusText = computed(() => {
const m: Record<string, string> = {
pending: '等待中',
running: '运行中',
completed: '已完成',
failed: '已失败',
}
return m[currentTask.value?.status || ''] || ''
})
const stats = computed(() => [
{
label: '未处理图片',
value: detailedStats.value.unprocessed_images,
emoji: '🖼️',
bg: '#f4f4f5',
route: '/files/images',
},
{
label: '待处理Excel',
value: detailedStats.value.unprocessed_excel,
emoji: '📊',
bg: '#f4f4f5',
route: '/files/tables',
},
{
label: '已完成采购单',
value: detailedStats.value.completed_results,
emoji: '✅',
bg: '#f0fdf4',
route: '/files/orders',
},
{
label: '已处理总数',
value: detailedStats.value.total_processed,
emoji: '📦',
bg: '#f4f4f5',
route: null,
},
])
function fmtSize(b: number): string {
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): string {
const d = new Date()
d.setSeconds(d.getSeconds() - (logs.value.length - i))
return d.toTimeString().slice(0, 8)
}
function logCls(line: string): string {
if (line.includes('失败') || line.includes('错误')) return 'err'
if (line.includes('完成')) return 'ok'
return ''
}
function clearLogs(): void {
ps.logs.splice(0)
}
async function refreshStats(): Promise<void> {
try {
const res = await api.get('/files/stats/detailed')
detailedStats.value = res.data
} catch {
// silent
}
}
function triggerInput(): void {
fileInput.value?.click()
}
async function handleDrop(e: DragEvent): Promise<void> {
isDragOver.value = false
if (e.dataTransfer?.files) {
await upload(Array.from(e.dataTransfer.files))
}
}
async function handleSelect(e: Event): Promise<void> {
const el = e.target as HTMLInputElement
if (el.files) {
await upload(Array.from(el.files))
el.value = ''
}
}
function getTargetDir(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''
if (['jpg', 'jpeg', 'png', 'bmp'].includes(ext)) return 'input'
if (['xls', 'xlsx'].includes(ext)) return 'output'
return 'input'
}
function getFileTypeLabel(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''
if (['jpg', 'jpeg', 'png', 'bmp'].includes(ext)) return 'OCR'
if (['xls', 'xlsx'].includes(ext)) return 'Excel'
return ''
}
async function upload(files: File[]): Promise<void> {
uploading.value = true
uploadPct.value = 0
const uploadedFiles: { name: string; type: string }[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
uploadingName.value = file.name
const target = getTargetDir(file.name)
const fd = new FormData()
fd.append('file', file)
try {
await api.post(`/files/upload?target=${target}`, fd, {
onUploadProgress: (e) => {
uploadPct.value = Math.round(
((i + (e.loaded / (e.total || 1))) / files.length) * 100
)
},
})
const typeLabel = getFileTypeLabel(file.name)
uploadedFiles.push({ name: file.name, type: typeLabel })
ElMessage.success(`${file.name}${typeLabel === 'OCR' ? 'OCR识别队列' : 'Excel处理队列'}`)
} catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`)
}
}
uploading.value = false
uploadingName.value = ''
uploadPct.value = 0
refreshStats()
// Auto-process: run pipeline for images, excel for Excel files
if (uploadedFiles.length > 0) {
const hasImages = uploadedFiles.some(f => f.type === 'OCR')
const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
if (hasImages) {
ElMessage.info('自动启动OCR识别...')
await doAction('/processing/ocr-batch')
} else if (hasExcel) {
ElMessage.info('自动启动Excel处理...')
await doAction('/processing/excel')
}
}
}
async function doAction(endpoint: string): Promise<void> {
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')
// Auto-refresh stats when task completes
watch(
() => currentTask.value?.status,
(status) => {
if (status === 'completed' || status === 'failed') {
refreshStats()
}
}
)
// Auto-scroll log panel
watch(
logs,
async () => {
await nextTick()
if (logBox.value) {
logBox.value.scrollTop = logBox.value.scrollHeight
}
},
{ deep: true }
)
onMounted(() => {
refreshStats()
})
</script>
<style scoped>
.dashboard {
width: 100%;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--border-light);
transition: border-color 0.15s ease;
}
.stat-card:hover {
border-color: #d4d4d8;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-emoji {
font-size: 20px;
line-height: 1;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
font-family: var(--font-mono);
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
}
/* ── Main grid ── */
.main-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: 16px;
align-items: start;
}
/* ── Card ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 12px;
transition: border-color 0.15s ease;
}
.card:hover {
border-color: #d4d4d8;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-head h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* ── Drop zone ── */
.drop-zone {
border: 1px dashed #d4d4d8;
border-radius: var(--radius);
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: all 0.15s ease;
background: #fafafa;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #a1a1aa;
background: #f4f4f5;
}
.drop-zone.dragover {
border-color: var(--primary);
}
.drop-icon {
color: #a1a1aa;
margin-bottom: 12px;
transition: color 0.15s;
}
.drop-zone:hover .drop-icon {
color: var(--text-primary);
}
.drop-text {
font-size: 14px;
color: var(--text-secondary);
}
.drop-link {
color: var(--text-primary);
font-weight: 600;
}
.drop-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
/* ── Upload progress ── */
.upload-section {
margin-top: 14px;
}
.upload-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.upload-filename {
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
}
.upload-pct {
font-size: 12px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-primary);
}
.upload-bar {
height: 4px;
background: #f4f4f5;
border-radius: 999px;
overflow: hidden;
}
.upload-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 999px;
transition: width 0.3s ease;
}
/* ── Action buttons ── */
.action-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 1px solid #e4e4e7;
border-radius: var(--radius-sm);
background: #ffffff;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
}
.action-btn:hover:not(:disabled) {
background: #f4f4f5;
border-color: #d4d4d8;
}
.action-btn:active:not(:disabled) {
background: #e4e4e7;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-icon.secondary {
background: #f4f4f5;
color: #525252;
}
.action-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.action-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.action-desc {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Progress area ── */
.progress-card {
display: flex;
flex-direction: column;
}
.progress-area {
padding: 4px 0;
}
.progress-bar-wrapper {
margin-bottom: 12px;
}
.progress-bar-track {
width: 100%;
height: 6px;
background: #f4f4f5;
border-radius: 999px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.5s ease;
min-width: 0;
}
.progress-meta {
display: flex;
align-items: center;
gap: 12px;
}
.progress-pct {
font-size: 20px;
font-weight: 700;
font-family: var(--font-mono);
line-height: 1;
}
.progress-msg {
font-size: 13px;
color: var(--text-muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── 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 {
display: flex;
flex-direction: column;
max-height: 600px;
}
.log-box {
flex: 1;
max-height: 600px;
min-height: 400px;
overflow-y: auto;
background: #09090b;
border-radius: var(--radius-sm);
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
}
.log-line {
color: #a1a1aa;
padding: 1px 0;
}
.log-line.err {
color: #ef4444;
}
.log-line.ok {
color: #22c55e;
}
.log-time {
color: #525252;
margin-right: 8px;
font-size: 11px;
user-select: none;
}
/* ── Responsive ── */
@media (max-width: 1024px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.main-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.stats-row {
grid-template-columns: 1fr;
}
.action-grid {
grid-template-columns: 1fr;
}
}
</style>
+447
View File
@@ -0,0 +1,447 @@
<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 -->
<el-menu
:default-active="route.path"
:default-openeds="filesMenuOpen"
:collapse="isCollapse"
mode="vertical"
background-color="transparent"
text-color="var(--text-sidebar)"
active-text-color="#fafafa"
class="sidebar-nav"
router
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<template #title>处理中心</template>
</el-menu-item>
<el-sub-menu index="/files">
<template #title>
<el-icon><FolderOpened /></el-icon>
<span>文件处理</span>
</template>
<el-menu-item index="/files/orders">采购单</el-menu-item>
<el-menu-item index="/files/tables">表格处理</el-menu-item>
<el-menu-item index="/files/images">图片处理</el-menu-item>
</el-sub-menu>
<el-menu-item index="/tasks">
<el-icon><Timer /></el-icon>
<template #title>任务历史</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Notebook /></el-icon>
<template #title>日志中心</template>
</el-menu-item>
<el-menu-item index="/memory">
<el-icon><Memo /></el-icon>
<template #title>记忆库</template>
</el-menu-item>
<el-menu-item index="/barcodes">
<el-icon><Connection /></el-icon>
<template #title>条码映射</template>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<template #title>系统配置</template>
</el-menu-item>
<el-menu-item index="/sync">
<el-icon><Cloudy /></el-icon>
<template #title>云端同步</template>
</el-menu-item>
</el-menu>
<!-- 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, FolderOpened,
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 filesMenuOpen = ['/files']
const pageTitles: Record<string, string> = {
'/': '处理中心',
'/files/orders': '采购单',
'/files/tables': '表格处理',
'/files/images': '图片处理',
'/tasks': '任务历史',
'/logs': '日志中心',
'/memory': '记忆库',
'/barcodes': '条码映射',
'/config': '系统配置',
'/sync': '云端同步',
}
const pageTitle = computed(() => {
return pageTitles[route.path] || '处理中心'
})
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: #09090b;
display: flex;
flex-direction: column;
transition: width 0.3s var(--ease-out);
overflow: hidden;
border-right: 1px solid #18181b;
}
.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: #18181b;
border: 1px solid #27272a;
color: #fafafa;
flex-shrink: 0;
}
.logo-text {
font-size: 17px;
font-weight: 700;
color: #fff;
white-space: nowrap;
letter-spacing: -0.3px;
}
/* ── Nav items (el-menu) ── */
.sidebar-nav {
flex: 1;
padding: 0 12px;
border-right: none;
overflow: hidden;
}
.sidebar-nav :deep(.el-menu-item),
.sidebar-nav :deep(.el-sub-menu__title) {
height: 42px;
line-height: 42px;
border-radius: 10px;
margin-bottom: 2px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s var(--ease-out);
}
.sidebar-nav :deep(.el-menu-item:hover),
.sidebar-nav :deep(.el-sub-menu__title:hover) {
background: rgba(255,255,255,0.06) !important;
}
.sidebar-nav :deep(.el-menu-item.is-active) {
background: rgba(255,255,255,0.1) !important;
color: #fafafa !important;
position: relative;
}
.sidebar-nav :deep(.el-menu-item.is-active)::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
border-radius: 0 3px 3px 0;
background: #fafafa;
}
.sidebar-nav :deep(.el-sub-menu .el-menu-item) {
padding-left: 52px !important;
height: 38px;
line-height: 38px;
font-size: 13px;
}
.sidebar-nav :deep(.el-sub-menu .el-menu) {
background: rgba(255,255,255,0.03) !important;
border-radius: 8px;
margin: 2px 0;
}
.sidebar-nav :deep(.el-sub-menu__icon-arrow) {
color: var(--text-sidebar);
}
.sidebar-nav :deep(.el-menu--collapse .el-sub-menu__title span),
.sidebar-nav :deep(.el-menu--collapse .el-sub-menu__title .el-sub-menu__icon-arrow) {
display: none;
}
.sidebar-nav :deep(.el-menu--collapse .el-menu-item .el-icon),
.sidebar-nav :deep(.el-menu--collapse .el-sub-menu__title .el-icon) {
margin: 0;
}
.nav-badge {
margin-left: auto;
font-size: 11px;
background: var(--primary);
color: #fff;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
/* ── Footer ── */
.sidebar-footer {
padding: 12px;
border-top: 1px solid #18181b;
}
.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: #ffffff;
border-bottom: 1px solid #e4e4e7;
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: #18181b;
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>
+226
View File
@@ -0,0 +1,226 @@
<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: #fafafa;
position: relative;
}
.login-container {
position: relative;
width: 380px;
padding: 40px 36px;
background: #ffffff;
border: 1px solid #e4e4e7;
border-radius: 14px;
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1);
}
.brand {
text-align: center;
margin-bottom: 32px;
}
.brand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: #f4f4f5;
color: var(--text-primary);
margin-bottom: 16px;
}
.brand h1 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.brand p {
font-size: 14px;
color: var(--text-muted);
margin-top: 4px;
}
.login-form {
margin-bottom: 24px;
}
.login-form :deep(.el-input__wrapper) {
border: 1px solid #e4e4e7 !important;
border-radius: 6px !important;
box-shadow: none !important;
background: #ffffff !important;
transition: border-color 0.15s ease;
}
.login-form :deep(.el-input__wrapper:hover) {
border-color: #a1a1aa !important;
}
.login-form :deep(.el-input__wrapper.is-focus) {
border-color: #18181b !important;
box-shadow: 0 0 0 1px #18181b !important;
}
.login-form :deep(.el-input__inner) {
color: #18181b;
font-size: 14px;
}
.login-form :deep(.el-input__inner::placeholder) {
color: #a1a1aa;
}
.login-form :deep(.el-input__prefix .el-icon) {
color: #a1a1aa;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
border-radius: 6px !important;
background: #18181b !important;
border: 1px solid #18181b !important;
color: #fff !important;
box-shadow: none !important;
transition: all 0.15s ease;
}
.login-btn:hover {
background: #27272a !important;
border-color: #27272a !important;
}
.login-btn:active {
background: #09090b !important;
}
.hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.hint-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #d4d4d8;
}
</style>
+302
View File
@@ -0,0 +1,302 @@
<template>
<div class="logs-page">
<!-- Stats row -->
<div class="stats-row animate-in">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
<el-icon :size="20" color="#6366f1"><Notebook /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.today_count }}</span>
<span class="stat-label">今日请求</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
<el-icon :size="20" color="#ef4444"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.error_count }}</span>
<span class="stat-label">错误数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><Timer /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.avg_duration_ms }}ms</span>
<span class="stat-label">平均耗时</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ logStats.error_rate }}%</span>
<span class="stat-label">错误率</span>
</div>
</div>
</div>
<!-- Main table card -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>请求日志</h3>
<div class="card-actions">
<el-select v-model="filterMethod" placeholder="方法" clearable size="small" style="width: 100px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态码" clearable size="small" style="width: 100px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="2xx" value="200" />
<el-option label="4xx" value="400" />
<el-option label="5xx" value="500" />
</el-select>
<el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @keyup.enter="loadData" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="500" size="small" class="log-table">
<el-table-column prop="timestamp" label="时间" width="170">
<template #default="{ row }">
<span class="time-cell">{{ formatTime(row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="method" label="方法" width="80">
<template #default="{ row }">
<span class="method-tag" :class="row.method.toLowerCase()">{{ row.method }}</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="250" show-overflow-tooltip />
<el-table-column prop="status_code" label="状态码" width="80">
<template #default="{ row }">
<span class="status-code" :class="statusCls(row.status_code)">{{ row.status_code }}</span>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="90">
<template #default="{ row }">
<span class="duration-cell" :class="{ slow: row.duration_ms > 1000 }">{{ row.duration_ms?.toFixed(0) }}ms</span>
</template>
</el-table-column>
<el-table-column prop="user" label="用户" width="80" />
</el-table>
<div class="pagination-bar">
<span class="pagination-info"> {{ total }} 条记录</span>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadData" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const searchPath = ref('')
const filterMethod = ref('')
const filterStatus = ref('')
const items = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const logStats = reactive({ today_count: 0, error_count: 0, avg_duration_ms: 0, error_rate: 0 })
function formatTime(iso: string) {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function statusCls(code: number) {
if (code >= 500) return 's5xx'
if (code >= 400) return 's4xx'
return 's2xx'
}
async function loadData() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterMethod.value) params.method = filterMethod.value
if (filterStatus.value) params.status_code = parseInt(filterStatus.value)
if (searchPath.value) params.path = searchPath.value
const res = await api.get('/logs', { params })
items.value = res.data.items
total.value = res.data.total
} catch {
ElMessage.error('加载日志失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await api.get('/logs/stats')
Object.assign(logStats, res.data)
} catch {}
}
onMounted(() => {
loadData()
loadStats()
})
</script>
<style scoped>
.logs-page {
width: 100%;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border-light);
transition: all 0.2s var(--ease-out);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
display: flex;
gap: 8px;
align-items: center;
}
.log-table {
border-radius: 10px;
overflow: hidden;
}
.time-cell {
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.method-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
}
.method-tag.get { background: rgba(16,185,129,0.1); color: #10b981; }
.method-tag.post { background: rgba(99,102,241,0.1); color: #6366f1; }
.method-tag.put { background: rgba(245,158,11,0.1); color: #f59e0b; }
.method-tag.delete { background: rgba(239,68,68,0.1); color: #ef4444; }
.status-code {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.status-code.s2xx { color: #10b981; }
.status-code.s4xx { color: #f59e0b; }
.status-code.s5xx { color: #ef4444; }
.duration-cell {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.duration-cell.slow {
color: #ef4444;
font-weight: 600;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.pagination-info {
font-size: 13px;
color: var(--text-muted);
}
</style>
+380
View File
@@ -0,0 +1,380 @@
<template>
<div class="memory-page">
<!-- Stats row -->
<div class="stats-row animate-in">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
<el-icon :size="20" color="#6366f1"><Memo /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">总记录数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><CircleCheck /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ highConfidence }}</span>
<span class="stat-label">高置信度</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ lowConfidence }}</span>
<span class="stat-label">低置信度</span>
</div>
</div>
</div>
<!-- Main table card -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>商品记忆库</h3>
<div class="card-actions">
<el-input
v-model="search"
placeholder="搜索条码或名称..."
clearable
style="width: 240px"
@keyup.enter="loadData"
@clear="loadData"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="warning" plain @click="reimport">重新导入</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
stripe
max-height="600"
size="small"
class="memory-table"
>
<el-table-column prop="barcode" label="条码" width="150" fixed>
<template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="120" />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="price" label="单价" width="100">
<template #default="{ row }">
<span class="price-cell">{{ row.price != null ? row.price.toFixed(4) : '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="100">
<template #default="{ row }">
<span class="confidence-badge" :class="confCls(row.confidence)">
{{ row.confidence }}
</span>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="80" />
<el-table-column prop="use_count" label="使用次数" width="90" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<span class="pagination-info"> {{ total }} 条记录</span>
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
<!-- Edit dialog -->
<el-dialog v-model="showEdit" title="编辑记忆记录" width="480px" :close-on-click-modal="false">
<el-form :model="editForm" label-width="80px">
<el-form-item label="条码">
<el-input :model-value="editForm.barcode" disabled />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="editForm.spec" />
</el-form-item>
<el-form-item label="单位">
<el-input v-model="editForm.unit" />
</el-form-item>
<el-form-item label="单价">
<el-input-number v-model="editForm.price" :precision="4" :step="0.01" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="置信度">
<el-slider v-model="editForm.confidence" :max="100" show-input />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const items = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const highConfidence = computed(() => items.value.filter(i => i.confidence >= 80).length)
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 50).length)
const showEdit = ref(false)
const editForm = reactive({
barcode: '',
name: '',
spec: '',
unit: '',
price: 0,
confidence: 0,
})
function confCls(c: number) {
if (c >= 80) return 'high'
if (c >= 50) return 'mid'
return 'low'
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/memory', {
params: { search: search.value, page: page.value, page_size: pageSize.value },
})
items.value = res.data.items
total.value = res.data.total
} catch (err: any) {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function editItem(row: any) {
editForm.barcode = row.barcode
editForm.name = row.name || ''
editForm.spec = row.spec || ''
editForm.unit = row.unit || ''
editForm.price = row.price || 0
editForm.confidence = row.confidence || 0
showEdit.value = true
}
async function saveEdit() {
try {
await api.put(`/memory/${editForm.barcode}`, {
name: editForm.name,
spec: editForm.spec,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
ElMessage.success('保存成功')
showEdit.value = false
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '保存失败')
}
}
async function deleteItem(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.barcode} 的记忆记录?`, '确认')
await api.delete(`/memory/${row.barcode}`)
ElMessage.success('已删除')
loadData()
} catch {}
}
async function reimport() {
try {
await ElMessageBox.confirm('将从模板文件重新导入商品数据,确定继续?', '确认')
loading.value = true
const res = await api.post('/memory/reimport')
ElMessage.success(res.data.message)
loadData()
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.response?.data?.detail || '导入失败')
}
} finally {
loading.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.memory-page {
width: 100%;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border-light);
transition: all 0.2s var(--ease-out);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* ── Card ── */
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* ── Table ── */
.memory-table {
border-radius: 10px;
overflow: hidden;
}
.barcode-cell {
font-family: var(--font-mono);
font-size: 13px;
color: var(--info);
}
.price-cell {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.confidence-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-mono);
}
.confidence-badge.high {
background: rgba(16,185,129,0.1);
color: #10b981;
}
.confidence-badge.mid {
background: rgba(245,158,11,0.1);
color: #f59e0b;
}
.confidence-badge.low {
background: rgba(239,68,68,0.1);
color: #ef4444;
}
/* ── Pagination ── */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.pagination-info {
font-size: 13px;
color: var(--text-muted);
}
</style>
+371
View File
@@ -0,0 +1,371 @@
<template>
<div class="sync-page">
<!-- Status card -->
<div class="card animate-in">
<div class="card-head">
<h3>云端同步</h3>
<el-button size="small" @click="checkStatus" :icon="Refresh">刷新状态</el-button>
</div>
<!-- Enabled state -->
<div v-if="syncStatus.enabled" class="sync-enabled">
<!-- Connection info -->
<div class="connection-card">
<div class="connection-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="1.5">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="connection-info">
<span class="connection-status">已连接</span>
<span class="connection-url">{{ syncStatus.repo_url }}</span>
</div>
</div>
<!-- Action buttons -->
<div class="sync-actions">
<button class="sync-btn push" @click="doPush" :disabled="syncing">
<div class="sync-btn-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17,8 12,3 7,8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<div class="sync-btn-info">
<span class="sync-btn-name">推送到云端</span>
<span class="sync-btn-desc">上传本地数据到 Gitea</span>
</div>
</button>
<button class="sync-btn pull" @click="doPull" :disabled="syncing">
<div class="sync-btn-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<div class="sync-btn-info">
<span class="sync-btn-name">从云端拉取</span>
<span class="sync-btn-desc">下载远程数据到本地</span>
</div>
</button>
</div>
<!-- Sync progress -->
<div v-if="currentTask" class="progress-section animate-in">
<div class="progress-header">
<span class="progress-title">同步进度</span>
<el-tag :type="statusType" size="small" effect="dark">{{ statusText }}</el-tag>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" :style="{ width: currentTask.progress + '%' }"></div>
</div>
<p class="progress-msg">{{ currentTask.message }}</p>
<div v-if="logs.length > 0" class="sync-logs">
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="logCls(line)">{{ line }}</div>
</div>
</div>
</div>
<!-- Disabled state -->
<div v-else class="sync-disabled">
<div class="disabled-icon">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h4>云端同步未启用</h4>
<p>请在系统配置页面设置 Gitea 相关参数以启用同步功能</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { useProcessingStore } from '../stores/processing'
import api from '../api'
const processingStore = useProcessingStore()
const syncing = ref(false)
const syncStatus = ref({ enabled: false, repo_url: '' })
const currentTask = computed(() => processingStore.currentTask)
const logs = computed(() => processingStore.logs)
const statusType = computed(() => {
const m: Record<string, string> = { pending: 'info', running: 'warning', completed: 'success', failed: 'danger' }
return m[currentTask.value?.status || ''] || 'info'
})
const statusText = computed(() => {
const m: Record<string, string> = { pending: '等待中', running: '同步中', completed: '已完成', failed: '已失败' }
return m[currentTask.value?.status || ''] || ''
})
function logCls(line: string) {
if (line.includes('失败') || line.includes('错误')) return 'err'
if (line.includes('完成')) return 'ok'
return ''
}
async function checkStatus() {
try {
const res = await api.get('/sync/status')
syncStatus.value = res.data
} catch {}
}
async function doPush() {
syncing.value = true
try {
await processingStore.startTask('/sync/push')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '推送失败')
} finally {
syncing.value = false
}
}
async function doPull() {
syncing.value = true
try {
await processingStore.startTask('/sync/pull')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '拉取失败')
} finally {
syncing.value = false
}
}
onMounted(checkStatus)
</script>
<style scoped>
.sync-page {
max-width: 800px;
}
/* ── Card ── */
.card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Connection info ── */
.connection-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
background: rgba(16,185,129,0.05);
border: 1px solid rgba(16,185,129,0.15);
border-radius: 12px;
margin-bottom: 24px;
}
.connection-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(16,185,129,0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.connection-status {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--success);
}
.connection-url {
display: block;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-top: 2px;
}
/* ── Sync actions ── */
.sync-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.sync-btn {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border: 1px solid var(--border-light);
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: all 0.2s var(--ease-out);
text-align: left;
}
.sync-btn:hover:not(:disabled) {
border-color: var(--amber-400);
box-shadow: 0 0 0 3px rgba(255,193,7,0.08);
transform: translateY(-1px);
}
.sync-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sync-btn-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sync-btn.push .sync-btn-icon {
background: rgba(99,102,241,0.08);
color: var(--info);
}
.sync-btn.pull .sync-btn-icon {
background: rgba(16,185,129,0.08);
color: var(--success);
}
.sync-btn-name {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.sync-btn-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Progress ── */
.progress-section {
padding: 20px;
background: #fafbfc;
border-radius: 12px;
border: 1px solid var(--border-subtle);
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.progress-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.progress-bar-wrap {
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--amber-400), var(--amber-600));
border-radius: 3px;
transition: width 0.4s var(--ease-out);
}
.progress-msg {
margin-top: 8px;
font-size: 13px;
color: var(--text-secondary);
}
/* ── Logs ── */
.sync-logs {
margin-top: 16px;
background: #0f1117;
border-radius: 10px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
max-height: 200px;
overflow-y: auto;
}
.log-line {
color: #94a3b8;
padding: 1px 0;
}
.log-line.err { color: #f87171; }
.log-line.ok { color: #34d399; }
/* ── Disabled state ── */
.sync-disabled {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.disabled-icon {
opacity: 0.5;
margin-bottom: 4px;
}
.sync-disabled h4 {
font-size: 16px;
font-weight: 600;
color: var(--text-secondary);
}
.sync-disabled p {
font-size: 13px;
color: var(--text-muted);
}
</style>
+432
View File
@@ -0,0 +1,432 @@
<template>
<div class="tasks-page">
<!-- Stats row -->
<div class="stats-row animate-in">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
<el-icon :size="20" color="#6366f1"><Timer /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.total }}</span>
<span class="stat-label">总任务数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><CircleCheck /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.completed }}</span>
<span class="stat-label">成功</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
<el-icon :size="20" color="#ef4444"><CircleClose /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.failed }}</span>
<span class="stat-label">失败</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Loading /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ taskStats.running }}</span>
<span class="stat-label">运行中</span>
</div>
</div>
</div>
<!-- Main table card -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>任务历史</h3>
<div class="card-actions">
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width: 120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="成功" value="completed" />
<el-option label="失败" value="failed" />
<el-option label="运行中" value="running" />
</el-select>
<el-select v-model="filterName" placeholder="类型" clearable size="small" style="width: 150px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="一键全流程" value="一键全流程处理" />
<el-option label="批量OCR" value="批量OCR识别" />
<el-option label="Excel处理" value="Excel标准化处理" />
<el-option label="合并采购单" value="合并采购单" />
</el-select>
<el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @keyup.enter="loadData" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="500" size="small" class="task-table">
<el-table-column prop="id" label="ID" width="100">
<template #default="{ row }">
<span class="task-id">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="类型" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<span class="status-tag" :class="row.status">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="进度" width="140">
<template #default="{ row }">
<el-progress :percentage="row.progress" :stroke-width="6" :status="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'exception' : ''" />
</template>
</el-table-column>
<el-table-column prop="message" label="消息" min-width="200" show-overflow-tooltip />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
<el-button v-if="row.status === 'failed'" type="warning" link size="small" @click="retryTask(row)">重试</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<span class="pagination-info"> {{ total }} 条记录</span>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadData" />
</div>
</div>
<!-- Detail dialog -->
<el-dialog v-model="showDetailDialog" title="任务详情" width="700px" :close-on-click-modal="false">
<div v-if="detailTask" class="task-detail">
<div class="detail-meta">
<div class="meta-item"><span class="meta-label">任务ID</span><span class="meta-value">{{ detailTask.id }}</span></div>
<div class="meta-item"><span class="meta-label">类型</span><span class="meta-value">{{ detailTask.name }}</span></div>
<div class="meta-item"><span class="meta-label">状态</span><span class="status-tag" :class="detailTask.status">{{ statusLabel(detailTask.status) }}</span></div>
<div class="meta-item"><span class="meta-label">进度</span><span class="meta-value">{{ detailTask.progress }}%</span></div>
</div>
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
<h4>结果文件</h4>
<div v-for="f in detailTask.result_files" :key="f" class="file-chip">{{ f }}</div>
</div>
<div class="detail-logs">
<h4>执行日志</h4>
<div class="log-box">
<div v-if="detailTask.log_lines.length === 0" class="log-empty">暂无日志</div>
<div v-for="(line, i) in detailTask.log_lines" :key="i" class="log-line" :class="logCls(line)">{{ line }}</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="showDetailDialog = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const filterStatus = ref('')
const filterName = ref('')
const items = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 0 })
const showDetailDialog = ref(false)
const detailTask = ref<any>(null)
function statusLabel(s: string) {
const m: Record<string, string> = { pending: '等待中', running: '运行中', completed: '成功', failed: '失败' }
return m[s] || s
}
function formatTime(iso: string) {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function logCls(line: string) {
if (line.includes('失败') || line.includes('错误') || line.includes('Error')) return 'err'
if (line.includes('完成')) return 'ok'
return ''
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/tasks', {
params: { page: page.value, page_size: pageSize.value, status: filterStatus.value, name: filterName.value, search: search.value },
})
items.value = res.data.items
total.value = res.data.total
} catch {
ElMessage.error('加载任务历史失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await api.get('/tasks/stats')
Object.assign(taskStats, res.data)
} catch {}
}
function showDetail(row: any) {
detailTask.value = row
showDetailDialog.value = true
}
async function retryTask(row: any) {
try {
await api.post(`/tasks/${row.id}/retry`)
ElMessage.success('重试任务已创建')
loadData()
loadStats()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '重试失败')
}
}
onMounted(() => {
loadData()
loadStats()
})
</script>
<style scoped>
.tasks-page {
width: 100%;
}
.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>
+249
View File
@@ -0,0 +1,249 @@
<template>
<div class="file-page animate-in">
<div class="page-header">
<div class="header-left">
<h3>图片处理</h3>
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button type="primary" :disabled="!selected.length" @click="batchPipeline">
批量生成采购单 ({{ selected.length }})
</el-button>
<el-button :disabled="!selected.length" @click="batchOcr">
批量OCR
</el-button>
<el-button :disabled="!selected.length" @click="batchDownload">
批量下载
</el-button>
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
@selection-change="onSelect"
stripe
style="width: 100%"
>
<el-table-column type="selection" width="45" />
<el-table-column label="图片文件名" min-width="200">
<template #default="{ row }">
<span class="file-name primary">{{ row.input_image || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.output_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Excel文件" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.output_excel || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.result_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="采购单" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.result_purchase || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="pipelineFile(row)">
生成采购单
</el-button>
<el-button link type="primary" size="small" @click="ocrFile(row)">
仅OCR
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import api from '../../api'
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize } })
items.value = res.data.items
total.value = res.data.total
} catch {}
loading.value = false
}
function onSelect(rows: any[]) { selected.value = rows }
async function pipelineFile(row: any) {
try {
const res = await api.post('/processing/pipeline-single', { filename: row.input_image })
ElMessage.success(`处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function ocrFile(row: any) {
try {
const res = await api.post('/processing/ocr-single', { filename: row.input_image })
ElMessage.success(`OCR任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || 'OCR失败')
}
}
async function downloadFile(row: any) {
const token = localStorage.getItem('token')
if (row.result_purchase) {
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
} else if (row.output_excel) {
window.open(`/api/files/download/output/${encodeURIComponent(row.output_excel)}?token=${token}`, '_blank')
}
}
async function deleteFile(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.input_image}`, '确认')
await api.delete(`/files/input/${encodeURIComponent(row.input_image)}`)
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
ElMessage.success('已删除')
loadData()
} catch {}
}
async function batchPipeline() {
try {
const filenames = selected.value.map(r => r.input_image).filter(Boolean)
const res = await api.post('/processing/pipeline', { files: filenames })
ElMessage.success(`批量处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function batchOcr() {
try {
const filenames = selected.value.map(r => r.input_image).filter(Boolean)
const res = await api.post('/processing/ocr-batch', { files: filenames })
ElMessage.success(`批量OCR任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || 'OCR失败')
}
}
async function batchDownload() {
const token = localStorage.getItem('token')
for (const row of selected.value) {
if (row.result_purchase) {
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
}
}
}
async function batchDelete() {
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
for (const row of selected.value) {
if (row.input_image) {
await api.delete(`/files/input/${encodeURIComponent(row.input_image)}`)
}
if (row.id) {
await api.delete('/files/relations', { data: { ids: [row.id] } })
}
}
ElMessage.success('批量删除完成')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.file-page {
width: 100%;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
}
.file-name.primary {
font-weight: 600;
color: var(--text-primary);
}
.file-name.secondary {
color: var(--text-muted);
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="file-page animate-in">
<div class="page-header">
<div class="header-left">
<h3>采购单管理</h3>
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button type="primary" :disabled="!selected.length" @click="batchMerge">
合并选中 ({{ selected.length }})
</el-button>
<el-button :disabled="!selected.length" @click="batchDownload">
批量下载
</el-button>
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
@selection-change="onSelect"
stripe
style="width: 100%"
>
<el-table-column type="selection" width="45" />
<el-table-column label="采购单文件名" min-width="200">
<template #default="{ row }">
<span class="file-name primary">{{ row.result_purchase || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.output_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Excel处理文件" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.output_excel || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.input_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Input图片" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.input_image || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadFile(row)">
下载
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import api from '../../api'
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize } })
items.value = res.data.items
total.value = res.data.total
} catch {}
loading.value = false
}
function onSelect(rows: any[]) { selected.value = rows }
async function downloadFile(row: any) {
const token = localStorage.getItem('token')
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
}
async function deleteFile(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.result_purchase}`, '确认')
await api.delete(`/files/result/${encodeURIComponent(row.result_purchase)}`)
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
ElMessage.success('已删除')
loadData()
} catch {}
}
async function batchMerge() {
if (!selected.value.length) return
try {
const filenames = selected.value.map(r => r.result_purchase).filter(Boolean)
const res = await api.post('/processing/merge-batch', { filenames })
ElMessage.success(`合并任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '合并失败')
}
}
async function batchDownload() {
const token = localStorage.getItem('token')
for (const row of selected.value) {
if (row.result_purchase) {
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
}
}
}
async function batchDelete() {
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
for (const row of selected.value) {
if (row.result_purchase) {
await api.delete(`/files/result/${encodeURIComponent(row.result_purchase)}`)
}
if (row.id) {
await api.delete('/files/relations', { data: { ids: [row.id] } })
}
}
ElMessage.success('批量删除完成')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.file-page {
width: 100%;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
}
.file-name.primary {
font-weight: 600;
color: var(--text-primary);
}
.file-name.secondary {
color: var(--text-muted);
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="file-page animate-in">
<div class="page-header">
<div class="header-left">
<h3>表格处理</h3>
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button type="primary" :disabled="!selected.length" @click="batchProcess">
批量处理 ({{ selected.length }})
</el-button>
<el-button :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
<el-button type="danger" @click="clearAll">
删除全部
</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
@selection-change="onSelect"
stripe
style="width: 100%"
>
<el-table-column type="selection" width="45" />
<el-table-column label="Excel处理文件" min-width="200">
<template #default="{ row }">
<span class="file-name primary">{{ row.output_excel || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.result_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="采购单文件" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.result_purchase || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.input_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Input图片" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.input_image || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="processFile(row)">
处理
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import api from '../../api'
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize } })
items.value = res.data.items
total.value = res.data.total
} catch {}
loading.value = false
}
function onSelect(rows: any[]) { selected.value = rows }
async function processFile(row: any) {
try {
const res = await api.post('/processing/excel-single', { filename: row.output_excel })
ElMessage.success(`处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function deleteFile(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.output_excel}`, '确认')
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
ElMessage.success('已删除')
loadData()
} catch {}
}
async function batchProcess() {
try {
const filenames = selected.value.map(r => r.output_excel).filter(Boolean)
const res = await api.post('/processing/excel', { files: filenames })
ElMessage.success(`批量处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function batchDelete() {
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
for (const row of selected.value) {
if (row.output_excel) {
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
}
if (row.id) {
await api.delete('/files/relations', { data: { ids: [row.id] } })
}
}
ElMessage.success('批量删除完成')
loadData()
} catch {}
}
async function clearAll() {
try {
await ElMessageBox.confirm('确定清空所有 Excel 处理文件?此操作不可恢复。', '确认')
await api.post('/files/clear/output')
await api.post('/files/relations/sync')
ElMessage.success('已清空')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.file-page {
width: 100%;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
}
.file-name.primary {
font-weight: 600;
color: var(--text-primary);
}
.file-name.secondary {
color: var(--text-muted);
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
build: {
outDir: '../backend/static',
emptyOutDir: true,
},
})
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.1.0
python-multipart>=0.0.6
aiofiles>=23.2.0
websockets>=12.0
pydantic>=2.5.0
werkzeug>=3.0.0