Compare commits
35 Commits
e441ac82a8
...
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 |
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ 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 需要持久化)"""
|
||||||
# 保存前临时清空 API 密钥,避免写入文件
|
# 保存前临时清空 API 密钥,避免写入文件(这些从 .env 读取)
|
||||||
saved_keys = {}
|
saved_keys = {}
|
||||||
for option in ('api_key', 'secret_key'):
|
for option in ('api_key', 'secret_key'):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
@@ -40,7 +40,7 @@ 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]
|
[WebAuth]
|
||||||
username = admin
|
username = admin
|
||||||
|
|||||||
+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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,256 +0,0 @@
|
|||||||
# 日志系统 + 任务历史 + 文件管理 设计文档
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:writing-plans to create an implementation plan from this spec.
|
|
||||||
|
|
||||||
**Goal:** 为益选 OCR Web 系统添加持久化日志、任务历史和增强文件管理,提升生产环境可观测性和用户体验。
|
|
||||||
|
|
||||||
**Architecture:** 单一 SQLite 数据库 (`data/web_data.db`) 存储三类数据,FastAPI 中间件自动采集 HTTP 日志,TaskManager 改造为写入 DB,前端新增两个独立页面。
|
|
||||||
|
|
||||||
**Tech Stack:** FastAPI middleware, SQLite (via existing DBPool), Vue 3 + Element Plus, Pinia
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 数据库设计
|
|
||||||
|
|
||||||
数据库文件: `data/web_data.db`,通过现有 `DBPool` 管理。
|
|
||||||
|
|
||||||
### 1.1 `http_logs` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS http_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
timestamp TEXT NOT NULL, -- ISO 8601
|
|
||||||
method TEXT NOT NULL, -- GET/POST/PUT/DELETE
|
|
||||||
path TEXT NOT NULL, -- /api/memory
|
|
||||||
status_code INTEGER, -- 200, 404, 500
|
|
||||||
duration_ms REAL, -- 请求耗时(ms)
|
|
||||||
user TEXT, -- 当前用户名
|
|
||||||
ip TEXT, -- 客户端 IP
|
|
||||||
detail TEXT -- 错误详情/备注
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_http_logs_timestamp ON http_logs(timestamp);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_http_logs_status ON http_logs(status_code);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 `task_history` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS task_history (
|
|
||||||
id TEXT PRIMARY KEY, -- 8-char UUID
|
|
||||||
name TEXT NOT NULL, -- pipeline/ocr-batch/excel/merge/sync-push/sync-pull
|
|
||||||
status TEXT NOT NULL, -- pending/running/completed/failed
|
|
||||||
progress INTEGER DEFAULT 0,
|
|
||||||
message TEXT,
|
|
||||||
result_files TEXT, -- JSON array of filenames
|
|
||||||
error TEXT,
|
|
||||||
log_lines TEXT, -- JSON array of log strings
|
|
||||||
created_at TEXT NOT NULL, -- ISO 8601
|
|
||||||
updated_at TEXT NOT NULL, -- ISO 8601
|
|
||||||
completed_at TEXT -- ISO 8601, null if not done
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_history_status ON task_history(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_history_created ON task_history(created_at);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 `file_metadata` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS file_metadata (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
directory TEXT NOT NULL, -- input/output/result
|
|
||||||
size INTEGER,
|
|
||||||
action TEXT NOT NULL, -- upload/delete/clear
|
|
||||||
user TEXT,
|
|
||||||
timestamp TEXT NOT NULL, -- ISO 8601
|
|
||||||
task_id TEXT -- 关联的任务 ID (可选)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_file_metadata_timestamp ON file_metadata(timestamp);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 自动清理
|
|
||||||
|
|
||||||
30 天过期清理,在服务器启动时执行,之后每天通过 `asyncio` 定时任务执行一次:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def cleanup_old_records():
|
|
||||||
cutoff = (datetime.now() - timedelta(days=30)).isoformat()
|
|
||||||
await db_pool.execute_write("DELETE FROM http_logs WHERE timestamp < ?", cutoff)
|
|
||||||
await db_pool.execute_write("DELETE FROM task_history WHERE created_at < ?", cutoff)
|
|
||||||
await db_pool.execute_write("DELETE FROM file_metadata WHERE timestamp < ?", cutoff)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 后端架构
|
|
||||||
|
|
||||||
### 2.1 新增文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `web/backend/services/db_schema.py` | 建表 SQL + `init_db()` + `cleanup_old_records()` |
|
|
||||||
| `web/backend/middleware/logging.py` | HTTP 请求日志中间件 |
|
|
||||||
| `web/backend/routers/logs.py` | 日志查询 API |
|
|
||||||
| `web/backend/routers/tasks.py` | 任务历史 API |
|
|
||||||
|
|
||||||
### 2.2 修改文件
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `web/backend/main.py` | lifespan 中调用 `init_db()`,挂载 logging 中间件,注册 logs/tasks 路由 |
|
|
||||||
| `web/backend/services/task_manager.py` | `update_progress()` 和 `_finish()` 写入 task_history 表 |
|
|
||||||
| `web/backend/routers/files.py` | upload/delete/clear 操作写入 file_metadata 表 |
|
|
||||||
|
|
||||||
### 2.3 API 端点
|
|
||||||
|
|
||||||
**日志 (`/api/logs`)**
|
|
||||||
- `GET /api/logs` — 分页查询
|
|
||||||
- 参数: `page`, `page_size`, `method`, `status_code`, `path`(搜索), `start_date`, `end_date`
|
|
||||||
- 返回: `{ items: [...], total: number }`
|
|
||||||
- `GET /api/logs/stats` — 统计概览
|
|
||||||
- 返回: `{ today_count, error_count, avg_duration_ms, error_rate }`
|
|
||||||
|
|
||||||
**任务历史 (`/api/tasks`)**
|
|
||||||
- `GET /api/tasks` — 分页查询
|
|
||||||
- 参数: `page`, `page_size`, `status`, `name`(类型筛选), `search`
|
|
||||||
- 返回: `{ items: [...], total: number }`
|
|
||||||
- `GET /api/tasks/{task_id}` — 任务详情(含完整 log_lines)
|
|
||||||
- `POST /api/tasks/{task_id}/retry` — 重试失败任务
|
|
||||||
- 根据 `name` 字段重新调用对应处理端点
|
|
||||||
|
|
||||||
**文件历史 (`/api/files`)**
|
|
||||||
- `GET /api/files/history` — 文件操作记录
|
|
||||||
- 参数: `page`, `page_size`, `directory`, `action`
|
|
||||||
- 返回: `{ items: [...], total: number }`
|
|
||||||
- `GET /api/files/stats` — 存储统计
|
|
||||||
- 返回: `{ directories: [{ name, file_count, total_size }] }`
|
|
||||||
|
|
||||||
### 2.4 中间件设计
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def logging_middleware(request: Request, call_next):
|
|
||||||
# 跳过静态资源和 WebSocket
|
|
||||||
if request.url.path.startswith("/assets") or request.url.path.startswith("/ws"):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
start = time.time()
|
|
||||||
response = await call_next(request)
|
|
||||||
duration_ms = (time.time() - start) * 1000
|
|
||||||
|
|
||||||
# 异步写入日志(不阻塞响应)
|
|
||||||
asyncio.create_task(write_log(
|
|
||||||
method=request.method,
|
|
||||||
path=request.url.path,
|
|
||||||
status_code=response.status_code,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
user=get_current_user_from_request(request),
|
|
||||||
ip=request.client.host,
|
|
||||||
))
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 TaskManager 改造
|
|
||||||
|
|
||||||
现有 `TaskManager.update_progress()` 和 `_finish()` 方法中增加 DB 写入:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def update_progress(self, task_id: str, progress: int, message: str):
|
|
||||||
task = self._tasks[task_id]
|
|
||||||
task.progress = progress
|
|
||||||
task.message = message
|
|
||||||
task.log_lines.append(message)
|
|
||||||
# 新增:写入 DB
|
|
||||||
await self._db.execute_write(
|
|
||||||
"UPDATE task_history SET progress=?, message=?, log_lines=?, updated_at=? WHERE id=?",
|
|
||||||
progress, message, json.dumps(task.log_lines), datetime.now().isoformat(), task_id
|
|
||||||
)
|
|
||||||
await self._broadcast(task)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 前端设计
|
|
||||||
|
|
||||||
### 3.1 新增页面
|
|
||||||
|
|
||||||
**侧边栏导航新增 2 项:**
|
|
||||||
|
|
||||||
| 页面 | 路由 | 图标 | 标签 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| 任务历史 | `/tasks` | `Timer` | - |
|
|
||||||
| 日志中心 | `/logs` | `Notebook` | - |
|
|
||||||
|
|
||||||
### 3.2 任务历史页面 (`Tasks.vue`)
|
|
||||||
|
|
||||||
**布局:**
|
|
||||||
- 顶部统计卡片行(4 卡片):总任务数 / 成功 / 失败 / 运行中
|
|
||||||
- 筛选栏:状态下拉(全部/成功/失败/运行中)+ 类型下拉(全部/pipeline/ocr/excel/merge)+ 搜索框
|
|
||||||
- 表格列:任务ID、类型、状态(彩色标签)、进度条、耗时、创建时间、操作
|
|
||||||
- 操作:查看详情(弹窗显示完整日志流)、重试(仅失败任务)
|
|
||||||
|
|
||||||
**详情弹窗:**
|
|
||||||
- 任务基本信息(类型/状态/耗时/结果文件)
|
|
||||||
- 终端风格日志流(复用 Dashboard 的 log-box 样式)
|
|
||||||
- 结果文件列表(可下载)
|
|
||||||
|
|
||||||
### 3.3 日志中心页面 (`Logs.vue`)
|
|
||||||
|
|
||||||
**布局:**
|
|
||||||
- 顶部统计卡片行(4 卡片):今日请求 / 错误数 / 平均耗时 / 错误率
|
|
||||||
- 筛选栏:时间范围选择器(今天/7天/30天)+ 方法筛选(GET/POST/PUT/DELETE)+ 状态码筛选(2xx/4xx/5xx)+ 路径搜索
|
|
||||||
- 表格列:时间、方法(彩色标签)、路径、状态码(颜色区分)、耗时、用户
|
|
||||||
- 点击行展开详情面板(IP 地址、错误信息)
|
|
||||||
|
|
||||||
### 3.4 Dashboard 增强
|
|
||||||
|
|
||||||
- stats-row 第三列从硬编码 "记忆库 5591" 改为动态存储统计(磁盘用量)
|
|
||||||
- 文件列表区新增「操作历史」按钮,弹窗显示该目录的 file_metadata 记录
|
|
||||||
|
|
||||||
### 3.5 新增文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `web/frontend/src/views/Tasks.vue` | 任务历史页面 |
|
|
||||||
| `web/frontend/src/views/Logs.vue` | 日志中心页面 |
|
|
||||||
| `web/frontend/src/stores/tasks.ts` | 任务历史状态管理(可选,可用 api 直接调用) |
|
|
||||||
|
|
||||||
### 3.6 修改文件
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `web/frontend/src/views/Layout.vue` | navItems 新增 2 项 |
|
|
||||||
| `web/frontend/src/router/index.ts` | 新增 2 个路由 |
|
|
||||||
| `web/frontend/src/views/Dashboard.vue` | stats-row 动态化 + 文件历史弹窗 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 安全与性能
|
|
||||||
|
|
||||||
- 日志查询 API 仅限认证用户
|
|
||||||
- HTTP 日志不记录请求体(避免泄露敏感数据)
|
|
||||||
- 中间件使用 `asyncio.create_task()` 异步写入,不阻塞响应
|
|
||||||
- 日志表索引:`timestamp`、`status_code`、`path`
|
|
||||||
- 任务表索引:`status`、`created_at`
|
|
||||||
- 自动清理 30 天前的记录,防止数据库无限增长
|
|
||||||
- 分页查询默认 page_size=50,最大 200
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 实施顺序
|
|
||||||
|
|
||||||
1. **Phase 1: 数据库 + 后端**
|
|
||||||
- db_schema.py(建表 + 清理)
|
|
||||||
- logging 中间件
|
|
||||||
- task_manager 改造
|
|
||||||
- files.py 改造
|
|
||||||
- logs.py + tasks.py 路由
|
|
||||||
|
|
||||||
2. **Phase 2: 前端页面**
|
|
||||||
- Tasks.vue
|
|
||||||
- Logs.vue
|
|
||||||
- Layout.vue 路由注册
|
|
||||||
- Dashboard.vue 增强
|
|
||||||
|
|
||||||
3. **Phase 3: 集成测试**
|
|
||||||
- npm run build
|
|
||||||
- 端到端验证:操作 → 日志记录 → 任务历史 → 文件历史
|
|
||||||
+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,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
|
||||||
@@ -64,6 +64,11 @@ async def get_config(
|
|||||||
return result
|
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("")
|
@router.put("")
|
||||||
async def update_config(
|
async def update_config(
|
||||||
body: ConfigUpdate,
|
body: ConfigUpdate,
|
||||||
@@ -72,6 +77,9 @@ async def update_config(
|
|||||||
if body.section not in _ALLOWED_SECTIONS:
|
if body.section not in _ALLOWED_SECTIONS:
|
||||||
raise HTTPException(403, f"不允许修改配置节: {body.section}")
|
raise HTTPException(403, f"不允许修改配置节: {body.section}")
|
||||||
|
|
||||||
|
if _is_masked(body.key, body.value):
|
||||||
|
raise HTTPException(400, "敏感字段不能直接提交掩码值,请先清除输入框再输入真实值")
|
||||||
|
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
try:
|
try:
|
||||||
cfg.update(body.section, body.key, body.value)
|
cfg.update(body.section, body.key, body.value)
|
||||||
@@ -88,11 +96,19 @@ async def bulk_update_config(
|
|||||||
):
|
):
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
updated = []
|
updated = []
|
||||||
|
skipped = []
|
||||||
for item in body.updates:
|
for item in body.updates:
|
||||||
if item.section not in _ALLOWED_SECTIONS:
|
if item.section not in _ALLOWED_SECTIONS:
|
||||||
continue
|
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)
|
cfg.update(item.section, item.key, item.value)
|
||||||
updated.append(f"[{item.section}] {item.key}")
|
updated.append(f"[{item.section}] {item.key}")
|
||||||
|
|
||||||
cfg.save_config()
|
cfg.save_config()
|
||||||
return {"message": f"已更新 {len(updated)} 项", "updated": updated}
|
msg = f"已更新 {len(updated)} 项"
|
||||||
|
if skipped:
|
||||||
|
msg += f",跳过 {len(skipped)} 项掩码值"
|
||||||
|
return {"message": msg, "updated": updated, "skipped": skipped}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
|
|||||||
from ..services.db_schema import (
|
from ..services.db_schema import (
|
||||||
insert_file_metadata, query_file_history, query_file_stats,
|
insert_file_metadata, query_file_history, query_file_stats,
|
||||||
query_file_relations, delete_file_relations, sync_file_relations,
|
query_file_relations, delete_file_relations, sync_file_relations,
|
||||||
query_file_relations_stats,
|
query_file_relations_stats, reset_file_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -169,6 +169,38 @@ async def delete_file(
|
|||||||
return {"message": f"已删除 {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}")
|
@router.post("/clear/{directory}")
|
||||||
async def clear_directory(
|
async def clear_directory(
|
||||||
directory: str,
|
directory: str,
|
||||||
@@ -269,9 +301,12 @@ async def get_file_relations(
|
|||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
sort_by: Optional[str] = None,
|
sort_by: Optional[str] = None,
|
||||||
sort_order: str = "desc",
|
sort_order: str = "desc",
|
||||||
|
sync: bool = Query(True, description="Auto-sync file relations before querying"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Query file relations with optional view filter."""
|
"""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,
|
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size,
|
||||||
sort_by=sort_by, sort_order=sort_order)
|
sort_by=sort_by, sort_order=sort_order)
|
||||||
return {"items": items, "total": total}
|
return {"items": items, "total": total}
|
||||||
@@ -294,6 +329,24 @@ async def sync_relations(
|
|||||||
return {"message": "文件关系表已重建"}
|
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")
|
@router.delete("/relations")
|
||||||
async def delete_relations(
|
async def delete_relations(
|
||||||
body: RelationDeleteRequest,
|
body: RelationDeleteRequest,
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ async def ocr_batch(
|
|||||||
"""Run OCR on all images in input/."""
|
"""Run OCR on all images in input/."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("批量OCR识别")
|
task = tm.create_task("批量OCR识别")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/ocr-batch", "body": {}}
|
||||||
|
|
||||||
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
||||||
files = _list_input_files(filter_ext=list(image_exts))
|
files = _list_input_files(filter_ext=list(image_exts))
|
||||||
@@ -296,6 +297,7 @@ async def process_excel(
|
|||||||
"""Convert OCR output Excel files to standardized purchase orders."""
|
"""Convert OCR output Excel files to standardized purchase orders."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("Excel标准化处理")
|
task = tm.create_task("Excel标准化处理")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/excel", "body": body.dict()}
|
||||||
|
|
||||||
excel_exts = {'.xls', '.xlsx'}
|
excel_exts = {'.xls', '.xlsx'}
|
||||||
if body.files:
|
if body.files:
|
||||||
@@ -354,6 +356,7 @@ async def merge_orders(
|
|||||||
"""Merge selected purchase order files into one PosPal template."""
|
"""Merge selected purchase order files into one PosPal template."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("合并采购单")
|
task = tm.create_task("合并采购单")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/merge", "body": body.dict()}
|
||||||
|
|
||||||
# If specific files provided, use them; otherwise merge all
|
# If specific files provided, use them; otherwise merge all
|
||||||
if body.filenames:
|
if body.filenames:
|
||||||
@@ -399,6 +402,7 @@ async def full_pipeline(
|
|||||||
"""Run the full pipeline: OCR -> Excel -> Result (NO merge)."""
|
"""Run the full pipeline: OCR -> Excel -> Result (NO merge)."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("一键全流程处理")
|
task = tm.create_task("一键全流程处理")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/pipeline", "body": body.dict()}
|
||||||
|
|
||||||
async def _bg():
|
async def _bg():
|
||||||
def do_work():
|
def do_work():
|
||||||
@@ -501,6 +505,7 @@ async def ocr_single(
|
|||||||
"""OCR a single image file."""
|
"""OCR a single image file."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task(f"OCR: {body.filename}")
|
task = tm.create_task(f"OCR: {body.filename}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/ocr-single", "body": body.dict()}
|
||||||
|
|
||||||
file_path = _input_dir / body.filename
|
file_path = _input_dir / body.filename
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -544,6 +549,7 @@ async def excel_single(
|
|||||||
"""Process a single Excel file to purchase order."""
|
"""Process a single Excel file to purchase order."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task(f"Excel处理: {body.filename}")
|
task = tm.create_task(f"Excel处理: {body.filename}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/excel-single", "body": body.dict()}
|
||||||
|
|
||||||
file_path = _output_dir / body.filename
|
file_path = _output_dir / body.filename
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -582,6 +588,7 @@ async def pipeline_single(
|
|||||||
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
|
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task(f"全流程: {body.filename}")
|
task = tm.create_task(f"全流程: {body.filename}")
|
||||||
|
task.metadata = {"endpoint": "/api/processing/pipeline-single", "body": body.dict()}
|
||||||
|
|
||||||
file_path = _input_dir / body.filename
|
file_path = _input_dir / body.filename
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -659,6 +666,7 @@ async def merge_batch(
|
|||||||
"""Merge selected purchase order files into one PosPal template."""
|
"""Merge selected purchase order files into one PosPal template."""
|
||||||
tm = _get_task_manager(request)
|
tm = _get_task_manager(request)
|
||||||
task = tm.create_task("批量合并采购单")
|
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()]
|
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
|
||||||
if not file_paths:
|
if not file_paths:
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ async def sync_status(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
from app.config.settings import ConfigManager
|
from app.config.settings import ConfigManager
|
||||||
|
import httpx as _httpx
|
||||||
cfg = ConfigManager()
|
cfg = ConfigManager()
|
||||||
base_url = cfg.get("Gitea", "base_url", fallback="").strip()
|
base_url = cfg.get("Gitea", "base_url", fallback="").strip()
|
||||||
owner = cfg.get("Gitea", "owner", fallback="").strip()
|
owner = cfg.get("Gitea", "owner", fallback="").strip()
|
||||||
@@ -85,6 +86,22 @@ async def sync_status(
|
|||||||
token = cfg.get("Gitea", "token", fallback="").strip()
|
token = cfg.get("Gitea", "token", fallback="").strip()
|
||||||
enabled = bool(base_url and owner and repo and token)
|
enabled = bool(base_url and owner and repo and token)
|
||||||
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
|
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
|
||||||
return {"enabled": enabled, "repo_url": repo_url}
|
|
||||||
except Exception:
|
connected = False
|
||||||
return {"enabled": False, "repo_url": ""}
|
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)}
|
||||||
|
|||||||
@@ -112,6 +112,29 @@ async def get_task(
|
|||||||
return task
|
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")
|
@router.post("/{task_id}/retry")
|
||||||
async def retry_task(
|
async def retry_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
@@ -121,7 +144,34 @@ async def retry_task(
|
|||||||
"""Retry a failed task by re-invoking its processing endpoint.
|
"""Retry a failed task by re-invoking its processing endpoint.
|
||||||
|
|
||||||
Only tasks with status ``failed`` may be retried.
|
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()
|
loop = asyncio.get_event_loop()
|
||||||
task = await loop.run_in_executor(
|
task = await loop.run_in_executor(
|
||||||
None, lambda: db_schema.query_task_by_id(task_id),
|
None, lambda: db_schema.query_task_by_id(task_id),
|
||||||
@@ -142,18 +192,18 @@ async def retry_task(
|
|||||||
detail=f"未知的任务类型: {task_name}",
|
detail=f"未知的任务类型: {task_name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the internal URL to the processing endpoint.
|
# 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}"
|
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
||||||
url = f"{base_url}{endpoint}"
|
url = f"{base_url}{endpoint}"
|
||||||
|
|
||||||
# Forward the Authorization header so the processing endpoint can
|
|
||||||
# authenticate the request.
|
|
||||||
auth_header = request.headers.get("authorization")
|
auth_header = request.headers.get("authorization")
|
||||||
headers: dict[str, str] = {}
|
headers = {}
|
||||||
if auth_header:
|
if auth_header:
|
||||||
headers["authorization"] = auth_header
|
headers["authorization"] = auth_header
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(url, headers=headers)
|
resp = await client.post(url, headers=headers)
|
||||||
|
|
||||||
return resp.json()
|
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||||
|
|||||||
@@ -333,6 +333,28 @@ def query_task_stats() -> dict:
|
|||||||
conn.close()
|
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
|
# Query functions — File metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -653,6 +675,71 @@ def sync_file_relations():
|
|||||||
conn.close()
|
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:
|
def query_file_relations_stats() -> dict:
|
||||||
"""Get detailed file statistics for Dashboard.
|
"""Get detailed file statistics for Dashboard.
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ class Task:
|
|||||||
result_files: List[str] = field(default_factory=list)
|
result_files: List[str] = field(default_factory=list)
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
log_lines: List[str] = field(default_factory=list)
|
log_lines: List[str] = field(default_factory=list)
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
d = {
|
||||||
"task_id": self.id,
|
"task_id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
@@ -40,6 +41,9 @@ class Task:
|
|||||||
"error": self.error,
|
"error": self.error,
|
||||||
"log_lines": self.log_lines[-100:],
|
"log_lines": self.log_lines[-100:],
|
||||||
}
|
}
|
||||||
|
if self.metadata:
|
||||||
|
d["metadata"] = self.metadata
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
@@ -135,6 +139,21 @@ class TaskManager:
|
|||||||
)
|
)
|
||||||
self._schedule(self._broadcast(task_id))
|
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):
|
def set_failed(self, task_id: str, error: str):
|
||||||
task = self._tasks.get(task_id)
|
task = self._tasks.get(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
import './styles/global.css'
|
import './styles/global.css'
|
||||||
@@ -10,6 +10,11 @@ import router from './router'
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, _instance, info) => {
|
||||||
|
console.error('Vue error:', err, info)
|
||||||
|
ElMessage.error('操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus, { locale: zhCn })
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|||||||
@@ -83,14 +83,4 @@ router.beforeEach((to, from, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track route loading state for page transitions
|
|
||||||
let routeLoadingTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
router.afterEach(() => {
|
|
||||||
if (routeLoadingTimer) clearTimeout(routeLoadingTimer)
|
|
||||||
routeLoadingTimer = setTimeout(() => {
|
|
||||||
routeLoadingTimer = null
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
export interface TaskInfo {
|
export interface TaskInfo {
|
||||||
@@ -13,44 +13,64 @@ export interface TaskInfo {
|
|||||||
log_lines: string[]
|
log_lines: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskConnection {
|
||||||
|
ws: WebSocket | null
|
||||||
|
reconnectAttempts: number
|
||||||
|
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
}
|
||||||
|
|
||||||
export const useProcessingStore = defineStore('processing', () => {
|
export const useProcessingStore = defineStore('processing', () => {
|
||||||
const currentTask = ref<TaskInfo | null>(null)
|
// --- 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 tasks = ref<TaskInfo[]>([])
|
||||||
const logs = ref<string[]>([])
|
const logs = ref<string[]>([])
|
||||||
const taskSource = ref<string>('')
|
const taskSource = ref<string>('')
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
// --- Per-task WebSocket management ---
|
||||||
let reconnectAttempts = 0
|
const taskConnections = new Map<string, TaskConnection>()
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let currentTaskId: string | null = null
|
|
||||||
const MAX_RECONNECT = 5
|
const MAX_RECONNECT = 5
|
||||||
|
|
||||||
function connectWebSocket(taskId: string) {
|
function connectWebSocket(taskId: string) {
|
||||||
disconnectWebSocket()
|
disconnectTaskWS(taskId)
|
||||||
currentTaskId = taskId
|
taskConnections.set(taskId, { ws: null, reconnectAttempts: 0, reconnectTimer: null })
|
||||||
reconnectAttempts = 0
|
|
||||||
doConnect(taskId)
|
doConnect(taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function doConnect(taskId: string) {
|
function doConnect(taskId: string) {
|
||||||
|
const conn = taskConnections.get(taskId)
|
||||||
|
if (!conn) return
|
||||||
|
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const host = window.location.host
|
const host = window.location.host
|
||||||
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
|
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
|
||||||
|
|
||||||
ws = new WebSocket(url)
|
const socket = new WebSocket(url)
|
||||||
|
conn.ws = socket
|
||||||
|
|
||||||
ws.onopen = () => {
|
socket.onopen = () => {
|
||||||
reconnectAttempts = 0
|
conn.reconnectAttempts = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
if (data.error) return // ignore error messages from ws
|
if (data.error) return
|
||||||
currentTask.value = data
|
|
||||||
logs.value = data.log_lines || []
|
|
||||||
|
|
||||||
|
// 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)
|
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
tasks.value[idx] = data
|
tasks.value[idx] = data
|
||||||
@@ -58,30 +78,33 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
tasks.value.unshift(data)
|
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') {
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
setTimeout(() => disconnectWebSocket(), 2000)
|
setTimeout(() => disconnectTaskWS(data.task_id), 2000)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
socket.onerror = () => {
|
||||||
// Error will be followed by onclose, which handles reconnection
|
// Error will be followed by onclose, which handles reconnection
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
socket.onclose = () => {
|
||||||
ws = null
|
conn.ws = null
|
||||||
// Auto-reconnect if task is still running and not manually disconnected
|
const task = activeTasks.value.get(taskId)
|
||||||
const task = currentTask.value
|
|
||||||
if (
|
if (
|
||||||
currentTaskId === taskId &&
|
|
||||||
task &&
|
task &&
|
||||||
(task.status === 'pending' || task.status === 'running') &&
|
(task.status === 'pending' || task.status === 'running') &&
|
||||||
reconnectAttempts < MAX_RECONNECT
|
conn.reconnectAttempts < MAX_RECONNECT
|
||||||
) {
|
) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
|
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 10000)
|
||||||
reconnectAttempts++
|
conn.reconnectAttempts++
|
||||||
reconnectTimer = setTimeout(() => {
|
conn.reconnectTimer = setTimeout(() => {
|
||||||
if (currentTaskId === taskId) {
|
if (taskConnections.has(taskId)) {
|
||||||
doConnect(taskId)
|
doConnect(taskId)
|
||||||
}
|
}
|
||||||
}, delay)
|
}, delay)
|
||||||
@@ -89,24 +112,58 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function disconnectWebSocket() {
|
||||||
currentTaskId = null
|
for (const taskId of Array.from(taskConnections.keys())) {
|
||||||
reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
disconnectTaskWS(taskId)
|
||||||
if (reconnectTimer) {
|
|
||||||
clearTimeout(reconnectTimer)
|
|
||||||
reconnectTimer = null
|
|
||||||
}
|
}
|
||||||
if (ws) {
|
|
||||||
ws.close()
|
|
||||||
ws = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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') {
|
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
|
||||||
const res = await api.post(endpoint, body || {})
|
const res = await api.post(endpoint, body || {})
|
||||||
const taskId = res.data.task_id
|
const taskId = res.data.task_id
|
||||||
taskSource.value = source
|
taskSource.value = source
|
||||||
currentTask.value = {
|
const taskInfo: TaskInfo = {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
name: res.data.message || '',
|
name: res.data.message || '',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -116,15 +173,23 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
log_lines: [],
|
log_lines: [],
|
||||||
}
|
}
|
||||||
|
activeTasks.value.set(taskId, taskInfo)
|
||||||
logs.value = []
|
logs.value = []
|
||||||
connectWebSocket(taskId)
|
connectWebSocket(taskId)
|
||||||
return taskId
|
return taskId
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollTaskStatus(taskId: string) {
|
return {
|
||||||
const res = await api.get(`/processing/status/${taskId}`)
|
activeTasks,
|
||||||
return res.data
|
activeTaskList,
|
||||||
|
currentTask,
|
||||||
|
tasks,
|
||||||
|
logs,
|
||||||
|
taskSource,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
startTask,
|
||||||
|
removeTask,
|
||||||
|
retryTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -183,19 +183,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
|
import { Search, Refresh, Connection, Right, Setting } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
// Debounce helper
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -347,7 +337,9 @@ async function deleteItem(row: any) {
|
|||||||
await api.delete(`/barcodes/${row.barcode}`)
|
await api.delete(`/barcodes/${row.barcode}`)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
|
|||||||
@@ -39,13 +39,22 @@
|
|||||||
v-for="(value, key) in config[activeTab]"
|
v-for="(value, key) in config[activeTab]"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="field-row"
|
class="field-row"
|
||||||
:class="{ edited: edited[activeTab]?.[key] !== undefined && edited[activeTab][key] !== value }"
|
:class="{ edited: isEdited(activeTab, key, value) }"
|
||||||
>
|
>
|
||||||
<label class="field-label">
|
<label class="field-label">
|
||||||
{{ key }}
|
{{ key }}
|
||||||
<span v-if="edited[activeTab]?.[key] !== undefined && edited[activeTab][key] !== value" class="edited-dot"></span>
|
<span v-if="isEdited(activeTab, key, value)" class="edited-dot"></span>
|
||||||
</label>
|
</label>
|
||||||
<el-input
|
<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)"
|
:model-value="getEditedValue(activeTab, key, value)"
|
||||||
@update:model-value="setEditedValue(activeTab, key, $event)"
|
@update:model-value="setEditedValue(activeTab, key, $event)"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -80,6 +89,19 @@ const sectionLabels: Record<string, string> = {
|
|||||||
WebAuth: 'Web 认证',
|
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() {
|
async function loadConfig() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -109,6 +131,10 @@ async function saveAll() {
|
|||||||
const updates: { section: string; key: string; value: string }[] = []
|
const updates: { section: string; key: string; value: string }[] = []
|
||||||
for (const [section, keys] of Object.entries(edited)) {
|
for (const [section, keys] of Object.entries(edited)) {
|
||||||
for (const [key, value] of Object.entries(keys)) {
|
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 })
|
updates.push({ section, key, value })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,27 +35,34 @@
|
|||||||
<div class="main-grid">
|
<div class="main-grid">
|
||||||
<!-- Left column: Progress + Logs -->
|
<!-- Left column: Progress + Logs -->
|
||||||
<div class="col-left">
|
<div class="col-left">
|
||||||
<!-- Progress -->
|
<!-- Active tasks list -->
|
||||||
<div class="card progress-card animate-in animate-in-delay-1">
|
<div class="card progress-card animate-in animate-in-delay-1">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>处理进度</h3>
|
<h3>处理进度</h3>
|
||||||
<el-tag v-if="currentTask" :type="statusType" size="small" effect="dark">
|
<el-tag v-if="visibleTasks.length > 0" size="small" effect="dark">
|
||||||
{{ statusText }}
|
{{ visibleTasks.length }} 个任务
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentTask" class="progress-area">
|
<div v-if="visibleTasks.length > 0" class="task-cards">
|
||||||
<div class="progress-bar-wrapper">
|
<div v-for="task in visibleTasks" :key="task.task_id" class="task-card-item">
|
||||||
<div class="progress-bar-track">
|
<div class="task-card-header">
|
||||||
<div
|
<span class="task-name">{{ task.name }}</span>
|
||||||
class="progress-bar-fill"
|
<el-tag :type="statusTagType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
|
||||||
:style="{ width: currentTask.progress + '%', background: statusColor }"
|
|
||||||
></div>
|
|
||||||
</div>
|
</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 class="progress-meta">
|
|
||||||
<span class="progress-pct" :style="{ color: statusColor }">{{ currentTask.progress }}%</span>
|
|
||||||
<span class="progress-msg">{{ currentTask.message }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
@@ -71,8 +78,11 @@
|
|||||||
<div class="card log-card animate-in animate-in-delay-2">
|
<div class="card log-card animate-in animate-in-delay-2">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>处理日志</h3>
|
<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>
|
<el-button size="small" link @click="clearLogs">清空</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div ref="logBox" class="log-box">
|
<div ref="logBox" class="log-box">
|
||||||
<div v-if="logs.length === 0" class="empty-state small">
|
<div v-if="logs.length === 0" class="empty-state small">
|
||||||
<p>暂无日志</p>
|
<p>暂无日志</p>
|
||||||
@@ -173,6 +183,18 @@
|
|||||||
<span class="action-desc">处理Excel生成采购单</span>
|
<span class="action-desc">处理Excel生成采购单</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
@@ -182,7 +204,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Refresh, Document, Grid } from '@element-plus/icons-vue'
|
import { Refresh, Document, Grid } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../stores/processing'
|
import { useProcessingStore } from '../stores/processing'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
@@ -207,41 +229,10 @@ const detailedStats = ref({
|
|||||||
total_processed: 0,
|
total_processed: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTask = computed(() => {
|
const visibleTasks = computed(() =>
|
||||||
if (ps.taskSource !== 'sync') return ps.currentTask
|
ps.taskSource !== 'sync' ? ps.activeTaskList : []
|
||||||
return null
|
)
|
||||||
})
|
const logs = computed(() => ps.logs.slice(0, 50))
|
||||||
const logs = computed(() => ps.logs.slice(0, 10))
|
|
||||||
|
|
||||||
const statusType = computed(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
pending: 'info',
|
|
||||||
running: 'warning',
|
|
||||||
completed: 'success',
|
|
||||||
failed: 'danger',
|
|
||||||
}
|
|
||||||
return m[currentTask.value?.status || ''] || 'info'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusColor = computed(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
pending: '#a1a1aa',
|
|
||||||
running: '#f97316',
|
|
||||||
completed: '#22c55e',
|
|
||||||
failed: '#ef4444',
|
|
||||||
}
|
|
||||||
return m[currentTask.value?.status || ''] || '#a1a1aa'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusText = computed(() => {
|
|
||||||
const m: Record<string, string> = {
|
|
||||||
pending: '等待中',
|
|
||||||
running: '运行中',
|
|
||||||
completed: '已完成',
|
|
||||||
failed: '已失败',
|
|
||||||
}
|
|
||||||
return m[currentTask.value?.status || ''] || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = computed(() => [
|
const stats = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -290,6 +281,29 @@ function clearLogs(): void {
|
|||||||
ps.logs.splice(0)
|
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> {
|
async function refreshStats(): Promise<void> {
|
||||||
statsLoading.value = true
|
statsLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -400,11 +414,38 @@ const runPipeline = () => doAction('/processing/pipeline')
|
|||||||
const runOcr = () => doAction('/processing/ocr-batch')
|
const runOcr = () => doAction('/processing/ocr-batch')
|
||||||
const runExcel = () => doAction('/processing/excel')
|
const runExcel = () => doAction('/processing/excel')
|
||||||
|
|
||||||
// Auto-refresh stats when task completes
|
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(
|
watch(
|
||||||
() => currentTask.value?.status,
|
() => visibleTasks.value.map(t => t.status),
|
||||||
(status) => {
|
(statuses) => {
|
||||||
if (status === 'completed' || status === 'failed') {
|
if (statuses.some(s => s === 'completed' || s === 'failed')) {
|
||||||
refreshStats()
|
refreshStats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,6 +713,16 @@ onMounted(() => {
|
|||||||
color: #525252;
|
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 {
|
.action-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -693,6 +744,75 @@ onMounted(() => {
|
|||||||
text-overflow: ellipsis;
|
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 area ── */
|
||||||
.progress-card {
|
.progress-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-container class="layout">
|
||||||
<el-aside :width="isCollapse ? '72px' : '240px'" class="sidebar">
|
<el-aside v-show="!isMobile" :width="isCollapse ? '72px' : '240px'" class="sidebar">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
|
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
|
||||||
<div class="logo-mark">
|
<div class="logo-mark">
|
||||||
@@ -46,10 +46,6 @@
|
|||||||
<el-icon><Timer /></el-icon>
|
<el-icon><Timer /></el-icon>
|
||||||
<template #title>任务历史</template>
|
<template #title>任务历史</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/logs">
|
|
||||||
<el-icon><Notebook /></el-icon>
|
|
||||||
<template #title>日志中心</template>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item index="/memory">
|
<el-menu-item index="/memory">
|
||||||
<el-icon><Memo /></el-icon>
|
<el-icon><Memo /></el-icon>
|
||||||
<template #title>记忆库</template>
|
<template #title>记忆库</template>
|
||||||
@@ -83,6 +79,9 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-left">
|
<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>
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
@@ -122,15 +121,90 @@
|
|||||||
</el-container>
|
</el-container>
|
||||||
</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 -->
|
<!-- Change password dialog -->
|
||||||
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
|
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||||
<el-form :model="pwdForm" label-width="70px">
|
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="70px">
|
||||||
<el-form-item label="旧密码">
|
<el-form-item label="旧密码" prop="old_password">
|
||||||
<el-input v-model="pwdForm.old_password" type="password" show-password />
|
<el-input v-model="pwdForm.old_password" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新密码">
|
<el-form-item label="新密码" prop="new_password">
|
||||||
<el-input v-model="pwdForm.new_password" type="password" show-password />
|
<el-input v-model="pwdForm.new_password" type="password" show-password />
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showPwd = false">取消</el-button>
|
<el-button @click="showPwd = false">取消</el-button>
|
||||||
@@ -140,12 +214,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, reactive, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, FolderOpened,
|
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, FolderOpened,
|
||||||
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
|
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight, Menu as MenuIcon
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
@@ -155,42 +229,72 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
|
const mobileDrawer = ref(false)
|
||||||
const showPwd = ref(false)
|
const showPwd = ref(false)
|
||||||
const pwdForm = reactive({ old_password: '', new_password: '' })
|
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||||
const isOnline = ref(navigator.onLine !== false)
|
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
|
// Track online/offline status
|
||||||
function updateOnlineStatus() {
|
function updateOnlineStatus() {
|
||||||
isOnline.value = navigator.onLine !== false
|
isOnline.value = navigator.onLine
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
|
||||||
|
// 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('online', updateOnlineStatus)
|
||||||
window.addEventListener('offline', updateOnlineStatus)
|
window.addEventListener('offline', updateOnlineStatus)
|
||||||
|
window.addEventListener('resize', updateMobileState)
|
||||||
|
await authStore.fetchUser()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('online', updateOnlineStatus)
|
window.removeEventListener('online', updateOnlineStatus)
|
||||||
window.removeEventListener('offline', updateOnlineStatus)
|
window.removeEventListener('offline', updateOnlineStatus)
|
||||||
|
window.removeEventListener('resize', updateMobileState)
|
||||||
})
|
})
|
||||||
|
|
||||||
const navItems: { path: string; label: string; icon: any; badge?: string }[] = [
|
|
||||||
{ path: '/', label: '处理中心', icon: HomeFilled },
|
|
||||||
{ path: '/tasks', label: '任务历史', icon: Timer },
|
|
||||||
{ path: '/logs', label: '日志中心', icon: Notebook },
|
|
||||||
{ path: '/memory', label: '记忆库', icon: Memo },
|
|
||||||
{ path: '/barcodes', label: '条码映射', icon: Connection },
|
|
||||||
{ path: '/config', label: '系统配置', icon: Setting },
|
|
||||||
{ path: '/sync', label: '云端同步', icon: Cloudy },
|
|
||||||
]
|
|
||||||
|
|
||||||
const filesMenuOpen = ['/files']
|
const filesMenuOpen = ['/files']
|
||||||
|
|
||||||
|
function onMenuSelect() {
|
||||||
|
mobileDrawer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
'/': '处理中心',
|
'/': '处理中心',
|
||||||
'/files/orders': '采购单',
|
'/files/orders': '采购单',
|
||||||
'/files/tables': '表格处理',
|
'/files/tables': '表格处理',
|
||||||
'/files/images': '图片处理',
|
'/files/images': '图片处理',
|
||||||
'/tasks': '任务历史',
|
'/tasks': '任务历史',
|
||||||
'/logs': '日志中心',
|
|
||||||
'/memory': '记忆库',
|
'/memory': '记忆库',
|
||||||
'/barcodes': '条码映射',
|
'/barcodes': '条码映射',
|
||||||
'/config': '系统配置',
|
'/config': '系统配置',
|
||||||
@@ -208,19 +312,26 @@ function handleCommand(cmd: string) {
|
|||||||
} else if (cmd === 'password') {
|
} else if (cmd === 'password') {
|
||||||
pwdForm.old_password = ''
|
pwdForm.old_password = ''
|
||||||
pwdForm.new_password = ''
|
pwdForm.new_password = ''
|
||||||
|
pwdForm.confirm_password = ''
|
||||||
showPwd.value = true
|
showPwd.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
if (!pwdForm.new_password) { ElMessage.warning('请输入新密码'); return }
|
if (!pwdFormRef.value) return
|
||||||
|
await pwdFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/change-password', pwdForm)
|
await api.post('/auth/change-password', {
|
||||||
|
old_password: pwdForm.old_password,
|
||||||
|
new_password: pwdForm.new_password
|
||||||
|
})
|
||||||
ElMessage.success('密码修改成功')
|
ElMessage.success('密码修改成功')
|
||||||
showPwd.value = false
|
showPwd.value = false
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(err.response?.data?.detail || '修改失败')
|
ElMessage.error(err.response?.data?.detail || '修改失败')
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -394,6 +505,18 @@ async function changePassword() {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -401,6 +524,50 @@ async function changePassword() {
|
|||||||
letter-spacing: -0.3px;
|
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 {
|
.user-chip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -484,4 +651,29 @@ async function changePassword() {
|
|||||||
.page-leave-to {
|
.page-leave-to {
|
||||||
opacity: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -103,16 +103,7 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchPath = ref('')
|
const searchPath = ref('')
|
||||||
@@ -161,7 +152,9 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get('/logs/stats')
|
const res = await api.get('/logs/stats')
|
||||||
Object.assign(logStats, res.data)
|
Object.assign(logStats, res.data)
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('加载统计数据失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -313,4 +306,35 @@ onUnmounted(cancelSearch)
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -161,17 +161,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
// Debounce utility
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -218,11 +208,11 @@ async function loadData() {
|
|||||||
confidenceStats.low = res.data.stats.low || 0
|
confidenceStats.low = res.data.stats.low || 0
|
||||||
confidenceStats.total = res.data.stats.total || 0
|
confidenceStats.total = res.data.stats.total || 0
|
||||||
} else {
|
} else {
|
||||||
// Fallback: compute from current page items
|
// Fallback: stats not available from server, default to 0
|
||||||
confidenceStats.high = items.value.filter(i => i.confidence > 50).length
|
confidenceStats.high = 0
|
||||||
confidenceStats.medium = items.value.filter(i => i.confidence >= 10 && i.confidence <= 50).length
|
confidenceStats.medium = 0
|
||||||
confidenceStats.low = items.value.filter(i => i.confidence < 10).length
|
confidenceStats.low = 0
|
||||||
confidenceStats.total = total.value
|
confidenceStats.total = 0
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error('加载失败')
|
||||||
@@ -298,7 +288,9 @@ async function deleteItem(row: any) {
|
|||||||
await api.delete(`/memory/${row.barcode}`)
|
await api.delete(`/memory/${row.barcode}`)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reimport() {
|
async function reimport() {
|
||||||
|
|||||||
@@ -10,16 +10,24 @@
|
|||||||
<!-- Enabled state -->
|
<!-- Enabled state -->
|
||||||
<div v-if="syncStatus.enabled" class="sync-enabled">
|
<div v-if="syncStatus.enabled" class="sync-enabled">
|
||||||
<!-- Connection info -->
|
<!-- Connection info -->
|
||||||
<div class="connection-card">
|
<div class="connection-card" :class="{ 'connection-error': !syncStatus.connected }">
|
||||||
<div class="connection-icon">
|
<div class="connection-icon">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="1.5">
|
<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"/>
|
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
|
||||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
</svg>
|
</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>
|
||||||
<div class="connection-info">
|
<div class="connection-info">
|
||||||
<span class="connection-status">已连接</span>
|
<span class="connection-status" :class="{ 'status-error': !syncStatus.connected }">
|
||||||
|
{{ syncStatus.connected ? '已连接' : '连接失败' }}
|
||||||
|
</span>
|
||||||
<span class="connection-url">{{ syncStatus.repo_url }}</span>
|
<span class="connection-url">{{ syncStatus.repo_url }}</span>
|
||||||
|
<span v-if="syncStatus.error" class="connection-error-msg">{{ syncStatus.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,7 +104,7 @@ import api from '../api'
|
|||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const syncStatus = ref({ enabled: false, repo_url: '' })
|
const syncStatus = ref({ enabled: false, connected: false, repo_url: '', error: '' })
|
||||||
|
|
||||||
const currentTask = computed(() => {
|
const currentTask = computed(() => {
|
||||||
if (processingStore.taskSource === 'sync') return processingStore.currentTask
|
if (processingStore.taskSource === 'sync') return processingStore.currentTask
|
||||||
@@ -123,7 +131,9 @@ async function checkStatus() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get('/sync/status')
|
const res = await api.get('/sync/status')
|
||||||
syncStatus.value = res.data
|
syncStatus.value = res.data
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('检查同步状态失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doPush() {
|
async function doPush() {
|
||||||
@@ -212,6 +222,10 @@ onMounted(checkStatus)
|
|||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-status.status-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.connection-url {
|
.connection-url {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -220,6 +234,18 @@ onMounted(checkStatus)
|
|||||||
margin-top: 2px;
|
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 ── */
|
||||||
.sync-actions {
|
.sync-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="clearAllTasks">清除全部</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,10 +89,11 @@
|
|||||||
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
|
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
|
<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 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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -137,19 +139,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -204,7 +197,9 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get('/tasks/stats')
|
const res = await api.get('/tasks/stats')
|
||||||
Object.assign(taskStats, res.data)
|
Object.assign(taskStats, res.data)
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('加载统计数据失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -221,6 +216,30 @@ async function copyPath(text: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function retryTask(row: any) {
|
||||||
try {
|
try {
|
||||||
await api.post(`/tasks/${row.id}/retry`)
|
await api.post(`/tasks/${row.id}/retry`)
|
||||||
@@ -460,4 +479,35 @@ onUnmounted(cancelSearch)
|
|||||||
|
|
||||||
.log-line.err { color: #f87171; }
|
.log-line.err { color: #f87171; }
|
||||||
.log-line.ok { color: #34d399; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
||||||
批量删除
|
批量删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
<input
|
<input
|
||||||
ref="uploadInput"
|
ref="uploadInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -78,10 +81,11 @@
|
|||||||
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="320" fixed="right">
|
<el-table-column label="操作" width="370" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
<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="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="pipelineFile(row)">生成采购单</el-button>
|
||||||
<el-button link type="primary" size="small" @click="ocrFile(row)">仅OCR</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>
|
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
|
||||||
@@ -89,19 +93,20 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
<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 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-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-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>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
<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 class="detail-logs">
|
||||||
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
<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 v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
<div class="pagination-wrap">
|
||||||
@@ -121,9 +126,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../../stores/processing'
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -135,65 +143,28 @@ const sortBy = ref('created_at')
|
|||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
const uploadInput = ref<HTMLInputElement>()
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
const showPreview = ref(false)
|
|
||||||
const previewType = ref('')
|
|
||||||
const previewSrc = ref('')
|
|
||||||
const previewRows = ref<string[][]>([])
|
|
||||||
const showDetailDlg = ref(false)
|
const showDetailDlg = ref(false)
|
||||||
const detailLogs = ref<string[]>([])
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
|
||||||
return m[s] || 'info'
|
|
||||||
}
|
|
||||||
function statusText(s: string) {
|
|
||||||
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
|
|
||||||
return m[s] || s
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(t: string): string {
|
|
||||||
if (!t) return '--'
|
|
||||||
return t.replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 } })
|
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
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
async function previewFile(row: any) {
|
async function previewFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const fname = row.input_image || row.output_excel || row.result_purchase
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
|
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
|
||||||
try {
|
await openPreview(dir, fname)
|
||||||
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 = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -269,7 +240,9 @@ async function deleteFile(row: any) {
|
|||||||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchPipeline() {
|
async function batchPipeline() {
|
||||||
@@ -304,17 +277,35 @@ async function batchDownload() {
|
|||||||
async function batchDelete() {
|
async function batchDelete() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
||||||
for (const row of selected.value) {
|
const files = selected.value
|
||||||
if (row.input_image) {
|
.filter(r => r.input_image)
|
||||||
await api.delete(`/files/input/${encodeURIComponent(row.input_image)}`)
|
.map(r => ({ directory: 'input', filename: r.input_image }))
|
||||||
}
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
if (row.id) {
|
if (res.data.errors?.length) {
|
||||||
await api.delete('/files/relations', { data: { ids: [row.id] } })
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
ElMessage.success('批量删除完成')
|
ElMessage.success('批量删除完成')
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} 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)
|
onMounted(loadData)
|
||||||
@@ -377,4 +368,20 @@ onMounted(loadData)
|
|||||||
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
.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-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% }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
||||||
批量删除
|
批量删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- Preview dialog -->
|
<!-- Preview dialog -->
|
||||||
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
<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 class="preview-body">
|
||||||
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
||||||
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
|
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
|
||||||
@@ -94,14 +97,13 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Detail dialog -->
|
<!-- Detail dialog -->
|
||||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
<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 class="detail-logs">
|
||||||
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
<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 v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
</div>
|
||||||
<el-button @click="showDetailDlg = false">关闭</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
<div class="pagination-wrap">
|
||||||
@@ -121,9 +123,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../../stores/processing'
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -134,76 +139,29 @@ const selected = ref<any[]>([])
|
|||||||
const sortBy = ref('created_at')
|
const sortBy = ref('created_at')
|
||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
|
|
||||||
// Preview
|
|
||||||
const showPreview = ref(false)
|
|
||||||
const previewType = ref('')
|
|
||||||
const previewSrc = ref('')
|
|
||||||
const previewRows = ref<string[][]>([])
|
|
||||||
|
|
||||||
// Detail
|
// Detail
|
||||||
const showDetailDlg = ref(false)
|
const showDetailDlg = ref(false)
|
||||||
const detailLogs = ref<string[]>([])
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
|
||||||
return m[s] || 'info'
|
|
||||||
}
|
|
||||||
function statusText(s: string) {
|
|
||||||
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
|
|
||||||
return m[s] || s
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(t: string): string {
|
|
||||||
if (!t) return '--'
|
|
||||||
return t.replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 } })
|
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
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
async function previewFile(row: any) {
|
async function previewFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const fname = row.result_purchase || row.output_excel || row.input_image
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
|
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
|
||||||
const url = `/api/files/preview/${dir}/${encodeURIComponent(fname)}`
|
await openPreview(dir, fname)
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, { 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 = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -239,7 +197,9 @@ async function deleteFile(row: any) {
|
|||||||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchMerge() {
|
async function batchMerge() {
|
||||||
@@ -265,17 +225,35 @@ async function batchDownload() {
|
|||||||
async function batchDelete() {
|
async function batchDelete() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
||||||
for (const row of selected.value) {
|
const files = selected.value
|
||||||
if (row.result_purchase) {
|
.filter(r => r.result_purchase)
|
||||||
await api.delete(`/files/result/${encodeURIComponent(row.result_purchase)}`)
|
.map(r => ({ directory: 'result', filename: r.result_purchase }))
|
||||||
}
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
if (row.id) {
|
if (res.data.errors?.length) {
|
||||||
await api.delete('/files/relations', { data: { ids: [row.id] } })
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
ElMessage.success('批量删除完成')
|
ElMessage.success('批量删除完成')
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} 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)
|
onMounted(loadData)
|
||||||
@@ -356,4 +334,20 @@ onMounted(loadData)
|
|||||||
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
|
.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-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% }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<el-button :disabled="!selected.length" @click="batchDelete">
|
<el-button :disabled="!selected.length" @click="batchDelete">
|
||||||
批量删除
|
批量删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
<el-button type="danger" @click="clearAll">
|
<el-button type="danger" @click="clearAll">
|
||||||
删除全部
|
删除全部
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -75,29 +78,31 @@
|
|||||||
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
<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="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="primary" size="small" @click="processFile(row)">处理</el-button>
|
||||||
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
|
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
<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 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-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-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>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
<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 class="detail-logs">
|
||||||
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
<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 v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
<div class="pagination-wrap">
|
||||||
@@ -117,9 +122,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../../stores/processing'
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -131,65 +139,28 @@ const sortBy = ref('created_at')
|
|||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
const uploadInput = ref<HTMLInputElement>()
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
const showPreview = ref(false)
|
|
||||||
const previewType = ref('')
|
|
||||||
const previewSrc = ref('')
|
|
||||||
const previewRows = ref<string[][]>([])
|
|
||||||
const showDetailDlg = ref(false)
|
const showDetailDlg = ref(false)
|
||||||
const detailLogs = ref<string[]>([])
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
|
||||||
return m[s] || 'info'
|
|
||||||
}
|
|
||||||
function statusText(s: string) {
|
|
||||||
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
|
|
||||||
return m[s] || s
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(t: string): string {
|
|
||||||
if (!t) return '--'
|
|
||||||
return t.replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 } })
|
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
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
async function previewFile(row: any) {
|
async function previewFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const fname = row.output_excel || row.result_purchase || row.input_image
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
||||||
try {
|
await openPreview(dir, fname)
|
||||||
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 = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -240,6 +211,15 @@ async function processFile(row: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function deleteFile(row: any) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除 ${row.output_excel}?`, '确认')
|
await ElMessageBox.confirm(`确定删除 ${row.output_excel}?`, '确认')
|
||||||
@@ -247,7 +227,9 @@ async function deleteFile(row: any) {
|
|||||||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchProcess() {
|
async function batchProcess() {
|
||||||
@@ -263,17 +245,35 @@ async function batchProcess() {
|
|||||||
async function batchDelete() {
|
async function batchDelete() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
||||||
for (const row of selected.value) {
|
const files = selected.value
|
||||||
if (row.output_excel) {
|
.filter(r => r.output_excel)
|
||||||
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
|
.map(r => ({ directory: 'output', filename: r.output_excel }))
|
||||||
}
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
if (row.id) {
|
if (res.data.errors?.length) {
|
||||||
await api.delete('/files/relations', { data: { ids: [row.id] } })
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
ElMessage.success('批量删除完成')
|
ElMessage.success('批量删除完成')
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} 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() {
|
async function clearAll() {
|
||||||
@@ -283,7 +283,9 @@ async function clearAll() {
|
|||||||
await api.post('/files/relations/sync')
|
await api.post('/files/relations/sync')
|
||||||
ElMessage.success('已清空')
|
ElMessage.success('已清空')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('清空失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
@@ -346,4 +348,20 @@ onMounted(loadData)
|
|||||||
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
.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-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% }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user