Compare commits

..

37 Commits

Author SHA1 Message Date
houhuan 968a6f8d22 web端优化 2026-05-15 16:39:29 +08:00
houhuan 735989f0ae fix: missing closing brace in Config.vue isEdited function 2026-05-14 22:04:55 +08:00
houhuan 0d378b9f35 feat: add clear-all button to Dashboard quick actions 2026-05-14 22:01:54 +08:00
houhuan 809cc5fd81 fix: show sensitive config fields as empty with placeholder, skip masked values on save 2026-05-14 17:07:39 +08:00
houhuan 69efff3cb4 fix: persist Gitea token to config file instead of clearing it on save 2026-05-14 17:03:27 +08:00
houhuan 7e735cdf72 fix: prevent Gitea token corruption from masked config values, add real connection test 2026-05-14 16:36:41 +08:00
houhuan 69473320b3 feat: remove Logs page from sidebar navigation 2026-05-14 16:21:36 +08:00
houhuan d585a6baaa feat: add download button to Tables/Images views, add task history delete/clear-all 2026-05-14 16:12:09 +08:00
houhuan 0e273111a2 fix: correct template_file path to include templates/ prefix for Docker 2026-05-14 15:39:02 +08:00
houhuan d0a1c3dce5 fix: add python3-tk to Docker image for tkinter dependency 2026-05-14 01:25:06 +08:00
houhuan d299db0ab2 fix: move BatchDeleteRequest class before its use in endpoint
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:19:17 +08:00
houhuan 80a0e7eeb6 fix: frontend Docker build output to ./dist instead of ../backend/static
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:52:48 +08:00
houhuan d5b4cc7b77 docs: expand Docker deployment instructions in README
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:38:58 +08:00
houhuan 5e69e5a841 feat: add Docker deployment (backend:18889, frontend:18888)
- Dockerfile.backend: Python 3.11 + FastAPI + uvicorn
- Dockerfile.frontend: Node 20 build + Nginx serve
- docker-compose.yml: orchestration with data volume mount
- nginx.conf: API/WebSocket proxy to backend
- web/backend/requirements.txt: Python dependencies
- .dockerignore: exclude venv/node_modules/data from build

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:36:52 +08:00
houhuan 0c28031e81 docs: update README with web features, remove dev planning docs
- Add Web 端功能 section covering all features
- Add web/ directory to project structure
- Add Web startup instructions (backend + frontend)
- Remove docs/superpowers/ (internal dev planning docs)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:53:25 +08:00
houhuan 7dabb2ce66 fix: dialog width 75% instead of fullscreen
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:59:33 +08:00
houhuan 2196a25aee fix: detail dialog matches preview dialog style (fullscreen)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:56:16 +08:00
houhuan 7baf784a39 feat: processing flow enhancement + responsive UI
Phase 2 - Processing flow:
- Multi-task monitoring: store supports concurrent task tracking
- Task retry: POST /api/tasks/{id}/retry re-runs failed tasks
- Dashboard multi-task cards with progress, error details, retry/dismiss
- Log panel expanded from 10 to 50 lines with "view all" link

Phase 3 - UI/UX:
- Mobile sidebar drawer (< 768px) with hamburger menu
- Layout responsive styles (768px, 480px breakpoints)
- Tasks/Logs pages responsive (stat cards, filters, columns)
- File views responsive (header wrap, button sizing)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:18:18 +08:00
houhuan 32af38fe2a docs: add processing flow + UI/UX enhancement plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 19:09:27 +08:00
houhuan 13ef605481 feat: auto-sync file list + clear processing cache
- Auto-sync file_relations on every query (files appear immediately)
- Add POST /api/files/reset-cache endpoint to delete output/result
  files and reset status to pending for reprocessing
- Add "清除处理缓存" button to all 3 file views

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 22:18:47 +08:00
houhuan ec8d0d7db6 fix: handle empty catch in Tables.vue clearAll
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:49:58 +08:00
houhuan 17c45cab3f feat: add batch-delete API endpoint, replace N+1 frontend calls
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:45:28 +08:00
houhuan 3a49780d8d refactor: remove dead code (pollTaskStatus, routeLoadingTimer)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:43:15 +08:00
houhuan 3f8e34c07f fix: add error handling to Barcodes, Tasks, Logs, Sync views
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:41:50 +08:00
houhuan d94e416202 fix: Memory.vue stats fallback and error handling 2026-05-12 21:40:24 +08:00
houhuan fa43a9770e refactor: Orders.vue uses shared composables, fix error handling 2026-05-12 21:38:45 +08:00
houhuan 1a4522bd02 refactor: Tables.vue uses shared composables, fix error handling 2026-05-12 21:35:10 +08:00
houhuan 7e15431937 refactor: Images.vue uses shared composables, fix error handling
- Import statusType/statusText/fmtTime from useFileUtils composable
- Use useFilePreview composable for preview state and logic
- Remove duplicated preview refs, cleanupPreview, and utility functions
- Add try/finally to loadData for reliable loading state
- Properly handle cancel vs error in deleteFile and batchDelete

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:31:40 +08:00
houhuan 7e63dda522 fix: fetchUser on mount, password validation, remove dead code
- Call authStore.fetchUser() in onMounted so avatar shows username after refresh
- Simplify navigator.onLine checks (remove redundant !== false)
- Remove unused navItems array (dead code from earlier iteration)
- Add password form validation with confirm password field and rules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:29:03 +08:00
houhuan 26f6275f4e fix: add global Vue error handler with user-facing toast 2026-05-12 21:26:38 +08:00
houhuan 2d79c05cf1 refactor: add useFilePreview composable for shared preview logic 2026-05-12 21:25:08 +08:00
houhuan 50ee6ac5bd refactor: add useFileUtils composable for shared file helpers 2026-05-12 21:23:28 +08:00
houhuan 2a2b4c639e refactor: extract useDebounce composable from 4 duplicate copies
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:19:30 +08:00
houhuan beaf7c6203 docs: add frontend bug fix and code quality implementation plan
12 tasks covering: useDebounce/useFileUtils/useFilePreview composables,
global error handler, fetchUser fix, file view refactoring, error handling
across 9 views, dead code cleanup, password validation, batch-delete API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:14:29 +08:00
houhuan 7c3616ff98 docs: add frontend bug fix and code quality design spec
Addresses 11 issues across 3 phases: critical bug fixes (fetchUser,
silent errors, loading states, stats fallback, global error handler),
code quality (extract composables, remove dead code), and minor
improvements (password validation, batch delete endpoint).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:06:05 +08:00
houhuan e441ac82a8 Refactor processing logic and enhance error handling
- Cleaned up code in processing.py by removing inline semicolons and improving readability.
- Updated upsert_file_relation calls to ensure consistent handling of file relations.
- Enhanced query_file_relations in db_schema.py to support filtering by file existence.
- Improved API error handling in index.ts with user-friendly messages for 401 and 403 errors.
- Added online/offline status tracking in Layout.vue.
- Implemented debounced search functionality across multiple views to optimize performance.
- Introduced loading skeletons in Dashboard.vue for better user experience during data fetching.
- Enhanced file preview cleanup logic in Images.vue, Orders.vue, and Tables.vue to prevent memory leaks.
- Updated global styles to include new loading and notification animations.
2026-05-12 18:37:23 +08:00
houhuan 81bafaf557 fix: sync/barcode/memory overhaul + detailed logs + preview + result tracking
- Sync: fix GiteaSync constructor + add push()/pull() methods
- Barcode: two-tab layout matching GUI (mapping + special rules)
- Memory: spec→specification unification, manual add, confidence/price tracking
- Processing: TaskLogHandler captures detailed logs (barcode mapping, unit conversion)
- Preview: fullscreen dialog for file preview (image/Excel) in Orders/Tables/Images
- Detail: per-file log filtering in file pages
- Tasks: result files now per-task, add copy path button
- Config: reactive edited state + save_config fix
- Dashboard: sync task isolation, log limit 10

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 19:37:10 +08:00
45 changed files with 3340 additions and 3211 deletions
+20
View File
@@ -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
+29
View File
@@ -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"]
+19
View File
@@ -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;"]
+134 -1
View File
@@ -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 错误自动显示用户提示
- **表单验证**:修改密码等操作有完整的输入验证
## 供应商智能路由 ## 供应商智能路由
| 供应商 | 识别特征 | 处理逻辑 | | 供应商 | 识别特征 | 处理逻辑 |
+1 -1
View File
@@ -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'
+11 -10
View File
@@ -104,24 +104,25 @@ class ConfigManager:
logger.info(f"已创建默认配置文件: {self.config_file}") logger.info(f"已创建默认配置文件: {self.config_file}")
def save_config(self) -> None: def save_config(self) -> None:
"""保存配置到文件(API 密钥不写入文件)""" """保存配置到文件(API 密钥不写入文件Gitea token 需要持久化"""
try: # 保存前临时清空 API 密钥,避免写入文件(这些从 .env 读取)
# 保存前临时清空 API 密钥,避免写入文件
saved_keys = {} saved_keys = {}
for option in ('api_key', 'secret_key'): for option in ('api_key', 'secret_key'):
try:
saved_keys[option] = self.config.get('API', option, fallback='') saved_keys[option] = self.config.get('API', option, fallback='')
except Exception:
saved_keys[option] = ''
self.config.set('API', option, '') self.config.set('API', option, '')
try:
with open(self.config_file, 'w', encoding='utf-8') as f: with open(self.config_file, 'w', encoding='utf-8') as f:
self.config.write(f) self.config.write(f)
# 恢复内存中的值
for option, val in saved_keys.items():
self.config.set('API', option, val)
logger.info(f"配置已保存到: {self.config_file}") logger.info(f"配置已保存到: {self.config_file}")
except Exception as e: finally:
logger.error(f"保存配置文件时出错: {e}") # 恢复内存中的值(即使写入失败也恢复)
for option, val in saved_keys.items():
if val:
self.config.set('API', option, val)
def get(self, section: str, option: str, fallback: Any = None) -> Any: def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""获取配置值""" """获取配置值"""
+304 -225
View File
@@ -1,21 +1,18 @@
""" """
商品资料 SQLite 数据库 + 商品记忆库 商品资料 SQLite 数据库 + 商品记忆库
将商品资料 (条码/名称/进货价/单位/规格) 存储在 SQLite 中,
支持从 Excel 自动导入、按条码快速查询、以及从 OCR 处理结果中学习。
记忆库功能: 记忆库功能:
- 处理完每单后自动学习商品数据 - 处理每步后自动学习商品数据(置信度+一致性加速)
- 下次处理时用记忆库补全 OCR 缺失/错误的字段 - OCR 字段缺失时用记忆库补全 (conf > 50 直接采用)
- 通过置信度系统控制数据质量 - 价格异常检测:偏差 > 2倍触发补全,偏差 > 50% 记录预警
- 支持云端同步 - 批量预加载 → 内存操作 → 批量写回,保障性能
""" """
import os import os
import json import json
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple, Callable
import pandas as pd import pandas as pd
@@ -40,26 +37,27 @@ class ProductDatabase:
source TEXT DEFAULT 'template', source TEXT DEFAULT 'template',
confidence INTEGER DEFAULT 0, confidence INTEGER DEFAULT 0,
usage_count INTEGER DEFAULT 0, usage_count INTEGER DEFAULT 0,
last_seen TEXT last_seen TEXT,
avg_price REAL DEFAULT 0.0,
min_price REAL DEFAULT 0.0,
max_price REAL DEFAULT 0.0,
price_count INTEGER DEFAULT 0
); );
""" """
# 新增列定义(用于迁移)
_NEW_COLUMNS = { _NEW_COLUMNS = {
'specification': "TEXT DEFAULT ''", 'specification': "TEXT DEFAULT ''",
'source': "TEXT DEFAULT 'template'", 'source': "TEXT DEFAULT 'template'",
'confidence': 'INTEGER DEFAULT 0', 'confidence': 'INTEGER DEFAULT 0',
'usage_count': 'INTEGER DEFAULT 0', 'usage_count': 'INTEGER DEFAULT 0',
'last_seen': 'TEXT', 'last_seen': 'TEXT',
'avg_price': 'REAL DEFAULT 0.0',
'min_price': 'REAL DEFAULT 0.0',
'max_price': 'REAL DEFAULT 0.0',
'price_count': 'INTEGER DEFAULT 0',
} }
def __init__(self, db_path: str, excel_source: str): def __init__(self, db_path: str, excel_source: str):
"""初始化数据库,如果 SQLite 不存在则自动从 Excel 导入
Args:
db_path: SQLite 数据库文件路径
excel_source: 商品资料 Excel 文件路径
"""
self.db_path = db_path self.db_path = db_path
self.excel_source = excel_source self.excel_source = excel_source
self._ensure_db() self._ensure_db()
@@ -68,16 +66,13 @@ class ProductDatabase:
return sqlite3.connect(self.db_path) return sqlite3.connect(self.db_path)
def _ensure_db(self): def _ensure_db(self):
"""确保数据库存在,不存在则从 Excel 导入"""
if os.path.exists(self.db_path): if os.path.exists(self.db_path):
self._migrate_schema() self._migrate_schema()
return return
if not os.path.exists(self.excel_source): if not os.path.exists(self.excel_source):
logger.warning(f"商品资料 Excel 不存在,跳过导入: {self.excel_source}") logger.warning(f"商品资料 Excel 不存在: {self.excel_source}")
self._create_empty_db() self._create_empty_db()
return return
logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}") logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}")
os.makedirs(os.path.dirname(self.db_path), exist_ok=True) os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
self._create_empty_db() self._create_empty_db()
@@ -85,7 +80,6 @@ class ProductDatabase:
logger.info(f"商品资料导入完成: {count} 条记录") logger.info(f"商品资料导入完成: {count} 条记录")
def _create_empty_db(self): def _create_empty_db(self):
"""创建空数据库"""
conn = self._connect() conn = self._connect()
try: try:
conn.executescript(self.SCHEMA) conn.executescript(self.SCHEMA)
@@ -94,52 +88,35 @@ class ProductDatabase:
conn.close() conn.close()
def _migrate_schema(self): def _migrate_schema(self):
"""幂等迁移:为已有数据库添加新列"""
conn = self._connect() conn = self._connect()
try: try:
cursor = conn.execute("PRAGMA table_info(products)") cursor = conn.execute("PRAGMA table_info(products)")
existing_cols = {row[1] for row in cursor.fetchall()} existing_cols = {row[1] for row in cursor.fetchall()}
for col_name, col_type in self._NEW_COLUMNS.items(): for col_name, col_type in self._NEW_COLUMNS.items():
if col_name not in existing_cols: if col_name not in existing_cols:
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}") conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
logger.info(f"数据库迁移: 添加列 {col_name}") logger.info(f"数据库迁移: 添加列 {col_name}")
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
# ══════════════════════════════════════════════════════════════
# 导入
# ══════════════════════════════════════════════════════════════
def import_from_excel(self, excel_path: str) -> int: def import_from_excel(self, excel_path: str) -> int:
"""从 Excel 导入商品资料(source=template, confidence=100
Args:
excel_path: Excel 文件路径
Returns:
导入的记录数
"""
df = smart_read_excel(excel_path) df = smart_read_excel(excel_path)
if df is None or df.empty: if df is None or df.empty:
logger.warning(f"Excel 文件为空或读取失败: {excel_path}")
return 0 return 0
# 查找条码列
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode') barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
if not barcode_col: if not barcode_col:
logger.error(f"Excel 中未找到条码列: {list(df.columns)}")
return 0 return 0
# 查找进货价列
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price') price_col = ColumnMapper.find_column(list(df.columns), 'unit_price')
# 进货价可能没有标准别名,补充查找
if not price_col: if not price_col:
for col in df.columns: for col in df.columns:
col_str = str(col).strip() if '进货价' in str(col).strip():
if '进货价' in col_str:
price_col = col price_col = col
break break
# 查找名称列、单位列、规格列 (可选)
name_col = ColumnMapper.find_column(list(df.columns), 'name') name_col = ColumnMapper.find_column(list(df.columns), 'name')
unit_col = ColumnMapper.find_column(list(df.columns), 'unit') unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
spec_col = ColumnMapper.find_column(list(df.columns), 'specification') spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
@@ -150,7 +127,6 @@ class ProductDatabase:
barcode = str(row.get(barcode_col, '')).strip() barcode = str(row.get(barcode_col, '')).strip()
if not barcode or barcode == 'nan': if not barcode or barcode == 'nan':
continue continue
price = 0.0 price = 0.0
if price_col: if price_col:
try: try:
@@ -159,43 +135,32 @@ class ProductDatabase:
price = float(p) price = float(p)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
name = str(row.get(name_col, '')).strip() if name_col else '' name = str(row.get(name_col, '')).strip() if name_col else ''
if name == 'nan': if name == 'nan': name = ''
name = ''
unit = str(row.get(unit_col, '')).strip() if unit_col else '' unit = str(row.get(unit_col, '')).strip() if unit_col else ''
if unit == 'nan': if unit == 'nan': unit = ''
unit = ''
spec = str(row.get(spec_col, '')).strip() if spec_col else '' spec = str(row.get(spec_col, '')).strip() if spec_col else ''
if spec == 'nan': if spec == 'nan': spec = ''
spec = '' # template 源置信度 50
rows.append((barcode, name, price, unit, now, spec, 'template', 50, 0, now,
rows.append((barcode, name, price, unit, now, spec, 'template', 100, 0, now)) price, price, price, 1 if price > 0 else 0))
if not rows: if not rows:
logger.warning(f"Excel 中未解析出有效记录: {excel_path}")
return 0 return 0
conn = self._connect() conn = self._connect()
try: try:
conn.executemany( conn.executemany(
"INSERT OR REPLACE INTO products " "INSERT OR REPLACE INTO products "
"(barcode, name, price, unit, updated_at, specification, source, confidence, usage_count, last_seen) " "(barcode, name, price, unit, updated_at, specification, source, confidence, "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "usage_count, last_seen, avg_price, min_price, max_price, price_count) "
rows "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) rows)
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
return len(rows) return len(rows)
def reimport(self) -> int: def reimport(self) -> int:
"""重新从 Excel 导入(清空现有数据后重新导入)
Returns:
导入的记录数
"""
conn = self._connect() conn = self._connect()
try: try:
conn.execute("DELETE FROM products") conn.execute("DELETE FROM products")
@@ -204,203 +169,343 @@ class ProductDatabase:
conn.close() conn.close()
return self.import_from_excel(self.excel_source) return self.import_from_excel(self.excel_source)
# ── 基础查询(保持兼容) ────────────────────────────────── # ══════════════════════════════════════════════════════════════
# 查询
# ══════════════════════════════════════════════════════════════
def get_price(self, barcode: str) -> Optional[float]: def get_price(self, barcode: str) -> Optional[float]:
"""按条码查询进货价"""
conn = self._connect() conn = self._connect()
try: try:
cursor = conn.execute( row = conn.execute("SELECT avg_price FROM products WHERE barcode=?",
"SELECT price FROM products WHERE barcode = ?", (str(barcode).strip(),)).fetchone()
(str(barcode).strip(),) return row[0] if row and row[0] else None
)
row = cursor.fetchone()
return row[0] if row else None
finally: finally:
conn.close() conn.close()
def get_prices(self, barcodes: List[str]) -> Dict[str, float]: def get_prices(self, barcodes: List[str]) -> Dict[str, float]:
"""批量查询进货价"""
if not barcodes: if not barcodes:
return {} return {}
conn = self._connect() conn = self._connect()
try: try:
placeholders = ','.join('?' * len(barcodes)) placeholders = ','.join('?' * len(barcodes))
cursor = conn.execute( rows = conn.execute(
f"SELECT barcode, price FROM products WHERE barcode IN ({placeholders})", f"SELECT barcode, avg_price FROM products WHERE barcode IN ({placeholders})",
[str(b).strip() for b in barcodes] [str(b).strip() for b in barcodes]).fetchall()
) return {r[0]: r[1] for r in rows if r[1]}
return {row[0]: row[1] for row in cursor.fetchall()}
finally: finally:
conn.close() conn.close()
def count(self) -> int: def count(self) -> int:
"""返回商品总数"""
conn = self._connect() conn = self._connect()
try: try:
cursor = conn.execute("SELECT COUNT(*) FROM products") return conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
return cursor.fetchone()[0]
finally: finally:
conn.close() conn.close()
# ── 记忆库查询 ────────────────────────────────────────────
def get_memory(self, barcode: str) -> Optional[Dict]: def get_memory(self, barcode: str) -> Optional[Dict]:
"""查询单条商品记忆"""
conn = self._connect() conn = self._connect()
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
cursor = conn.execute( row = conn.execute("SELECT * FROM products WHERE barcode=?",
"SELECT * FROM products WHERE barcode = ?", (str(barcode).strip(),)).fetchone()
(str(barcode).strip(),) return dict(row) if row else None
)
row = cursor.fetchone()
if row:
return dict(row)
return None
finally: finally:
conn.close() conn.close()
def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]: def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]:
"""批量查询商品记忆"""
if not barcodes: if not barcodes:
return {} return {}
conn = self._connect() conn = self._connect()
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
placeholders = ','.join('?' * len(barcodes)) placeholders = ','.join('?' * len(barcodes))
cursor = conn.execute( rows = conn.execute(
f"SELECT * FROM products WHERE barcode IN ({placeholders})", f"SELECT * FROM products WHERE barcode IN ({placeholders})",
[str(b).strip() for b in barcodes] [str(b).strip() for b in barcodes]).fetchall()
) return {r['barcode']: dict(r) for r in rows}
return {row['barcode']: dict(row) for row in cursor.fetchall()}
finally: finally:
conn.close() conn.close()
def get_all_memories(self) -> List[Dict]: def get_all_memories(self) -> List[Dict]:
"""返回全部记录(UI 用)"""
conn = self._connect() conn = self._connect()
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
cursor = conn.execute( return [dict(row) for row in
"SELECT * FROM products ORDER BY usage_count DESC, barcode" conn.execute("SELECT * FROM products ORDER BY usage_count DESC, barcode").fetchall()]
)
return [dict(row) for row in cursor.fetchall()]
finally: finally:
conn.close() conn.close()
# ── 学习逻辑 ────────────────────────────────────────────── # ══════════════════════════════════════════════════════════════
# 批量预加载 — 性能核心
# ══════════════════════════════════════════════════════════════
def learn_from_product(self, product: Dict, source: str = 'ocr') -> None: def load_batch(self, barcodes: List[str]) -> Dict[str, Dict]:
"""从处理结果中学习单条商品数据 """批量预加载条码记忆到 dict — 单次 SQL,后续纯内存操作"""
if not barcodes:
return {}
conn = self._connect()
conn.row_factory = sqlite3.Row
try:
placeholders = ','.join('?' * len(barcodes))
rows = conn.execute(
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
[str(b).strip() for b in barcodes]).fetchall()
return {r['barcode']: dict(r) for r in rows}
finally:
conn.close()
Args: # ══════════════════════════════════════════════════════════════
product: 商品字典 (barcode, name, specification, unit, price, ...) # 学习逻辑 — 一致性加速 + 价格区间
source: 数据来源 ('template', 'ocr', 'user_confirmed') # ══════════════════════════════════════════════════════════════
def learn_from_product(self, product: Dict, source: str = 'ocr',
memory: Dict[str, Dict] = None,
add_log: Callable = None) -> Optional[str]:
"""
从处理结果中学习,返回日志字符串。
memory: 可选的预加载批量内存,传入则零 DB 查询。
""" """
barcode = str(product.get('barcode', '')).strip() barcode = str(product.get('barcode', '')).strip()
if not barcode: if not barcode:
return return None
now = datetime.now().isoformat()
name = str(product.get('name', '')) name = str(product.get('name', ''))
spec = str(product.get('specification', '')) spec = str(product.get('specification', ''))
unit = str(product.get('unit', '')) unit = str(product.get('unit', ''))
price = float(product.get('price', 0)) price = float(product.get('price', 0))
now = datetime.now().isoformat()
# 查现有记录(优先从内存查)
if memory is not None and barcode in memory:
row = memory[barcode]
old_name = row.get('name', '')
old_spec = row.get('specification', '')
old_unit = row.get('unit', '')
old_conf = row.get('confidence', 0)
old_count = row.get('usage_count', 0)
old_avg = row.get('avg_price', 0) or 0
old_min = row.get('min_price') or price
old_max = row.get('max_price') or price
pc = row.get('price_count', 0) or 0
exists = True
else:
conn = self._connect() conn = self._connect()
try: try:
cursor = conn.execute( cursor = conn.execute(
"SELECT confidence, usage_count FROM products WHERE barcode = ?", "SELECT name, specification, unit, confidence, usage_count, "
(barcode,) "avg_price, min_price, max_price, price_count FROM products WHERE barcode=?",
) (barcode,)).fetchone()
row = cursor.fetchone() finally:
conn.close()
if row is None: if cursor is None:
# 新记录 exists = False
conf = {'template': 100, 'user_confirmed': 90}.get(source, 50)
conn.execute(
"INSERT INTO products "
"(barcode, name, specification, unit, price, source, confidence, usage_count, last_seen, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
(barcode, name, spec, unit, price, source, conf, now, now)
)
else: else:
old_conf, old_count = row old_name, old_spec, old_unit, old_conf, old_count, old_avg, old_min, old_max, pc = cursor
new_count = old_count + 1 old_avg = old_avg or 0
pc = pc or 0
old_min = old_min if old_min is not None else price
old_max = old_max if old_max is not None else price
exists = True
if source == 'template': new_count = old_count + 1 if exists else 1
new_conf = 100
elif source == 'user_confirmed': # ── 置信度 ──
if source == 'user_confirmed':
new_conf = 90 new_conf = 90
else: # ocr elif source == 'template':
new_conf = min(80, old_conf + 10) if old_conf < 80 else old_conf new_conf = 50
elif exists and old_conf < 50:
# 一致性加速
spec_match = bool(spec and old_spec and spec == old_spec)
unit_match = bool(unit and old_unit and unit == old_unit)
if spec_match and unit_match:
boost = 10
elif unit_match:
boost = 5
else:
boost = 3
new_conf = min(50, old_conf + boost)
elif exists:
new_conf = old_conf # > 50 稳定不变
else:
new_conf = 10 # 新 OCR 记录
if source in ('template', 'user_confirmed'): # ── 价格区间 ──
# 高权威来源:全字段覆盖 if price > 0:
new_pc = (pc if exists else 0) + 1
new_avg = ((old_avg * (new_pc - 1)) + price) / new_pc if exists else price
new_min = min(old_min, price) if exists else price
new_max = max(old_max, price) if exists else price
else:
new_avg = old_avg if exists else 0
new_min = old_min if exists else 0
new_max = old_max if exists else 0
new_pc = pc if exists else 0
# ── 写入 ──
conn = self._connect()
try:
if not exists:
conn.execute(
"INSERT INTO products (barcode, name, specification, unit, price, "
"source, confidence, usage_count, last_seen, updated_at, "
"avg_price, min_price, max_price, price_count) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(barcode, name, spec, unit, price, source, new_conf, 1, now, now,
new_avg, new_min, new_max, new_pc))
log = f"记忆库新增: {barcode} {name} 源={source} 可信度={new_conf}"
else:
# 高可信度源全字段覆盖;低可信度仅填空
if source in ('template', 'user_confirmed') or new_conf > 50:
conn.execute( conn.execute(
"UPDATE products SET name=?, specification=?, unit=?, price=?, " "UPDATE products SET name=?, specification=?, unit=?, price=?, "
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? " "source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
"WHERE barcode=?", "avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
(name, spec, unit, price, source, new_conf, new_count, now, now, barcode) (name or old_name, spec or old_spec, unit or old_unit, price,
) source, new_conf, new_count, now, now,
new_avg, new_min, new_max, new_pc, barcode))
else: else:
# OCR:仅填充空字段,不更新 price
conn.execute( conn.execute(
"UPDATE products SET " "UPDATE products SET "
"name = CASE WHEN name='' THEN ? ELSE name END, " "name=CASE WHEN name='' THEN ? ELSE name END, "
"specification = CASE WHEN specification='' THEN ? ELSE specification END, " "specification=CASE WHEN specification='' THEN ? ELSE specification END, "
"unit = CASE WHEN unit='' THEN ? ELSE unit END, " "unit=CASE WHEN unit='' THEN ? ELSE unit END, "
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? " "source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
"WHERE barcode=?", "avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
(name, spec, unit, source, new_conf, new_count, now, now, barcode) (name, spec, unit, source, new_conf, new_count, now, now,
) new_avg, new_min, new_max, new_pc, barcode))
log = f"记忆库更新: {barcode} 可信度{old_conf if exists else 0}{new_conf}"
if price > 0:
log += f" 均价{new_avg:.4f}({new_pc}次)"
conn.commit() conn.commit()
# 更新内存 dict(如果传入了)
if memory is not None and barcode in memory:
memory[barcode].update({
'confidence': new_conf, 'usage_count': new_count,
'avg_price': new_avg, 'min_price': new_min,
'max_price': new_max, 'price_count': new_pc,
'name': name or old_name,
'specification': spec or old_spec,
'unit': unit or old_unit,
})
if add_log:
add_log(log)
return log
finally: finally:
conn.close() conn.close()
def learn_from_products(self, products: List[Dict], source: str = 'ocr') -> int: def learn_from_products(self, products: List[Dict], source: str = 'ocr',
"""批量学习,返回更新条数""" add_log: Callable = None) -> int:
"""批量学习 — 先批量预加载,再逐条处理,返回更新条数"""
barcodes = [str(p.get('barcode', '')) for p in products if p.get('barcode')]
memory = self.load_batch(barcodes)
count = 0 count = 0
for p in products: for p in products:
try: try:
self.learn_from_product(p, source) result = self.learn_from_product(p, source, memory=memory, add_log=add_log)
if result:
count += 1 count += 1
except Exception as e: except Exception as e:
logger.warning(f"学习商品记忆失败: {e}") logger.warning(f"学习商品记忆失败: {e}")
return count return count
# ══════════════════════════════════════════════════════════════
# 记忆辅助 — OCR 补全
# ══════════════════════════════════════════════════════════════
def _price_anomaly(self, product: Dict, mem: Dict) -> bool:
"""价格异常:> 2倍偏差"""
price = float(product.get('price', 0))
avg = mem.get('avg_price', 0)
if not price or not avg:
return False
return price > avg * 2 or price < avg * 0.5
def fill_from_memory(self, barcode: str, ocr_result: Dict,
memory: Dict[str, Dict] = None) -> Tuple[Dict, str]:
"""用记忆库补全 OCR 缺失字段。返回 (补全后的dict, 日志字符串)"""
if memory:
mem = memory.get(barcode)
else:
mem = self.get_memory(barcode)
if not mem or mem.get('confidence', 0) < 10:
return ocr_result, ""
logs = []
result = dict(ocr_result)
conf = mem.get('confidence', 0)
has_spec = result.get('specification')
has_unit = result.get('unit')
price = float(result.get('price', 0))
if conf > 50 and not has_spec and mem.get('specification'):
result['specification'] = mem['specification']
logs.append(f"规格补全(可信{conf}): {barcode}{mem['specification']}")
elif not has_spec and mem.get('specification') and self._price_anomaly(result, mem):
result['specification'] = mem['specification']
logs.append(f"价格异常→规格补全: {barcode} 本次{price:.2f} vs 均价{mem['avg_price']:.2f}{mem['specification']}")
if conf > 50 and not has_unit and mem.get('unit'):
result['unit'] = mem['unit']
logs.append(f"单位补全(可信{conf}): {barcode}{mem['unit']}")
elif not has_unit and mem.get('unit') and self._price_anomaly(result, mem):
result['unit'] = mem['unit']
logs.append(f"价格异常→单位补全: {barcode}{mem['unit']}")
return result, "; ".join(logs)
def price_warning(self, barcode: str, price: float,
memory: Dict[str, Dict] = None) -> Optional[str]:
"""价格预警。> 50% 偏差告警"""
if memory:
mem = memory.get(barcode)
else:
mem = self.get_memory(barcode)
if not mem or not mem.get('avg_price'):
return None
avg = mem['avg_price']
min_p = mem.get('min_price', avg)
max_p = mem.get('max_price', avg)
pc = mem.get('price_count', 0)
if price > avg * 1.5 or price < avg * 0.5:
return (f"单价预警: {barcode} 本次{price:.4f}元 vs "
f"历史均价{avg:.4f} (范围{min_p:.4f}~{max_p:.4f}, {pc}次)")
return None
# ══════════════════════════════════════════════════════════════
# 手动编辑
# ══════════════════════════════════════════════════════════════
def update_memory(self, barcode: str, fields: Dict) -> bool: def update_memory(self, barcode: str, fields: Dict) -> bool:
"""手动编辑记录(UI 用,source→user_confirmed, confidence→90"""
barcode = str(barcode).strip() barcode = str(barcode).strip()
if not barcode: if not barcode:
return False return False
allowed = {'name', 'specification', 'unit', 'price', 'confidence'}
allowed = {'name', 'specification', 'unit', 'price'}
updates = {k: v for k, v in fields.items() if k in allowed} updates = {k: v for k, v in fields.items() if k in allowed}
if not updates: if not updates:
return False return False
now = datetime.now().isoformat() now = datetime.now().isoformat()
set_clause = ', '.join(f"{k}=?" for k in updates) set_clause = ', '.join(f"{k}=?" for k in updates)
values = list(updates.values()) values = list(updates.values())
extra_sql = ", source='user_confirmed'"
if 'confidence' not in updates:
extra_sql += ", confidence=90"
conn = self._connect() conn = self._connect()
try: try:
conn.execute( conn.execute(
f"UPDATE products SET {set_clause}, source='user_confirmed', confidence=90, " f"UPDATE products SET {set_clause}{extra_sql}, updated_at=? WHERE barcode=?",
"updated_at=? WHERE barcode=?", values + [now, barcode])
values + [now, barcode]
)
conn.commit() conn.commit()
return conn.total_changes > 0 return conn.total_changes > 0
finally: finally:
conn.close() conn.close()
def delete_memory(self, barcode: str) -> bool: def delete_memory(self, barcode: str) -> bool:
"""删除记录"""
conn = self._connect() conn = self._connect()
try: try:
conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),)) conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),))
@@ -409,51 +514,39 @@ class ProductDatabase:
finally: finally:
conn.close() conn.close()
# ── 云端同步 ────────────────────────────────────────────── # ══════════════════════════════════════════════════════════════
# 云端同步
# ══════════════════════════════════════════════════════════════
def export_for_sync(self) -> Dict: def export_for_sync(self) -> Dict:
"""导出全部记录为 JSON-serializable dict(按条码索引)"""
conn = self._connect() conn = self._connect()
try: try:
cursor = conn.execute( cursor = conn.execute(
"SELECT barcode, name, specification, unit, price, source, " "SELECT barcode, name, specification, unit, price, source, "
"confidence, usage_count, last_seen FROM products" "confidence, usage_count, last_seen, avg_price, min_price, max_price, price_count "
) "FROM products")
result = {} result = {}
for row in cursor.fetchall(): for row in cursor.fetchall():
result[row[0]] = { result[row[0]] = {
'name': row[1], 'name': row[1], 'specification': row[2], 'unit': row[3],
'specification': row[2], 'price': row[4], 'source': row[5], 'confidence': row[6],
'unit': row[3], 'usage_count': row[7], 'last_seen': row[8],
'price': row[4], 'avg_price': row[9], 'min_price': row[10],
'source': row[5], 'max_price': row[11], 'price_count': row[12],
'confidence': row[6],
'usage_count': row[7],
'last_seen': row[8],
} }
return result return result
finally: finally:
conn.close() conn.close()
def import_from_sync(self, data: Dict) -> int: def import_from_sync(self, data: Dict) -> int:
"""从云端 JSON 导入,高置信度优先合并
Args:
data: {barcode: {name, specification, unit, price, source, confidence, ...}}
Returns:
导入/更新的记录数
"""
now = datetime.now().isoformat() now = datetime.now().isoformat()
count = 0 count = 0
conn = self._connect() conn = self._connect()
try: try:
for barcode, info in data.items(): for barcode, info in data.items():
barcode = str(barcode).strip() barcode = str(barcode).strip()
if not barcode: if not barcode:
continue continue
name = str(info.get('name', '')) name = str(info.get('name', ''))
spec = str(info.get('specification', '')) spec = str(info.get('specification', ''))
unit = str(info.get('unit', '')) unit = str(info.get('unit', ''))
@@ -462,69 +555,55 @@ class ProductDatabase:
remote_conf = int(info.get('confidence', 50)) remote_conf = int(info.get('confidence', 50))
remote_count = int(info.get('usage_count', 1)) remote_count = int(info.get('usage_count', 1))
remote_seen = str(info.get('last_seen', now)) remote_seen = str(info.get('last_seen', now))
remote_avg = float(info.get('avg_price', price))
remote_min = float(info.get('min_price', price))
remote_max = float(info.get('max_price', price))
remote_pc = int(info.get('price_count', 1))
cursor = conn.execute( row = conn.execute("SELECT confidence FROM products WHERE barcode=?",
"SELECT confidence FROM products WHERE barcode = ?", (barcode,)).fetchone()
(barcode,)
)
row = cursor.fetchone()
if row is None: if row is None:
# 新记录,直接插入
conn.execute( conn.execute(
"INSERT INTO products " "INSERT INTO products (barcode, name, specification, unit, price, "
"(barcode, name, specification, unit, price, source, confidence, usage_count, last_seen, updated_at) " "source, confidence, usage_count, last_seen, updated_at, "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "avg_price, min_price, max_price, price_count) "
(barcode, name, spec, unit, price, remote_source, remote_conf, remote_count, remote_seen, now) "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) (barcode, name, spec, unit, price, remote_source, remote_conf,
remote_count, remote_seen, now,
remote_avg, remote_min, remote_max, remote_pc))
count += 1 count += 1
else: else:
local_conf = row[0] local_conf = row[0]
if remote_conf > local_conf: if remote_conf > local_conf:
# 云端置信度更高,覆盖
conn.execute( conn.execute(
"UPDATE products SET name=?, specification=?, unit=?, price=?, " "UPDATE products SET name=?, specification=?, unit=?, price=?, "
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? " "source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
"WHERE barcode=?", "avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
(name, spec, unit, price, remote_source, remote_conf, remote_count, remote_seen, now, barcode) (name, spec, unit, price, remote_source, remote_conf,
) remote_count, remote_seen, now,
remote_avg, remote_min, remote_max, remote_pc, barcode))
count += 1 count += 1
elif remote_conf == local_conf: elif remote_conf == local_conf:
# 置信度相同,填充空字段
conn.execute( conn.execute(
"UPDATE products SET " "UPDATE products SET "
"name = CASE WHEN name='' THEN ? ELSE name END, " "name=CASE WHEN name='' THEN ? ELSE name END, "
"specification = CASE WHEN specification='' THEN ? ELSE specification END, " "specification=CASE WHEN specification='' THEN ? ELSE specification END, "
"unit = CASE WHEN unit='' THEN ? ELSE unit END, " "unit=CASE WHEN unit='' THEN ? ELSE unit END, "
"usage_count = MAX(usage_count, ?), " "usage_count=MAX(usage_count, ?), updated_at=? WHERE barcode=?",
"updated_at=? WHERE barcode=?", (name, spec, unit, remote_count, now, barcode))
(name, spec, unit, remote_count, now, barcode)
)
count += 1 count += 1
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
return count return count
def _export_memory_json(self, json_path: str = None) -> str: def _export_memory_json(self, json_path=None):
"""导出记忆库为本地 JSON 文件 """导出记忆库为 JSON(兼容旧代码调用)"""
import os as _os
Args:
json_path: 输出路径,默认 data/product_memory.json
Returns:
写入的文件路径
"""
if json_path is None: if json_path is None:
json_path = os.path.join(os.path.dirname(self.db_path), 'product_memory.json') json_path = _os.path.join(_os.path.dirname(self.db_path), 'product_memory.json')
data = self.export_for_sync() data = self.export_for_sync()
os.makedirs(os.path.dirname(json_path), exist_ok=True) _os.makedirs(_os.path.dirname(json_path), exist_ok=True)
with open(json_path, 'w', encoding='utf-8') as f: with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2) json.dump(data, f, ensure_ascii=False, indent=2)
logger.debug(f"商品记忆库已导出: {json_path} ({len(data)} 条)")
return json_path return json_path
+63
View File
@@ -165,6 +165,69 @@ class GiteaSync:
existing_sha = self.file_exists(remote_path) existing_sha = self.file_exists(remote_path)
return self.push_file(remote_path, content, message, sha=existing_sha) return self.push_file(remote_path, content, message, sha=existing_sha)
def push(self) -> str:
"""推送本地数据到云端:product_cache.json + barcode_mappings.json"""
import os
from pathlib import Path
project_root = Path(__file__).resolve().parent.parent.parent.parent
results = []
# 1. Product cache
from app.core.db.product_db import ProductDatabase
excel_source = str(project_root / "templates" / "商品资料.xlsx")
db_path = str(project_root / "data" / "product_cache.db")
product_db = ProductDatabase(db_path, excel_source)
product_data = product_db.export_for_sync()
sha = self.push_json("product_cache.json", product_data, "sync: update product cache")
results.append(f"product_cache: {'ok' if sha else 'skip'}")
# 2. Barcode mappings
barcode_path = project_root / "config" / "barcode_mappings.json"
if barcode_path.exists():
with open(barcode_path, "r", encoding="utf-8") as f:
barcode_data = json.loads(f.read())
sha = self.push_json("barcode_mappings.json", barcode_data, "sync: update barcode mappings")
results.append(f"barcode_mappings: {'ok' if sha else 'skip'}")
return "; ".join(results) if results else "无数据需要同步"
def pull(self) -> str:
"""从云端拉取数据并写入本地文件"""
import os
from pathlib import Path
project_root = Path(__file__).resolve().parent.parent.parent.parent
results = []
# 1. Product cache
result = self.pull_json("product_cache.json")
if result is not None:
data, sha = result
from app.core.db.product_db import ProductDatabase
excel_source = str(project_root / "templates" / "商品资料.xlsx")
db_path = str(project_root / "data" / "product_cache.db")
os.makedirs(os.path.dirname(db_path), exist_ok=True)
product_db = ProductDatabase(db_path, excel_source)
count = product_db.import_from_sync(data)
results.append(f"product_cache: 导入 {count}")
else:
results.append("product_cache: 云端无数据")
# 2. Barcode mappings
barcode_result = self.pull_json("barcode_mappings.json")
if barcode_result is not None:
barcode_data, sha = barcode_result
barcode_path = project_root / "config" / "barcode_mappings.json"
barcode_path.parent.mkdir(parents=True, exist_ok=True)
with open(barcode_path, "w", encoding="utf-8") as f:
json.dump(barcode_data, f, ensure_ascii=False, indent=2)
results.append(f"barcode_mappings: 已更新")
else:
results.append("barcode_mappings: 云端无数据")
return "; ".join(results) if results else "无数据需要同步"
@classmethod @classmethod
def from_config(cls, config) -> Optional["GiteaSync"]: def from_config(cls, config) -> Optional["GiteaSync"]:
"""从 ConfigManager 创建实例 """从 ConfigManager 创建实例
+3 -3
View File
@@ -13,7 +13,7 @@ input_folder = data/input
output_folder = data/output output_folder = data/output
temp_folder = data/temp temp_folder = data/temp
template_folder = templates template_folder = templates
template_file = templates\银豹-采购单模板.xls template_file = templates/银豹-采购单模板.xls
processed_record = data/processed_files.json processed_record = data/processed_files.json
data_dir = data data_dir = data
product_db = data/product_cache.db product_db = data/product_cache.db
@@ -27,7 +27,7 @@ skip_existing = true
[File] [File]
allowed_extensions = .jpg,.jpeg,.png,.bmp allowed_extensions = .jpg,.jpeg,.png,.bmp
excel_extension = .xlsx excel_extension = .xlsx
max_file_size_mb = 4 max_file_size_mb = 5
[Templates] [Templates]
purchase_order = 银豹-采购单模板.xls purchase_order = 银豹-采购单模板.xls
@@ -40,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
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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";
}
}
View File
+19
View File
@@ -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 -10
View File
@@ -17,13 +17,22 @@ _mappings_file = _project_root / "config" / "barcode_mappings.json"
class BarcodeMapping(BaseModel): class BarcodeMapping(BaseModel):
barcode: str barcode: str
target: str target: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
# Special rule fields
multiplier: Optional[int] = None
target_unit: Optional[str] = None
fixed_price: Optional[float] = None
specification: Optional[str] = None
class BarcodeMappingUpdate(BaseModel): class BarcodeMappingUpdate(BaseModel):
target: Optional[str] = None target: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
multiplier: Optional[int] = None
target_unit: Optional[str] = None
fixed_price: Optional[float] = None
specification: Optional[str] = None
def _load_mappings() -> Dict: def _load_mappings() -> Dict:
@@ -51,12 +60,29 @@ async def list_barcodes(
if isinstance(info, dict): if isinstance(info, dict):
target = info.get("map_to", info.get("target", "")) target = info.get("map_to", info.get("target", ""))
desc = info.get("description", "") desc = info.get("description", "")
item = {
"barcode": barcode,
"target": target,
"description": desc,
"multiplier": info.get("multiplier"),
"target_unit": info.get("target_unit"),
"fixed_price": info.get("fixed_price"),
"specification": info.get("specification"),
}
else: else:
target = str(info) item = {
desc = "" "barcode": barcode,
if search and search not in barcode and search not in target and search not in desc: "target": str(info),
"description": "",
"multiplier": None,
"target_unit": None,
"fixed_price": None,
"specification": None,
}
s = search.lower() if search else ""
if s and s not in barcode.lower() and s not in item["target"].lower() and s not in (desc or "").lower():
continue continue
items.append({"barcode": barcode, "target": target, "description": desc}) items.append(item)
return {"items": items, "total": len(items)} return {"items": items, "total": len(items)}
@@ -82,9 +108,22 @@ async def create_barcode(
mappings = _load_mappings() mappings = _load_mappings()
if body.barcode in mappings: if body.barcode in mappings:
raise HTTPException(409, f"条码 {body.barcode} 已存在") raise HTTPException(409, f"条码 {body.barcode} 已存在")
mappings[body.barcode] = {"map_to": body.target, "description": body.description or ""}
entry: dict = {"description": body.description or ""}
if body.multiplier:
entry["multiplier"] = body.multiplier
if body.target_unit:
entry["target_unit"] = body.target_unit
if body.fixed_price is not None:
entry["fixed_price"] = body.fixed_price
if body.specification:
entry["specification"] = body.specification
else:
entry["map_to"] = body.target or ""
mappings[body.barcode] = entry
_save_mappings(mappings) _save_mappings(mappings)
return {"message": f"已创建映射 {body.barcode}{body.target}"} return {"message": f"已创建规则 {body.barcode}"}
@router.put("/{barcode}") @router.put("/{barcode}")
@@ -95,20 +134,35 @@ async def update_barcode(
): ):
mappings = _load_mappings() mappings = _load_mappings()
if barcode not in mappings: if barcode not in mappings:
raise HTTPException(404, f"未找到条码映射 {barcode}") raise HTTPException(404, f"未找到条码规则 {barcode}")
existing = mappings[barcode] existing = mappings[barcode]
if not isinstance(existing, dict): if not isinstance(existing, dict):
existing = {"map_to": str(existing), "description": ""} existing = {"map_to": str(existing), "description": ""}
if body.target is not None: # Check if this is a special rule (has multiplier) or being converted to one
if body.multiplier is not None:
# Convert to special rule: remove map_to, add multiplier fields
existing.pop("map_to", None)
existing["multiplier"] = body.multiplier
if body.target_unit is not None:
existing["target_unit"] = body.target_unit
if body.fixed_price is not None:
existing["fixed_price"] = body.fixed_price
if body.specification is not None:
existing["specification"] = body.specification
elif body.target is not None:
# Convert to simple mapping: remove special fields, add map_to
for k in ("multiplier", "target_unit", "fixed_price", "specification"):
existing.pop(k, None)
existing["map_to"] = body.target existing["map_to"] = body.target
if body.description is not None: if body.description is not None:
existing["description"] = body.description existing["description"] = body.description
mappings[barcode] = existing mappings[barcode] = existing
_save_mappings(mappings) _save_mappings(mappings)
return {"message": f"已更新映射 {barcode}"} return {"message": f"已更新规则 {barcode}"}
@router.delete("/{barcode}") @router.delete("/{barcode}")
+17 -1
View File
@@ -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}
+103 -3
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Request from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from ..auth.dependencies import get_current_user, get_current_user_flexible from ..auth.dependencies import get_current_user, get_current_user_flexible
@@ -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,
@@ -267,10 +299,16 @@ async def get_file_relations(
status: Optional[str] = None, status: Optional[str] = None,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200), page_size: int = Query(50, ge=1, le=200),
sort_by: Optional[str] = None,
sort_order: str = "desc",
sync: bool = Query(True, description="Auto-sync file relations before querying"),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""Query file relations with optional view filter.""" """Query file relations with optional view filter."""
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size) if sync:
sync_file_relations()
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size,
sort_by=sort_by, sort_order=sort_order)
return {"items": items, "total": total} return {"items": items, "total": total}
@@ -291,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,
@@ -299,3 +355,47 @@ async def delete_relations(
"""Delete file relation records by IDs.""" """Delete file relation records by IDs."""
delete_file_relations(body.ids) delete_file_relations(body.ids)
return {"message": f"已删除 {len(body.ids)} 条关系记录"} return {"message": f"已删除 {len(body.ids)} 条关系记录"}
# ---------------------------------------------------------------------------
# File preview
# ---------------------------------------------------------------------------
@router.get("/preview/{directory}/{filename:path}")
async def preview_file(
directory: str,
filename: str,
current_user: dict = Depends(get_current_user),
):
"""Preview file content: images served directly, Excel returned as JSON grid."""
# Security: only allow specific directories
if directory not in ("input", "output", "result"):
raise HTTPException(403, "不允许访问该目录")
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
file_path = dir_map[directory] / filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {filename}")
ext = file_path.suffix.lower()
# Images: serve directly
if ext in ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'):
return FileResponse(str(file_path))
# Excel: read and return as JSON grid
if ext in ('.xls', '.xlsx'):
try:
import pandas as pd
from fastapi.responses import JSONResponse
df = pd.read_excel(str(file_path), header=None)
# Fill NaN with empty string
df = df.fillna('')
rows = []
for _, row in df.iterrows():
rows.append([str(v) if v != '' else '' for v in row])
# Limit to first 200 rows
return JSONResponse({"type": "excel", "rows": rows[:200], "total_rows": len(rows)})
except Exception as e:
raise HTTPException(500, f"读取文件失败: {e}")
raise HTTPException(400, f"不支持预览的文件类型: {ext}")
+46 -3
View File
@@ -18,18 +18,31 @@ _excel_source = str(_project_root / "templates" / "商品资料.xlsx")
class MemoryItem(BaseModel): class MemoryItem(BaseModel):
barcode: str barcode: str
name: str name: str
spec: Optional[str] = None specification: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
price: Optional[float] = None price: Optional[float] = None
avg_price: Optional[float] = None
min_price: Optional[float] = None
max_price: Optional[float] = None
price_count: int = 0
confidence: int = 0 confidence: int = 0
source: str = "ocr" source: str = "ocr"
last_used: Optional[str] = None last_used: Optional[str] = None
use_count: int = 0 use_count: int = 0
class MemoryCreate(BaseModel):
barcode: str
name: Optional[str] = ""
specification: Optional[str] = None
unit: Optional[str] = None
price: Optional[float] = None
confidence: int = 50
class MemoryUpdate(BaseModel): class MemoryUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
spec: Optional[str] = None specification: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
price: Optional[float] = None price: Optional[float] = None
confidence: Optional[int] = None confidence: Optional[int] = None
@@ -40,6 +53,7 @@ class MemoryListResponse(BaseModel):
total: int total: int
page: int page: int
page_size: int page_size: int
stats: Optional[Dict] = None
def _get_db(): def _get_db():
@@ -51,9 +65,13 @@ def _row_to_item(row: Dict) -> MemoryItem:
return MemoryItem( return MemoryItem(
barcode=row.get("barcode", ""), barcode=row.get("barcode", ""),
name=row.get("name", ""), name=row.get("name", ""),
spec=row.get("spec"), specification=row.get("specification"),
unit=row.get("unit"), unit=row.get("unit"),
price=row.get("price"), price=row.get("price"),
avg_price=row.get("avg_price"),
min_price=row.get("min_price"),
max_price=row.get("max_price"),
price_count=row.get("price_count", 0),
confidence=row.get("confidence", 0), confidence=row.get("confidence", 0),
source=row.get("source", "ocr"), source=row.get("source", "ocr"),
last_used=row.get("last_used"), last_used=row.get("last_used"),
@@ -79,11 +97,17 @@ async def list_memory(
start = (page - 1) * page_size start = (page - 1) * page_size
page_items = results[start:start + page_size] page_items = results[start:start + page_size]
# Compute confidence stats from all results (not just current page)
high = sum(1 for r in results if r.get("confidence", 0) > 50)
medium = sum(1 for r in results if 10 <= r.get("confidence", 0) <= 50)
low = sum(1 for r in results if r.get("confidence", 0) < 10)
return MemoryListResponse( return MemoryListResponse(
items=[_row_to_item(r) for r in page_items], items=[_row_to_item(r) for r in page_items],
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
stats={"high": high, "medium": medium, "low": low, "total": total},
) )
@@ -99,6 +123,25 @@ async def get_memory(
return product return product
@router.post("")
async def create_memory(
body: MemoryCreate,
current_user: dict = Depends(get_current_user),
):
db = _get_db()
existing = db.get_memory(body.barcode)
if existing:
raise HTTPException(409, f"条码 {body.barcode} 已存在,请使用编辑功能")
db.learn_from_product({
"barcode": body.barcode,
"name": body.name or "",
"specification": body.specification or "",
"unit": body.unit or "",
"price": body.price or 0,
}, source="user_confirmed")
return {"message": f"已创建记忆记录 {body.barcode}"}
@router.put("/{barcode}") @router.put("/{barcode}")
async def update_memory( async def update_memory(
barcode: str, barcode: str,
+195 -14
View File
@@ -1,8 +1,10 @@
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline.""" """Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
import asyncio import asyncio
import logging
import os import os
import sys import sys
import threading
import traceback import traceback
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
@@ -18,6 +20,66 @@ router = APIRouter(prefix="/api/processing", tags=["processing"])
_wrapper = ServiceWrapper(max_workers=3) _wrapper = ServiceWrapper(max_workers=3)
# ── Thread-safe log capture ──
_tlocal = threading.local()
class TaskLogHandler(logging.Handler):
"""Capture all log records during task execution and forward to tm.add_log()"""
def emit(self, record: logging.LogRecord):
ctx = getattr(_tlocal, 'ctx', None)
if ctx:
tm = ctx.get('tm')
task_id = ctx.get('task_id')
if tm and task_id:
msg = self.format(record)
if any(skip in msg for skip in ['DEBUG:', 'urllib3', 'charset_normalizer']):
return
tm.add_log(task_id, msg)
_log_handler = TaskLogHandler()
_log_handler.setLevel(logging.DEBUG)
_log_handler.setFormatter(logging.Formatter('%(message)s'))
_root_logger = logging.getLogger()
_configured = False
def _setup_log_capture():
global _configured
if not _configured:
_root_logger.addHandler(_log_handler)
_configured = True
def _start_log_capture(tm, task_id: str):
_setup_log_capture()
_root_logger.setLevel(logging.DEBUG)
_tlocal.ctx = {'tm': tm, 'task_id': task_id}
def _stop_log_capture():
_tlocal.ctx = None
def _add_result_file(name: str):
files = getattr(_tlocal, 'result_files', None)
if files is not None:
files.append(name)
def _wrap_with_capture(tm, task_id, func):
"""Wrap a do_work function with log capture setup/teardown."""
def wrapped():
_start_log_capture(tm, task_id)
_tlocal.result_files = []
try:
return func()
finally:
_stop_log_capture()
return wrapped
_project_root = Path(__file__).resolve().parent.parent.parent.parent _project_root = Path(__file__).resolve().parent.parent.parent.parent
_input_dir = _project_root / "data" / "input" _input_dir = _project_root / "data" / "input"
_output_dir = _project_root / "data" / "output" _output_dir = _project_root / "data" / "output"
@@ -74,6 +136,92 @@ def _run_background(coro):
asyncio.ensure_future(coro) asyncio.ensure_future(coro)
def _run_background_with_log(coro, tm, task_id: str):
"""Schedule a coroutine with log capture during execution."""
async def _wrapped():
_start_log_capture(tm, task_id)
try:
await coro
finally:
_stop_log_capture()
asyncio.ensure_future(_wrapped())
def _get_product_db():
from app.core.db.product_db import ProductDatabase
return ProductDatabase(
str(_project_root / 'data' / 'product_cache.db'),
str(_project_root / 'templates' / '商品资料.xlsx')
)
def _learn_products_from_excel(excel_path: Path, tm, task_id, source: str = 'ocr'):
"""从处理后的Excel文件学习商品数据到记忆库"""
try:
from app.core.utils.file_utils import smart_read_excel
df = smart_read_excel(str(excel_path))
if df is None or df.empty:
return
except Exception:
return
from app.core.handlers.column_mapper import ColumnMapper
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
if not barcode_col:
return
name_col = ColumnMapper.find_column(list(df.columns), 'name')
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price') or ColumnMapper.find_column(list(df.columns), 'price')
db = _get_product_db()
barcodes = [str(r.get(barcode_col, '')).strip() for _, r in df.iterrows() if str(r.get(barcode_col, '')).strip()]
memory = db.load_batch(barcodes)
learned = 0
for _, row in df.iterrows():
barcode = str(row.get(barcode_col, '')).strip()
if not barcode or barcode == 'nan':
continue
price = 0.0
if price_col:
try:
p = row.get(price_col)
if p is not None and str(p).strip() not in ('', 'nan', 'None'):
price = float(p)
except (ValueError, TypeError):
pass
product = {
'barcode': barcode,
'name': str(row.get(name_col, '')).strip() if name_col else '',
'specification': str(row.get(spec_col, '')).strip() if spec_col else '',
'unit': str(row.get(unit_col, '')).strip() if unit_col else '',
'price': price,
}
# 1. 记忆辅助补全
filled, fill_log = db.fill_from_memory(barcode, product, memory)
if fill_log:
tm.add_log(task_id, f" {fill_log}")
# 2. 价格预警
warn = db.price_warning(barcode, price, memory)
if warn:
tm.add_log(task_id, f" {warn}")
# 3. 学习
log = db.learn_from_product(filled, source=source, memory=memory, add_log=None)
if log:
tm.add_log(task_id, f" {log}")
learned += 1
if learned:
tm.add_log(task_id, f"[记忆库] 从 {excel_path.name} 学习了 {learned} 条商品数据")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Batch endpoints # Batch endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -86,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))
@@ -118,15 +267,22 @@ async def ocr_batch(
candidate = _output_dir / f"{out_stem}{ext}" candidate = _output_dir / f"{out_stem}{ext}"
if candidate.exists(): if candidate.exists():
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done') upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
_add_result_file(candidate.name)
break break
tm.add_log(task.id, f"[OCR] 完成: {f.name}") tm.add_log(task.id, f"[OCR] 完成: {f.name}")
# Learn products into memory from OCR output
out_file = _output_dir / f"{out_stem}.xlsx"
if not out_file.exists():
out_file = _output_dir / f"{out_stem}.xls"
if out_file.exists():
_learn_products_from_excel(out_file, tm, task.id, source='ocr')
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}") tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
result_files = [f.name for f in _output_dir.iterdir() if f.is_file()] result_files = list(getattr(_tlocal, 'result_files', []))
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成,共处理 {total} 个文件") tm.set_completed(task.id, result_files=result_files, message=f"OCR完成,共处理 {total} 个文件")
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建")
@@ -141,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:
@@ -163,6 +320,7 @@ async def process_excel(
if result_path.exists(): if result_path.exists():
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}") tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
_add_result_file(result_name)
continue continue
tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}") tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}")
@@ -172,14 +330,18 @@ async def process_excel(
# Find result file # Find result file
if result_path.exists(): if result_path.exists():
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
_add_result_file(result_name)
tm.add_log(task.id, f"[Excel] 完成: {f.name}") tm.add_log(task.id, f"[Excel] 完成: {f.name}")
# Learn products into memory from purchase order result
if result_path.exists():
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}") tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()] result_files = list(getattr(_tlocal, 'result_files', []))
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成,共 {total} 个文件") tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成,共 {total} 个文件")
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建")
@@ -194,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:
@@ -224,7 +387,7 @@ async def merge_orders(
tm.add_log(task.id, f"[合并] 失败: {e}") tm.add_log(task.id, f"[合并] 失败: {e}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建")
@@ -239,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():
@@ -272,8 +436,14 @@ async def full_pipeline(
candidate = _output_dir / f"{out_stem}{ext}" candidate = _output_dir / f"{out_stem}{ext}"
if candidate.exists(): if candidate.exists():
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done') upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
_add_result_file(candidate.name)
break break
tm.add_log(task.id, f"[OCR] 完成: {f.name}") tm.add_log(task.id, f"[OCR] 完成: {f.name}")
out_file = _output_dir / f"{out_stem}.xlsx"
if not out_file.exists():
out_file = _output_dir / f"{out_stem}.xls"
if out_file.exists():
_learn_products_from_excel(out_file, tm, task.id, source='ocr')
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}") tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
@@ -293,6 +463,7 @@ async def full_pipeline(
if result_path.exists(): if result_path.exists():
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}") tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
_add_result_file(result_name)
tm.update_progress(task.id, pct, f"跳过: {f.name}") tm.update_progress(task.id, pct, f"跳过: {f.name}")
continue continue
@@ -301,18 +472,21 @@ async def full_pipeline(
order_svc.process_excel(str(f)) order_svc.process_excel(str(f))
if result_path.exists(): if result_path.exists():
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
_add_result_file(result_name)
tm.add_log(task.id, f"[Excel] 完成: {f.name}") tm.add_log(task.id, f"[Excel] 完成: {f.name}")
if result_path.exists():
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}") tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()] result_files = list(getattr(_tlocal, 'result_files', []))
tm.set_completed(task.id, result_files=result_files, message="全流程处理完成(不含合并)") tm.set_completed(task.id, result_files=result_files, message="全流程处理完成(不含合并)")
except Exception as e: except Exception as e:
tb = traceback.format_exc() tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}") tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建")
@@ -331,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():
@@ -350,15 +525,16 @@ async def ocr_single(
candidate = _output_dir / f"{stem}{ext}" candidate = _output_dir / f"{stem}{ext}"
if candidate.exists(): if candidate.exists():
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done') upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
_add_result_file(candidate.name)
break break
tm.add_log(task.id, f"[OCR] 完成: {body.filename}") tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
result_files = [f.name for f in _output_dir.iterdir() if f.is_file()] result_files = list(getattr(_tlocal, 'result_files', []))
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成: {body.filename}") tm.set_completed(task.id, result_files=result_files, message=f"OCR完成: {body.filename}")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {e}") tm.add_log(task.id, f"[OCR] 失败: {e}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"OCR任务已创建: {body.filename}") return TaskResponse(task_id=task.id, status="accepted", message=f"OCR任务已创建: {body.filename}")
@@ -373,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():
@@ -390,13 +567,13 @@ async def excel_single(
if (_result_dir / result_name).exists(): if (_result_dir / result_name).exists():
upsert_file_relation(output_excel=body.filename, result_purchase=result_name, status='done') upsert_file_relation(output_excel=body.filename, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成: {body.filename}") tm.add_log(task.id, f"[Excel] 完成: {body.filename}")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()] result_files = list(getattr(_tlocal, 'result_files', []))
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成: {body.filename}") tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成: {body.filename}")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {e}") tm.add_log(task.id, f"[Excel] 失败: {e}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"Excel处理任务已创建: {body.filename}") return TaskResponse(task_id=task.id, status="accepted", message=f"Excel处理任务已创建: {body.filename}")
@@ -411,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():
@@ -433,12 +611,14 @@ async def pipeline_single(
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}") tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}")
upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done') upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done')
_add_result_file(out_name)
else: else:
ocr_svc.process_image(str(file_path)) ocr_svc.process_image(str(file_path))
for ext in ['.xlsx', '.xls']: for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{stem}{ext}" candidate = _output_dir / f"{stem}{ext}"
if candidate.exists(): if candidate.exists():
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done') upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
_add_result_file(candidate.name)
break break
tm.add_log(task.id, f"[OCR] 完成") tm.add_log(task.id, f"[OCR] 完成")
@@ -464,14 +644,14 @@ async def pipeline_single(
else: else:
tm.add_log(task.id, f"[错误] OCR未生成Excel文件") tm.add_log(task.id, f"[错误] OCR未生成Excel文件")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()] result_files = list(getattr(_tlocal, 'result_files', []))
tm.set_completed(task.id, result_files=result_files, message=f"全流程完成: {body.filename}") tm.set_completed(task.id, result_files=result_files, message=f"全流程完成: {body.filename}")
except Exception as e: except Exception as e:
tb = traceback.format_exc() tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}") tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"全流程任务已创建: {body.filename}") return TaskResponse(task_id=task.id, status="accepted", message=f"全流程任务已创建: {body.filename}")
@@ -486,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:
@@ -511,7 +692,7 @@ async def merge_batch(
tm.add_log(task.id, f"[合并] 失败: {e}") tm.add_log(task.id, f"[合并] 失败: {e}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work) await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
_run_background(_bg()) _run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="批量合并任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="批量合并任务已创建")
+52 -38
View File
@@ -1,5 +1,6 @@
"""Cloud sync endpoints (Gitea-based).""" """Cloud sync endpoints (Gitea-based)."""
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
@@ -23,7 +24,30 @@ def _get_sync():
from app.core.utils.cloud_sync import GiteaSync from app.core.utils.cloud_sync import GiteaSync
from app.config.settings import ConfigManager from app.config.settings import ConfigManager
cfg = ConfigManager() cfg = ConfigManager()
return GiteaSync(cfg) return GiteaSync.from_config(cfg)
def _run_sync_in_thread(tm, task_id, action_name, sync_method):
"""Run a blocking sync operation in a thread."""
def _run():
try:
tm.update_progress(task_id, 10, "正在初始化同步...")
sync = _get_sync()
if sync is None:
tm.set_failed(task_id, "Gitea 配置不完整,请先在系统配置中设置 base_url/owner/repo/token")
return
tm.update_progress(task_id, 30, f"正在{action_name}文件...")
tm.add_log(task_id, f"[{action_name}] 开始{action_name}")
result = sync_method(sync)
tm.add_log(task_id, f"[{action_name}] 完成: {result}")
tm.set_completed(task_id, message=f"{action_name}完成")
except Exception as e:
tm.set_failed(task_id, str(e))
pool = ThreadPoolExecutor(max_workers=1)
pool.submit(_run)
pool.shutdown(wait=False)
@router.post("/push", response_model=SyncResponse) @router.post("/push", response_model=SyncResponse)
@@ -33,21 +57,7 @@ async def sync_push(
): ):
tm = request.state.task_manager tm = request.state.task_manager
task = tm.create_task("推送到云端") task = tm.create_task("推送到云端")
_run_sync_in_thread(tm, task.id, "Push", lambda s: s.push())
async def _run():
try:
tm.update_progress(task.id, 10, "正在初始化同步...")
sync = _get_sync()
tm.update_progress(task.id, 30, "正在推送文件...")
tm.add_log(task.id, "[Push] 开始推送")
result = sync.push()
tm.add_log(task.id, f"[Push] 完成: {result}")
tm.set_completed(task.id, message="推送完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return SyncResponse(task_id=task.id, status="accepted", message="推送任务已创建") return SyncResponse(task_id=task.id, status="accepted", message="推送任务已创建")
@@ -58,21 +68,7 @@ async def sync_pull(
): ):
tm = request.state.task_manager tm = request.state.task_manager
task = tm.create_task("从云端拉取") task = tm.create_task("从云端拉取")
_run_sync_in_thread(tm, task.id, "Pull", lambda s: s.pull())
async def _run():
try:
tm.update_progress(task.id, 10, "正在初始化同步...")
sync = _get_sync()
tm.update_progress(task.id, 30, "正在拉取文件...")
tm.add_log(task.id, "[Pull] 开始拉取")
result = sync.pull()
tm.add_log(task.id, f"[Pull] 完成: {result}")
tm.set_completed(task.id, message="拉取完成")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio
asyncio.create_task(_run())
return SyncResponse(task_id=task.id, status="accepted", message="拉取任务已创建") return SyncResponse(task_id=task.id, status="accepted", message="拉取任务已创建")
@@ -82,12 +78,30 @@ 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="") base_url = cfg.get("Gitea", "base_url", fallback="").strip()
owner = cfg.get("Gitea", "owner", fallback="") owner = cfg.get("Gitea", "owner", fallback="").strip()
repo = cfg.get("Gitea", "repo", fallback="") repo = cfg.get("Gitea", "repo", fallback="").strip()
enabled = bool(base_url and owner and repo) token = cfg.get("Gitea", "token", fallback="").strip()
enabled = bool(base_url and owner and repo and token)
repo_url = f"{base_url}/{owner}/{repo}" if enabled else "" 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)}
+55 -5
View File
@@ -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()}
+118 -11
View File
@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -475,16 +497,22 @@ def upsert_file_relation(input_image: str = None, output_excel: str = None,
def query_file_relations(view: str = None, status: str = None, def query_file_relations(view: str = None, status: str = None,
page: int = 1, page_size: int = 50) -> tuple[list[dict], int]: page: int = 1, page_size: int = 50,
sort_by: str = None, sort_order: str = "desc",
exists_only: bool = True) -> tuple[list[dict], int]:
"""Query file relations with optional view filter and pagination. """Query file relations with optional view filter and pagination.
view='orders': only rows with result_purchase, sorted by result_purchase view='orders': only rows with result_purchase, sorted by result_purchase
view='tables': only rows with output_excel, sorted by output_excel view='tables': only rows with output_excel, sorted by output_excel
view='images': only rows with input_image, sorted by input_image view='images': only rows with input_image, sorted by input_image
view=None: all rows view=None: all rows
exists_only=True: for a given view, only return rows where the primary file
still exists on disk (input_image for images,
output_excel for tables, result_purchase for orders)
Returns (items, total). Returns (items, total).
""" """
project_root = Path(__file__).resolve().parent.parent.parent.parent
conn = sqlite3.connect(_db_path) conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
@@ -508,22 +536,20 @@ def query_file_relations(view: str = None, status: str = None,
where = (" WHERE " + " AND ".join(clauses)) if clauses else "" where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
# Count # Sort
row = conn.execute( if sort_by and sort_by in ('created_at', 'updated_at', 'input_image', 'output_excel', 'result_purchase', 'status'):
f"SELECT COUNT(*) as cnt FROM file_relations{where}", params sort_col = sort_by
).fetchone() else:
total = row[0] if row else 0 sort_col = order_by.split()[0] if order_by else 'id'
sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
# Page # Fetch all matching rows (existence filter happens in Python)
offset = (page - 1) * page_size
params.extend([page_size, offset])
rows = conn.execute( rows = conn.execute(
f"SELECT * FROM file_relations{where} ORDER BY {order_by} LIMIT ? OFFSET ?", f"SELECT * FROM file_relations{where} ORDER BY {sort_col} {sort_dir}",
params, params,
).fetchall() ).fetchall()
items = [] items = []
project_root = Path(__file__).resolve().parent.parent.parent.parent
for r in rows: for r in rows:
d = dict(r) d = dict(r)
# Check file existence # Check file existence
@@ -539,8 +565,24 @@ def query_file_relations(view: str = None, status: str = None,
d['result_exists'] = (project_root / 'data' / 'result' / d['result_purchase']).exists() d['result_exists'] = (project_root / 'data' / 'result' / d['result_purchase']).exists()
else: else:
d['result_exists'] = False d['result_exists'] = False
# Filter: when exists_only is True, only keep rows whose primary file exists
if exists_only:
if view == 'images' and not d['input_exists']:
continue
if view == 'tables' and not d['output_exists']:
continue
if view == 'orders' and not d['result_exists']:
continue
items.append(d) items.append(d)
total = len(items)
# Page (Python-side after existence filtering)
start = (page - 1) * page_size
items = items[start:start + page_size]
return items, total return items, total
finally: finally:
conn.close() conn.close()
@@ -633,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.
+20 -1
View File
@@ -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:
-13
View File
@@ -7,16 +7,3 @@
<script setup lang="ts"> <script setup lang="ts">
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script> </script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>
+17 -2
View File
@@ -1,11 +1,12 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus'
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 30000, timeout: 30000,
}) })
// Request interceptor: attach JWT token // Request interceptor: attach JWT token + AbortController support
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token) {
@@ -14,13 +15,27 @@ api.interceptors.request.use((config) => {
return config return config
}) })
// Response interceptor: handle 401 // Response interceptor: handle 401 gracefully
let isRedirecting = false
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
// Prevent redirect loops: only redirect if not already on login page
if (!window.location.pathname.startsWith('/login') && !isRedirecting) {
isRedirecting = true
ElMessage.warning('登录已过期,请重新登录')
// Use a small delay to allow the current UI to settle
setTimeout(() => {
window.location.href = '/login' window.location.href = '/login'
isRedirecting = false
}, 500)
}
} else if (error.response?.status === 403) {
ElMessage.error('没有权限执行此操作')
} else if (error.response?.status >= 500) {
ElMessage.error('服务器错误,请稍后重试')
} }
return Promise.reject(error) return Promise.reject(error)
} }
@@ -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)
}
+6 -1
View File
@@ -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 })
+126 -25
View File
@@ -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,30 +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>('')
let ws: WebSocket | null = null // --- Per-task WebSocket management ---
const taskConnections = new Map<string, TaskConnection>()
const MAX_RECONNECT = 5
function connectWebSocket(taskId: string) { function connectWebSocket(taskId: string) {
disconnectWebSocket() disconnectTaskWS(taskId)
taskConnections.set(taskId, { ws: null, reconnectAttempts: 0, reconnectTimer: null })
doConnect(taskId)
}
function doConnect(taskId: string) {
const conn = taskConnections.get(taskId)
if (!conn) return
const token = localStorage.getItem('token') const 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.onmessage = (event) => { socket.onopen = () => {
conn.reconnectAttempts = 0
}
socket.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
currentTask.value = data if (data.error) return
logs.value = data.log_lines || []
// Update in tasks list // 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
@@ -44,33 +78,92 @@ export const useProcessingStore = defineStore('processing', () => {
tasks.value.unshift(data) tasks.value.unshift(data)
} }
// Auto-disconnect on completion // 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 = () => {
console.error('WebSocket error') // Error will be followed by onclose, which handles reconnection
} }
ws.onclose = () => { socket.onclose = () => {
ws = null conn.ws = null
const task = activeTasks.value.get(taskId)
if (
task &&
(task.status === 'pending' || task.status === 'running') &&
conn.reconnectAttempts < MAX_RECONNECT
) {
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 10000)
conn.reconnectAttempts++
conn.reconnectTimer = setTimeout(() => {
if (taskConnections.has(taskId)) {
doConnect(taskId)
}
}, delay)
}
} }
} }
function disconnectTaskWS(taskId: string) {
const conn = taskConnections.get(taskId)
if (!conn) return
conn.reconnectAttempts = MAX_RECONNECT // prevent reconnect
if (conn.reconnectTimer) {
clearTimeout(conn.reconnectTimer)
conn.reconnectTimer = null
}
if (conn.ws) {
conn.ws.close()
conn.ws = null
}
taskConnections.delete(taskId)
}
/** Disconnect all task WebSockets (backward compat) */
function disconnectWebSocket() { function disconnectWebSocket() {
if (ws) { for (const taskId of Array.from(taskConnections.keys())) {
ws.close() disconnectTaskWS(taskId)
ws = null
} }
} }
async function startTask(endpoint: string, body?: any) { function removeTask(taskId: string) {
disconnectTaskWS(taskId)
activeTasks.value.delete(taskId)
const idx = tasks.value.findIndex(t => t.task_id === taskId)
if (idx >= 0) tasks.value.splice(idx, 1)
}
async function retryTask(taskId: string) {
const res = await api.post(`/api/tasks/${taskId}/retry`)
const newTaskId: string = res.data.task_id
const taskInfo: TaskInfo = {
task_id: newTaskId,
name: res.data.message || '',
status: 'pending',
progress: 0,
message: '',
result_files: [],
error: null,
log_lines: [],
}
activeTasks.value.set(newTaskId, taskInfo)
connectWebSocket(newTaskId)
return newTaskId
}
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
const res = await api.post(endpoint, body || {}) const res = await api.post(endpoint, body || {})
const taskId = res.data.task_id const taskId = res.data.task_id
currentTask.value = { taskSource.value = source
const taskInfo: TaskInfo = {
task_id: taskId, task_id: taskId,
name: res.data.message || '', name: res.data.message || '',
status: 'pending', status: 'pending',
@@ -80,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, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
}) })
+101 -2
View File
@@ -3,7 +3,15 @@
Clean · Minimal · Zinc palette Clean · Minimal · Zinc palette
*/ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); /* Use system fonts with fallbacks — avoids blocking render on Google Fonts */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: local('Inter'), local('InterVariable'),
url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2') format('woff2');
}
:root { :root {
/* ── Backgrounds ── */ /* ── Backgrounds ── */
@@ -26,6 +34,19 @@
--danger-light: #fef2f2; --danger-light: #fef2f2;
--info: #18181b; --info: #18181b;
/* ── Extended palette ── */
--indigo-500: #6366f1;
--indigo-400: #818cf8;
--indigo-100: rgba(99,102,241,0.1);
--emerald-500: #10b981;
--emerald-100: rgba(16,185,129,0.1);
--amber-400: #fbbf24;
--amber-500: #f59e0b;
--amber-600: #d97706;
--amber-100: rgba(245,158,11,0.1);
--red-500: #ef4444;
--red-100: rgba(239,68,68,0.1);
/* ── Text ── */ /* ── Text ── */
--text-primary: #18181b; --text-primary: #18181b;
--text-secondary: #525252; --text-secondary: #525252;
@@ -51,11 +72,14 @@
/* ── Typography ── */ /* ── Typography ── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* ── Transitions ── */ /* ── Transitions ── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
} }
* { * {
@@ -393,3 +417,78 @@ body {
.animate-in-delay-2 { animation-delay: 0.1s; } .animate-in-delay-2 { animation-delay: 0.1s; }
.animate-in-delay-3 { animation-delay: 0.15s; } .animate-in-delay-3 { animation-delay: 0.15s; }
.animate-in-delay-4 { animation-delay: 0.2s; } .animate-in-delay-4 { animation-delay: 0.2s; }
/*
Skeleton Loading
*/
.skeleton {
background: linear-gradient(90deg, #f4f4f5 25%, #e4e4e7 50%, #f4f4f5 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-text {
height: 14px;
margin-bottom: 8px;
width: 100%;
}
.skeleton-text.short {
width: 60%;
}
.skeleton-circle {
width: 40px;
height: 40px;
border-radius: 50%;
}
.skeleton-card {
height: 80px;
border-radius: var(--radius);
}
/*
Loading Overlay
*/
.loading-overlay {
position: relative;
pointer-events: none;
opacity: 0.7;
}
.loading-overlay::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255,255,255,0.5);
border-radius: inherit;
z-index: 1;
}
/*
Toast / Notification Transitions
*/
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
+266 -81
View File
@@ -7,48 +7,66 @@
<el-icon :size="20" color="#6366f1"><Connection /></el-icon> <el-icon :size="20" color="#6366f1"><Connection /></el-icon>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<span class="stat-value">{{ items.length }}</span> <span class="stat-value">{{ mappingItems.length + specialItems.length }}</span>
<span class="stat-label">映射规则</span> <span class="stat-label">规则</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><Right /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ mappingItems.length }}</span>
<span class="stat-label">条码映射</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Setting /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ specialItems.length }}</span>
<span class="stat-label">特殊处理</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Main table card --> <!-- Two-tab layout matching GUI -->
<div class="card animate-in animate-in-delay-1"> <div class="card animate-in animate-in-delay-1">
<div class="card-head"> <el-tabs v-model="activeTab" @tab-change="onTabChange">
<h3>条码映射管理</h3> <!-- Tab 1: 条码映射 -->
<div class="card-actions"> <el-tab-pane label="条码映射" name="mapping">
<div class="tab-toolbar">
<el-input <el-input
v-model="search" v-model="search"
placeholder="搜索条码..." placeholder="搜索条码..."
clearable clearable
style="width: 200px" style="width: 220px"
@keyup.enter="loadData" @input="debouncedSearch"
@clear="loadData" @clear="loadData"
> >
<template #prefix> <template #prefix><el-icon><Search /></el-icon></template>
<el-icon><Search /></el-icon>
</template>
</el-input> </el-input>
<div class="tab-actions">
<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="primary" @click="openAdd" :icon="Plus">新增映射</el-button> <el-button size="small" type="primary" @click="openMappingAdd">新增映射</el-button>
</div> </div>
</div> </div>
<el-table :data="items" v-loading="loading" stripe max-height="600" size="small" class="barcode-table"> <el-table :data="mappingItems" v-loading="loading" stripe max-height="500" size="small">
<el-table-column prop="barcode" label="原始条码" width="200"> <el-table-column prop="barcode" label="条码" width="200">
<template #default="{ row }"> <template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span> <span class="barcode-cell">{{ row.barcode }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="映射" width="60" align="center"> <el-table-column label="" width="40" align="center">
<template #default> <template #default>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/> <path d="M5 12h14M12 5l7 7-7 7"/>
</svg> </svg>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="target" label="目标条码" width="200"> <el-table-column label="目标条码" width="200">
<template #default="{ row }"> <template #default="{ row }">
<span class="barcode-cell target">{{ row.target }}</span> <span class="barcode-cell target">{{ row.target }}</span>
</template> </template>
@@ -56,49 +74,144 @@
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip /> <el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right"> <el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button> <el-button type="primary" link size="small" @click="editMapping(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button> <el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane>
<!-- Tab 2: 特殊处理 -->
<el-tab-pane label="特殊处理" name="special">
<div class="tab-toolbar">
<el-input
v-model="search"
placeholder="搜索条码..."
clearable
style="width: 220px"
@input="debouncedSearch"
@clear="loadData"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="tab-actions">
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="primary" @click="openSpecialAdd">新增特殊处理</el-button>
</div>
</div> </div>
<!-- Add/Edit dialog --> <el-table :data="specialItems" v-loading="loading" stripe max-height="500" size="small">
<el-dialog v-model="showAdd" :title="isEdit ? '编辑映射' : '新增映射'" width="450px" :close-on-click-modal="false"> <el-table-column prop="barcode" label="条码" width="180">
<el-form :model="form" label-width="80px"> <template #default="{ row }">
<el-form-item label="原始条码"> <span class="barcode-cell special-type">{{ row.barcode }}</span>
<el-input v-model="form.barcode" :disabled="isEdit" placeholder="输入原始条码" /> </template>
</el-table-column>
<el-table-column prop="multiplier" label="乘数" width="70" align="center">
<template #default="{ row }">
<span class="multiplier-badge">{{ row.multiplier }}</span>
</template>
</el-table-column>
<el-table-column prop="target_unit" label="目标单位" width="90" align="center" />
<el-table-column prop="fixed_price" label="固定单价" width="100" align="center">
<template #default="{ row }">
<span v-if="row.fixed_price != null" class="price-cell">{{ row.fixed_price.toFixed(4) }}</span>
<span v-else class="text-muted">--</span>
</template>
</el-table-column>
<el-table-column prop="specification" label="规格" width="90" align="center" />
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editSpecial(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<!-- Mapping add/edit dialog -->
<el-dialog v-model="showMapping" :title="mappingEdit ? '编辑条码映射' : '新增条码映射'" width="450px" :close-on-click-modal="false">
<el-form :model="mappingForm" label-width="80px">
<el-form-item label="源条码">
<el-input v-model="mappingForm.barcode" :disabled="mappingEdit" placeholder="输入原始条码" />
</el-form-item> </el-form-item>
<el-form-item label="目标条码"> <el-form-item label="目标条码">
<el-input v-model="form.target" placeholder="输入目标条码" /> <el-input v-model="mappingForm.target" placeholder="输入目标条码" />
</el-form-item> </el-form-item>
<el-form-item label="说明"> <el-form-item label="说明">
<el-input v-model="form.description" placeholder="映射说明(可选" /> <el-input v-model="mappingForm.description" placeholder="可选" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="showAdd = false">取消</el-button> <el-button @click="showMapping = false">取消</el-button>
<el-button type="primary" @click="saveMapping">保存</el-button> <el-button type="primary" @click="saveMapping">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- Special rule add/edit dialog -->
<el-dialog v-model="showSpecial" :title="specialEdit ? '编辑特殊处理' : '新增特殊处理'" width="480px" :close-on-click-modal="false">
<el-form :model="specialForm" label-width="80px">
<el-form-item label="条码">
<el-input v-model="specialForm.barcode" :disabled="specialEdit" placeholder="输入条码" />
</el-form-item>
<el-form-item label="乘数">
<el-input-number v-model="specialForm.multiplier" :min="1" :step="1" style="width: 100%" placeholder="如: 10" />
</el-form-item>
<el-form-item label="目标单位">
<el-input v-model="specialForm.targetUnit" placeholder="如: 瓶、个、对" />
</el-form-item>
<el-form-item label="固定单价">
<el-input-number v-model="specialForm.fixedPrice" :precision="4" :step="0.01" :min="0" style="width: 100%" placeholder="可选" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="specialForm.specification" placeholder="如: 1*30" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="specialForm.description" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSpecial = false">取消</el-button>
<el-button type="primary" @click="saveSpecial">保存</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } 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 } 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'
const loading = ref(false) const loading = ref(false)
const search = ref('') const search = ref('')
const items = ref<any[]>([]) const rawItems = ref<any[]>([])
const showAdd = ref(false) const activeTab = ref('mapping')
const isEdit = ref(false)
const form = reactive({ // Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
const specialItems = computed(() => rawItems.value.filter(r => r.multiplier))
// Mapping form
const showMapping = ref(false)
const mappingEdit = ref(false)
const mappingForm = reactive({ barcode: '', target: '', description: '' })
// Special form
const showSpecial = ref(false)
const specialEdit = ref(false)
const specialForm = reactive({
barcode: '', barcode: '',
target: '', multiplier: null as number | null,
targetUnit: '',
fixedPrice: null as number | null,
specification: '',
description: '', description: '',
}) })
@@ -106,7 +219,7 @@ async function loadData() {
loading.value = true loading.value = true
try { try {
const res = await api.get('/barcodes', { params: { search: search.value } }) const res = await api.get('/barcodes', { params: { search: search.value } })
items.value = res.data.items rawItems.value = res.data.items
} catch { } catch {
ElMessage.error('加载失败') ElMessage.error('加载失败')
} finally { } finally {
@@ -114,74 +227,137 @@ async function loadData() {
} }
} }
function openAdd() { function onTabChange() {
resetForm() // Keep search across tabs
showAdd.value = true
} }
function editItem(row: any) { // Mapping CRUD
isEdit.value = true function openMappingAdd() {
form.barcode = row.barcode mappingEdit.value = false
form.target = row.target mappingForm.barcode = ''
form.description = row.description || '' mappingForm.target = ''
showAdd.value = true mappingForm.description = ''
showMapping.value = true
} }
function resetForm() { function editMapping(row: any) {
form.barcode = '' mappingEdit.value = true
form.target = '' mappingForm.barcode = row.barcode
form.description = '' mappingForm.target = row.target
isEdit.value = false mappingForm.description = row.description || ''
showMapping.value = true
} }
async function saveMapping() { async function saveMapping() {
if (!form.barcode || !form.target) { if (!mappingForm.barcode || !mappingForm.target) {
ElMessage.warning('请填写条码和目标') ElMessage.warning('请填写条码和目标条码')
return return
} }
try { try {
if (isEdit.value) { if (mappingEdit.value) {
await api.put(`/barcodes/${form.barcode}`, { await api.put(`/barcodes/${mappingForm.barcode}`, {
target: form.target, target: mappingForm.target,
description: form.description, description: mappingForm.description,
}) })
ElMessage.success('已更新') ElMessage.success('已更新')
} else { } else {
await api.post('/barcodes', form) await api.post('/barcodes', {
barcode: mappingForm.barcode,
target: mappingForm.target,
description: mappingForm.description,
})
ElMessage.success('已创建') ElMessage.success('已创建')
} }
showAdd.value = false showMapping.value = false
resetForm()
loadData() loadData()
} catch (err: any) { } catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败') ElMessage.error(err.response?.data?.detail || '操作失败')
} }
} }
// Special CRUD
function openSpecialAdd() {
specialEdit.value = false
specialForm.barcode = ''
specialForm.multiplier = null
specialForm.targetUnit = ''
specialForm.fixedPrice = null
specialForm.specification = ''
specialForm.description = ''
showSpecial.value = true
}
function editSpecial(row: any) {
specialEdit.value = true
specialForm.barcode = row.barcode
specialForm.multiplier = row.multiplier
specialForm.targetUnit = row.target_unit || ''
specialForm.fixedPrice = row.fixed_price ?? null
specialForm.specification = row.specification || ''
specialForm.description = row.description || ''
showSpecial.value = true
}
async function saveSpecial() {
if (!specialForm.barcode) {
ElMessage.warning('请填写条码')
return
}
if (!specialForm.multiplier) {
ElMessage.warning('请填写乘数')
return
}
try {
const body: any = {
multiplier: specialForm.multiplier,
target_unit: specialForm.targetUnit || null,
fixed_price: specialForm.fixedPrice ?? null,
specification: specialForm.specification || null,
description: specialForm.description,
}
if (specialEdit.value) {
await api.put(`/barcodes/${specialForm.barcode}`, body)
ElMessage.success('已更新')
} else {
await api.post('/barcodes', { barcode: specialForm.barcode, ...body })
ElMessage.success('已创建')
}
showSpecial.value = false
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
// Shared
async function deleteItem(row: any) { async function deleteItem(row: any) {
try { try {
await ElMessageBox.confirm(`确定删除映射 ${row.barcode}${row.target}`, '确认') const desc = row.target ? `${row.barcode}${row.target}` : `${row.barcode}`
await ElMessageBox.confirm(`确定删除规则 ${desc}`, '确认')
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)
onUnmounted(cancelSearch)
</script> </script>
<style scoped> <style scoped>
.barcodes-page { .barcodes-page {
max-width: 1200px; width: 100%;
} }
/* ── Stats row ── */ /* ── Stats row ── */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(1, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 20px; margin-bottom: 20px;
max-width: 300px; max-width: 560px;
} }
.stat-card { .stat-card {
@@ -230,38 +406,23 @@ onMounted(loadData)
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
transition: box-shadow 0.2s;
} }
.card:hover { /* ── Tab toolbar ── */
box-shadow: var(--shadow-md); .tab-toolbar {
}
.card-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 16px; margin-bottom: 14px;
} }
.card-head h3 { .tab-actions {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
} }
/* ── Table ── */ /* ── Barcode cells ── */
.barcode-table {
border-radius: 10px;
overflow: hidden;
}
.barcode-cell { .barcode-cell {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: 13px;
@@ -271,4 +432,28 @@ onMounted(loadData)
.barcode-cell.target { .barcode-cell.target {
color: var(--success); color: var(--success);
} }
.barcode-cell.special-type {
color: var(--warning);
}
.multiplier-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
background: rgba(245,158,11,0.1);
color: var(--warning);
font-weight: 600;
font-family: var(--font-mono);
}
.price-cell {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.text-muted {
color: var(--text-muted);
}
</style> </style>
+49 -2
View File
@@ -39,9 +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: isEdited(activeTab, key, value) }"
> >
<label class="field-label">{{ key }}</label> <label class="field-label">
{{ key }}
<span v-if="isEdited(activeTab, key, value)" class="edited-dot"></span>
</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"
@@ -64,7 +77,7 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const activeTab = ref('') const activeTab = ref('')
const config = ref<Record<string, Record<string, string>>>({}) const config = ref<Record<string, Record<string, string>>>({})
const edited: Record<string, Record<string, string>> = {} const edited = reactive<Record<string, Record<string, string>>>({})
const sectionLabels: Record<string, string> = { const sectionLabels: Record<string, string> = {
API: 'API 配置', API: 'API 配置',
@@ -76,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 {
@@ -105,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 })
} }
} }
@@ -241,5 +271,22 @@ onMounted(loadConfig)
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-mono); font-family: var(--font-mono);
word-break: break-all; word-break: break-all;
display: flex;
align-items: center;
gap: 6px;
}
.field-row.edited {
background: rgba(99,102,241,0.04);
border-radius: 6px;
padding: 2px 0;
}
.edited-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--indigo-500);
flex-shrink: 0;
} }
</style> </style>
+202 -62
View File
@@ -3,6 +3,19 @@
<!-- Top stats row --> <!-- Top stats row -->
<div class="stats-row animate-in"> <div class="stats-row animate-in">
<div <div
v-if="statsLoading"
v-for="n in 4"
:key="'sk'+n"
class="stat-card"
>
<div class="skeleton skeleton-circle" style="width:44px;height:44px;border-radius:12px"></div>
<div class="stat-info" style="gap:4px">
<div class="skeleton skeleton-text" style="width:48px;height:24px"></div>
<div class="skeleton skeleton-text short" style="width:64px;height:14px"></div>
</div>
</div>
<div
v-if="!statsLoading"
class="stat-card" class="stat-card"
v-for="stat in stats" v-for="stat in stats"
:key="stat.label" :key="stat.label"
@@ -22,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">
@@ -58,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>
@@ -160,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>
@@ -169,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'
@@ -183,6 +218,7 @@ const uploadingName = ref('')
const processing = ref(false) const processing = ref(false)
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
const logBox = ref<HTMLElement>() const logBox = ref<HTMLElement>()
const statsLoading = ref(true)
const detailedStats = ref({ const detailedStats = ref({
input_images: 0, input_images: 0,
@@ -193,38 +229,10 @@ const detailedStats = ref({
total_processed: 0, total_processed: 0,
}) })
const currentTask = computed(() => ps.currentTask) const visibleTasks = computed(() =>
const logs = computed(() => ps.logs) ps.taskSource !== 'sync' ? ps.activeTaskList : []
)
const statusType = computed(() => { const logs = computed(() => ps.logs.slice(0, 50))
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(() => [
{ {
@@ -257,12 +265,6 @@ const stats = computed(() => [
}, },
]) ])
function fmtSize(b: number): string {
if (b < 1024) return b + ' B'
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
return (b / 1048576).toFixed(1) + ' MB'
}
function fmtTime(i: number): string { function fmtTime(i: number): string {
const d = new Date() const d = new Date()
d.setSeconds(d.getSeconds() - (logs.value.length - i)) d.setSeconds(d.getSeconds() - (logs.value.length - i))
@@ -279,12 +281,38 @@ 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
try { try {
const res = await api.get('/files/stats/detailed') const res = await api.get('/files/stats/detailed')
detailedStats.value = res.data detailedStats.value = res.data
} catch { } catch {
// silent // silent
} finally {
statsLoading.value = false
} }
} }
@@ -325,6 +353,7 @@ async function upload(files: File[]): Promise<void> {
uploading.value = true uploading.value = true
uploadPct.value = 0 uploadPct.value = 0
const uploadedFiles: { name: string; type: string }[] = [] const uploadedFiles: { name: string; type: string }[] = []
const failedFiles: string[] = []
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i] const file = files[i]
uploadingName.value = file.name uploadingName.value = file.name
@@ -341,9 +370,8 @@ async function upload(files: File[]): Promise<void> {
}) })
const typeLabel = getFileTypeLabel(file.name) const typeLabel = getFileTypeLabel(file.name)
uploadedFiles.push({ name: file.name, type: typeLabel }) uploadedFiles.push({ name: file.name, type: typeLabel })
ElMessage.success(`${file.name}${typeLabel === 'OCR' ? 'OCR识别队列' : 'Excel处理队列'}`)
} catch (err: any) { } catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`) failedFiles.push(file.name)
} }
} }
uploading.value = false uploading.value = false
@@ -351,15 +379,21 @@ async function upload(files: File[]): Promise<void> {
uploadPct.value = 0 uploadPct.value = 0
refreshStats() refreshStats()
// Auto-process: run pipeline for images, excel for Excel files // Show upload results
if (uploadedFiles.length > 0) {
ElMessage.success(`${uploadedFiles.length} 个文件上传成功`)
}
if (failedFiles.length > 0) {
ElMessage.error(`${failedFiles.length} 个文件上传失败: ${failedFiles.join(', ')}`)
}
// Auto-process: pipeline for images, excel for Excel files
if (uploadedFiles.length > 0) { if (uploadedFiles.length > 0) {
const hasImages = uploadedFiles.some(f => f.type === 'OCR') const hasImages = uploadedFiles.some(f => f.type === 'OCR')
const hasExcel = uploadedFiles.some(f => f.type === 'Excel') const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
if (hasImages) { if (hasImages) {
ElMessage.info('自动启动OCR识别...') await doAction('/processing/pipeline')
await doAction('/processing/ocr-batch')
} else if (hasExcel) { } else if (hasExcel) {
ElMessage.info('自动启动Excel处理...')
await doAction('/processing/excel') await doAction('/processing/excel')
} }
} }
@@ -380,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()
} }
} }
@@ -652,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;
@@ -673,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;
+257 -25
View File
@@ -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,9 +79,17 @@
<!-- 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">
<!-- Online indicator -->
<div v-if="!isOnline" class="offline-badge">
<span class="offline-dot"></span>
离线
</div>
<el-dropdown @command="handleCommand" trigger="click"> <el-dropdown @command="handleCommand" trigger="click">
<div class="user-chip"> <div class="user-chip">
<div class="user-avatar">{{ (authStore.username || 'U')[0].toUpperCase() }}</div> <div class="user-avatar">{{ (authStore.username || 'U')[0].toUpperCase() }}</div>
@@ -117,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>
@@ -135,12 +214,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, reactive } 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'
@@ -150,28 +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 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)
const navItems: { path: string; label: string; icon: any; badge?: string }[] = [ // Track online/offline status
{ path: '/', label: '处理中心', icon: HomeFilled }, function updateOnlineStatus() {
{ path: '/tasks', label: '任务历史', icon: Timer }, isOnline.value = navigator.onLine
{ path: '/logs', label: '日志中心', icon: Notebook }, }
{ path: '/memory', label: '记忆库', icon: Memo },
{ path: '/barcodes', label: '条码映射', icon: Connection }, // Track viewport for mobile drawer
{ path: '/config', label: '系统配置', icon: Setting }, function updateMobileState() {
{ path: '/sync', label: '云端同步', icon: Cloudy }, isMobile.value = window.innerWidth < 768
] }
// Close drawer on route change
watch(() => route.path, () => {
mobileDrawer.value = false
})
onMounted(async () => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
window.addEventListener('resize', updateMobileState)
await authStore.fetchUser()
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
window.removeEventListener('resize', updateMobileState)
})
const filesMenuOpen = ['/files'] 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': '系统配置',
@@ -189,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>
@@ -375,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;
@@ -382,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;
@@ -415,6 +601,27 @@ async function changePassword() {
color: var(--text-primary); color: var(--text-primary);
} }
/* ── Offline badge ── */
.offline-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--radius-sm);
background: rgba(239,68,68,0.1);
color: #ef4444;
font-size: 12px;
font-weight: 600;
}
.offline-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* ── Content ── */ /* ── Content ── */
.content { .content {
flex: 1; flex: 1;
@@ -444,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>
+24
View File
@@ -223,4 +223,28 @@ async function handleLogin() {
border-radius: 50%; border-radius: 50%;
background: #d4d4d8; background: #d4d4d8;
} }
/* ── Ambient background ── */
.bg-grid {
position: absolute;
inset: 0;
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px);
mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
-webkit-mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
pointer-events: none;
}
.bg-glow {
position: absolute;
width: 600px;
height: 600px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(99,102,241,0.06) 0%, transparent 70%);
pointer-events: none;
}
</style> </style>
+41 -3
View File
@@ -58,7 +58,7 @@
<el-option label="4xx" value="400" /> <el-option label="4xx" value="400" />
<el-option label="5xx" value="500" /> <el-option label="5xx" value="500" />
</el-select> </el-select>
<el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @keyup.enter="loadData" @clear="loadData"> <el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @input="debouncedSearch" @clear="loadData">
<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>
@@ -99,10 +99,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' 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'
const loading = ref(false) const loading = ref(false)
const searchPath = ref('') const searchPath = ref('')
@@ -115,6 +116,9 @@ const total = ref(0)
const logStats = reactive({ today_count: 0, error_count: 0, avg_duration_ms: 0, error_rate: 0 }) const logStats = reactive({ today_count: 0, error_count: 0, avg_duration_ms: 0, error_rate: 0 })
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
function formatTime(iso: string) { function formatTime(iso: string) {
if (!iso) return '-' if (!iso) return '-'
const d = new Date(iso) const d = new Date(iso)
@@ -148,13 +152,16 @@ 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(() => {
loadData() loadData()
loadStats() loadStats()
}) })
onUnmounted(cancelSearch)
</script> </script>
<style scoped> <style scoped>
@@ -299,4 +306,35 @@ onMounted(() => {
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>
+122 -24
View File
@@ -8,7 +8,7 @@
</div> </div>
<div class="stat-info"> <div class="stat-info">
<span class="stat-value">{{ total }}</span> <span class="stat-value">{{ total }}</span>
<span class="stat-label">总记录</span> <span class="stat-label">总记录</span>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -17,16 +17,25 @@
</div> </div>
<div class="stat-info"> <div class="stat-info">
<span class="stat-value">{{ highConfidence }}</span> <span class="stat-value">{{ highConfidence }}</span>
<span class="stat-label">置信度</span> <span class="stat-label">可信 (&gt;50)</span>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)"> <div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon> <el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
</div> </div>
<div class="stat-info">
<span class="stat-value">{{ mediumConfidence }}</span>
<span class="stat-label">中可信 (10~50)</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
<el-icon :size="20" color="#ef4444"><Warning /></el-icon>
</div>
<div class="stat-info"> <div class="stat-info">
<span class="stat-value">{{ lowConfidence }}</span> <span class="stat-value">{{ lowConfidence }}</span>
<span class="stat-label">置信度</span> <span class="stat-label">可信 (&lt;10)</span>
</div> </div>
</div> </div>
</div> </div>
@@ -41,7 +50,7 @@
placeholder="搜索条码或名称..." placeholder="搜索条码或名称..."
clearable clearable
style="width: 240px" style="width: 240px"
@keyup.enter="loadData" @input="debouncedSearch"
@clear="loadData" @clear="loadData"
> >
<template #prefix> <template #prefix>
@@ -49,6 +58,7 @@
</template> </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="primary" @click="openAdd">新增记录</el-button>
<el-button size="small" type="warning" plain @click="reimport">重新导入</el-button> <el-button size="small" type="warning" plain @click="reimport">重新导入</el-button>
</div> </div>
</div> </div>
@@ -67,22 +77,35 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip /> <el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="120" /> <el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="unit" label="单位" width="80" /> <el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="price" label="价" width="100"> <el-table-column label="价" width="100">
<template #default="{ row }"> <template #default="{ row }">
<span class="price-cell">{{ row.price != null ? row.price.toFixed(4) : '-' }}</span> <span class="price-cell">{{ row.avg_price != null ? row.avg_price.toFixed(4) : '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="confidence" label="置信度" width="100"> <el-table-column label="价格范围" width="140">
<template #default="{ row }">
<span v-if="row.price_count > 0" class="price-range">
{{ row.min_price?.toFixed(2) }}~{{ row.max_price?.toFixed(2) }}
</span>
<span v-else class="text-muted">--</span>
</template>
</el-table-column>
<el-table-column label="记录次数" width="90" align="center">
<template #default="{ row }">
<span :class="row.price_count > 3 ? 'count-high' : 'count-low'">{{ row.price_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="可信度" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span class="confidence-badge" :class="confCls(row.confidence)"> <span class="confidence-badge" :class="confCls(row.confidence)">
{{ row.confidence }} {{ row.confidence }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="source" label="来源" width="80" /> <el-table-column prop="source" label="来源" width="75" align="center" />
<el-table-column prop="use_count" label="使用次数" width="90" /> <el-table-column prop="use_count" label="出现" width="60" align="center" />
<el-table-column label="操作" width="130" fixed="right"> <el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button> <el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
@@ -104,16 +127,16 @@
</div> </div>
<!-- Edit dialog --> <!-- Edit dialog -->
<el-dialog v-model="showEdit" title="编辑记忆记录" width="480px" :close-on-click-modal="false"> <el-dialog v-model="showEdit" :title="isAdd ? '新增记忆记录' : '编辑记忆记录'" width="480px" :close-on-click-modal="false">
<el-form :model="editForm" label-width="80px"> <el-form :model="editForm" label-width="80px">
<el-form-item label="条码"> <el-form-item label="条码">
<el-input :model-value="editForm.barcode" disabled /> <el-input v-model="editForm.barcode" :disabled="!isAdd" />
</el-form-item> </el-form-item>
<el-form-item label="名称"> <el-form-item label="名称">
<el-input v-model="editForm.name" /> <el-input v-model="editForm.name" />
</el-form-item> </el-form-item>
<el-form-item label="规格"> <el-form-item label="规格">
<el-input v-model="editForm.spec" /> <el-input v-model="editForm.specification" />
</el-form-item> </el-form-item>
<el-form-item label="单位"> <el-form-item label="单位">
<el-input v-model="editForm.unit" /> <el-input v-model="editForm.unit" />
@@ -134,10 +157,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } 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, 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'
const loading = ref(false) const loading = ref(false)
const search = ref('') const search = ref('')
@@ -146,22 +170,26 @@ const page = ref(1)
const pageSize = ref(50) const pageSize = ref(50)
const total = ref(0) const total = ref(0)
const highConfidence = computed(() => items.value.filter(i => i.confidence >= 80).length) // Confidence stats from server-side (not just current page)
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 50).length) const confidenceStats = reactive({ high: 0, medium: 0, low: 0, total: 0 })
const highConfidence = computed(() => confidenceStats.high)
const mediumConfidence = computed(() => confidenceStats.medium)
const lowConfidence = computed(() => confidenceStats.low)
const showEdit = ref(false) const showEdit = ref(false)
const isAdd = ref(false)
const editForm = reactive({ const editForm = reactive({
barcode: '', barcode: '',
name: '', name: '',
spec: '', specification: '',
unit: '', unit: '',
price: 0, price: 0,
confidence: 0, confidence: 50,
}) })
function confCls(c: number) { function confCls(c: number) {
if (c >= 80) return 'high' if (c > 50) return 'high'
if (c >= 50) return 'mid' if (c >= 10) return 'mid'
return 'low' return 'low'
} }
@@ -173,6 +201,19 @@ async function loadData() {
}) })
items.value = res.data.items items.value = res.data.items
total.value = res.data.total total.value = res.data.total
// Update confidence stats from API response if available
if (res.data.stats) {
confidenceStats.high = res.data.stats.high || 0
confidenceStats.medium = res.data.stats.medium || 0
confidenceStats.low = res.data.stats.low || 0
confidenceStats.total = res.data.stats.total || 0
} else {
// Fallback: stats not available from server, default to 0
confidenceStats.high = 0
confidenceStats.medium = 0
confidenceStats.low = 0
confidenceStats.total = 0
}
} catch (err: any) { } catch (err: any) {
ElMessage.error('加载失败') ElMessage.error('加载失败')
} finally { } finally {
@@ -180,10 +221,28 @@ async function loadData() {
} }
} }
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(() => {
page.value = 1
loadData()
}, 400)
function openAdd() {
isAdd.value = true
editForm.barcode = ''
editForm.name = ''
editForm.specification = ''
editForm.unit = ''
editForm.price = 0
editForm.confidence = 50
showEdit.value = true
}
function editItem(row: any) { function editItem(row: any) {
isAdd.value = false
editForm.barcode = row.barcode editForm.barcode = row.barcode
editForm.name = row.name || '' editForm.name = row.name || ''
editForm.spec = row.spec || '' editForm.specification = row.specification || ''
editForm.unit = row.unit || '' editForm.unit = row.unit || ''
editForm.price = row.price || 0 editForm.price = row.price || 0
editForm.confidence = row.confidence || 0 editForm.confidence = row.confidence || 0
@@ -191,15 +250,31 @@ function editItem(row: any) {
} }
async function saveEdit() { async function saveEdit() {
if (!editForm.barcode) {
ElMessage.warning('请输入条码')
return
}
try { try {
if (isAdd.value) {
await api.post('/memory', {
barcode: editForm.barcode,
name: editForm.name,
specification: editForm.specification,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
ElMessage.success('添加成功')
} else {
await api.put(`/memory/${editForm.barcode}`, { await api.put(`/memory/${editForm.barcode}`, {
name: editForm.name, name: editForm.name,
spec: editForm.spec, specification: editForm.specification,
unit: editForm.unit, unit: editForm.unit,
price: editForm.price, price: editForm.price,
confidence: editForm.confidence, confidence: editForm.confidence,
}) })
ElMessage.success('保存成功') ElMessage.success('保存成功')
}
showEdit.value = false showEdit.value = false
loadData() loadData()
} catch (err: any) { } catch (err: any) {
@@ -213,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() {
@@ -233,6 +310,7 @@ async function reimport() {
} }
onMounted(loadData) onMounted(loadData)
onUnmounted(cancelSearch)
</script> </script>
<style scoped> <style scoped>
@@ -243,7 +321,7 @@ onMounted(loadData)
/* ── Stats row ── */ /* ── Stats row ── */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -363,6 +441,26 @@ onMounted(loadData)
color: #ef4444; color: #ef4444;
} }
.price-range {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.count-high {
font-weight: 600;
color: var(--success);
}
.count-low {
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
/* ── Pagination ── */ /* ── Pagination ── */
.pagination-bar { .pagination-bar {
display: flex; display: flex;
+39 -10
View File
@@ -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,9 +104,12 @@ 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(() => processingStore.currentTask) const currentTask = computed(() => {
if (processingStore.taskSource === 'sync') return processingStore.currentTask
return null
})
const logs = computed(() => processingStore.logs) const logs = computed(() => processingStore.logs)
const statusType = computed(() => { const statusType = computed(() => {
@@ -120,13 +131,15 @@ 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() {
syncing.value = true syncing.value = true
try { try {
await processingStore.startTask('/sync/push') await processingStore.startTask('/sync/push', undefined, 'sync')
} catch (err: any) { } catch (err: any) {
ElMessage.error(err.response?.data?.detail || '推送失败') ElMessage.error(err.response?.data?.detail || '推送失败')
} finally { } finally {
@@ -137,7 +150,7 @@ async function doPush() {
async function doPull() { async function doPull() {
syncing.value = true syncing.value = true
try { try {
await processingStore.startTask('/sync/pull') await processingStore.startTask('/sync/pull', undefined, 'sync')
} catch (err: any) { } catch (err: any) {
ElMessage.error(err.response?.data?.detail || '拉取失败') ElMessage.error(err.response?.data?.detail || '拉取失败')
} finally { } finally {
@@ -209,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;
@@ -217,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;
@@ -240,7 +269,7 @@ onMounted(checkStatus)
.sync-btn:hover:not(:disabled) { .sync-btn:hover:not(:disabled) {
border-color: var(--amber-400); border-color: var(--amber-400);
box-shadow: 0 0 0 3px rgba(255,193,7,0.08); box-shadow: 0 0 0 3px rgba(251,191,36,0.08);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -312,7 +341,7 @@ onMounted(checkStatus)
.progress-bar-fill { .progress-bar-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--amber-400), var(--amber-600)); background: linear-gradient(90deg, var(--amber-400), var(--amber-600, #d97706));
border-radius: 3px; border-radius: 3px;
transition: width 0.4s var(--ease-out); transition: width 0.4s var(--ease-out);
} }
+94 -13
View File
@@ -58,10 +58,11 @@
<el-option label="Excel处理" value="Excel标准化处理" /> <el-option label="Excel处理" value="Excel标准化处理" />
<el-option label="合并采购单" value="合并采购单" /> <el-option label="合并采购单" value="合并采购单" />
</el-select> </el-select>
<el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @keyup.enter="loadData" @clear="loadData"> <el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @input="debouncedSearch" @clear="loadData">
<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>
@@ -114,7 +116,10 @@
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files"> <div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
<h4>结果文件</h4> <h4>结果文件</h4>
<div v-for="f in detailTask.result_files" :key="f" class="file-chip">{{ f }}</div> <div v-for="f in detailTask.result_files" :key="f" class="file-row-detail">
<span class="file-path-text">{{ f }}</span>
<el-button size="small" @click="copyPath(f)">复制路径</el-button>
</div>
</div> </div>
<div class="detail-logs"> <div class="detail-logs">
@@ -133,10 +138,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } 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'
const loading = ref(false) const loading = ref(false)
const search = ref('') const search = ref('')
@@ -149,6 +155,9 @@ const total = ref(0)
const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 0 }) const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 0 })
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
const showDetailDialog = ref(false) const showDetailDialog = ref(false)
const detailTask = ref<any>(null) const detailTask = ref<any>(null)
@@ -188,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) {
@@ -196,6 +207,39 @@ function showDetail(row: any) {
showDetailDialog.value = true showDetailDialog.value = true
} }
async function copyPath(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制路径')
} catch {
ElMessage.error('复制失败')
}
}
async function deleteTask(row: any) {
try {
await ElMessageBox.confirm(`确定删除任务 #${row.id}`, '确认删除')
await api.delete(`/tasks/${row.id}`)
ElMessage.success('已删除')
loadData()
loadStats()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '删除失败')
}
}
async function clearAllTasks() {
try {
await ElMessageBox.confirm('确定清除所有任务历史记录?此操作不可撤销。', '确认清除', { type: 'warning' })
await api.delete('/tasks')
ElMessage.success('已清除所有任务历史')
loadData()
loadStats()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '清除失败')
}
}
async function retryTask(row: any) { async function retryTask(row: any) {
try { try {
await api.post(`/tasks/${row.id}/retry`) await api.post(`/tasks/${row.id}/retry`)
@@ -211,6 +255,7 @@ onMounted(() => {
loadData() loadData()
loadStats() loadStats()
}) })
onUnmounted(cancelSearch)
</script> </script>
<style scoped> <style scoped>
@@ -394,15 +439,20 @@ onMounted(() => {
margin-bottom: 8px; margin-bottom: 8px;
} }
.file-chip { .file-row-detail {
display: inline-block; display: flex;
padding: 4px 10px; align-items: center;
background: rgba(16,185,129,0.08); justify-content: space-between;
padding: 6px 10px;
background: #f9fafb;
border: 1px solid var(--border-light);
border-radius: 6px; border-radius: 6px;
font-size: 12px; margin-bottom: 6px;
}
.file-path-text {
font-size: 13px;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--success); color: var(--text-primary);
margin: 0 4px 4px 0;
} }
.log-box { .log-box {
@@ -429,4 +479,35 @@ onMounted(() => {
.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>
+169 -31
View File
@@ -6,6 +6,7 @@
<el-tag type="info" size="small"> {{ total }} </el-tag> <el-tag type="info" size="small"> {{ total }} </el-tag>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-button @click="triggerUpload">上传图片</el-button>
<el-button type="primary" :disabled="!selected.length" @click="batchPipeline"> <el-button type="primary" :disabled="!selected.length" @click="batchPipeline">
批量生成采购单 ({{ selected.length }}) 批量生成采购单 ({{ selected.length }})
</el-button> </el-button>
@@ -18,6 +19,17 @@
<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
ref="uploadInput"
type="file"
multiple
accept=".jpg,.jpeg,.png,.bmp"
hidden
@change="handleUpload"
/>
</div> </div>
</div> </div>
@@ -25,6 +37,7 @@
:data="items" :data="items"
v-loading="loading" v-loading="loading"
@selection-change="onSelect" @selection-change="onSelect"
@sort-change="onSortChange"
stripe stripe
style="width: 100%" style="width: 100%"
> >
@@ -63,21 +76,39 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag> <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" align="center"> <el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="pipelineFile(row)"> <span class="time-text">{{ fmtTime(row.created_at) }}</span>
生成采购单 </template>
</el-button> </el-table-column>
<el-button link type="primary" size="small" @click="ocrFile(row)"> <el-table-column label="操作" width="370" fixed="right">
仅OCR <template #default="{ row }">
</el-button> <el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)"> <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> <el-button link type="primary" size="small" @click="pipelineFile(row)">生成采购单</el-button>
<el-button link type="primary" size="small" @click="ocrFile(row)">仅OCR</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
<div class="preview-body">
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
</div>
</el-dialog>
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="showDetailDlg = false">
<div class="preview-body">
<div class="detail-logs">
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
</div>
</div>
</el-dialog>
<div class="pagination-wrap"> <div class="pagination-wrap">
<el-pagination <el-pagination
v-model:current-page="page" v-model:current-page="page"
@@ -94,36 +125,87 @@
import { ref, onMounted } from 'vue' 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 { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
import { useFilePreview } from '../../composables/useFilePreview'
import api from '../../api' import api from '../../api'
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)
const page = ref(1) const page = ref(1)
const pageSize = 50 const pageSize = 50
const loading = ref(false) const loading = ref(true)
const selected = ref<any[]>([]) const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
const uploadInput = ref<HTMLInputElement>()
function statusType(s: string) { const showDetailDlg = ref(false)
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' } const detailLogs = ref<string[]>([])
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() { 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 } }) 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) {
const fname = row.input_image || row.output_excel || row.result_purchase
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
await openPreview(dir, fname)
}
function showDetail(row: any) {
const fname = row.input_image || row.output_excel || row.result_purchase
const stem = fname.replace(/\.[^.]+$/, '')
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
showDetailDlg.value = true
}
function onSortChange({ prop, order }: any) {
if (prop === 'created_at') {
sortBy.value = 'created_at'
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
sortBy.value = ''
sortOrder.value = 'desc'
}
loadData()
}
function triggerUpload() {
uploadInput.value?.click()
}
async function handleUpload(e: Event) {
const el = e.target as HTMLInputElement
if (!el.files || !el.files.length) return
for (const file of Array.from(el.files)) {
const fd = new FormData()
fd.append('file', file)
try {
await api.post('/files/upload?target=input', fd)
ElMessage.success(`已上传: ${file.name}`)
} catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`)
}
}
el.value = ''
loadData()
}
async function pipelineFile(row: any) { async function pipelineFile(row: any) {
try { try {
const res = await api.post('/processing/pipeline-single', { filename: row.input_image }) const res = await api.post('/processing/pipeline-single', { filename: row.input_image })
@@ -158,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() {
@@ -193,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)
@@ -241,9 +343,45 @@ onMounted(loadData)
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
.time-text {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.pagination-wrap { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; margin-top: 16px;
} }
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
.preview-table tr:nth-child(even) { background:#fafafa }
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
.detail-line { color:#a1a1aa }
.detail-line.err { color:#ef4444 }
.detail-line.ok { color:#22c55e }
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
/* ── Responsive ── */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-actions {
flex-wrap: wrap;
gap: 6px;
}
.header-actions .el-button {
font-size: 12px;
}
}
</style> </style>
+169 -28
View File
@@ -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>
@@ -22,6 +25,7 @@
:data="items" :data="items"
v-loading="loading" v-loading="loading"
@selection-change="onSelect" @selection-change="onSelect"
@sort-change="onSortChange"
stripe stripe
style="width: 100%" style="width: 100%"
> >
@@ -60,18 +64,48 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag> <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140" align="center"> <el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadFile(row)"> <span class="time-text">{{ fmtTime(row.created_at) }}</span>
下载 </template>
</el-button> </el-table-column>
<el-button link type="danger" size="small" @click="deleteFile(row)"> <el-table-column label="操作" width="240" fixed="right">
删除 <template #default="{ 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="downloadFile(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>
<!-- Preview dialog -->
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
<div class="preview-body">
<div v-if="previewType === 'image'" class="preview-image-wrap">
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
</div>
<div v-else-if="previewType === 'excel'" class="preview-table-wrap">
<table class="preview-table">
<tr v-for="(row, ri) in previewRows" :key="ri">
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
</tr>
</table>
</div>
<div v-else style="text-align:center;color:var(--text-muted);padding:40px">暂无可预览内容</div>
</div>
</el-dialog>
<!-- Detail dialog -->
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog">
<div class="preview-body">
<div class="detail-logs">
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
</div>
</div>
</el-dialog>
<div class="pagination-wrap"> <div class="pagination-wrap">
<el-pagination <el-pagination
v-model:current-page="page" v-model:current-page="page"
@@ -88,36 +122,69 @@
import { ref, onMounted } from 'vue' 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 { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
import { useFilePreview } from '../../composables/useFilePreview'
import api from '../../api' import api from '../../api'
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)
const page = ref(1) const page = ref(1)
const pageSize = 50 const pageSize = 50
const loading = ref(false) const loading = ref(true)
const selected = ref<any[]>([]) const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
function statusType(s: string) { // Detail
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' } const showDetailDlg = ref(false)
return m[s] || 'info' const detailLogs = ref<string[]>([])
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
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 } }) 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) {
const fname = row.result_purchase || row.output_excel || row.input_image
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
await openPreview(dir, fname)
}
function showDetail(row: any) {
const fname = row.result_purchase || row.output_excel || row.input_image
const stem = fname.replace(/\.[^.]+$/, '')
// Filter logs from the processing store that mention this file
detailLogs.value = (processingStore.logs || []).filter(
(l: string) => l.includes(fname) || l.includes(stem)
)
showDetailDlg.value = true
}
function onSortChange({ prop, order }: any) {
if (prop === 'created_at') {
sortBy.value = 'created_at'
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
sortBy.value = ''
sortOrder.value = 'desc'
}
loadData()
}
async function downloadFile(row: any) { async function downloadFile(row: any) {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank') window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
@@ -130,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() {
@@ -156,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)
@@ -204,9 +291,63 @@ onMounted(loadData)
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
.time-text {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.pagination-wrap { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; margin-top: 16px;
} }
.preview-table td {
border: 1px solid var(--border-light);
padding: 4px 8px;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-table tr:nth-child(even) { background: #fafafa; }
.preview-table tr:first-child { background: #f0f0f0; font-weight: 600; }
.detail-logs {
max-height: 60vh;
overflow-y: auto;
background: #09090b;
border-radius: 8px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.8;
}
.detail-line { color: #a1a1aa; }
.detail-line.err { color: #ef4444; }
.detail-line.ok { color: #22c55e; }
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex; flex-direction:column; width:96vw; height:94vh; margin:3vh 2vw; border-radius:12px!important }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1; min-height:0; padding:8px 16px 16px; overflow:hidden; display:flex; flex-direction:column }
.preview-body { flex:1; min-height:0; display:flex; flex-direction:column }
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
/* ── Responsive ── */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-actions {
flex-wrap: wrap;
gap: 6px;
}
.header-actions .el-button {
font-size: 12px;
}
}
</style> </style>
+180 -29
View File
@@ -6,15 +6,27 @@
<el-tag type="info" size="small"> {{ total }} </el-tag> <el-tag type="info" size="small"> {{ total }} </el-tag>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-button @click="triggerUpload">上传Excel</el-button>
<el-button type="primary" :disabled="!selected.length" @click="batchProcess"> <el-button type="primary" :disabled="!selected.length" @click="batchProcess">
批量处理 ({{ selected.length }}) 批量处理 ({{ selected.length }})
</el-button> </el-button>
<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>
<input
ref="uploadInput"
type="file"
multiple
accept=".xls,.xlsx"
hidden
@change="handleUpload"
/>
</div> </div>
</div> </div>
@@ -22,6 +34,7 @@
:data="items" :data="items"
v-loading="loading" v-loading="loading"
@selection-change="onSelect" @selection-change="onSelect"
@sort-change="onSortChange"
stripe stripe
style="width: 100%" style="width: 100%"
> >
@@ -60,18 +73,38 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag> <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140" align="center"> <el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="processFile(row)"> <span class="time-text">{{ fmtTime(row.created_at) }}</span>
处理 </template>
</el-button> </el-table-column>
<el-button link type="danger" size="small" @click="deleteFile(row)"> <el-table-column label="操作" width="320" fixed="right">
删除 <template #default="{ 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="downloadFile(row)">下载</el-button>
<el-button link type="primary" size="small" @click="processFile(row)">处理</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
<div class="preview-body">
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
</div>
</el-dialog>
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog">
<div class="preview-body">
<div class="detail-logs">
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
</div>
</div>
</el-dialog>
<div class="pagination-wrap"> <div class="pagination-wrap">
<el-pagination <el-pagination
v-model:current-page="page" v-model:current-page="page"
@@ -88,36 +121,87 @@
import { ref, onMounted } from 'vue' 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 { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
import { useFilePreview } from '../../composables/useFilePreview'
import api from '../../api' import api from '../../api'
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)
const page = ref(1) const page = ref(1)
const pageSize = 50 const pageSize = 50
const loading = ref(false) const loading = ref(true)
const selected = ref<any[]>([]) const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
const uploadInput = ref<HTMLInputElement>()
function statusType(s: string) { const showDetailDlg = ref(false)
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' } const detailLogs = ref<string[]>([])
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() { 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 } }) 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) {
const fname = row.output_excel || row.result_purchase || row.input_image
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
await openPreview(dir, fname)
}
function showDetail(row: any) {
const fname = row.output_excel || row.result_purchase || row.input_image
const stem = fname.replace(/\.[^.]+$/, '')
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
showDetailDlg.value = true
}
function onSortChange({ prop, order }: any) {
if (prop === 'created_at') {
sortBy.value = 'created_at'
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
sortBy.value = ''
sortOrder.value = 'desc'
}
loadData()
}
function triggerUpload() {
uploadInput.value?.click()
}
async function handleUpload(e: Event) {
const el = e.target as HTMLInputElement
if (!el.files || !el.files.length) return
for (const file of Array.from(el.files)) {
const fd = new FormData()
fd.append('file', file)
try {
await api.post('/files/upload?target=output', fd)
ElMessage.success(`已上传: ${file.name}`)
} catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`)
}
}
el.value = ''
loadData()
}
async function processFile(row: any) { async function processFile(row: any) {
try { try {
const res = await api.post('/processing/excel-single', { filename: row.output_excel }) const res = await api.post('/processing/excel-single', { filename: row.output_excel })
@@ -127,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}`, '确认')
@@ -134,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() {
@@ -150,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() {
@@ -170,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)
@@ -208,9 +323,45 @@ onMounted(loadData)
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
.time-text {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.pagination-wrap { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; margin-top: 16px;
} }
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
.preview-table tr:nth-child(even) { background:#fafafa }
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
.detail-line { color:#a1a1aa }
.detail-line.err { color:#ef4444 }
.detail-line.ok { color:#22c55e }
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
/* ── Responsive ── */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-actions {
flex-wrap: wrap;
gap: 6px;
}
.header-actions .el-button {
font-size: 12px;
}
}
</style> </style>