Compare commits
48 Commits
d267a1d1fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 968a6f8d22 | |||
| 735989f0ae | |||
| 0d378b9f35 | |||
| 809cc5fd81 | |||
| 69efff3cb4 | |||
| 7e735cdf72 | |||
| 69473320b3 | |||
| d585a6baaa | |||
| 0e273111a2 | |||
| d0a1c3dce5 | |||
| d299db0ab2 | |||
| 80a0e7eeb6 | |||
| d5b4cc7b77 | |||
| 5e69e5a841 | |||
| 0c28031e81 | |||
| 7dabb2ce66 | |||
| 2196a25aee | |||
| 7baf784a39 | |||
| 32af38fe2a | |||
| 13ef605481 | |||
| ec8d0d7db6 | |||
| 17c45cab3f | |||
| 3a49780d8d | |||
| 3f8e34c07f | |||
| d94e416202 | |||
| fa43a9770e | |||
| 1a4522bd02 | |||
| 7e15431937 | |||
| 7e63dda522 | |||
| 26f6275f4e | |||
| 2d79c05cf1 | |||
| 50ee6ac5bd | |||
| 2a2b4c639e | |||
| beaf7c6203 | |||
| 7c3616ff98 | |||
| e441ac82a8 | |||
| 81bafaf557 | |||
| c18039f790 | |||
| 0721ed099c | |||
| dedc3b4183 | |||
| 79522d8356 | |||
| c49105a678 | |||
| 5de8694eec | |||
| 205e18563d | |||
| 56561068ea | |||
| 280b94ae1d | |||
| 71c0ba9c96 | |||
| c1826918aa |
@@ -0,0 +1,20 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.claude/
|
||||||
|
.playwright-mcp/
|
||||||
|
.trae/
|
||||||
|
node_modules/
|
||||||
|
web/frontend/node_modules/
|
||||||
|
data/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
release/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
*.exe
|
||||||
+11
-7
@@ -18,13 +18,8 @@ release/
|
|||||||
logs/
|
logs/
|
||||||
data/temp/
|
data/temp/
|
||||||
|
|
||||||
# Runtime outputs
|
# Runtime data (all runtime outputs, caches, databases)
|
||||||
data/output/
|
data/
|
||||||
data/result/
|
|
||||||
data/input/
|
|
||||||
data/product_cache.db
|
|
||||||
data/user_settings.json
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# Claude Code / IDE
|
# Claude Code / IDE
|
||||||
.claude/
|
.claude/
|
||||||
@@ -34,6 +29,15 @@ data/user_settings.json
|
|||||||
# Old project
|
# Old project
|
||||||
wework_xiaoai_bot/
|
wework_xiaoai_bot/
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Frontend build output
|
||||||
|
web/backend/static/
|
||||||
|
|
||||||
|
# Screenshots (from testing)
|
||||||
|
*.png
|
||||||
|
|
||||||
# OS/IDE
|
# OS/IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies (tkinter needed by app/core processing)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
python3-tk \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY web/backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY config/ ./config/
|
||||||
|
COPY config.ini ./
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
COPY web/ ./web/
|
||||||
|
|
||||||
|
# Create data directories
|
||||||
|
RUN mkdir -p data/input data/output data/result data/temp
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 18889
|
||||||
|
|
||||||
|
# Run
|
||||||
|
CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "18889"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY web/frontend/package.json web/frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY web/frontend/ .
|
||||||
|
# Override outDir for Docker build (vite config uses ../backend/static for local dev)
|
||||||
|
RUN npx vite build --outDir ./dist
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 18888
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
### 桌面端 (GUI / CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -30,6 +32,68 @@ python headless_api.py data/input/xxx.jpg --barcode 6920584471055 --target 69205
|
|||||||
python build_exe.py
|
python build_exe.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Web 端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端依赖
|
||||||
|
cd web/backend && pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 前端依赖
|
||||||
|
cd web/frontend && npm install
|
||||||
|
|
||||||
|
# 启动后端 (端口 8000)
|
||||||
|
cd web && python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# 启动前端开发服务器 (端口 5173)
|
||||||
|
cd web/frontend && npm run dev
|
||||||
|
|
||||||
|
# 构建前端到后端静态目录
|
||||||
|
cd web/frontend && npm run build
|
||||||
|
# 构建后直接访问 http://localhost:8000 即可
|
||||||
|
|
||||||
|
# 生产部署 (仅后端,前端已内嵌)
|
||||||
|
cd web && python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**默认账号:** `admin` / `admin123`(首次登录后建议修改密码)
|
||||||
|
|
||||||
|
### Docker 部署 (生产环境)
|
||||||
|
|
||||||
|
**环境要求:** Docker + Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆代码
|
||||||
|
git clone https://gitea.94kan.cn/houhuan/orc-order-v2.git
|
||||||
|
cd orc-order-v2
|
||||||
|
|
||||||
|
# 2. 配置环境变量(百度 OCR API 密钥、Gitea Token 等)
|
||||||
|
cp .env.example .env # 若无 .env.example,手动创建 .env
|
||||||
|
# 编辑 .env 填入:
|
||||||
|
# BAIDU_OCR_API_KEY=xxx
|
||||||
|
# BAIDU_OCR_SECRET_KEY=xxx
|
||||||
|
# GITEA_TOKEN=xxx
|
||||||
|
|
||||||
|
# 3. 构建并启动
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 4. 访问
|
||||||
|
# 前端: http://服务器IP:18888
|
||||||
|
# 后端 API: http://服务器IP:18889
|
||||||
|
# 默认账号: admin / admin123(首次登录后建议修改密码)
|
||||||
|
|
||||||
|
# 常用命令
|
||||||
|
docker-compose logs -f # 查看日志
|
||||||
|
docker-compose restart # 重启服务
|
||||||
|
docker-compose down # 停止服务
|
||||||
|
docker-compose up -d --build # 重新构建并启动
|
||||||
|
```
|
||||||
|
|
||||||
|
**端口说明:**
|
||||||
|
- `18888` — 前端 (Nginx),对外访问入口
|
||||||
|
- `18889` — 后端 API (FastAPI),前端自动代理,无需直接访问
|
||||||
|
|
||||||
|
**数据持久化:** `data/` 目录挂载到宿主机,SQLite 数据库、上传文件、处理结果不会因容器重建而丢失。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -50,16 +114,85 @@ python build_exe.py
|
|||||||
│ │ └── utils/ # 工具(日志、文件、字符串、云端同步、对话框)
|
│ │ └── utils/ # 工具(日志、文件、字符串、云端同步、对话框)
|
||||||
│ ├── services/ # 业务服务(订单、OCR、处理器调度)
|
│ ├── services/ # 业务服务(订单、OCR、处理器调度)
|
||||||
│ └── ui/ # GUI 模块(主题、日志、快捷键、主窗口)
|
│ └── ui/ # GUI 模块(主题、日志、快捷键、主窗口)
|
||||||
|
├── web/ # Web 端
|
||||||
|
│ ├── backend/
|
||||||
|
│ │ ├── main.py # FastAPI 入口
|
||||||
|
│ │ ├── auth/ # JWT 认证(登录、Token、权限)
|
||||||
|
│ │ ├── routers/ # API 路由(文件、处理、记忆、条码、同步、任务、日志)
|
||||||
|
│ │ ├── services/ # 后端服务(任务管理、数据库、文件同步)
|
||||||
|
│ │ └── middleware/ # HTTP 日志中间件
|
||||||
|
│ └── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── views/ # 页面(Dashboard、Layout、文件管理、任务、日志等)
|
||||||
|
│ │ ├── stores/ # Pinia 状态管理(auth、processing)
|
||||||
|
│ │ ├── composables/ # 共享逻辑(useDebounce、useFileUtils、useFilePreview)
|
||||||
|
│ │ ├── api.ts # Axios 封装
|
||||||
|
│ │ └── router/ # Vue Router 路由
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── 银豹-采购单模板.xls # 输出模板(条码/采购量/赠送量/单价)
|
│ ├── 银豹-采购单模板.xls # 输出模板(条码/采购量/赠送量/单价)
|
||||||
│ └── 商品资料.xlsx # 单价校验参考数据
|
│ └── 商品资料.xlsx # 单价校验参考数据
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── input/ # 输入文件
|
│ ├── input/ # 输入文件
|
||||||
│ ├── output/ # OCR 输出
|
│ ├── output/ # OCR 输出
|
||||||
│ └── result/ # 最终采购单
|
│ ├── result/ # 最终采购单
|
||||||
|
│ └── web_data.db # Web 端数据库(SQLite)
|
||||||
└── tests/ # 单元测试(191 个)
|
└── tests/ # 单元测试(191 个)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Web 端功能
|
||||||
|
|
||||||
|
基于 Vue 3 + Element Plus + FastAPI 的浏览器端管理界面,与桌面端共享同一个 `data/` 目录。
|
||||||
|
|
||||||
|
### 处理中心 (Dashboard)
|
||||||
|
|
||||||
|
- **一键全流程**:上传图片或 Excel 后,一键完成 OCR → 标准化 → 合并全流程
|
||||||
|
- **批量 OCR / 批量处理**:可单独执行 OCR 识别或 Excel 标准化步骤
|
||||||
|
- **实时进度**:WebSocket 推送任务进度、日志、状态变更
|
||||||
|
- **多任务监控**:同时查看多个运行中任务的进度和日志
|
||||||
|
- **任务重试**:失败任务可查看错误详情并一键重试
|
||||||
|
|
||||||
|
### 文件管理
|
||||||
|
|
||||||
|
- **图片处理**:管理 `data/input/` 中的图片文件,支持上传、预览、批量 OCR、批量生成采购单
|
||||||
|
- **表格处理**:管理 `data/output/` 中的 Excel 文件,支持上传、预览、批量标准化处理
|
||||||
|
- **采购单管理**:管理 `data/result/` 中的采购单,支持预览、下载、合并、批量删除
|
||||||
|
- **实时同步**:页面加载时自动同步磁盘文件到数据库,新文件立即可见
|
||||||
|
- **清除处理缓存**:删除已处理的输出文件,允许重新处理
|
||||||
|
|
||||||
|
### 任务与日志
|
||||||
|
|
||||||
|
- **任务历史**:查看所有处理任务的状态、进度、日志,支持按状态和类型筛选
|
||||||
|
- **HTTP 日志**:记录所有 API 请求,支持按方法和状态码筛选
|
||||||
|
|
||||||
|
### 记忆库
|
||||||
|
|
||||||
|
- **产品记忆**:自动从 OCR 和处理结果中学习产品信息
|
||||||
|
- **置信度系统**:根据出现次数自动评估记忆可靠度
|
||||||
|
- **搜索与管理**:支持搜索、编辑、删除记忆条目
|
||||||
|
|
||||||
|
### 条码映射
|
||||||
|
|
||||||
|
- **映射规则管理**:添加、编辑、删除条码转换规则
|
||||||
|
- **批量操作**:支持批量导入和删除映射
|
||||||
|
|
||||||
|
### 云端同步
|
||||||
|
|
||||||
|
- **Gitea 同步**:通过 Gitea REST API 在多台设备间同步配置文件
|
||||||
|
- **一键推拉**:选择文件推送或拉取,无需 git 客户端
|
||||||
|
|
||||||
|
### 系统配置
|
||||||
|
|
||||||
|
- **配置编辑**:在浏览器中编辑系统配置(API 密钥、路径、参数)
|
||||||
|
- **修改密码**:支持修改 Web 端登录密码
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
|
||||||
|
- **响应式布局**:适配桌面和移动端,小屏幕自动切换为抽屉式导航
|
||||||
|
- **全局错误处理**:未捕获的 Vue 错误自动显示用户提示
|
||||||
|
- **表单验证**:修改密码等操作有完整的输入验证
|
||||||
|
|
||||||
## 供应商智能路由
|
## 供应商智能路由
|
||||||
|
|
||||||
| 供应商 | 识别特征 | 处理逻辑 |
|
| 供应商 | 识别特征 | 处理逻辑 |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ DEFAULT_CONFIG = {
|
|||||||
'result_folder': 'data/result',
|
'result_folder': 'data/result',
|
||||||
'temp_folder': 'data/temp',
|
'temp_folder': 'data/temp',
|
||||||
'template_folder': 'templates',
|
'template_folder': 'templates',
|
||||||
'template_file': '银豹-采购单模板.xls',
|
'template_file': 'templates/银豹-采购单模板.xls',
|
||||||
'processed_record': 'data/processed_files.json',
|
'processed_record': 'data/processed_files.json',
|
||||||
'data_dir': 'data',
|
'data_dir': 'data',
|
||||||
'product_db': 'data/product_cache.db'
|
'product_db': 'data/product_cache.db'
|
||||||
|
|||||||
+14
-13
@@ -104,24 +104,25 @@ class ConfigManager:
|
|||||||
logger.info(f"已创建默认配置文件: {self.config_file}")
|
logger.info(f"已创建默认配置文件: {self.config_file}")
|
||||||
|
|
||||||
def save_config(self) -> None:
|
def save_config(self) -> None:
|
||||||
"""保存配置到文件(API 密钥不写入文件)"""
|
"""保存配置到文件(API 密钥不写入文件,Gitea token 需要持久化)"""
|
||||||
try:
|
# 保存前临时清空 API 密钥,避免写入文件(这些从 .env 读取)
|
||||||
# 保存前临时清空 API 密钥,避免写入文件
|
saved_keys = {}
|
||||||
saved_keys = {}
|
for option in ('api_key', 'secret_key'):
|
||||||
for option in ('api_key', 'secret_key'):
|
try:
|
||||||
saved_keys[option] = self.config.get('API', option, fallback='')
|
saved_keys[option] = self.config.get('API', option, fallback='')
|
||||||
self.config.set('API', option, '')
|
except Exception:
|
||||||
|
saved_keys[option] = ''
|
||||||
|
self.config.set('API', option, '')
|
||||||
|
|
||||||
|
try:
|
||||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||||
self.config.write(f)
|
self.config.write(f)
|
||||||
|
|
||||||
# 恢复内存中的值
|
|
||||||
for option, val in saved_keys.items():
|
|
||||||
self.config.set('API', option, val)
|
|
||||||
|
|
||||||
logger.info(f"配置已保存到: {self.config_file}")
|
logger.info(f"配置已保存到: {self.config_file}")
|
||||||
except Exception as e:
|
finally:
|
||||||
logger.error(f"保存配置文件时出错: {e}")
|
# 恢复内存中的值(即使写入失败也恢复)
|
||||||
|
for option, val in saved_keys.items():
|
||||||
|
if val:
|
||||||
|
self.config.set('API', option, val)
|
||||||
|
|
||||||
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
||||||
"""获取配置值"""
|
"""获取配置值"""
|
||||||
|
|||||||
+308
-229
@@ -1,21 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
商品资料 SQLite 数据库 + 商品记忆库
|
商品资料 SQLite 数据库 + 商品记忆库
|
||||||
|
|
||||||
将商品资料 (条码/名称/进货价/单位/规格) 存储在 SQLite 中,
|
|
||||||
支持从 Excel 自动导入、按条码快速查询、以及从 OCR 处理结果中学习。
|
|
||||||
|
|
||||||
记忆库功能:
|
记忆库功能:
|
||||||
- 处理完每单后自动学习商品数据
|
- 处理每步后自动学习商品数据(置信度+一致性加速)
|
||||||
- 下次处理时用记忆库补全 OCR 缺失/错误的字段
|
- OCR 字段缺失时用记忆库补全 (conf > 50 直接采用)
|
||||||
- 通过置信度系统控制数据质量
|
- 价格异常检测:偏差 > 2倍触发补全,偏差 > 50% 记录预警
|
||||||
- 支持云端同步
|
- 批量预加载 → 内存操作 → 批量写回,保障性能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Tuple, Callable
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
@@ -40,26 +37,27 @@ class ProductDatabase:
|
|||||||
source TEXT DEFAULT 'template',
|
source TEXT DEFAULT 'template',
|
||||||
confidence INTEGER DEFAULT 0,
|
confidence INTEGER DEFAULT 0,
|
||||||
usage_count INTEGER DEFAULT 0,
|
usage_count INTEGER DEFAULT 0,
|
||||||
last_seen TEXT
|
last_seen TEXT,
|
||||||
|
avg_price REAL DEFAULT 0.0,
|
||||||
|
min_price REAL DEFAULT 0.0,
|
||||||
|
max_price REAL DEFAULT 0.0,
|
||||||
|
price_count INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 新增列定义(用于迁移)
|
|
||||||
_NEW_COLUMNS = {
|
_NEW_COLUMNS = {
|
||||||
'specification': "TEXT DEFAULT ''",
|
'specification': "TEXT DEFAULT ''",
|
||||||
'source': "TEXT DEFAULT 'template'",
|
'source': "TEXT DEFAULT 'template'",
|
||||||
'confidence': 'INTEGER DEFAULT 0',
|
'confidence': 'INTEGER DEFAULT 0',
|
||||||
'usage_count': 'INTEGER DEFAULT 0',
|
'usage_count': 'INTEGER DEFAULT 0',
|
||||||
'last_seen': 'TEXT',
|
'last_seen': 'TEXT',
|
||||||
|
'avg_price': 'REAL DEFAULT 0.0',
|
||||||
|
'min_price': 'REAL DEFAULT 0.0',
|
||||||
|
'max_price': 'REAL DEFAULT 0.0',
|
||||||
|
'price_count': 'INTEGER DEFAULT 0',
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, db_path: str, excel_source: str):
|
def __init__(self, db_path: str, excel_source: str):
|
||||||
"""初始化数据库,如果 SQLite 不存在则自动从 Excel 导入
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: SQLite 数据库文件路径
|
|
||||||
excel_source: 商品资料 Excel 文件路径
|
|
||||||
"""
|
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.excel_source = excel_source
|
self.excel_source = excel_source
|
||||||
self._ensure_db()
|
self._ensure_db()
|
||||||
@@ -68,16 +66,13 @@ class ProductDatabase:
|
|||||||
return sqlite3.connect(self.db_path)
|
return sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
def _ensure_db(self):
|
def _ensure_db(self):
|
||||||
"""确保数据库存在,不存在则从 Excel 导入"""
|
|
||||||
if os.path.exists(self.db_path):
|
if os.path.exists(self.db_path):
|
||||||
self._migrate_schema()
|
self._migrate_schema()
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(self.excel_source):
|
if not os.path.exists(self.excel_source):
|
||||||
logger.warning(f"商品资料 Excel 不存在,跳过导入: {self.excel_source}")
|
logger.warning(f"商品资料 Excel 不存在: {self.excel_source}")
|
||||||
self._create_empty_db()
|
self._create_empty_db()
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}")
|
logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}")
|
||||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||||
self._create_empty_db()
|
self._create_empty_db()
|
||||||
@@ -85,7 +80,6 @@ class ProductDatabase:
|
|||||||
logger.info(f"商品资料导入完成: {count} 条记录")
|
logger.info(f"商品资料导入完成: {count} 条记录")
|
||||||
|
|
||||||
def _create_empty_db(self):
|
def _create_empty_db(self):
|
||||||
"""创建空数据库"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.executescript(self.SCHEMA)
|
conn.executescript(self.SCHEMA)
|
||||||
@@ -94,52 +88,35 @@ class ProductDatabase:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def _migrate_schema(self):
|
def _migrate_schema(self):
|
||||||
"""幂等迁移:为已有数据库添加新列"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute("PRAGMA table_info(products)")
|
cursor = conn.execute("PRAGMA table_info(products)")
|
||||||
existing_cols = {row[1] for row in cursor.fetchall()}
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
for col_name, col_type in self._NEW_COLUMNS.items():
|
for col_name, col_type in self._NEW_COLUMNS.items():
|
||||||
if col_name not in existing_cols:
|
if col_name not in existing_cols:
|
||||||
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
|
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
|
||||||
logger.info(f"数据库迁移: 添加列 {col_name}")
|
logger.info(f"数据库迁移: 添加列 {col_name}")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 导入
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def import_from_excel(self, excel_path: str) -> int:
|
def import_from_excel(self, excel_path: str) -> int:
|
||||||
"""从 Excel 导入商品资料(source=template, confidence=100)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
excel_path: Excel 文件路径
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
导入的记录数
|
|
||||||
"""
|
|
||||||
df = smart_read_excel(excel_path)
|
df = smart_read_excel(excel_path)
|
||||||
if df is None or df.empty:
|
if df is None or df.empty:
|
||||||
logger.warning(f"Excel 文件为空或读取失败: {excel_path}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 查找条码列
|
|
||||||
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
||||||
if not barcode_col:
|
if not barcode_col:
|
||||||
logger.error(f"Excel 中未找到条码列: {list(df.columns)}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 查找进货价列
|
|
||||||
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price')
|
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price')
|
||||||
# 进货价可能没有标准别名,补充查找
|
|
||||||
if not price_col:
|
if not price_col:
|
||||||
for col in df.columns:
|
for col in df.columns:
|
||||||
col_str = str(col).strip()
|
if '进货价' in str(col).strip():
|
||||||
if '进货价' in col_str:
|
|
||||||
price_col = col
|
price_col = col
|
||||||
break
|
break
|
||||||
|
|
||||||
# 查找名称列、单位列、规格列 (可选)
|
|
||||||
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
||||||
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
||||||
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
||||||
@@ -150,7 +127,6 @@ class ProductDatabase:
|
|||||||
barcode = str(row.get(barcode_col, '')).strip()
|
barcode = str(row.get(barcode_col, '')).strip()
|
||||||
if not barcode or barcode == 'nan':
|
if not barcode or barcode == 'nan':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price = 0.0
|
price = 0.0
|
||||||
if price_col:
|
if price_col:
|
||||||
try:
|
try:
|
||||||
@@ -159,43 +135,32 @@ class ProductDatabase:
|
|||||||
price = float(p)
|
price = float(p)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
name = str(row.get(name_col, '')).strip() if name_col else ''
|
name = str(row.get(name_col, '')).strip() if name_col else ''
|
||||||
if name == 'nan':
|
if name == 'nan': name = ''
|
||||||
name = ''
|
|
||||||
unit = str(row.get(unit_col, '')).strip() if unit_col else ''
|
unit = str(row.get(unit_col, '')).strip() if unit_col else ''
|
||||||
if unit == 'nan':
|
if unit == 'nan': unit = ''
|
||||||
unit = ''
|
|
||||||
spec = str(row.get(spec_col, '')).strip() if spec_col else ''
|
spec = str(row.get(spec_col, '')).strip() if spec_col else ''
|
||||||
if spec == 'nan':
|
if spec == 'nan': spec = ''
|
||||||
spec = ''
|
# template 源置信度 50
|
||||||
|
rows.append((barcode, name, price, unit, now, spec, 'template', 50, 0, now,
|
||||||
rows.append((barcode, name, price, unit, now, spec, 'template', 100, 0, now))
|
price, price, price, 1 if price > 0 else 0))
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
logger.warning(f"Excel 中未解析出有效记录: {excel_path}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR REPLACE INTO products "
|
"INSERT OR REPLACE INTO products "
|
||||||
"(barcode, name, price, unit, updated_at, specification, source, confidence, usage_count, last_seen) "
|
"(barcode, name, price, unit, updated_at, specification, source, confidence, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"usage_count, last_seen, avg_price, min_price, max_price, price_count) "
|
||||||
rows
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
rows)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
def reimport(self) -> int:
|
def reimport(self) -> int:
|
||||||
"""重新从 Excel 导入(清空现有数据后重新导入)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
导入的记录数
|
|
||||||
"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.execute("DELETE FROM products")
|
conn.execute("DELETE FROM products")
|
||||||
@@ -204,203 +169,343 @@ class ProductDatabase:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.import_from_excel(self.excel_source)
|
return self.import_from_excel(self.excel_source)
|
||||||
|
|
||||||
# ── 基础查询(保持兼容) ──────────────────────────────────
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 查询
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def get_price(self, barcode: str) -> Optional[float]:
|
def get_price(self, barcode: str) -> Optional[float]:
|
||||||
"""按条码查询进货价"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
row = conn.execute("SELECT avg_price FROM products WHERE barcode=?",
|
||||||
"SELECT price FROM products WHERE barcode = ?",
|
(str(barcode).strip(),)).fetchone()
|
||||||
(str(barcode).strip(),)
|
return row[0] if row and row[0] else None
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_prices(self, barcodes: List[str]) -> Dict[str, float]:
|
def get_prices(self, barcodes: List[str]) -> Dict[str, float]:
|
||||||
"""批量查询进货价"""
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
placeholders = ','.join('?' * len(barcodes))
|
placeholders = ','.join('?' * len(barcodes))
|
||||||
cursor = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT barcode, price FROM products WHERE barcode IN ({placeholders})",
|
f"SELECT barcode, avg_price FROM products WHERE barcode IN ({placeholders})",
|
||||||
[str(b).strip() for b in barcodes]
|
[str(b).strip() for b in barcodes]).fetchall()
|
||||||
)
|
return {r[0]: r[1] for r in rows if r[1]}
|
||||||
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
"""返回商品总数"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute("SELECT COUNT(*) FROM products")
|
return conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
|
||||||
return cursor.fetchone()[0]
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ── 记忆库查询 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_memory(self, barcode: str) -> Optional[Dict]:
|
def get_memory(self, barcode: str) -> Optional[Dict]:
|
||||||
"""查询单条商品记忆"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
row = conn.execute("SELECT * FROM products WHERE barcode=?",
|
||||||
"SELECT * FROM products WHERE barcode = ?",
|
(str(barcode).strip(),)).fetchone()
|
||||||
(str(barcode).strip(),)
|
return dict(row) if row else None
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]:
|
def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]:
|
||||||
"""批量查询商品记忆"""
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
placeholders = ','.join('?' * len(barcodes))
|
placeholders = ','.join('?' * len(barcodes))
|
||||||
cursor = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
||||||
[str(b).strip() for b in barcodes]
|
[str(b).strip() for b in barcodes]).fetchall()
|
||||||
)
|
return {r['barcode']: dict(r) for r in rows}
|
||||||
return {row['barcode']: dict(row) for row in cursor.fetchall()}
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_all_memories(self) -> List[Dict]:
|
def get_all_memories(self) -> List[Dict]:
|
||||||
"""返回全部记录(UI 用)"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
return [dict(row) for row in
|
||||||
"SELECT * FROM products ORDER BY usage_count DESC, barcode"
|
conn.execute("SELECT * FROM products ORDER BY usage_count DESC, barcode").fetchall()]
|
||||||
)
|
|
||||||
return [dict(row) for row in cursor.fetchall()]
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ── 学习逻辑 ──────────────────────────────────────────────
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 批量预加载 — 性能核心
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def learn_from_product(self, product: Dict, source: str = 'ocr') -> None:
|
def load_batch(self, barcodes: List[str]) -> Dict[str, Dict]:
|
||||||
"""从处理结果中学习单条商品数据
|
"""批量预加载条码记忆到 dict — 单次 SQL,后续纯内存操作"""
|
||||||
|
if not barcodes:
|
||||||
|
return {}
|
||||||
|
conn = self._connect()
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
placeholders = ','.join('?' * len(barcodes))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
||||||
|
[str(b).strip() for b in barcodes]).fetchall()
|
||||||
|
return {r['barcode']: dict(r) for r in rows}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
Args:
|
# ══════════════════════════════════════════════════════════════
|
||||||
product: 商品字典 (barcode, name, specification, unit, price, ...)
|
# 学习逻辑 — 一致性加速 + 价格区间
|
||||||
source: 数据来源 ('template', 'ocr', 'user_confirmed')
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def learn_from_product(self, product: Dict, source: str = 'ocr',
|
||||||
|
memory: Dict[str, Dict] = None,
|
||||||
|
add_log: Callable = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从处理结果中学习,返回日志字符串。
|
||||||
|
memory: 可选的预加载批量内存,传入则零 DB 查询。
|
||||||
"""
|
"""
|
||||||
barcode = str(product.get('barcode', '')).strip()
|
barcode = str(product.get('barcode', '')).strip()
|
||||||
if not barcode:
|
if not barcode:
|
||||||
return
|
return None
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
name = str(product.get('name', ''))
|
name = str(product.get('name', ''))
|
||||||
spec = str(product.get('specification', ''))
|
spec = str(product.get('specification', ''))
|
||||||
unit = str(product.get('unit', ''))
|
unit = str(product.get('unit', ''))
|
||||||
price = float(product.get('price', 0))
|
price = float(product.get('price', 0))
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# 查现有记录(优先从内存查)
|
||||||
|
if memory is not None and barcode in memory:
|
||||||
|
row = memory[barcode]
|
||||||
|
old_name = row.get('name', '')
|
||||||
|
old_spec = row.get('specification', '')
|
||||||
|
old_unit = row.get('unit', '')
|
||||||
|
old_conf = row.get('confidence', 0)
|
||||||
|
old_count = row.get('usage_count', 0)
|
||||||
|
old_avg = row.get('avg_price', 0) or 0
|
||||||
|
old_min = row.get('min_price') or price
|
||||||
|
old_max = row.get('max_price') or price
|
||||||
|
pc = row.get('price_count', 0) or 0
|
||||||
|
exists = True
|
||||||
|
else:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT name, specification, unit, confidence, usage_count, "
|
||||||
|
"avg_price, min_price, max_price, price_count FROM products WHERE barcode=?",
|
||||||
|
(barcode,)).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if cursor is None:
|
||||||
|
exists = False
|
||||||
|
else:
|
||||||
|
old_name, old_spec, old_unit, old_conf, old_count, old_avg, old_min, old_max, pc = cursor
|
||||||
|
old_avg = old_avg or 0
|
||||||
|
pc = pc or 0
|
||||||
|
old_min = old_min if old_min is not None else price
|
||||||
|
old_max = old_max if old_max is not None else price
|
||||||
|
exists = True
|
||||||
|
|
||||||
|
new_count = old_count + 1 if exists else 1
|
||||||
|
|
||||||
|
# ── 置信度 ──
|
||||||
|
if source == 'user_confirmed':
|
||||||
|
new_conf = 90
|
||||||
|
elif source == 'template':
|
||||||
|
new_conf = 50
|
||||||
|
elif exists and old_conf < 50:
|
||||||
|
# 一致性加速
|
||||||
|
spec_match = bool(spec and old_spec and spec == old_spec)
|
||||||
|
unit_match = bool(unit and old_unit and unit == old_unit)
|
||||||
|
if spec_match and unit_match:
|
||||||
|
boost = 10
|
||||||
|
elif unit_match:
|
||||||
|
boost = 5
|
||||||
|
else:
|
||||||
|
boost = 3
|
||||||
|
new_conf = min(50, old_conf + boost)
|
||||||
|
elif exists:
|
||||||
|
new_conf = old_conf # > 50 稳定不变
|
||||||
|
else:
|
||||||
|
new_conf = 10 # 新 OCR 记录
|
||||||
|
|
||||||
|
# ── 价格区间 ──
|
||||||
|
if price > 0:
|
||||||
|
new_pc = (pc if exists else 0) + 1
|
||||||
|
new_avg = ((old_avg * (new_pc - 1)) + price) / new_pc if exists else price
|
||||||
|
new_min = min(old_min, price) if exists else price
|
||||||
|
new_max = max(old_max, price) if exists else price
|
||||||
|
else:
|
||||||
|
new_avg = old_avg if exists else 0
|
||||||
|
new_min = old_min if exists else 0
|
||||||
|
new_max = old_max if exists else 0
|
||||||
|
new_pc = pc if exists else 0
|
||||||
|
|
||||||
|
# ── 写入 ──
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
if not exists:
|
||||||
"SELECT confidence, usage_count FROM products WHERE barcode = ?",
|
|
||||||
(barcode,)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
# 新记录
|
|
||||||
conf = {'template': 100, 'user_confirmed': 90}.get(source, 50)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO products "
|
"INSERT INTO products (barcode, name, specification, unit, price, "
|
||||||
"(barcode, name, specification, unit, price, source, confidence, usage_count, last_seen, updated_at) "
|
"source, confidence, usage_count, last_seen, updated_at, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
|
"avg_price, min_price, max_price, price_count) "
|
||||||
(barcode, name, spec, unit, price, source, conf, now, now)
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
(barcode, name, spec, unit, price, source, new_conf, 1, now, now,
|
||||||
|
new_avg, new_min, new_max, new_pc))
|
||||||
|
log = f"记忆库新增: {barcode} {name} 源={source} 可信度={new_conf}"
|
||||||
else:
|
else:
|
||||||
old_conf, old_count = row
|
# 高可信度源全字段覆盖;低可信度仅填空
|
||||||
new_count = old_count + 1
|
if source in ('template', 'user_confirmed') or new_conf > 50:
|
||||||
|
|
||||||
if source == 'template':
|
|
||||||
new_conf = 100
|
|
||||||
elif source == 'user_confirmed':
|
|
||||||
new_conf = 90
|
|
||||||
else: # ocr
|
|
||||||
new_conf = min(80, old_conf + 10) if old_conf < 80 else old_conf
|
|
||||||
|
|
||||||
if source in ('template', 'user_confirmed'):
|
|
||||||
# 高权威来源:全字段覆盖
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? "
|
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
||||||
"WHERE barcode=?",
|
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
||||||
(name, spec, unit, price, source, new_conf, new_count, now, now, barcode)
|
(name or old_name, spec or old_spec, unit or old_unit, price,
|
||||||
)
|
source, new_conf, new_count, now, now,
|
||||||
|
new_avg, new_min, new_max, new_pc, barcode))
|
||||||
else:
|
else:
|
||||||
# OCR:仅填充空字段,不更新 price
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET "
|
"UPDATE products SET "
|
||||||
"name = CASE WHEN name='' THEN ? ELSE name END, "
|
"name=CASE WHEN name='' THEN ? ELSE name END, "
|
||||||
"specification = CASE WHEN specification='' THEN ? ELSE specification END, "
|
"specification=CASE WHEN specification='' THEN ? ELSE specification END, "
|
||||||
"unit = CASE WHEN unit='' THEN ? ELSE unit END, "
|
"unit=CASE WHEN unit='' THEN ? ELSE unit END, "
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? "
|
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
||||||
"WHERE barcode=?",
|
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
||||||
(name, spec, unit, source, new_conf, new_count, now, now, barcode)
|
(name, spec, unit, source, new_conf, new_count, now, now,
|
||||||
)
|
new_avg, new_min, new_max, new_pc, barcode))
|
||||||
|
log = f"记忆库更新: {barcode} 可信度{old_conf if exists else 0}→{new_conf}"
|
||||||
|
if price > 0:
|
||||||
|
log += f" 均价{new_avg:.4f}({new_pc}次)"
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# 更新内存 dict(如果传入了)
|
||||||
|
if memory is not None and barcode in memory:
|
||||||
|
memory[barcode].update({
|
||||||
|
'confidence': new_conf, 'usage_count': new_count,
|
||||||
|
'avg_price': new_avg, 'min_price': new_min,
|
||||||
|
'max_price': new_max, 'price_count': new_pc,
|
||||||
|
'name': name or old_name,
|
||||||
|
'specification': spec or old_spec,
|
||||||
|
'unit': unit or old_unit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if add_log:
|
||||||
|
add_log(log)
|
||||||
|
return log
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def learn_from_products(self, products: List[Dict], source: str = 'ocr') -> int:
|
def learn_from_products(self, products: List[Dict], source: str = 'ocr',
|
||||||
"""批量学习,返回更新条数"""
|
add_log: Callable = None) -> int:
|
||||||
|
"""批量学习 — 先批量预加载,再逐条处理,返回更新条数"""
|
||||||
|
barcodes = [str(p.get('barcode', '')) for p in products if p.get('barcode')]
|
||||||
|
memory = self.load_batch(barcodes)
|
||||||
count = 0
|
count = 0
|
||||||
for p in products:
|
for p in products:
|
||||||
try:
|
try:
|
||||||
self.learn_from_product(p, source)
|
result = self.learn_from_product(p, source, memory=memory, add_log=add_log)
|
||||||
count += 1
|
if result:
|
||||||
|
count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"学习商品记忆失败: {e}")
|
logger.warning(f"学习商品记忆失败: {e}")
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 记忆辅助 — OCR 补全
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _price_anomaly(self, product: Dict, mem: Dict) -> bool:
|
||||||
|
"""价格异常:> 2倍偏差"""
|
||||||
|
price = float(product.get('price', 0))
|
||||||
|
avg = mem.get('avg_price', 0)
|
||||||
|
if not price or not avg:
|
||||||
|
return False
|
||||||
|
return price > avg * 2 or price < avg * 0.5
|
||||||
|
|
||||||
|
def fill_from_memory(self, barcode: str, ocr_result: Dict,
|
||||||
|
memory: Dict[str, Dict] = None) -> Tuple[Dict, str]:
|
||||||
|
"""用记忆库补全 OCR 缺失字段。返回 (补全后的dict, 日志字符串)"""
|
||||||
|
if memory:
|
||||||
|
mem = memory.get(barcode)
|
||||||
|
else:
|
||||||
|
mem = self.get_memory(barcode)
|
||||||
|
|
||||||
|
if not mem or mem.get('confidence', 0) < 10:
|
||||||
|
return ocr_result, ""
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
result = dict(ocr_result)
|
||||||
|
conf = mem.get('confidence', 0)
|
||||||
|
|
||||||
|
has_spec = result.get('specification')
|
||||||
|
has_unit = result.get('unit')
|
||||||
|
price = float(result.get('price', 0))
|
||||||
|
|
||||||
|
if conf > 50 and not has_spec and mem.get('specification'):
|
||||||
|
result['specification'] = mem['specification']
|
||||||
|
logs.append(f"规格补全(可信{conf}): {barcode} → {mem['specification']}")
|
||||||
|
elif not has_spec and mem.get('specification') and self._price_anomaly(result, mem):
|
||||||
|
result['specification'] = mem['specification']
|
||||||
|
logs.append(f"价格异常→规格补全: {barcode} 本次{price:.2f} vs 均价{mem['avg_price']:.2f} → {mem['specification']}")
|
||||||
|
|
||||||
|
if conf > 50 and not has_unit and mem.get('unit'):
|
||||||
|
result['unit'] = mem['unit']
|
||||||
|
logs.append(f"单位补全(可信{conf}): {barcode} → {mem['unit']}")
|
||||||
|
elif not has_unit and mem.get('unit') and self._price_anomaly(result, mem):
|
||||||
|
result['unit'] = mem['unit']
|
||||||
|
logs.append(f"价格异常→单位补全: {barcode} → {mem['unit']}")
|
||||||
|
|
||||||
|
return result, "; ".join(logs)
|
||||||
|
|
||||||
|
def price_warning(self, barcode: str, price: float,
|
||||||
|
memory: Dict[str, Dict] = None) -> Optional[str]:
|
||||||
|
"""价格预警。> 50% 偏差告警"""
|
||||||
|
if memory:
|
||||||
|
mem = memory.get(barcode)
|
||||||
|
else:
|
||||||
|
mem = self.get_memory(barcode)
|
||||||
|
if not mem or not mem.get('avg_price'):
|
||||||
|
return None
|
||||||
|
avg = mem['avg_price']
|
||||||
|
min_p = mem.get('min_price', avg)
|
||||||
|
max_p = mem.get('max_price', avg)
|
||||||
|
pc = mem.get('price_count', 0)
|
||||||
|
if price > avg * 1.5 or price < avg * 0.5:
|
||||||
|
return (f"单价预警: {barcode} 本次{price:.4f}元 vs "
|
||||||
|
f"历史均价{avg:.4f} (范围{min_p:.4f}~{max_p:.4f}, {pc}次)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 手动编辑
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def update_memory(self, barcode: str, fields: Dict) -> bool:
|
def update_memory(self, barcode: str, fields: Dict) -> bool:
|
||||||
"""手动编辑记录(UI 用,source→user_confirmed, confidence→90)"""
|
|
||||||
barcode = str(barcode).strip()
|
barcode = str(barcode).strip()
|
||||||
if not barcode:
|
if not barcode:
|
||||||
return False
|
return False
|
||||||
|
allowed = {'name', 'specification', 'unit', 'price', 'confidence'}
|
||||||
allowed = {'name', 'specification', 'unit', 'price'}
|
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
set_clause = ', '.join(f"{k}=?" for k in updates)
|
set_clause = ', '.join(f"{k}=?" for k in updates)
|
||||||
values = list(updates.values())
|
values = list(updates.values())
|
||||||
|
extra_sql = ", source='user_confirmed'"
|
||||||
|
if 'confidence' not in updates:
|
||||||
|
extra_sql += ", confidence=90"
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE products SET {set_clause}, source='user_confirmed', confidence=90, "
|
f"UPDATE products SET {set_clause}{extra_sql}, updated_at=? WHERE barcode=?",
|
||||||
"updated_at=? WHERE barcode=?",
|
values + [now, barcode])
|
||||||
values + [now, barcode]
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn.total_changes > 0
|
return conn.total_changes > 0
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def delete_memory(self, barcode: str) -> bool:
|
def delete_memory(self, barcode: str) -> bool:
|
||||||
"""删除记录"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),))
|
conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),))
|
||||||
@@ -409,51 +514,39 @@ class ProductDatabase:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ── 云端同步 ──────────────────────────────────────────────
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 云端同步
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def export_for_sync(self) -> Dict:
|
def export_for_sync(self) -> Dict:
|
||||||
"""导出全部记录为 JSON-serializable dict(按条码索引)"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT barcode, name, specification, unit, price, source, "
|
"SELECT barcode, name, specification, unit, price, source, "
|
||||||
"confidence, usage_count, last_seen FROM products"
|
"confidence, usage_count, last_seen, avg_price, min_price, max_price, price_count "
|
||||||
)
|
"FROM products")
|
||||||
result = {}
|
result = {}
|
||||||
for row in cursor.fetchall():
|
for row in cursor.fetchall():
|
||||||
result[row[0]] = {
|
result[row[0]] = {
|
||||||
'name': row[1],
|
'name': row[1], 'specification': row[2], 'unit': row[3],
|
||||||
'specification': row[2],
|
'price': row[4], 'source': row[5], 'confidence': row[6],
|
||||||
'unit': row[3],
|
'usage_count': row[7], 'last_seen': row[8],
|
||||||
'price': row[4],
|
'avg_price': row[9], 'min_price': row[10],
|
||||||
'source': row[5],
|
'max_price': row[11], 'price_count': row[12],
|
||||||
'confidence': row[6],
|
|
||||||
'usage_count': row[7],
|
|
||||||
'last_seen': row[8],
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def import_from_sync(self, data: Dict) -> int:
|
def import_from_sync(self, data: Dict) -> int:
|
||||||
"""从云端 JSON 导入,高置信度优先合并
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: {barcode: {name, specification, unit, price, source, confidence, ...}}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
导入/更新的记录数
|
|
||||||
"""
|
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
for barcode, info in data.items():
|
for barcode, info in data.items():
|
||||||
barcode = str(barcode).strip()
|
barcode = str(barcode).strip()
|
||||||
if not barcode:
|
if not barcode:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
name = str(info.get('name', ''))
|
name = str(info.get('name', ''))
|
||||||
spec = str(info.get('specification', ''))
|
spec = str(info.get('specification', ''))
|
||||||
unit = str(info.get('unit', ''))
|
unit = str(info.get('unit', ''))
|
||||||
@@ -462,69 +555,55 @@ class ProductDatabase:
|
|||||||
remote_conf = int(info.get('confidence', 50))
|
remote_conf = int(info.get('confidence', 50))
|
||||||
remote_count = int(info.get('usage_count', 1))
|
remote_count = int(info.get('usage_count', 1))
|
||||||
remote_seen = str(info.get('last_seen', now))
|
remote_seen = str(info.get('last_seen', now))
|
||||||
|
remote_avg = float(info.get('avg_price', price))
|
||||||
|
remote_min = float(info.get('min_price', price))
|
||||||
|
remote_max = float(info.get('max_price', price))
|
||||||
|
remote_pc = int(info.get('price_count', 1))
|
||||||
|
|
||||||
cursor = conn.execute(
|
row = conn.execute("SELECT confidence FROM products WHERE barcode=?",
|
||||||
"SELECT confidence FROM products WHERE barcode = ?",
|
(barcode,)).fetchone()
|
||||||
(barcode,)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
# 新记录,直接插入
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO products "
|
"INSERT INTO products (barcode, name, specification, unit, price, "
|
||||||
"(barcode, name, specification, unit, price, source, confidence, usage_count, last_seen, updated_at) "
|
"source, confidence, usage_count, last_seen, updated_at, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"avg_price, min_price, max_price, price_count) "
|
||||||
(barcode, name, spec, unit, price, remote_source, remote_conf, remote_count, remote_seen, now)
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
(barcode, name, spec, unit, price, remote_source, remote_conf,
|
||||||
|
remote_count, remote_seen, now,
|
||||||
|
remote_avg, remote_min, remote_max, remote_pc))
|
||||||
count += 1
|
count += 1
|
||||||
else:
|
else:
|
||||||
local_conf = row[0]
|
local_conf = row[0]
|
||||||
if remote_conf > local_conf:
|
if remote_conf > local_conf:
|
||||||
# 云端置信度更高,覆盖
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? "
|
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
||||||
"WHERE barcode=?",
|
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
||||||
(name, spec, unit, price, remote_source, remote_conf, remote_count, remote_seen, now, barcode)
|
(name, spec, unit, price, remote_source, remote_conf,
|
||||||
)
|
remote_count, remote_seen, now,
|
||||||
|
remote_avg, remote_min, remote_max, remote_pc, barcode))
|
||||||
count += 1
|
count += 1
|
||||||
elif remote_conf == local_conf:
|
elif remote_conf == local_conf:
|
||||||
# 置信度相同,填充空字段
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET "
|
"UPDATE products SET "
|
||||||
"name = CASE WHEN name='' THEN ? ELSE name END, "
|
"name=CASE WHEN name='' THEN ? ELSE name END, "
|
||||||
"specification = CASE WHEN specification='' THEN ? ELSE specification END, "
|
"specification=CASE WHEN specification='' THEN ? ELSE specification END, "
|
||||||
"unit = CASE WHEN unit='' THEN ? ELSE unit END, "
|
"unit=CASE WHEN unit='' THEN ? ELSE unit END, "
|
||||||
"usage_count = MAX(usage_count, ?), "
|
"usage_count=MAX(usage_count, ?), updated_at=? WHERE barcode=?",
|
||||||
"updated_at=? WHERE barcode=?",
|
(name, spec, unit, remote_count, now, barcode))
|
||||||
(name, spec, unit, remote_count, now, barcode)
|
|
||||||
)
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def _export_memory_json(self, json_path: str = None) -> str:
|
def _export_memory_json(self, json_path=None):
|
||||||
"""导出记忆库为本地 JSON 文件
|
"""导出记忆库为 JSON(兼容旧代码调用)"""
|
||||||
|
import os as _os
|
||||||
Args:
|
|
||||||
json_path: 输出路径,默认 data/product_memory.json
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
写入的文件路径
|
|
||||||
"""
|
|
||||||
if json_path is None:
|
if json_path is None:
|
||||||
json_path = os.path.join(os.path.dirname(self.db_path), 'product_memory.json')
|
json_path = _os.path.join(_os.path.dirname(self.db_path), 'product_memory.json')
|
||||||
|
|
||||||
data = self.export_for_sync()
|
data = self.export_for_sync()
|
||||||
os.makedirs(os.path.dirname(json_path), exist_ok=True)
|
_os.makedirs(_os.path.dirname(json_path), exist_ok=True)
|
||||||
|
|
||||||
with open(json_path, 'w', encoding='utf-8') as f:
|
with open(json_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
logger.debug(f"商品记忆库已导出: {json_path} ({len(data)} 条)")
|
|
||||||
return json_path
|
return json_path
|
||||||
|
|||||||
@@ -165,6 +165,69 @@ class GiteaSync:
|
|||||||
existing_sha = self.file_exists(remote_path)
|
existing_sha = self.file_exists(remote_path)
|
||||||
return self.push_file(remote_path, content, message, sha=existing_sha)
|
return self.push_file(remote_path, content, message, sha=existing_sha)
|
||||||
|
|
||||||
|
def push(self) -> str:
|
||||||
|
"""推送本地数据到云端:product_cache.json + barcode_mappings.json"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# 1. Product cache
|
||||||
|
from app.core.db.product_db import ProductDatabase
|
||||||
|
excel_source = str(project_root / "templates" / "商品资料.xlsx")
|
||||||
|
db_path = str(project_root / "data" / "product_cache.db")
|
||||||
|
product_db = ProductDatabase(db_path, excel_source)
|
||||||
|
product_data = product_db.export_for_sync()
|
||||||
|
sha = self.push_json("product_cache.json", product_data, "sync: update product cache")
|
||||||
|
results.append(f"product_cache: {'ok' if sha else 'skip'}")
|
||||||
|
|
||||||
|
# 2. Barcode mappings
|
||||||
|
barcode_path = project_root / "config" / "barcode_mappings.json"
|
||||||
|
if barcode_path.exists():
|
||||||
|
with open(barcode_path, "r", encoding="utf-8") as f:
|
||||||
|
barcode_data = json.loads(f.read())
|
||||||
|
sha = self.push_json("barcode_mappings.json", barcode_data, "sync: update barcode mappings")
|
||||||
|
results.append(f"barcode_mappings: {'ok' if sha else 'skip'}")
|
||||||
|
|
||||||
|
return "; ".join(results) if results else "无数据需要同步"
|
||||||
|
|
||||||
|
def pull(self) -> str:
|
||||||
|
"""从云端拉取数据并写入本地文件"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# 1. Product cache
|
||||||
|
result = self.pull_json("product_cache.json")
|
||||||
|
if result is not None:
|
||||||
|
data, sha = result
|
||||||
|
from app.core.db.product_db import ProductDatabase
|
||||||
|
excel_source = str(project_root / "templates" / "商品资料.xlsx")
|
||||||
|
db_path = str(project_root / "data" / "product_cache.db")
|
||||||
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
|
product_db = ProductDatabase(db_path, excel_source)
|
||||||
|
count = product_db.import_from_sync(data)
|
||||||
|
results.append(f"product_cache: 导入 {count} 条")
|
||||||
|
else:
|
||||||
|
results.append("product_cache: 云端无数据")
|
||||||
|
|
||||||
|
# 2. Barcode mappings
|
||||||
|
barcode_result = self.pull_json("barcode_mappings.json")
|
||||||
|
if barcode_result is not None:
|
||||||
|
barcode_data, sha = barcode_result
|
||||||
|
barcode_path = project_root / "config" / "barcode_mappings.json"
|
||||||
|
barcode_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(barcode_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(barcode_data, f, ensure_ascii=False, indent=2)
|
||||||
|
results.append(f"barcode_mappings: 已更新")
|
||||||
|
else:
|
||||||
|
results.append("barcode_mappings: 云端无数据")
|
||||||
|
|
||||||
|
return "; ".join(results) if results else "无数据需要同步"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config) -> Optional["GiteaSync"]:
|
def from_config(cls, config) -> Optional["GiteaSync"]:
|
||||||
"""从 ConfigManager 创建实例
|
"""从 ConfigManager 创建实例
|
||||||
|
|||||||
+8
-3
@@ -13,7 +13,7 @@ input_folder = data/input
|
|||||||
output_folder = data/output
|
output_folder = data/output
|
||||||
temp_folder = data/temp
|
temp_folder = data/temp
|
||||||
template_folder = templates
|
template_folder = templates
|
||||||
template_file = templates\银豹-采购单模板.xls
|
template_file = templates/银豹-采购单模板.xls
|
||||||
processed_record = data/processed_files.json
|
processed_record = data/processed_files.json
|
||||||
data_dir = data
|
data_dir = data
|
||||||
product_db = data/product_cache.db
|
product_db = data/product_cache.db
|
||||||
@@ -27,7 +27,7 @@ skip_existing = true
|
|||||||
[File]
|
[File]
|
||||||
allowed_extensions = .jpg,.jpeg,.png,.bmp
|
allowed_extensions = .jpg,.jpeg,.png,.bmp
|
||||||
excel_extension = .xlsx
|
excel_extension = .xlsx
|
||||||
max_file_size_mb = 4
|
max_file_size_mb = 5
|
||||||
|
|
||||||
[Templates]
|
[Templates]
|
||||||
purchase_order = 银豹-采购单模板.xls
|
purchase_order = 银豹-采购单模板.xls
|
||||||
@@ -40,4 +40,9 @@ version = 2026.05.05.0239
|
|||||||
base_url = https://gitea.94kan.cn
|
base_url = https://gitea.94kan.cn
|
||||||
owner = houhuan
|
owner = houhuan
|
||||||
repo = yixuan-sync-data
|
repo = yixuan-sync-data
|
||||||
token = 50b61e43a141d606ae2529cd1755bc666d800e08
|
token =
|
||||||
|
|
||||||
|
[WebAuth]
|
||||||
|
username = admin
|
||||||
|
password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ output_folder = data/output
|
|||||||
result_folder = data/result
|
result_folder = data/result
|
||||||
temp_folder = data/temp
|
temp_folder = data/temp
|
||||||
template_folder = templates
|
template_folder = templates
|
||||||
template_file = 银豹-采购单模板.xls
|
template_file = templates/银豹-采购单模板.xls
|
||||||
processed_record = data/processed_files.json
|
processed_record = data/processed_files.json
|
||||||
data_dir = data
|
data_dir = data
|
||||||
product_db = data/product_cache.db
|
product_db = data/product_cache.db
|
||||||
@@ -37,5 +37,5 @@ item_data = 商品资料.xlsx
|
|||||||
base_url = https://gitea.94kan.cn
|
base_url = https://gitea.94kan.cn
|
||||||
owner = houhuan
|
owner = houhuan
|
||||||
repo = yixuan-sync-data
|
repo = yixuan-sync-data
|
||||||
token =
|
token = 50b61e43a141d606ae2529cd1755bc666d800e08
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
container_name: yixuan-backend
|
||||||
|
ports:
|
||||||
|
- "18889:18889"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./config.ini:/app/config.ini:ro
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
- ./templates:/app/templates:ro
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
container_name: yixuan-frontend
|
||||||
|
ports:
|
||||||
|
- "18888:18888"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
server {
|
||||||
|
listen 18888;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# API proxy to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:18889;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy to backend
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://backend:18889;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vue Router history mode - serve index.html for all routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""FastAPI auth dependencies"""
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status, Query, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from .jwt_handler import decode_token
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return {"username": username}
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_ws(token: str = Query(...)) -> dict:
|
||||||
|
"""WebSocket auth via query parameter"""
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return {"username": username}
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_flexible(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
|
||||||
|
token: str = Query(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Auth from header OR query param (for file downloads in browser)."""
|
||||||
|
token_str = None
|
||||||
|
if credentials:
|
||||||
|
token_str = credentials.credentials
|
||||||
|
elif token:
|
||||||
|
token_str = token
|
||||||
|
|
||||||
|
if not token_str:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供认证凭据")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = decode_token(token_str)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return {"username": username}
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""JWT token creation and validation"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
|
||||||
|
from ..config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=JWT_EXPIRE_HOURS))
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, get_or_generate_secret(), algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, get_or_generate_secret(), algorithms=[JWT_ALGORITHM])
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Auth API endpoints"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .jwt_handler import create_access_token
|
||||||
|
from .dependencies import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Default credentials (should be changed on first login)
|
||||||
|
DEFAULT_USERNAME = "admin"
|
||||||
|
DEFAULT_PASSWORD = "admin123"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_credentials() -> tuple[str, bytes]:
|
||||||
|
"""Get username and password hash from config or defaults"""
|
||||||
|
try:
|
||||||
|
from app.config.settings import ConfigManager
|
||||||
|
cfg = ConfigManager()
|
||||||
|
username = cfg.get('WebAuth', 'username', fallback=DEFAULT_USERNAME)
|
||||||
|
pw_hash = cfg.get('WebAuth', 'password_hash', fallback='')
|
||||||
|
if not pw_hash:
|
||||||
|
# First run: store default password hash
|
||||||
|
pw_hash = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode()
|
||||||
|
try:
|
||||||
|
cfg.update('WebAuth', 'username', DEFAULT_USERNAME)
|
||||||
|
cfg.update('WebAuth', 'password_hash', pw_hash)
|
||||||
|
cfg.save_config()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return username, pw_hash.encode()
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_USERNAME, bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
async def login(req: LoginRequest):
|
||||||
|
stored_username, stored_hash = _get_credentials()
|
||||||
|
|
||||||
|
if req.username != stored_username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
if not bcrypt.checkpw(req.password.encode(), stored_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
token = create_access_token({"sub": req.username})
|
||||||
|
return LoginResponse(access_token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(current_user: dict = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
_, stored_hash = _get_credentials()
|
||||||
|
|
||||||
|
if not bcrypt.checkpw(req.old_password.encode(), stored_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||||
|
|
||||||
|
new_hash = bcrypt.hashpw(req.new_password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
try:
|
||||||
|
from app.config.settings import ConfigManager
|
||||||
|
cfg = ConfigManager()
|
||||||
|
cfg.update('WebAuth', 'password_hash', new_hash)
|
||||||
|
cfg.save_config()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"保存密码失败: {e}")
|
||||||
|
|
||||||
|
return {"message": "密码修改成功"}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Web-specific configuration"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "")
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp'}
|
||||||
|
ALLOWED_EXCEL_EXTENSIONS = {'.xlsx', '.xls'}
|
||||||
|
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | ALLOWED_EXCEL_EXTENSIONS
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||||
|
|
||||||
|
# Auth rate limit
|
||||||
|
LOGIN_RATE_LIMIT = 5 # per minute
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_generate_secret() -> str:
|
||||||
|
"""Get JWT secret from env or auto-generate on first run"""
|
||||||
|
global JWT_SECRET_KEY
|
||||||
|
if not JWT_SECRET_KEY:
|
||||||
|
secret_file = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
'data', '.jwt_secret'
|
||||||
|
)
|
||||||
|
if os.path.exists(secret_file):
|
||||||
|
with open(secret_file, 'r') as f:
|
||||||
|
JWT_SECRET_KEY = f.read().strip()
|
||||||
|
if not JWT_SECRET_KEY:
|
||||||
|
JWT_SECRET_KEY = secrets.token_urlsafe(48)
|
||||||
|
os.makedirs(os.path.dirname(secret_file), exist_ok=True)
|
||||||
|
with open(secret_file, 'w') as f:
|
||||||
|
f.write(JWT_SECRET_KEY)
|
||||||
|
return JWT_SECRET_KEY
|
||||||
@@ -0,0 +1,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"))
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Web backend dependencies
|
||||||
|
fastapi>=0.104.0
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
bcrypt>=4.0.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
httpx>=0.25.0
|
||||||
|
werkzeug>=3.0.0
|
||||||
|
|
||||||
|
# Core app dependencies (needed by processing endpoints)
|
||||||
|
pandas>=1.3.0
|
||||||
|
openpyxl>=3.0.0
|
||||||
|
xlrd>=2.0.0,<2.1.0
|
||||||
|
xlwt>=1.3.0
|
||||||
|
xlutils>=2.0.0
|
||||||
|
numpy>=1.19.0
|
||||||
|
requests>=2.25.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
configparser>=5.0.0
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""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: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
# Special rule fields
|
||||||
|
multiplier: Optional[int] = None
|
||||||
|
target_unit: Optional[str] = None
|
||||||
|
fixed_price: Optional[float] = None
|
||||||
|
specification: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BarcodeMappingUpdate(BaseModel):
|
||||||
|
target: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
multiplier: Optional[int] = None
|
||||||
|
target_unit: Optional[str] = None
|
||||||
|
fixed_price: Optional[float] = None
|
||||||
|
specification: 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", "")
|
||||||
|
item = {
|
||||||
|
"barcode": barcode,
|
||||||
|
"target": target,
|
||||||
|
"description": desc,
|
||||||
|
"multiplier": info.get("multiplier"),
|
||||||
|
"target_unit": info.get("target_unit"),
|
||||||
|
"fixed_price": info.get("fixed_price"),
|
||||||
|
"specification": info.get("specification"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
item = {
|
||||||
|
"barcode": barcode,
|
||||||
|
"target": str(info),
|
||||||
|
"description": "",
|
||||||
|
"multiplier": None,
|
||||||
|
"target_unit": None,
|
||||||
|
"fixed_price": None,
|
||||||
|
"specification": None,
|
||||||
|
}
|
||||||
|
s = search.lower() if search else ""
|
||||||
|
if s and s not in barcode.lower() and s not in item["target"].lower() and s not in (desc or "").lower():
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
|
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} 已存在")
|
||||||
|
|
||||||
|
entry: dict = {"description": body.description or ""}
|
||||||
|
if body.multiplier:
|
||||||
|
entry["multiplier"] = body.multiplier
|
||||||
|
if body.target_unit:
|
||||||
|
entry["target_unit"] = body.target_unit
|
||||||
|
if body.fixed_price is not None:
|
||||||
|
entry["fixed_price"] = body.fixed_price
|
||||||
|
if body.specification:
|
||||||
|
entry["specification"] = body.specification
|
||||||
|
else:
|
||||||
|
entry["map_to"] = body.target or ""
|
||||||
|
|
||||||
|
mappings[body.barcode] = entry
|
||||||
|
_save_mappings(mappings)
|
||||||
|
return {"message": f"已创建规则 {body.barcode}"}
|
||||||
|
|
||||||
|
|
||||||
|
@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": ""}
|
||||||
|
|
||||||
|
# Check if this is a special rule (has multiplier) or being converted to one
|
||||||
|
if body.multiplier is not None:
|
||||||
|
# Convert to special rule: remove map_to, add multiplier fields
|
||||||
|
existing.pop("map_to", None)
|
||||||
|
existing["multiplier"] = body.multiplier
|
||||||
|
if body.target_unit is not None:
|
||||||
|
existing["target_unit"] = body.target_unit
|
||||||
|
if body.fixed_price is not None:
|
||||||
|
existing["fixed_price"] = body.fixed_price
|
||||||
|
if body.specification is not None:
|
||||||
|
existing["specification"] = body.specification
|
||||||
|
elif body.target is not None:
|
||||||
|
# Convert to simple mapping: remove special fields, add map_to
|
||||||
|
for k in ("multiplier", "target_unit", "fixed_price", "specification"):
|
||||||
|
existing.pop(k, None)
|
||||||
|
existing["map_to"] = body.target
|
||||||
|
|
||||||
|
if body.description is not None:
|
||||||
|
existing["description"] = body.description
|
||||||
|
|
||||||
|
mappings[barcode] = existing
|
||||||
|
_save_mappings(mappings)
|
||||||
|
return {"message": f"已更新规则 {barcode}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{barcode}")
|
||||||
|
async def delete_barcode(
|
||||||
|
barcode: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
mappings = _load_mappings()
|
||||||
|
if barcode not in mappings:
|
||||||
|
raise HTTPException(404, f"未找到条码映射 {barcode}")
|
||||||
|
del mappings[barcode]
|
||||||
|
_save_mappings(mappings)
|
||||||
|
return {"message": f"已删除映射 {barcode}"}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
def _is_masked(key: str, value: str) -> bool:
|
||||||
|
"""Check if a value looks like a masked sensitive field (contains asterisks)."""
|
||||||
|
return any(s in key.lower() for s in _SENSITIVE_KEYS) and '*' in value
|
||||||
|
|
||||||
|
|
||||||
|
@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}")
|
||||||
|
|
||||||
|
if _is_masked(body.key, body.value):
|
||||||
|
raise HTTPException(400, "敏感字段不能直接提交掩码值,请先清除输入框再输入真实值")
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
skipped = []
|
||||||
|
for item in body.updates:
|
||||||
|
if item.section not in _ALLOWED_SECTIONS:
|
||||||
|
continue
|
||||||
|
# Skip masked sensitive values to prevent destroying real credentials
|
||||||
|
if _is_masked(item.key, item.value):
|
||||||
|
skipped.append(f"[{item.section}] {item.key}")
|
||||||
|
continue
|
||||||
|
cfg.update(item.section, item.key, item.value)
|
||||||
|
updated.append(f"[{item.section}] {item.key}")
|
||||||
|
|
||||||
|
cfg.save_config()
|
||||||
|
msg = f"已更新 {len(updated)} 项"
|
||||||
|
if skipped:
|
||||||
|
msg += f",跳过 {len(skipped)} 项掩码值"
|
||||||
|
return {"message": msg, "updated": updated, "skipped": skipped}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""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, JSONResponse
|
||||||
|
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, reset_file_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
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}"}
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeleteRequest(BaseModel):
|
||||||
|
files: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-delete")
|
||||||
|
async def batch_delete_files(
|
||||||
|
req: BatchDeleteRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Batch delete files from disk and clean up relation records."""
|
||||||
|
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
|
||||||
|
deleted = 0
|
||||||
|
errors = []
|
||||||
|
for item in req.files:
|
||||||
|
d = item.get("directory", "")
|
||||||
|
fname = item.get("filename", "")
|
||||||
|
if d not in dir_map or not fname:
|
||||||
|
errors.append(f"无效参数: {d}/{fname}")
|
||||||
|
continue
|
||||||
|
file_path = dir_map[d] / fname
|
||||||
|
try:
|
||||||
|
if file_path.exists():
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
file_path.unlink()
|
||||||
|
deleted += 1
|
||||||
|
_record_file_action(fname, d, size, "delete", current_user.get("username"))
|
||||||
|
_cleanup_relation_for_deleted_file(d, fname)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{fname}: {str(e)}")
|
||||||
|
return {"deleted": deleted, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
sort_by: Optional[str] = None,
|
||||||
|
sort_order: str = "desc",
|
||||||
|
sync: bool = Query(True, description="Auto-sync file relations before querying"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Query file relations with optional view filter."""
|
||||||
|
if sync:
|
||||||
|
sync_file_relations()
|
||||||
|
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size,
|
||||||
|
sort_by=sort_by, sort_order=sort_order)
|
||||||
|
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": "文件关系表已重建"}
|
||||||
|
|
||||||
|
|
||||||
|
class ResetCacheRequest(BaseModel):
|
||||||
|
files: list[dict] # [{input_image, output_excel, result_purchase}, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-cache")
|
||||||
|
async def reset_cache(
|
||||||
|
req: ResetCacheRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete output/result files and reset status to pending for reprocessing.
|
||||||
|
|
||||||
|
Each item in files should have: {input_image?, output_excel?, result_purchase?}
|
||||||
|
The corresponding files on disk are deleted, and the relation status is reset.
|
||||||
|
"""
|
||||||
|
result = reset_file_cache(req.files)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@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)} 条关系记录"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File preview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/preview/{directory}/{filename:path}")
|
||||||
|
async def preview_file(
|
||||||
|
directory: str,
|
||||||
|
filename: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Preview file content: images served directly, Excel returned as JSON grid."""
|
||||||
|
# Security: only allow specific directories
|
||||||
|
if directory not in ("input", "output", "result"):
|
||||||
|
raise HTTPException(403, "不允许访问该目录")
|
||||||
|
|
||||||
|
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
|
||||||
|
file_path = dir_map[directory] / filename
|
||||||
|
if not file_path.is_file():
|
||||||
|
raise HTTPException(404, f"文件不存在: {filename}")
|
||||||
|
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
# Images: serve directly
|
||||||
|
if ext in ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'):
|
||||||
|
return FileResponse(str(file_path))
|
||||||
|
|
||||||
|
# Excel: read and return as JSON grid
|
||||||
|
if ext in ('.xls', '.xlsx'):
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
df = pd.read_excel(str(file_path), header=None)
|
||||||
|
# Fill NaN with empty string
|
||||||
|
df = df.fillna('')
|
||||||
|
rows = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
rows.append([str(v) if v != '' else '' for v in row])
|
||||||
|
# Limit to first 200 rows
|
||||||
|
return JSONResponse({"type": "excel", "rows": rows[:200], "total_rows": len(rows)})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"读取文件失败: {e}")
|
||||||
|
|
||||||
|
raise HTTPException(400, f"不支持预览的文件类型: {ext}")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""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
|
||||||
|
specification: Optional[str] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
avg_price: Optional[float] = None
|
||||||
|
min_price: Optional[float] = None
|
||||||
|
max_price: Optional[float] = None
|
||||||
|
price_count: int = 0
|
||||||
|
confidence: int = 0
|
||||||
|
source: str = "ocr"
|
||||||
|
last_used: Optional[str] = None
|
||||||
|
use_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCreate(BaseModel):
|
||||||
|
barcode: str
|
||||||
|
name: Optional[str] = ""
|
||||||
|
specification: Optional[str] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
confidence: int = 50
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
specification: 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
|
||||||
|
stats: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
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", ""),
|
||||||
|
specification=row.get("specification"),
|
||||||
|
unit=row.get("unit"),
|
||||||
|
price=row.get("price"),
|
||||||
|
avg_price=row.get("avg_price"),
|
||||||
|
min_price=row.get("min_price"),
|
||||||
|
max_price=row.get("max_price"),
|
||||||
|
price_count=row.get("price_count", 0),
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Compute confidence stats from all results (not just current page)
|
||||||
|
high = sum(1 for r in results if r.get("confidence", 0) > 50)
|
||||||
|
medium = sum(1 for r in results if 10 <= r.get("confidence", 0) <= 50)
|
||||||
|
low = sum(1 for r in results if r.get("confidence", 0) < 10)
|
||||||
|
|
||||||
|
return MemoryListResponse(
|
||||||
|
items=[_row_to_item(r) for r in page_items],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
stats={"high": high, "medium": medium, "low": low, "total": total},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.post("")
|
||||||
|
async def create_memory(
|
||||||
|
body: MemoryCreate,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
existing = db.get_memory(body.barcode)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, f"条码 {body.barcode} 已存在,请使用编辑功能")
|
||||||
|
db.learn_from_product({
|
||||||
|
"barcode": body.barcode,
|
||||||
|
"name": body.name or "",
|
||||||
|
"specification": body.specification or "",
|
||||||
|
"unit": body.unit or "",
|
||||||
|
"price": body.price or 0,
|
||||||
|
}, source="user_confirmed")
|
||||||
|
return {"message": f"已创建记忆记录 {body.barcode}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{barcode}")
|
||||||
|
async def update_memory(
|
||||||
|
barcode: str,
|
||||||
|
body: MemoryUpdate,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
existing = db.get_memory(barcode)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, f"未找到条码 {barcode}")
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_none=True)
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(400, "没有提供更新数据")
|
||||||
|
|
||||||
|
db.update_memory(barcode, update_data)
|
||||||
|
return {"message": f"已更新 {barcode}", "updated_fields": list(update_data.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{barcode}")
|
||||||
|
async def delete_memory(
|
||||||
|
barcode: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
existing = db.get_memory(barcode)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, f"未找到条码 {barcode}")
|
||||||
|
db.delete_memory(barcode)
|
||||||
|
return {"message": f"已删除 {barcode}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reimport")
|
||||||
|
async def reimport_memory(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
try:
|
||||||
|
count = db.reimport()
|
||||||
|
return {"message": f"重新导入完成,共导入 {count} 条记录", "count": count}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"导入失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/sync")
|
||||||
|
async def export_memory(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
data = db.export_for_sync()
|
||||||
|
return {"data": data, "count": len(data)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/sync")
|
||||||
|
async def import_memory(
|
||||||
|
data: dict,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
try:
|
||||||
|
count = db.import_from_sync(data.get("data", []))
|
||||||
|
return {"message": f"导入完成,共 {count} 条", "count": count}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"导入失败: {e}")
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ── Thread-safe log capture ──
|
||||||
|
_tlocal = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
class TaskLogHandler(logging.Handler):
|
||||||
|
"""Capture all log records during task execution and forward to tm.add_log()"""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord):
|
||||||
|
ctx = getattr(_tlocal, 'ctx', None)
|
||||||
|
if ctx:
|
||||||
|
tm = ctx.get('tm')
|
||||||
|
task_id = ctx.get('task_id')
|
||||||
|
if tm and task_id:
|
||||||
|
msg = self.format(record)
|
||||||
|
if any(skip in msg for skip in ['DEBUG:', 'urllib3', 'charset_normalizer']):
|
||||||
|
return
|
||||||
|
tm.add_log(task_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
_log_handler = TaskLogHandler()
|
||||||
|
_log_handler.setLevel(logging.DEBUG)
|
||||||
|
_log_handler.setFormatter(logging.Formatter('%(message)s'))
|
||||||
|
_root_logger = logging.getLogger()
|
||||||
|
_configured = False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_log_capture():
|
||||||
|
global _configured
|
||||||
|
if not _configured:
|
||||||
|
_root_logger.addHandler(_log_handler)
|
||||||
|
_configured = True
|
||||||
|
|
||||||
|
|
||||||
|
def _start_log_capture(tm, task_id: str):
|
||||||
|
_setup_log_capture()
|
||||||
|
_root_logger.setLevel(logging.DEBUG)
|
||||||
|
_tlocal.ctx = {'tm': tm, 'task_id': task_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_log_capture():
|
||||||
|
_tlocal.ctx = None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_result_file(name: str):
|
||||||
|
files = getattr(_tlocal, 'result_files', None)
|
||||||
|
if files is not None:
|
||||||
|
files.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_with_capture(tm, task_id, func):
|
||||||
|
"""Wrap a do_work function with log capture setup/teardown."""
|
||||||
|
def wrapped():
|
||||||
|
_start_log_capture(tm, task_id)
|
||||||
|
_tlocal.result_files = []
|
||||||
|
try:
|
||||||
|
return func()
|
||||||
|
finally:
|
||||||
|
_stop_log_capture()
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_background_with_log(coro, tm, task_id: str):
|
||||||
|
"""Schedule a coroutine with log capture during execution."""
|
||||||
|
|
||||||
|
async def _wrapped():
|
||||||
|
_start_log_capture(tm, task_id)
|
||||||
|
try:
|
||||||
|
await coro
|
||||||
|
finally:
|
||||||
|
_stop_log_capture()
|
||||||
|
|
||||||
|
asyncio.ensure_future(_wrapped())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_product_db():
|
||||||
|
from app.core.db.product_db import ProductDatabase
|
||||||
|
return ProductDatabase(
|
||||||
|
str(_project_root / 'data' / 'product_cache.db'),
|
||||||
|
str(_project_root / 'templates' / '商品资料.xlsx')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _learn_products_from_excel(excel_path: Path, tm, task_id, source: str = 'ocr'):
|
||||||
|
"""从处理后的Excel文件学习商品数据到记忆库"""
|
||||||
|
try:
|
||||||
|
from app.core.utils.file_utils import smart_read_excel
|
||||||
|
df = smart_read_excel(str(excel_path))
|
||||||
|
if df is None or df.empty:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.core.handlers.column_mapper import ColumnMapper
|
||||||
|
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
||||||
|
if not barcode_col:
|
||||||
|
return
|
||||||
|
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
||||||
|
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
||||||
|
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
||||||
|
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price') or ColumnMapper.find_column(list(df.columns), 'price')
|
||||||
|
|
||||||
|
db = _get_product_db()
|
||||||
|
barcodes = [str(r.get(barcode_col, '')).strip() for _, r in df.iterrows() if str(r.get(barcode_col, '')).strip()]
|
||||||
|
memory = db.load_batch(barcodes)
|
||||||
|
|
||||||
|
learned = 0
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
barcode = str(row.get(barcode_col, '')).strip()
|
||||||
|
if not barcode or barcode == 'nan':
|
||||||
|
continue
|
||||||
|
price = 0.0
|
||||||
|
if price_col:
|
||||||
|
try:
|
||||||
|
p = row.get(price_col)
|
||||||
|
if p is not None and str(p).strip() not in ('', 'nan', 'None'):
|
||||||
|
price = float(p)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
product = {
|
||||||
|
'barcode': barcode,
|
||||||
|
'name': str(row.get(name_col, '')).strip() if name_col else '',
|
||||||
|
'specification': str(row.get(spec_col, '')).strip() if spec_col else '',
|
||||||
|
'unit': str(row.get(unit_col, '')).strip() if unit_col else '',
|
||||||
|
'price': price,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 记忆辅助补全
|
||||||
|
filled, fill_log = db.fill_from_memory(barcode, product, memory)
|
||||||
|
if fill_log:
|
||||||
|
tm.add_log(task_id, f" {fill_log}")
|
||||||
|
|
||||||
|
# 2. 价格预警
|
||||||
|
warn = db.price_warning(barcode, price, memory)
|
||||||
|
if warn:
|
||||||
|
tm.add_log(task_id, f" {warn}")
|
||||||
|
|
||||||
|
# 3. 学习
|
||||||
|
log = db.learn_from_product(filled, source=source, memory=memory, add_log=None)
|
||||||
|
if log:
|
||||||
|
tm.add_log(task_id, f" {log}")
|
||||||
|
learned += 1
|
||||||
|
|
||||||
|
if learned:
|
||||||
|
tm.add_log(task_id, f"[记忆库] 从 {excel_path.name} 学习了 {learned} 条商品数据")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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识别")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/ocr-batch", "body": {}}
|
||||||
|
|
||||||
|
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')
|
||||||
|
_add_result_file(candidate.name)
|
||||||
|
break
|
||||||
|
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
||||||
|
# Learn products into memory from OCR output
|
||||||
|
out_file = _output_dir / f"{out_stem}.xlsx"
|
||||||
|
if not out_file.exists():
|
||||||
|
out_file = _output_dir / f"{out_stem}.xls"
|
||||||
|
if out_file.exists():
|
||||||
|
_learn_products_from_excel(out_file, tm, task.id, source='ocr')
|
||||||
|
except Exception as e:
|
||||||
|
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
|
||||||
|
|
||||||
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
|
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成,共处理 {total} 个文件")
|
||||||
|
|
||||||
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, 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标准化处理")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/excel", "body": body.dict()}
|
||||||
|
|
||||||
|
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')
|
||||||
|
_add_result_file(result_name)
|
||||||
|
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')
|
||||||
|
_add_result_file(result_name)
|
||||||
|
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
||||||
|
# Learn products into memory from purchase order result
|
||||||
|
if result_path.exists():
|
||||||
|
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
|
||||||
|
except Exception as e:
|
||||||
|
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
|
||||||
|
|
||||||
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
|
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成,共 {total} 个文件")
|
||||||
|
|
||||||
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, 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("合并采购单")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/merge", "body": body.dict()}
|
||||||
|
|
||||||
|
# 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(_wrap_with_capture(tm, task.id, 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("一键全流程处理")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/pipeline", "body": body.dict()}
|
||||||
|
|
||||||
|
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')
|
||||||
|
_add_result_file(candidate.name)
|
||||||
|
break
|
||||||
|
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
||||||
|
out_file = _output_dir / f"{out_stem}.xlsx"
|
||||||
|
if not out_file.exists():
|
||||||
|
out_file = _output_dir / f"{out_stem}.xls"
|
||||||
|
if out_file.exists():
|
||||||
|
_learn_products_from_excel(out_file, tm, task.id, source='ocr')
|
||||||
|
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')
|
||||||
|
_add_result_file(result_name)
|
||||||
|
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')
|
||||||
|
_add_result_file(result_name)
|
||||||
|
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
||||||
|
if result_path.exists():
|
||||||
|
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
|
||||||
|
except Exception as e:
|
||||||
|
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
|
||||||
|
|
||||||
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
|
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(_wrap_with_capture(tm, task.id, 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}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/ocr-single", "body": body.dict()}
|
||||||
|
|
||||||
|
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')
|
||||||
|
_add_result_file(candidate.name)
|
||||||
|
break
|
||||||
|
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
|
||||||
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
|
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(_wrap_with_capture(tm, task.id, 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}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/excel-single", "body": body.dict()}
|
||||||
|
|
||||||
|
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 = list(getattr(_tlocal, 'result_files', []))
|
||||||
|
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(_wrap_with_capture(tm, task.id, 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}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/pipeline-single", "body": body.dict()}
|
||||||
|
|
||||||
|
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')
|
||||||
|
_add_result_file(out_name)
|
||||||
|
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')
|
||||||
|
_add_result_file(candidate.name)
|
||||||
|
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 = list(getattr(_tlocal, 'result_files', []))
|
||||||
|
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(_wrap_with_capture(tm, task.id, 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("批量合并采购单")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/merge-batch", "body": body.dict()}
|
||||||
|
|
||||||
|
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(_wrap_with_capture(tm, task.id, 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()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Cloud sync endpoints (Gitea-based)."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
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.from_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sync_in_thread(tm, task_id, action_name, sync_method):
|
||||||
|
"""Run a blocking sync operation in a thread."""
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
tm.update_progress(task_id, 10, "正在初始化同步...")
|
||||||
|
sync = _get_sync()
|
||||||
|
if sync is None:
|
||||||
|
tm.set_failed(task_id, "Gitea 配置不完整,请先在系统配置中设置 base_url/owner/repo/token")
|
||||||
|
return
|
||||||
|
tm.update_progress(task_id, 30, f"正在{action_name}文件...")
|
||||||
|
tm.add_log(task_id, f"[{action_name}] 开始{action_name}")
|
||||||
|
result = sync_method(sync)
|
||||||
|
tm.add_log(task_id, f"[{action_name}] 完成: {result}")
|
||||||
|
tm.set_completed(task_id, message=f"{action_name}完成")
|
||||||
|
except Exception as e:
|
||||||
|
tm.set_failed(task_id, str(e))
|
||||||
|
|
||||||
|
pool = ThreadPoolExecutor(max_workers=1)
|
||||||
|
pool.submit(_run)
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
@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("推送到云端")
|
||||||
|
_run_sync_in_thread(tm, task.id, "Push", lambda s: s.push())
|
||||||
|
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("从云端拉取")
|
||||||
|
_run_sync_in_thread(tm, task.id, "Pull", lambda s: s.pull())
|
||||||
|
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
|
||||||
|
import httpx as _httpx
|
||||||
|
cfg = ConfigManager()
|
||||||
|
base_url = cfg.get("Gitea", "base_url", fallback="").strip()
|
||||||
|
owner = cfg.get("Gitea", "owner", fallback="").strip()
|
||||||
|
repo = cfg.get("Gitea", "repo", fallback="").strip()
|
||||||
|
token = cfg.get("Gitea", "token", fallback="").strip()
|
||||||
|
enabled = bool(base_url and owner and repo and token)
|
||||||
|
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
|
||||||
|
|
||||||
|
connected = False
|
||||||
|
error = ""
|
||||||
|
if enabled:
|
||||||
|
try:
|
||||||
|
async with _httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{base_url}/api/v1/repos/{owner}/{repo}",
|
||||||
|
headers={"Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
connected = resp.status_code == 200
|
||||||
|
if not connected:
|
||||||
|
error = f"Gitea 返回 {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return {"enabled": enabled, "connected": connected, "repo_url": repo_url, "error": error}
|
||||||
|
except Exception as e:
|
||||||
|
return {"enabled": False, "connected": False, "repo_url": "", "error": str(e)}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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.delete("/{task_id}")
|
||||||
|
async def delete_task(
|
||||||
|
task_id: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a single task by ID."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
deleted = await loop.run_in_executor(None, lambda: db_schema.delete_task(task_id))
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
return {"message": "已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("")
|
||||||
|
async def clear_all_tasks(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Clear all task history records."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
count = await loop.run_in_executor(None, db_schema.clear_task_history)
|
||||||
|
return {"message": f"已清除 {count} 条记录", "count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
For in-memory tasks with metadata, the original endpoint and request body
|
||||||
|
are used to faithfully reproduce the original call. For historical DB-only
|
||||||
|
tasks, the endpoint is looked up from ``_RETRY_ROUTE_MAP`` by task name.
|
||||||
|
"""
|
||||||
|
tm = request.state.task_manager
|
||||||
|
|
||||||
|
# --- Strategy 1: in-memory task with metadata ---
|
||||||
|
new_task = tm.retry_task(task_id)
|
||||||
|
if new_task is not None:
|
||||||
|
meta = new_task.metadata or {}
|
||||||
|
endpoint = meta.get("endpoint")
|
||||||
|
body = meta.get("body", {})
|
||||||
|
if endpoint:
|
||||||
|
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
||||||
|
url = f"{base_url}{endpoint}"
|
||||||
|
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, json=body, headers=headers)
|
||||||
|
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||||
|
|
||||||
|
# Metadata present but no endpoint — fall through to DB strategy
|
||||||
|
# (the new task was already created; caller can track it)
|
||||||
|
return {"task_id": new_task.id, "status": "retried"}
|
||||||
|
|
||||||
|
# --- Strategy 2: DB-only historical task (no in-memory record) ---
|
||||||
|
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}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new in-memory task to track the retry.
|
||||||
|
new_task = tm.create_task(task_name)
|
||||||
|
|
||||||
|
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
||||||
|
url = f"{base_url}{endpoint}"
|
||||||
|
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
headers = {}
|
||||||
|
if auth_header:
|
||||||
|
headers["authorization"] = auth_header
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(url, headers=headers)
|
||||||
|
|
||||||
|
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""WebSocket endpoint for real-time task progress."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||||
|
from ..auth.jwt_handler import decode_token
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["websocket"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/task/{task_id}")
|
||||||
|
async def task_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
task_id: str,
|
||||||
|
token: str = Query(...),
|
||||||
|
):
|
||||||
|
"""WebSocket for real-time task progress updates."""
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if not username:
|
||||||
|
await websocket.close(code=4001, reason="Invalid token")
|
||||||
|
return
|
||||||
|
except (JWTError, Exception):
|
||||||
|
await websocket.close(code=4001, reason="Invalid token")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
tm = websocket.app.state.task_manager
|
||||||
|
task = tm.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
await websocket.send_json({"error": "任务不存在"})
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
tm.subscribe(task_id, websocket)
|
||||||
|
await websocket.send_json(task.to_dict())
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
if data == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
tm.unsubscribe(task_id, websocket)
|
||||||
|
except Exception:
|
||||||
|
tm.unsubscribe(task_id, websocket)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""SQLite write serialization for async context"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
|
||||||
|
class DBPool:
|
||||||
|
"""Serializes SQLite writes via asyncio.Lock. Reads are concurrent."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._write_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def execute_write(self, fn: Callable, *args, **kwargs) -> Any:
|
||||||
|
async with self._write_lock:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||||
|
|
||||||
|
async def execute_read(self, fn: Callable, *args, **kwargs) -> Any:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
"""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()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task(task_id: str) -> bool:
|
||||||
|
"""Delete a single task by ID. Returns True if deleted."""
|
||||||
|
conn = sqlite3.connect(_db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute("DELETE FROM task_history WHERE id = ?", (task_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_task_history() -> int:
|
||||||
|
"""Delete all task history records. Returns number of deleted rows."""
|
||||||
|
conn = sqlite3.connect(_db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute("DELETE FROM task_history")
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
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,
|
||||||
|
sort_by: str = None, sort_order: str = "desc",
|
||||||
|
exists_only: bool = True) -> 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
|
||||||
|
exists_only=True: for a given view, only return rows where the primary file
|
||||||
|
still exists on disk (input_image for images,
|
||||||
|
output_excel for tables, result_purchase for orders)
|
||||||
|
|
||||||
|
Returns (items, total).
|
||||||
|
"""
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
if sort_by and sort_by in ('created_at', 'updated_at', 'input_image', 'output_excel', 'result_purchase', 'status'):
|
||||||
|
sort_col = sort_by
|
||||||
|
else:
|
||||||
|
sort_col = order_by.split()[0] if order_by else 'id'
|
||||||
|
sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
|
||||||
|
|
||||||
|
# Fetch all matching rows (existence filter happens in Python)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM file_relations{where} ORDER BY {sort_col} {sort_dir}",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
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
|
||||||
|
|
||||||
|
# Filter: when exists_only is True, only keep rows whose primary file exists
|
||||||
|
if exists_only:
|
||||||
|
if view == 'images' and not d['input_exists']:
|
||||||
|
continue
|
||||||
|
if view == 'tables' and not d['output_exists']:
|
||||||
|
continue
|
||||||
|
if view == 'orders' and not d['result_exists']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append(d)
|
||||||
|
|
||||||
|
total = len(items)
|
||||||
|
|
||||||
|
# Page (Python-side after existence filtering)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
items = items[start:start + page_size]
|
||||||
|
|
||||||
|
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 reset_file_cache(files: list[dict]) -> dict:
|
||||||
|
"""Delete output/result files and reset relation status to pending.
|
||||||
|
|
||||||
|
Each item: {input_image?, output_excel?, result_purchase?}
|
||||||
|
Deletes the corresponding files from disk and resets status.
|
||||||
|
"""
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
output_dir = project_root / 'data' / 'output'
|
||||||
|
result_dir = project_root / 'data' / 'result'
|
||||||
|
|
||||||
|
deleted_files = 0
|
||||||
|
reset_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
conn = sqlite3.connect(_db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
for item in files:
|
||||||
|
input_image = item.get('input_image')
|
||||||
|
output_excel = item.get('output_excel')
|
||||||
|
result_purchase = item.get('result_purchase')
|
||||||
|
|
||||||
|
# Delete output file from disk
|
||||||
|
if output_excel:
|
||||||
|
out_path = output_dir / output_excel
|
||||||
|
if out_path.exists():
|
||||||
|
try:
|
||||||
|
out_path.unlink()
|
||||||
|
deleted_files += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{output_excel}: {e}")
|
||||||
|
|
||||||
|
# Delete result file from disk
|
||||||
|
if result_purchase:
|
||||||
|
res_path = result_dir / result_purchase
|
||||||
|
if res_path.exists():
|
||||||
|
try:
|
||||||
|
res_path.unlink()
|
||||||
|
deleted_files += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{result_purchase}: {e}")
|
||||||
|
|
||||||
|
# Reset relation status to pending
|
||||||
|
if input_image:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE file_relations SET output_excel = NULL, result_purchase = NULL, "
|
||||||
|
"status = 'pending', updated_at = ? WHERE input_image = ?",
|
||||||
|
(datetime.now().isoformat(), input_image),
|
||||||
|
)
|
||||||
|
reset_count += conn.total_changes
|
||||||
|
elif output_excel:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE file_relations SET output_excel = NULL, result_purchase = NULL, "
|
||||||
|
"status = 'pending', updated_at = ? WHERE output_excel = ?",
|
||||||
|
(datetime.now().isoformat(), output_excel),
|
||||||
|
)
|
||||||
|
reset_count += conn.total_changes
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"deleted_files": deleted_files, "reset_relations": reset_count, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
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 []
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Async wrapper for synchronous app/ services"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceWrapper:
|
||||||
|
"""Wraps synchronous services for async FastAPI endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, max_workers: int = 3):
|
||||||
|
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||||
|
|
||||||
|
async def run_sync(self, fn: Callable, *args, **kwargs) -> Any:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
self._executor,
|
||||||
|
lambda: fn(*args, **kwargs)
|
||||||
|
)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""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)
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {
|
||||||
|
"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:],
|
||||||
|
}
|
||||||
|
if self.metadata:
|
||||||
|
d["metadata"] = self.metadata
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
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 retry_task(self, task_id: str) -> Optional[Task]:
|
||||||
|
"""Create a new task to retry a failed task with its original parameters.
|
||||||
|
|
||||||
|
Returns the new task if the original was failed and retryable, else None.
|
||||||
|
The caller is responsible for dispatching the actual work based on
|
||||||
|
``new_task.metadata``.
|
||||||
|
"""
|
||||||
|
original = self._tasks.get(task_id)
|
||||||
|
if not original or original.status != TaskStatus.FAILED:
|
||||||
|
return None
|
||||||
|
new_task = self.create_task(original.name)
|
||||||
|
if original.metadata:
|
||||||
|
new_task.metadata = dict(original.metadata)
|
||||||
|
return new_task
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>益选 OCR 订单处理系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1929
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaoaitext-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"element-plus": "^2.5.0",
|
||||||
|
"@element-plus/icons-vue": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.1.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor: attach JWT token + AbortController support
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Response interceptor: handle 401 gracefully
|
||||||
|
let isRedirecting = false
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
// Prevent redirect loops: only redirect if not already on login page
|
||||||
|
if (!window.location.pathname.startsWith('/login') && !isRedirecting) {
|
||||||
|
isRedirecting = true
|
||||||
|
ElMessage.warning('登录已过期,请重新登录')
|
||||||
|
// Use a small delay to allow the current UI to settle
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
isRedirecting = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
ElMessage.error('没有权限执行此操作')
|
||||||
|
} else if (error.response?.status >= 500) {
|
||||||
|
ElMessage.error('服务器错误,请稍后重试')
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const debounced = (...args: Parameters<T>) => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => fn(...args), delay)
|
||||||
|
}
|
||||||
|
const cancel = () => { if (timer) clearTimeout(timer) }
|
||||||
|
return { debounced, cancel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
export function useFilePreview() {
|
||||||
|
const showPreview = ref(false)
|
||||||
|
const previewType = ref<'image' | 'excel' | ''>('')
|
||||||
|
const previewSrc = ref('')
|
||||||
|
const previewRows = ref<string[][]>([])
|
||||||
|
|
||||||
|
async function openPreview(dir: string, fname: string) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
const ct = resp.headers.get('content-type') || ''
|
||||||
|
if (ct.includes('image')) {
|
||||||
|
previewType.value = 'image'
|
||||||
|
const blob = await resp.blob()
|
||||||
|
previewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.type === 'excel') {
|
||||||
|
previewType.value = 'excel'
|
||||||
|
previewRows.value = data.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showPreview.value = true
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('预览失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPreview() {
|
||||||
|
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewSrc.value)
|
||||||
|
}
|
||||||
|
previewSrc.value = ''
|
||||||
|
previewType.value = ''
|
||||||
|
previewRows.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showPreview, previewType, previewSrc, previewRows,
|
||||||
|
openPreview, cleanupPreview
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function statusType(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
done: 'success', merged: 'success', excel_done: 'warning',
|
||||||
|
ocr_done: 'info', pending: 'info'
|
||||||
|
}
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusText(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
done: '已完成', merged: '已合并', excel_done: '已处理',
|
||||||
|
ocr_done: '已OCR', pending: '待处理'
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(t: string): string {
|
||||||
|
if (!t) return '--'
|
||||||
|
return t.replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'element-plus/dist/locale/zh-cn.mjs' {
|
||||||
|
const locale: any
|
||||||
|
export default locale
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus, { ElMessage } 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.config.errorHandler = (err, _instance, info) => {
|
||||||
|
console.error('Vue error:', err, info)
|
||||||
|
ElMessage.error('操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('token') || '')
|
||||||
|
const username = ref('')
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
async function login(user: string, password: string) {
|
||||||
|
const res = await api.post('/auth/login', { username: user, password })
|
||||||
|
token.value = res.data.access_token
|
||||||
|
localStorage.setItem('token', token.value)
|
||||||
|
username.value = user
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
username.value = ''
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUser() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/me')
|
||||||
|
username.value = res.data.username
|
||||||
|
} catch {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, username, isAuthenticated, login, logout, fetchUser }
|
||||||
|
})
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } 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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskConnection {
|
||||||
|
ws: WebSocket | null
|
||||||
|
reconnectAttempts: number
|
||||||
|
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProcessingStore = defineStore('processing', () => {
|
||||||
|
// --- Multi-task tracking ---
|
||||||
|
const activeTasks = ref(new Map<string, TaskInfo>())
|
||||||
|
|
||||||
|
const activeTaskList = computed(() =>
|
||||||
|
Array.from(activeTasks.value.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentTask = computed<TaskInfo | null>(() =>
|
||||||
|
activeTaskList.value[0] ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Legacy compatibility ---
|
||||||
|
const tasks = ref<TaskInfo[]>([])
|
||||||
|
const logs = ref<string[]>([])
|
||||||
|
const taskSource = ref<string>('')
|
||||||
|
|
||||||
|
// --- Per-task WebSocket management ---
|
||||||
|
const taskConnections = new Map<string, TaskConnection>()
|
||||||
|
const MAX_RECONNECT = 5
|
||||||
|
|
||||||
|
function connectWebSocket(taskId: string) {
|
||||||
|
disconnectTaskWS(taskId)
|
||||||
|
taskConnections.set(taskId, { ws: null, reconnectAttempts: 0, reconnectTimer: null })
|
||||||
|
doConnect(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doConnect(taskId: string) {
|
||||||
|
const conn = taskConnections.get(taskId)
|
||||||
|
if (!conn) return
|
||||||
|
|
||||||
|
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}`
|
||||||
|
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
conn.ws = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
conn.reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.error) return
|
||||||
|
|
||||||
|
// Update activeTasks map
|
||||||
|
activeTasks.value.set(data.task_id, data)
|
||||||
|
|
||||||
|
// Legacy: update 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: update logs for the current (most recent) task
|
||||||
|
if (currentTask.value?.task_id === data.task_id) {
|
||||||
|
logs.value = data.log_lines || []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
|
setTimeout(() => disconnectTaskWS(data.task_id), 2000)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
// Error will be followed by onclose, which handles reconnection
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
conn.ws = null
|
||||||
|
const task = activeTasks.value.get(taskId)
|
||||||
|
if (
|
||||||
|
task &&
|
||||||
|
(task.status === 'pending' || task.status === 'running') &&
|
||||||
|
conn.reconnectAttempts < MAX_RECONNECT
|
||||||
|
) {
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 10000)
|
||||||
|
conn.reconnectAttempts++
|
||||||
|
conn.reconnectTimer = setTimeout(() => {
|
||||||
|
if (taskConnections.has(taskId)) {
|
||||||
|
doConnect(taskId)
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectTaskWS(taskId: string) {
|
||||||
|
const conn = taskConnections.get(taskId)
|
||||||
|
if (!conn) return
|
||||||
|
conn.reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
||||||
|
if (conn.reconnectTimer) {
|
||||||
|
clearTimeout(conn.reconnectTimer)
|
||||||
|
conn.reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (conn.ws) {
|
||||||
|
conn.ws.close()
|
||||||
|
conn.ws = null
|
||||||
|
}
|
||||||
|
taskConnections.delete(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect all task WebSockets (backward compat) */
|
||||||
|
function disconnectWebSocket() {
|
||||||
|
for (const taskId of Array.from(taskConnections.keys())) {
|
||||||
|
disconnectTaskWS(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTask(taskId: string) {
|
||||||
|
disconnectTaskWS(taskId)
|
||||||
|
activeTasks.value.delete(taskId)
|
||||||
|
const idx = tasks.value.findIndex(t => t.task_id === taskId)
|
||||||
|
if (idx >= 0) tasks.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryTask(taskId: string) {
|
||||||
|
const res = await api.post(`/api/tasks/${taskId}/retry`)
|
||||||
|
const newTaskId: string = res.data.task_id
|
||||||
|
const taskInfo: TaskInfo = {
|
||||||
|
task_id: newTaskId,
|
||||||
|
name: res.data.message || '',
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
result_files: [],
|
||||||
|
error: null,
|
||||||
|
log_lines: [],
|
||||||
|
}
|
||||||
|
activeTasks.value.set(newTaskId, taskInfo)
|
||||||
|
connectWebSocket(newTaskId)
|
||||||
|
return newTaskId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
|
||||||
|
const res = await api.post(endpoint, body || {})
|
||||||
|
const taskId = res.data.task_id
|
||||||
|
taskSource.value = source
|
||||||
|
const taskInfo: TaskInfo = {
|
||||||
|
task_id: taskId,
|
||||||
|
name: res.data.message || '',
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
result_files: [],
|
||||||
|
error: null,
|
||||||
|
log_lines: [],
|
||||||
|
}
|
||||||
|
activeTasks.value.set(taskId, taskInfo)
|
||||||
|
logs.value = []
|
||||||
|
connectWebSocket(taskId)
|
||||||
|
return taskId
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTasks,
|
||||||
|
activeTaskList,
|
||||||
|
currentTask,
|
||||||
|
tasks,
|
||||||
|
logs,
|
||||||
|
taskSource,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
startTask,
|
||||||
|
removeTask,
|
||||||
|
retryTask,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
益选 OCR — shadcn/ui Theme
|
||||||
|
Clean · Minimal · Zinc palette
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Use system fonts with fallbacks — avoids blocking render on Google Fonts */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: local('Inter'), local('InterVariable'),
|
||||||
|
url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
|
||||||
|
/* ── Extended palette ── */
|
||||||
|
--indigo-500: #6366f1;
|
||||||
|
--indigo-400: #818cf8;
|
||||||
|
--indigo-100: rgba(99,102,241,0.1);
|
||||||
|
--emerald-500: #10b981;
|
||||||
|
--emerald-100: rgba(16,185,129,0.1);
|
||||||
|
--amber-400: #fbbf24;
|
||||||
|
--amber-500: #f59e0b;
|
||||||
|
--amber-600: #d97706;
|
||||||
|
--amber-100: rgba(245,158,11,0.1);
|
||||||
|
--red-500: #ef4444;
|
||||||
|
--red-100: rgba(239,68,68,0.1);
|
||||||
|
|
||||||
|
/* ── 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', 'Cascadia Code', monospace;
|
||||||
|
|
||||||
|
/* ── Transitions ── */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--duration-fast: 0.15s;
|
||||||
|
--duration-normal: 0.2s;
|
||||||
|
--duration-slow: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
Skeleton Loading
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #f4f4f5 25%, #e4e4e7 50%, #f4f4f5 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text.short {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-circle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: 80px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
Loading Overlay
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
Toast / Notification Transitions
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<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">{{ mappingItems.length + specialItems.length }}</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"><Right /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ mappingItems.length }}</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"><Setting /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ specialItems.length }}</span>
|
||||||
|
<span class="stat-label">特殊处理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-tab layout matching GUI -->
|
||||||
|
<div class="card animate-in animate-in-delay-1">
|
||||||
|
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||||
|
<!-- ═══ Tab 1: 条码映射 ═══ -->
|
||||||
|
<el-tab-pane label="条码映射" name="mapping">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="search"
|
||||||
|
placeholder="搜索条码..."
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
@clear="loadData"
|
||||||
|
>
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openMappingAdd">新增映射</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="mappingItems" v-loading="loading" stripe max-height="500" size="small">
|
||||||
|
<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="40" 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 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="editMapping(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ═══ Tab 2: 特殊处理 ═══ -->
|
||||||
|
<el-tab-pane label="特殊处理" name="special">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="search"
|
||||||
|
placeholder="搜索条码..."
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
@clear="loadData"
|
||||||
|
>
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openSpecialAdd">新增特殊处理</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="specialItems" v-loading="loading" stripe max-height="500" size="small">
|
||||||
|
<el-table-column prop="barcode" label="条码" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="barcode-cell special-type">{{ row.barcode }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="multiplier" label="乘数" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="multiplier-badge">{{ row.multiplier }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="target_unit" label="目标单位" width="90" align="center" />
|
||||||
|
<el-table-column prop="fixed_price" label="固定单价" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.fixed_price != null" class="price-cell">{{ row.fixed_price.toFixed(4) }}</span>
|
||||||
|
<span v-else class="text-muted">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="specification" label="规格" width="90" align="center" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="editSpecial(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapping add/edit dialog -->
|
||||||
|
<el-dialog v-model="showMapping" :title="mappingEdit ? '编辑条码映射' : '新增条码映射'" width="450px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="mappingForm" label-width="80px">
|
||||||
|
<el-form-item label="源条码">
|
||||||
|
<el-input v-model="mappingForm.barcode" :disabled="mappingEdit" placeholder="输入原始条码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标条码">
|
||||||
|
<el-input v-model="mappingForm.target" placeholder="输入目标条码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="说明">
|
||||||
|
<el-input v-model="mappingForm.description" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showMapping = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveMapping">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Special rule add/edit dialog -->
|
||||||
|
<el-dialog v-model="showSpecial" :title="specialEdit ? '编辑特殊处理' : '新增特殊处理'" width="480px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="specialForm" label-width="80px">
|
||||||
|
<el-form-item label="条码">
|
||||||
|
<el-input v-model="specialForm.barcode" :disabled="specialEdit" placeholder="输入条码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="乘数">
|
||||||
|
<el-input-number v-model="specialForm.multiplier" :min="1" :step="1" style="width: 100%" placeholder="如: 10" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标单位">
|
||||||
|
<el-input v-model="specialForm.targetUnit" placeholder="如: 瓶、个、对" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="固定单价">
|
||||||
|
<el-input-number v-model="specialForm.fixedPrice" :precision="4" :step="0.01" :min="0" style="width: 100%" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规格">
|
||||||
|
<el-input v-model="specialForm.specification" placeholder="如: 1*30" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="specialForm.description" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showSpecial = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSpecial">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Refresh, Connection, Right, Setting } from '@element-plus/icons-vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const rawItems = ref<any[]>([])
|
||||||
|
const activeTab = ref('mapping')
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
|
||||||
|
|
||||||
|
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
|
||||||
|
const specialItems = computed(() => rawItems.value.filter(r => r.multiplier))
|
||||||
|
|
||||||
|
// ── Mapping form ──
|
||||||
|
const showMapping = ref(false)
|
||||||
|
const mappingEdit = ref(false)
|
||||||
|
const mappingForm = reactive({ barcode: '', target: '', description: '' })
|
||||||
|
|
||||||
|
// ── Special form ──
|
||||||
|
const showSpecial = ref(false)
|
||||||
|
const specialEdit = ref(false)
|
||||||
|
const specialForm = reactive({
|
||||||
|
barcode: '',
|
||||||
|
multiplier: null as number | null,
|
||||||
|
targetUnit: '',
|
||||||
|
fixedPrice: null as number | null,
|
||||||
|
specification: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/barcodes', { params: { search: search.value } })
|
||||||
|
rawItems.value = res.data.items
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabChange() {
|
||||||
|
// Keep search across tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping CRUD ──
|
||||||
|
function openMappingAdd() {
|
||||||
|
mappingEdit.value = false
|
||||||
|
mappingForm.barcode = ''
|
||||||
|
mappingForm.target = ''
|
||||||
|
mappingForm.description = ''
|
||||||
|
showMapping.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editMapping(row: any) {
|
||||||
|
mappingEdit.value = true
|
||||||
|
mappingForm.barcode = row.barcode
|
||||||
|
mappingForm.target = row.target
|
||||||
|
mappingForm.description = row.description || ''
|
||||||
|
showMapping.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMapping() {
|
||||||
|
if (!mappingForm.barcode || !mappingForm.target) {
|
||||||
|
ElMessage.warning('请填写源条码和目标条码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (mappingEdit.value) {
|
||||||
|
await api.put(`/barcodes/${mappingForm.barcode}`, {
|
||||||
|
target: mappingForm.target,
|
||||||
|
description: mappingForm.description,
|
||||||
|
})
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} else {
|
||||||
|
await api.post('/barcodes', {
|
||||||
|
barcode: mappingForm.barcode,
|
||||||
|
target: mappingForm.target,
|
||||||
|
description: mappingForm.description,
|
||||||
|
})
|
||||||
|
ElMessage.success('已创建')
|
||||||
|
}
|
||||||
|
showMapping.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.response?.data?.detail || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Special CRUD ──
|
||||||
|
function openSpecialAdd() {
|
||||||
|
specialEdit.value = false
|
||||||
|
specialForm.barcode = ''
|
||||||
|
specialForm.multiplier = null
|
||||||
|
specialForm.targetUnit = ''
|
||||||
|
specialForm.fixedPrice = null
|
||||||
|
specialForm.specification = ''
|
||||||
|
specialForm.description = ''
|
||||||
|
showSpecial.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSpecial(row: any) {
|
||||||
|
specialEdit.value = true
|
||||||
|
specialForm.barcode = row.barcode
|
||||||
|
specialForm.multiplier = row.multiplier
|
||||||
|
specialForm.targetUnit = row.target_unit || ''
|
||||||
|
specialForm.fixedPrice = row.fixed_price ?? null
|
||||||
|
specialForm.specification = row.specification || ''
|
||||||
|
specialForm.description = row.description || ''
|
||||||
|
showSpecial.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSpecial() {
|
||||||
|
if (!specialForm.barcode) {
|
||||||
|
ElMessage.warning('请填写条码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!specialForm.multiplier) {
|
||||||
|
ElMessage.warning('请填写乘数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
multiplier: specialForm.multiplier,
|
||||||
|
target_unit: specialForm.targetUnit || null,
|
||||||
|
fixed_price: specialForm.fixedPrice ?? null,
|
||||||
|
specification: specialForm.specification || null,
|
||||||
|
description: specialForm.description,
|
||||||
|
}
|
||||||
|
if (specialEdit.value) {
|
||||||
|
await api.put(`/barcodes/${specialForm.barcode}`, body)
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} else {
|
||||||
|
await api.post('/barcodes', { barcode: specialForm.barcode, ...body })
|
||||||
|
ElMessage.success('已创建')
|
||||||
|
}
|
||||||
|
showSpecial.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.response?.data?.detail || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared ──
|
||||||
|
async function deleteItem(row: any) {
|
||||||
|
try {
|
||||||
|
const desc = row.target ? `${row.barcode} → ${row.target}` : `${row.barcode}`
|
||||||
|
await ElMessageBox.confirm(`确定删除规则 ${desc}?`, '确认')
|
||||||
|
await api.delete(`/barcodes/${row.barcode}`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.barcodes-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats row ── */
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab toolbar ── */
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Barcode cells ── */
|
||||||
|
.barcode-cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-cell.target {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-cell.special-type {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplier-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(245,158,11,0.1);
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<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"
|
||||||
|
:class="{ edited: isEdited(activeTab, key, value) }"
|
||||||
|
>
|
||||||
|
<label class="field-label">
|
||||||
|
{{ key }}
|
||||||
|
<span v-if="isEdited(activeTab, key, value)" class="edited-dot"></span>
|
||||||
|
</label>
|
||||||
|
<el-input
|
||||||
|
v-if="isSensitive(key)"
|
||||||
|
:model-value="edited[activeTab]?.[key] ?? ''"
|
||||||
|
@update:model-value="setEditedValue(activeTab, key, $event)"
|
||||||
|
placeholder="已设置,留空保持不变"
|
||||||
|
size="small"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
: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 = reactive<Record<string, Record<string, string>>>({})
|
||||||
|
|
||||||
|
const sectionLabels: Record<string, string> = {
|
||||||
|
API: 'API 配置',
|
||||||
|
Paths: '路径设置',
|
||||||
|
Performance: '性能参数',
|
||||||
|
File: '文件设置',
|
||||||
|
Templates: '模板配置',
|
||||||
|
Gitea: 'Gitea 同步',
|
||||||
|
WebAuth: 'Web 认证',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_KEYS = new Set(['api_key', 'secret_key', 'token', 'password', 'api_secret', 'access_key'])
|
||||||
|
|
||||||
|
function isSensitive(key: string): boolean {
|
||||||
|
return SENSITIVE_KEYS.has(key.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEdited(section: string, key: string, original: string): boolean {
|
||||||
|
const val = edited[section]?.[key]
|
||||||
|
if (val === undefined) return false
|
||||||
|
if (isSensitive(key)) return val !== ''
|
||||||
|
return val !== original
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
// Skip empty sensitive fields (user left placeholder unchanged)
|
||||||
|
if (isSensitive(key) && !value) continue
|
||||||
|
// Skip masked values that somehow got through
|
||||||
|
if (isSensitive(key) && value.includes('*')) continue
|
||||||
|
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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row.edited {
|
||||||
|
background: rgba(99,102,241,0.04);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edited-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--indigo-500);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- Top stats row -->
|
||||||
|
<div class="stats-row animate-in">
|
||||||
|
<div
|
||||||
|
v-if="statsLoading"
|
||||||
|
v-for="n in 4"
|
||||||
|
:key="'sk'+n"
|
||||||
|
class="stat-card"
|
||||||
|
>
|
||||||
|
<div class="skeleton skeleton-circle" style="width:44px;height:44px;border-radius:12px"></div>
|
||||||
|
<div class="stat-info" style="gap:4px">
|
||||||
|
<div class="skeleton skeleton-text" style="width:48px;height:24px"></div>
|
||||||
|
<div class="skeleton skeleton-text short" style="width:64px;height:14px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!statsLoading"
|
||||||
|
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">
|
||||||
|
<!-- Active tasks list -->
|
||||||
|
<div class="card progress-card animate-in animate-in-delay-1">
|
||||||
|
<div class="card-head">
|
||||||
|
<h3>处理进度</h3>
|
||||||
|
<el-tag v-if="visibleTasks.length > 0" size="small" effect="dark">
|
||||||
|
{{ visibleTasks.length }} 个任务
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="visibleTasks.length > 0" class="task-cards">
|
||||||
|
<div v-for="task in visibleTasks" :key="task.task_id" class="task-card-item">
|
||||||
|
<div class="task-card-header">
|
||||||
|
<span class="task-name">{{ task.name }}</span>
|
||||||
|
<el-tag :type="statusTagType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-progress v-if="task.status === 'running' || task.status === 'pending'" :percentage="task.progress" :stroke-width="8" />
|
||||||
|
<div v-if="task.message" class="task-message">{{ task.message }}</div>
|
||||||
|
<!-- Error display -->
|
||||||
|
<el-alert v-if="task.status === 'failed' && task.error" :title="task.error" type="error" show-icon :closable="false" class="task-error" />
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="task-card-actions">
|
||||||
|
<el-button v-if="task.status === 'failed'" type="warning" size="small" @click="handleRetry(task.task_id)">重试</el-button>
|
||||||
|
<el-button v-if="task.status === 'completed' || task.status === 'failed'" size="small" @click="handleDismiss(task.task_id)">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
<!-- Log lines for this task -->
|
||||||
|
<div v-if="task.log_lines?.length" class="task-logs">
|
||||||
|
<div v-for="(log, i) in task.log_lines.slice(-50)" :key="i" class="log-line">{{ log }}</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<el-button size="small" link @click="$router.push('/tasks')">查看全部日志</el-button>
|
||||||
|
<el-button size="small" link @click="clearLogs">清空</el-button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<button class="action-btn action-btn-danger" @click="clearAll" :disabled="processing">
|
||||||
|
<div class="action-icon danger">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<polyline points="3,6 5,6 21,6"/>
|
||||||
|
<path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6M8,6V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2V6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="action-info">
|
||||||
|
<span class="action-name">清除全部</span>
|
||||||
|
<span class="action-desc">删除所有文件和处理记录</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Refresh, 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 statsLoading = ref(true)
|
||||||
|
|
||||||
|
const detailedStats = ref({
|
||||||
|
input_images: 0,
|
||||||
|
output_excel: 0,
|
||||||
|
unprocessed_images: 0,
|
||||||
|
unprocessed_excel: 0,
|
||||||
|
completed_results: 0,
|
||||||
|
total_processed: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleTasks = computed(() =>
|
||||||
|
ps.taskSource !== 'sync' ? ps.activeTaskList : []
|
||||||
|
)
|
||||||
|
const logs = computed(() => ps.logs.slice(0, 50))
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTagType(status: string): string {
|
||||||
|
const map: Record<string, string> = { pending: 'info', running: '', completed: 'success', failed: 'danger' }
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = { pending: '等待中', running: '运行中', completed: '已完成', failed: '失败' }
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(taskId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ps.retryTask(taskId)
|
||||||
|
ElMessage.success('已重新提交任务')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('重试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss(taskId: string): void {
|
||||||
|
ps.removeTask(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStats(): Promise<void> {
|
||||||
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/files/stats/detailed')
|
||||||
|
detailedStats.value = res.data
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[] = []
|
||||||
|
const failedFiles: 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 })
|
||||||
|
} catch (err: any) {
|
||||||
|
failedFiles.push(file.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploading.value = false
|
||||||
|
uploadingName.value = ''
|
||||||
|
uploadPct.value = 0
|
||||||
|
refreshStats()
|
||||||
|
|
||||||
|
// Show upload results
|
||||||
|
if (uploadedFiles.length > 0) {
|
||||||
|
ElMessage.success(`${uploadedFiles.length} 个文件上传成功`)
|
||||||
|
}
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
ElMessage.error(`${failedFiles.length} 个文件上传失败: ${failedFiles.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-process: 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) {
|
||||||
|
await doAction('/processing/pipeline')
|
||||||
|
} else if (hasExcel) {
|
||||||
|
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')
|
||||||
|
|
||||||
|
async function clearAll(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'将删除 input、output、result 目录下所有文件,并清除全部处理记录。此操作不可撤销,是否继续?',
|
||||||
|
'清除全部数据',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认清除', cancelButtonText: '取消' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
api.post('/files/clear/input'),
|
||||||
|
api.post('/files/clear/output'),
|
||||||
|
api.post('/files/clear/result'),
|
||||||
|
api.delete('/tasks'),
|
||||||
|
api.post('/files/relations/sync'),
|
||||||
|
])
|
||||||
|
ElMessage.success('已清除所有文件和处理记录')
|
||||||
|
refreshStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.response?.data?.detail || '清除失败')
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh stats when any task completes or fails
|
||||||
|
watch(
|
||||||
|
() => visibleTasks.value.map(t => t.status),
|
||||||
|
(statuses) => {
|
||||||
|
if (statuses.some(s => s === 'completed' || s === '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-icon.danger {
|
||||||
|
background: rgba(239,68,68,0.08);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover:not(:disabled) {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task cards ── */
|
||||||
|
.task-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-item {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-item:hover {
|
||||||
|
border-color: #d4d4d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-message {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-logs {
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #09090b;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-logs .log-line {
|
||||||
|
color: #a1a1aa;
|
||||||
|
padding: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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>
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
<template>
|
||||||
|
<el-container class="layout">
|
||||||
|
<el-aside v-show="!isMobile" :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="/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">
|
||||||
|
<button v-if="isMobile" class="hamburger-btn" @click="mobileDrawer = true">
|
||||||
|
<el-icon :size="22"><MenuIcon /></el-icon>
|
||||||
|
</button>
|
||||||
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<!-- Online indicator -->
|
||||||
|
<div v-if="!isOnline" class="offline-badge">
|
||||||
|
<span class="offline-dot"></span>
|
||||||
|
离线
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar drawer -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="mobileDrawer"
|
||||||
|
direction="ltr"
|
||||||
|
size="260px"
|
||||||
|
:with-header="false"
|
||||||
|
class="mobile-drawer"
|
||||||
|
>
|
||||||
|
<div class="drawer-sidebar">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<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>
|
||||||
|
<span class="logo-text">益选 OCR</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<el-menu
|
||||||
|
:default-active="route.path"
|
||||||
|
:default-openeds="filesMenuOpen"
|
||||||
|
mode="vertical"
|
||||||
|
background-color="transparent"
|
||||||
|
text-color="var(--text-sidebar)"
|
||||||
|
active-text-color="#fafafa"
|
||||||
|
class="sidebar-nav"
|
||||||
|
router
|
||||||
|
@select="onMenuSelect"
|
||||||
|
>
|
||||||
|
<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="/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>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- Change password dialog -->
|
||||||
|
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="70px">
|
||||||
|
<el-form-item label="旧密码" prop="old_password">
|
||||||
|
<el-input v-model="pwdForm.old_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="new_password">
|
||||||
|
<el-input v-model="pwdForm.new_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirm_password">
|
||||||
|
<el-input v-model="pwdForm.confirm_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, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import {
|
||||||
|
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, FolderOpened,
|
||||||
|
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight, Menu as MenuIcon
|
||||||
|
} 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 isMobile = ref(window.innerWidth < 768)
|
||||||
|
const mobileDrawer = ref(false)
|
||||||
|
const showPwd = ref(false)
|
||||||
|
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||||
|
const pwdFormRef = ref<FormInstance>()
|
||||||
|
const pwdRules: FormRules = {
|
||||||
|
old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirm_password: [
|
||||||
|
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string, callback: any) => {
|
||||||
|
if (value !== pwdForm.new_password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
|
||||||
|
// Track online/offline status
|
||||||
|
function updateOnlineStatus() {
|
||||||
|
isOnline.value = navigator.onLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track viewport for mobile drawer
|
||||||
|
function updateMobileState() {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drawer on route change
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
mobileDrawer.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('online', updateOnlineStatus)
|
||||||
|
window.addEventListener('offline', updateOnlineStatus)
|
||||||
|
window.addEventListener('resize', updateMobileState)
|
||||||
|
await authStore.fetchUser()
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('online', updateOnlineStatus)
|
||||||
|
window.removeEventListener('offline', updateOnlineStatus)
|
||||||
|
window.removeEventListener('resize', updateMobileState)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filesMenuOpen = ['/files']
|
||||||
|
|
||||||
|
function onMenuSelect() {
|
||||||
|
mobileDrawer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
'/': '处理中心',
|
||||||
|
'/files/orders': '采购单',
|
||||||
|
'/files/tables': '表格处理',
|
||||||
|
'/files/images': '图片处理',
|
||||||
|
'/tasks': '任务历史',
|
||||||
|
'/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 = ''
|
||||||
|
pwdForm.confirm_password = ''
|
||||||
|
showPwd.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
if (!pwdFormRef.value) return
|
||||||
|
await pwdFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
await api.post('/auth/change-password', {
|
||||||
|
old_password: pwdForm.old_password,
|
||||||
|
new_password: pwdForm.new_password
|
||||||
|
})
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hamburger button (mobile) ── */
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile drawer ── */
|
||||||
|
.mobile-drawer :deep(.el-drawer__body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sidebar {
|
||||||
|
background: #09090b;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sidebar .sidebar-logo {
|
||||||
|
padding: 20px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sidebar .sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-right: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Offline badge ── */
|
||||||
|
.offline-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(239,68,68,0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ef4444;
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.content {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ambient background ── */
|
||||||
|
.bg-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-size: 40px 40px;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px);
|
||||||
|
mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
|
||||||
|
-webkit-mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-glow {
|
||||||
|
position: absolute;
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: radial-gradient(circle, rgba(99,102,241,0.06) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<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" @input="debouncedSearch" @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, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
|
|
||||||
|
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 })
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ElMessage.error('加载统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<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">高可信 (>50)</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">{{ mediumConfidence }}</span>
|
||||||
|
<span class="stat-label">中可信 (10~50)</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">{{ lowConfidence }}</span>
|
||||||
|
<span class="stat-label">低可信 (<10)</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"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
@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">新增记录</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="specification" label="规格" width="120" />
|
||||||
|
<el-table-column prop="unit" label="单位" width="80" />
|
||||||
|
<el-table-column label="均价" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="price-cell">{{ row.avg_price != null ? row.avg_price.toFixed(4) : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格范围" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.price_count > 0" class="price-range">
|
||||||
|
{{ row.min_price?.toFixed(2) }}~{{ row.max_price?.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="记录次数" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="row.price_count > 3 ? 'count-high' : 'count-low'">{{ row.price_count || 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="可信度" width="90" align="center">
|
||||||
|
<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="75" align="center" />
|
||||||
|
<el-table-column prop="use_count" label="出现" width="60" align="center" />
|
||||||
|
<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="isAdd ? '新增记忆记录' : '编辑记忆记录'" width="480px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="editForm" label-width="80px">
|
||||||
|
<el-form-item label="条码">
|
||||||
|
<el-input v-model="editForm.barcode" :disabled="!isAdd" />
|
||||||
|
</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.specification" />
|
||||||
|
</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, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
// Confidence stats from server-side (not just current page)
|
||||||
|
const confidenceStats = reactive({ high: 0, medium: 0, low: 0, total: 0 })
|
||||||
|
const highConfidence = computed(() => confidenceStats.high)
|
||||||
|
const mediumConfidence = computed(() => confidenceStats.medium)
|
||||||
|
const lowConfidence = computed(() => confidenceStats.low)
|
||||||
|
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const isAdd = ref(false)
|
||||||
|
const editForm = reactive({
|
||||||
|
barcode: '',
|
||||||
|
name: '',
|
||||||
|
specification: '',
|
||||||
|
unit: '',
|
||||||
|
price: 0,
|
||||||
|
confidence: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
function confCls(c: number) {
|
||||||
|
if (c > 50) return 'high'
|
||||||
|
if (c >= 10) 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
|
||||||
|
// Update confidence stats from API response if available
|
||||||
|
if (res.data.stats) {
|
||||||
|
confidenceStats.high = res.data.stats.high || 0
|
||||||
|
confidenceStats.medium = res.data.stats.medium || 0
|
||||||
|
confidenceStats.low = res.data.stats.low || 0
|
||||||
|
confidenceStats.total = res.data.stats.total || 0
|
||||||
|
} else {
|
||||||
|
// Fallback: stats not available from server, default to 0
|
||||||
|
confidenceStats.high = 0
|
||||||
|
confidenceStats.medium = 0
|
||||||
|
confidenceStats.low = 0
|
||||||
|
confidenceStats.total = 0
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error('加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(() => {
|
||||||
|
page.value = 1
|
||||||
|
loadData()
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
isAdd.value = true
|
||||||
|
editForm.barcode = ''
|
||||||
|
editForm.name = ''
|
||||||
|
editForm.specification = ''
|
||||||
|
editForm.unit = ''
|
||||||
|
editForm.price = 0
|
||||||
|
editForm.confidence = 50
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editItem(row: any) {
|
||||||
|
isAdd.value = false
|
||||||
|
editForm.barcode = row.barcode
|
||||||
|
editForm.name = row.name || ''
|
||||||
|
editForm.specification = row.specification || ''
|
||||||
|
editForm.unit = row.unit || ''
|
||||||
|
editForm.price = row.price || 0
|
||||||
|
editForm.confidence = row.confidence || 0
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editForm.barcode) {
|
||||||
|
ElMessage.warning('请输入条码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isAdd.value) {
|
||||||
|
await api.post('/memory', {
|
||||||
|
barcode: editForm.barcode,
|
||||||
|
name: editForm.name,
|
||||||
|
specification: editForm.specification,
|
||||||
|
unit: editForm.unit,
|
||||||
|
price: editForm.price,
|
||||||
|
confidence: editForm.confidence,
|
||||||
|
})
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
} else {
|
||||||
|
await api.put(`/memory/${editForm.barcode}`, {
|
||||||
|
name: editForm.name,
|
||||||
|
specification: editForm.specification,
|
||||||
|
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 (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.memory-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats row ── */
|
||||||
|
.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 ── */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-range {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-high {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-low {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ── */
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
<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" :class="{ 'connection-error': !syncStatus.connected }">
|
||||||
|
<div class="connection-icon">
|
||||||
|
<svg v-if="syncStatus.connected" 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>
|
||||||
|
<svg v-else width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="connection-info">
|
||||||
|
<span class="connection-status" :class="{ 'status-error': !syncStatus.connected }">
|
||||||
|
{{ syncStatus.connected ? '已连接' : '连接失败' }}
|
||||||
|
</span>
|
||||||
|
<span class="connection-url">{{ syncStatus.repo_url }}</span>
|
||||||
|
<span v-if="syncStatus.error" class="connection-error-msg">{{ syncStatus.error }}</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, connected: false, repo_url: '', error: '' })
|
||||||
|
|
||||||
|
const currentTask = computed(() => {
|
||||||
|
if (processingStore.taskSource === 'sync') return processingStore.currentTask
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
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 {
|
||||||
|
ElMessage.error('检查同步状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPush() {
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
await processingStore.startTask('/sync/push', undefined, 'sync')
|
||||||
|
} 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', undefined, 'sync')
|
||||||
|
} 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-status.status-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-url {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-error {
|
||||||
|
background: rgba(239,68,68,0.05);
|
||||||
|
border-color: rgba(239,68,68,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-error-msg {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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(251,191,36,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, #d97706));
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.4s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-msg {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logs ── */
|
||||||
|
.sync-logs {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #0f1117;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line.err { color: #f87171; }
|
||||||
|
.log-line.ok { color: #34d399; }
|
||||||
|
|
||||||
|
/* ── Disabled state ── */
|
||||||
|
.sync-disabled {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-disabled h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-disabled p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
<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" @input="debouncedSearch" @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="danger" @click="clearAllTasks">清除全部</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="180" 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>
|
||||||
|
<el-button type="danger" link size="small" @click="deleteTask(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-row-detail">
|
||||||
|
<span class="file-path-text">{{ f }}</span>
|
||||||
|
<el-button size="small" @click="copyPath(f)">复制路径</el-button>
|
||||||
|
</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, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
|
|
||||||
|
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 })
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ElMessage.error('加载统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
detailTask.value = row
|
||||||
|
showDetailDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPath(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制路径')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除任务 #${row.id}?`, '确认删除')
|
||||||
|
await api.delete(`/tasks/${row.id}`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
loadData()
|
||||||
|
loadStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAllTasks() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定清除所有任务历史记录?此操作不可撤销。', '确认清除', { type: 'warning' })
|
||||||
|
await api.delete('/tasks')
|
||||||
|
ElMessage.success('已清除所有任务历史')
|
||||||
|
loadData()
|
||||||
|
loadStats()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '清除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
|
</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-row-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.file-path-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
<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 @click="triggerUpload">上传图片</el-button>
|
||||||
|
<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>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
|
<input
|
||||||
|
ref="uploadInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".jpg,.jpeg,.png,.bmp"
|
||||||
|
hidden
|
||||||
|
@change="handleUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="items"
|
||||||
|
v-loading="loading"
|
||||||
|
@selection-change="onSelect"
|
||||||
|
@sort-change="onSortChange"
|
||||||
|
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="170" align="center" sortable="custom" prop="created_at">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="370" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="downloadFile(row)">下载</el-button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
|
||||||
|
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="showDetailDlg = false">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div class="detail-logs">
|
||||||
|
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
||||||
|
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<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 { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 50
|
||||||
|
const loading = ref(true)
|
||||||
|
const selected = ref<any[]>([])
|
||||||
|
const sortBy = ref('created_at')
|
||||||
|
const sortOrder = ref('desc')
|
||||||
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const showDetailDlg = ref(false)
|
||||||
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
|
items.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
|
async function previewFile(row: any) {
|
||||||
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
|
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
|
||||||
|
await openPreview(dir, fname)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
|
||||||
|
showDetailDlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange({ prop, order }: any) {
|
||||||
|
if (prop === 'created_at') {
|
||||||
|
sortBy.value = 'created_at'
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = ''
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
uploadInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
if (!el.files || !el.files.length) return
|
||||||
|
for (const file of Array.from(el.files)) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
await api.post('/files/upload?target=input', fd)
|
||||||
|
ElMessage.success(`已上传: ${file.name}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(`上传失败: ${file.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.value = ''
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 个文件?`, '确认')
|
||||||
|
const files = selected.value
|
||||||
|
.filter(r => r.input_image)
|
||||||
|
.map(r => ({ directory: 'input', filename: r.input_image }))
|
||||||
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
|
if (res.data.errors?.length) {
|
||||||
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('批量删除完成')
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('批量删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetCache() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定清除选中的 ${selected.value.length} 个文件的处理缓存?删除后可重新处理。`, '确认')
|
||||||
|
const files = selected.value.map(r => ({
|
||||||
|
input_image: r.input_image,
|
||||||
|
output_excel: r.output_excel,
|
||||||
|
result_purchase: r.result_purchase,
|
||||||
|
}))
|
||||||
|
const res = await api.post('/files/reset-cache', { files })
|
||||||
|
ElMessage.success(`已清除 ${res.data.deleted_files} 个缓存文件`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('清除缓存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
|
||||||
|
.preview-table tr:nth-child(even) { background:#fafafa }
|
||||||
|
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
|
||||||
|
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
|
||||||
|
.detail-line { color:#a1a1aa }
|
||||||
|
.detail-line.err { color:#ef4444 }
|
||||||
|
.detail-line.ok { color:#22c55e }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
|
||||||
|
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
|
||||||
|
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
||||||
|
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
||||||
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-actions .el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<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>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="items"
|
||||||
|
v-loading="loading"
|
||||||
|
@selection-change="onSelect"
|
||||||
|
@sort-change="onSortChange"
|
||||||
|
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="170" align="center" sortable="custom" prop="created_at">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Preview dialog -->
|
||||||
|
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
||||||
|
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="previewType === 'excel'" class="preview-table-wrap">
|
||||||
|
<table class="preview-table">
|
||||||
|
<tr v-for="(row, ri) in previewRows" :key="ri">
|
||||||
|
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else style="text-align:center;color:var(--text-muted);padding:40px">暂无可预览内容</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Detail dialog -->
|
||||||
|
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div class="detail-logs">
|
||||||
|
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
||||||
|
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<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 { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 50
|
||||||
|
const loading = ref(true)
|
||||||
|
const selected = ref<any[]>([])
|
||||||
|
const sortBy = ref('created_at')
|
||||||
|
const sortOrder = ref('desc')
|
||||||
|
|
||||||
|
// Detail
|
||||||
|
const showDetailDlg = ref(false)
|
||||||
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
|
items.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
|
async function previewFile(row: any) {
|
||||||
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
|
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
|
||||||
|
await openPreview(dir, fname)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
// Filter logs from the processing store that mention this file
|
||||||
|
detailLogs.value = (processingStore.logs || []).filter(
|
||||||
|
(l: string) => l.includes(fname) || l.includes(stem)
|
||||||
|
)
|
||||||
|
showDetailDlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange({ prop, order }: any) {
|
||||||
|
if (prop === 'created_at') {
|
||||||
|
sortBy.value = 'created_at'
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = ''
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 个文件?`, '确认')
|
||||||
|
const files = selected.value
|
||||||
|
.filter(r => r.result_purchase)
|
||||||
|
.map(r => ({ directory: 'result', filename: r.result_purchase }))
|
||||||
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
|
if (res.data.errors?.length) {
|
||||||
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('批量删除完成')
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('批量删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetCache() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定清除选中的 ${selected.value.length} 个文件的处理缓存?删除后可重新处理。`, '确认')
|
||||||
|
const files = selected.value.map(r => ({
|
||||||
|
input_image: r.input_image,
|
||||||
|
output_excel: r.output_excel,
|
||||||
|
result_purchase: r.result_purchase,
|
||||||
|
}))
|
||||||
|
const res = await api.post('/files/reset-cache', { files })
|
||||||
|
ElMessage.success(`已清除 ${res.data.deleted_files} 个缓存文件`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('清除缓存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table td {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 4px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.preview-table tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.preview-table tr:first-child { background: #f0f0f0; font-weight: 600; }
|
||||||
|
|
||||||
|
.detail-logs {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #09090b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.detail-line { color: #a1a1aa; }
|
||||||
|
.detail-line.err { color: #ef4444; }
|
||||||
|
.detail-line.ok { color: #22c55e; }
|
||||||
|
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex; flex-direction:column; width:96vw; height:94vh; margin:3vh 2vw; border-radius:12px!important }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1; min-height:0; padding:8px 16px 16px; overflow:hidden; display:flex; flex-direction:column }
|
||||||
|
.preview-body { flex:1; min-height:0; display:flex; flex-direction:column }
|
||||||
|
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
|
||||||
|
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
|
||||||
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-actions .el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<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 @click="triggerUpload">上传Excel</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selected.length" @click="batchProcess">
|
||||||
|
批量处理 ({{ selected.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="batchDelete">
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="clearAll">
|
||||||
|
删除全部
|
||||||
|
</el-button>
|
||||||
|
<input
|
||||||
|
ref="uploadInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".xls,.xlsx"
|
||||||
|
hidden
|
||||||
|
@change="handleUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="items"
|
||||||
|
v-loading="loading"
|
||||||
|
@selection-change="onSelect"
|
||||||
|
@sort-change="onSortChange"
|
||||||
|
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="170" align="center" sortable="custom" prop="created_at">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="downloadFile(row)">下载</el-button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
|
||||||
|
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div class="detail-logs">
|
||||||
|
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
||||||
|
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<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 { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 50
|
||||||
|
const loading = ref(true)
|
||||||
|
const selected = ref<any[]>([])
|
||||||
|
const sortBy = ref('created_at')
|
||||||
|
const sortOrder = ref('desc')
|
||||||
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const showDetailDlg = ref(false)
|
||||||
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
|
items.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
|
async function previewFile(row: any) {
|
||||||
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
|
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
||||||
|
await openPreview(dir, fname)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
|
||||||
|
showDetailDlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange({ prop, order }: any) {
|
||||||
|
if (prop === 'created_at') {
|
||||||
|
sortBy.value = 'created_at'
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = ''
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
uploadInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
if (!el.files || !el.files.length) return
|
||||||
|
for (const file of Array.from(el.files)) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
await api.post('/files/upload?target=output', fd)
|
||||||
|
ElMessage.success(`已上传: ${file.name}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(`上传失败: ${file.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.value = ''
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 downloadFile(row: any) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (row.output_excel) {
|
||||||
|
window.open(`/api/files/download/output/${encodeURIComponent(row.output_excel)}?token=${token}`, '_blank')
|
||||||
|
} else if (row.result_purchase) {
|
||||||
|
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 个文件?`, '确认')
|
||||||
|
const files = selected.value
|
||||||
|
.filter(r => r.output_excel)
|
||||||
|
.map(r => ({ directory: 'output', filename: r.output_excel }))
|
||||||
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
|
if (res.data.errors?.length) {
|
||||||
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('批量删除完成')
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('批量删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetCache() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定清除选中的 ${selected.value.length} 个文件的处理缓存?删除后可重新处理。`, '确认')
|
||||||
|
const files = selected.value.map(r => ({
|
||||||
|
input_image: r.input_image,
|
||||||
|
output_excel: r.output_excel,
|
||||||
|
result_purchase: r.result_purchase,
|
||||||
|
}))
|
||||||
|
const res = await api.post('/files/reset-cache', { files })
|
||||||
|
ElMessage.success(`已清除 ${res.data.deleted_files} 个缓存文件`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('清除缓存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定清空所有 Excel 处理文件?此操作不可恢复。', '确认')
|
||||||
|
await api.post('/files/clear/output')
|
||||||
|
await api.post('/files/relations/sync')
|
||||||
|
ElMessage.success('已清空')
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('清空失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
|
||||||
|
.preview-table tr:nth-child(even) { background:#fafafa }
|
||||||
|
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
|
||||||
|
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
|
||||||
|
.detail-line { color:#a1a1aa }
|
||||||
|
.detail-line.err { color:#ef4444 }
|
||||||
|
.detail-line.ok { color:#22c55e }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
|
||||||
|
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
|
||||||
|
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
||||||
|
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
||||||
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-actions .el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../backend/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
aiofiles>=23.2.0
|
||||||
|
websockets>=12.0
|
||||||
|
pydantic>=2.5.0
|
||||||
|
werkzeug>=3.0.0
|
||||||
Reference in New Issue
Block a user