Compare commits
5 Commits
13ef605481
...
0c28031e81
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c28031e81 | |||
| 7dabb2ce66 | |||
| 2196a25aee | |||
| 7baf784a39 | |||
| 32af38fe2a |
@@ -15,6 +15,8 @@
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 桌面端 (GUI / CLI)
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
@@ -30,6 +32,31 @@ python headless_api.py data/input/xxx.jpg --barcode 6920584471055 --target 69205
|
||||
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`(首次登录后建议修改密码)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -50,16 +77,85 @@ python build_exe.py
|
||||
│ │ └── utils/ # 工具(日志、文件、字符串、云端同步、对话框)
|
||||
│ ├── services/ # 业务服务(订单、OCR、处理器调度)
|
||||
│ └── 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/
|
||||
│ ├── 银豹-采购单模板.xls # 输出模板(条码/采购量/赠送量/单价)
|
||||
│ └── 商品资料.xlsx # 单价校验参考数据
|
||||
├── data/
|
||||
│ ├── input/ # 输入文件
|
||||
│ ├── output/ # OCR 输出
|
||||
│ └── result/ # 最终采购单
|
||||
│ ├── result/ # 最终采购单
|
||||
│ └── web_data.db # Web 端数据库(SQLite)
|
||||
└── 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 错误自动显示用户提示
|
||||
- **表单验证**:修改密码等操作有完整的输入验证
|
||||
|
||||
## 供应商智能路由
|
||||
|
||||
| 供应商 | 识别特征 | 处理逻辑 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
|
||||
- 端到端验证:操作 → 日志记录 → 任务历史 → 文件历史
|
||||
@@ -1,211 +0,0 @@
|
||||
# 前端 Bug 修复 + 代码质量提升设计文档
|
||||
|
||||
日期: 2026-05-12
|
||||
状态: 待审核
|
||||
|
||||
## 背景
|
||||
|
||||
通过全面审计 Web 前端(Vue 3 + Element Plus),发现了 50+ 个问题。本文档聚焦于前端高优先级 Bug 修复和代码质量改进,分 3 个阶段执行。
|
||||
|
||||
## 阶段 1:关键 Bug 修复
|
||||
|
||||
### 1.1 修复 fetchUser 未调用
|
||||
|
||||
**问题**: `Layout.vue` 在 `onMounted` 时未调用 `authStore.fetchUser()`。页面刷新后 `authStore.username` 为空,头像显示 "U" 而非用户名首字母。
|
||||
|
||||
**修复**: 在 `Layout.vue` 的 `onMounted` 中添加 `await authStore.fetchUser()`。
|
||||
|
||||
**影响文件**: `web/frontend/src/views/Layout.vue`
|
||||
|
||||
### 1.2 修复静默吞错
|
||||
|
||||
**问题**: 以下文件的 catch 块完全为空,API 失败时用户无任何反馈:
|
||||
|
||||
| 文件 | 函数 | 行号 |
|
||||
|------|------|------|
|
||||
| `files/Images.vue` | `loadData()` | 165 |
|
||||
| `files/Tables.vue` | `loadData()` | 161 |
|
||||
| `files/Orders.vue` | `loadData()` | 168 |
|
||||
| `Sync.vue` | `checkStatus()` | 127 |
|
||||
| `Tasks.vue` | `loadStats()` | 207 |
|
||||
| `Logs.vue` | `loadStats()` | 164 |
|
||||
| `files/Images.vue` | `deleteFile()` | 272 |
|
||||
| `files/Tables.vue` | `deleteFile()` | 250 |
|
||||
| `files/Orders.vue` | `deleteFile()` | 242 |
|
||||
|
||||
**修复**: 所有 catch 块添加 `ElMessage.error()` 提示,使用中文错误消息。
|
||||
|
||||
**影响文件**: 上述 6 个 Vue 文件
|
||||
|
||||
### 1.3 修复 loading 状态管理
|
||||
|
||||
**问题**: `Images.vue`、`Tables.vue`、`Orders.vue` 的 `loadData()` 中 `loading.value = false` 不在 `finally` 块中。异常发生时 loading 转圈卡住。
|
||||
|
||||
**修复**: 统一改为 `try { ... } finally { loading.value = false }` 模式。
|
||||
|
||||
**影响文件**: `files/Images.vue`、`files/Tables.vue`、`files/Orders.vue`
|
||||
|
||||
### 1.4 修复内存统计回退逻辑
|
||||
|
||||
**问题**: `Memory.vue` 当 API 不返回 stats 时,从 `items.value`(当前页 50 条)计算置信度统计,显示为全局数据,严重误导用户。
|
||||
|
||||
**修复**: 移除误导性回退逻辑,当 stats 不可用时显示 "暂无统计数据" 占位。
|
||||
|
||||
**影响文件**: `web/frontend/src/views/Memory.vue`
|
||||
|
||||
### 1.5 添加全局错误处理
|
||||
|
||||
**问题**: `main.ts` 没有 `app.config.errorHandler`,未捕获的 Vue 错误只输出到 console.warn,用户无感知。
|
||||
|
||||
**修复**: 在 `main.ts` 注册全局错误处理器:
|
||||
|
||||
```typescript
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('Vue error:', err, info)
|
||||
ElMessage.error('操作失败,请稍后重试')
|
||||
}
|
||||
```
|
||||
|
||||
**影响文件**: `web/frontend/src/main.ts`
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2:代码质量 — 提取共享逻辑
|
||||
|
||||
### 2.1 提取 useDebounce composable
|
||||
|
||||
**问题**: `useDebounce` 函数在 4 个文件中完全重复(Memory.vue:166-174, Barcodes.vue:190-198, Tasks.vue:144-152, Logs.vue:107-115)。
|
||||
|
||||
**修复**: 创建 `web/frontend/src/composables/useDebounce.ts`:
|
||||
|
||||
```typescript
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useDebounce(fn: Function, delay = 300) {
|
||||
const timer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
return (...args: any[]) => {
|
||||
if (timer.value) clearTimeout(timer.value)
|
||||
timer.value = setTimeout(() => fn(...args), delay)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4 个文件改为 `import { useDebounce } from '@/composables/useDebounce'`。
|
||||
|
||||
**影响文件**:
|
||||
- 新建: `web/frontend/src/composables/useDebounce.ts`
|
||||
- 修改: `Memory.vue`、`Barcodes.vue`、`Tasks.vue`、`Logs.vue`
|
||||
|
||||
### 2.2 提取文件视图共享逻辑
|
||||
|
||||
**问题**: `Images.vue`、`Tables.vue`、`Orders.vue` 有大量重复代码:
|
||||
- `statusType()`、`statusText()`、`fmtTime()` 函数完全相同
|
||||
- 预览对话框模板 + 逻辑 + CSS 完全相同
|
||||
- 详情对话框模板 + 逻辑完全相同
|
||||
- 分页、多选、排序模式完全相同
|
||||
- 批量删除模式完全相同
|
||||
|
||||
**修复**: 创建 3 个共享 composable:
|
||||
|
||||
#### `composables/useFileUtils.ts`
|
||||
```typescript
|
||||
export function statusType(status: string): string { ... }
|
||||
export function statusText(status: string): string { ... }
|
||||
export function fmtTime(t: string): string { ... }
|
||||
```
|
||||
|
||||
#### `composables/useFilePreview.ts`
|
||||
```typescript
|
||||
export function useFilePreview() {
|
||||
// previewVisible, previewContent, previewTitle, previewLoading
|
||||
// openPreview(), closePreview()
|
||||
return { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### `composables/useFileSelection.ts`
|
||||
```typescript
|
||||
export function useFileSelection(fetchFn: Function) {
|
||||
// selectedFiles, currentPage, pageSize, total, sortProp, sortOrder
|
||||
// handleSelectionChange(), handlePageChange(), handleSortChange(), toggleSelectAll()
|
||||
return { ... }
|
||||
}
|
||||
```
|
||||
|
||||
3 个文件视图改为使用这些 composable,每个文件预计减少 100-150 行重复代码。
|
||||
|
||||
**影响文件**:
|
||||
- 新建: `composables/useFileUtils.ts`、`composables/useFilePreview.ts`、`composables/useFileSelection.ts`
|
||||
- 修改: `files/Images.vue`、`files/Tables.vue`、`files/Orders.vue`
|
||||
|
||||
### 2.3 清理死代码
|
||||
|
||||
| 文件 | 死代码 | 行号 |
|
||||
|------|--------|------|
|
||||
| `Layout.vue` | `navItems` 数组(未被引用) | 175-183 |
|
||||
| `stores/processing.ts` | `pollTaskStatus` 函数(未被调用) | 124-129 |
|
||||
| `router/index.ts` | `routeLoadingTimer` 逻辑(无消费者) | 87-94 |
|
||||
| `Barcodes.vue` | `Plus` 图标导入(未使用) | 186 |
|
||||
|
||||
**影响文件**: 上述 4 个文件
|
||||
|
||||
---
|
||||
|
||||
## 阶段 3:小改进
|
||||
|
||||
### 3.1 修改密码表单验证
|
||||
|
||||
**问题**: `Layout.vue` 的修改密码对话框无任何验证:无最小长度要求、无确认密码字段。
|
||||
|
||||
**修复**:
|
||||
- 添加 el-form 验证规则:密码最少 6 位
|
||||
- 添加确认密码字段,验证两次输入一致
|
||||
- 提交前调用 `formRef.validate()`
|
||||
|
||||
**影响文件**: `web/frontend/src/views/Layout.vue`
|
||||
|
||||
### 3.2 修复 Layout.vue 冗余代码
|
||||
|
||||
**问题**: `navigator.onLine !== false` 冗余(`navigator.onLine` 已经是 boolean)。
|
||||
|
||||
**修复**: 改为 `navigator.onLine`。
|
||||
|
||||
**影响文件**: `web/frontend/src/views/Layout.vue`
|
||||
|
||||
### 3.3 后端批量删除端点
|
||||
|
||||
**问题**: 前端批量删除是 N+1 API 调用(每个文件 2 次请求),50 个文件 = 100 次 HTTP 请求。
|
||||
|
||||
**修复**:
|
||||
- 后端添加 `POST /api/files/batch-delete` 端点,接受 `files: [{directory, filename}]` 数组
|
||||
- 前端批量删除改为单次 API 调用
|
||||
|
||||
**影响文件**:
|
||||
- 新建/修改: `web/backend/routers/files.py`
|
||||
- 修改: `files/Images.vue`、`files/Tables.vue`、`files/Orders.vue`
|
||||
|
||||
---
|
||||
|
||||
## 不在范围内
|
||||
|
||||
以下问题记录但不在本次修复范围内:
|
||||
- 响应式布局(仅 Dashboard 有 @media 查询)
|
||||
- 键盘快捷键
|
||||
- 类型感知配置编辑器
|
||||
- 后端安全问题(路径遍历、登录限流等)
|
||||
- WebSocket 认证 token 刷新
|
||||
- 产品记忆批量操作/导出
|
||||
- 任务取消支持
|
||||
|
||||
## 验证标准
|
||||
|
||||
1. 页面刷新后头像正确显示用户名首字母
|
||||
2. API 失败时用户看到错误提示 toast
|
||||
3. loading 状态不会卡住
|
||||
4. 统计数据准确或显示占位
|
||||
5. 未捕获的 Vue 错误有用户提示
|
||||
6. useDebounce 在 4 个文件中通过 import 使用,无重复定义
|
||||
7. 3 个文件视图使用共享 composable,每个文件减少 100+ 行
|
||||
8. 4 处死代码已清理
|
||||
9. 修改密码表单有验证规则和确认字段
|
||||
10. 批量删除为单次 API 调用
|
||||
@@ -234,6 +234,7 @@ async def ocr_batch(
|
||||
"""Run OCR on all images in input/."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task("批量OCR识别")
|
||||
task.metadata = {"endpoint": "/api/processing/ocr-batch", "body": {}}
|
||||
|
||||
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
|
||||
files = _list_input_files(filter_ext=list(image_exts))
|
||||
@@ -296,6 +297,7 @@ async def process_excel(
|
||||
"""Convert OCR output Excel files to standardized purchase orders."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task("Excel标准化处理")
|
||||
task.metadata = {"endpoint": "/api/processing/excel", "body": body.dict()}
|
||||
|
||||
excel_exts = {'.xls', '.xlsx'}
|
||||
if body.files:
|
||||
@@ -354,6 +356,7 @@ async def merge_orders(
|
||||
"""Merge selected purchase order files into one PosPal template."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task("合并采购单")
|
||||
task.metadata = {"endpoint": "/api/processing/merge", "body": body.dict()}
|
||||
|
||||
# If specific files provided, use them; otherwise merge all
|
||||
if body.filenames:
|
||||
@@ -399,6 +402,7 @@ async def full_pipeline(
|
||||
"""Run the full pipeline: OCR -> Excel -> Result (NO merge)."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task("一键全流程处理")
|
||||
task.metadata = {"endpoint": "/api/processing/pipeline", "body": body.dict()}
|
||||
|
||||
async def _bg():
|
||||
def do_work():
|
||||
@@ -501,6 +505,7 @@ async def ocr_single(
|
||||
"""OCR a single image file."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task(f"OCR: {body.filename}")
|
||||
task.metadata = {"endpoint": "/api/processing/ocr-single", "body": body.dict()}
|
||||
|
||||
file_path = _input_dir / body.filename
|
||||
if not file_path.is_file():
|
||||
@@ -544,6 +549,7 @@ async def excel_single(
|
||||
"""Process a single Excel file to purchase order."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task(f"Excel处理: {body.filename}")
|
||||
task.metadata = {"endpoint": "/api/processing/excel-single", "body": body.dict()}
|
||||
|
||||
file_path = _output_dir / body.filename
|
||||
if not file_path.is_file():
|
||||
@@ -582,6 +588,7 @@ async def pipeline_single(
|
||||
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task(f"全流程: {body.filename}")
|
||||
task.metadata = {"endpoint": "/api/processing/pipeline-single", "body": body.dict()}
|
||||
|
||||
file_path = _input_dir / body.filename
|
||||
if not file_path.is_file():
|
||||
@@ -659,6 +666,7 @@ async def merge_batch(
|
||||
"""Merge selected purchase order files into one PosPal template."""
|
||||
tm = _get_task_manager(request)
|
||||
task = tm.create_task("批量合并采购单")
|
||||
task.metadata = {"endpoint": "/api/processing/merge-batch", "body": body.dict()}
|
||||
|
||||
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
|
||||
if not file_paths:
|
||||
|
||||
@@ -121,7 +121,34 @@ async def retry_task(
|
||||
"""Retry a failed task by re-invoking its processing endpoint.
|
||||
|
||||
Only tasks with status ``failed`` may be retried.
|
||||
For in-memory tasks with metadata, the original endpoint and request body
|
||||
are used to faithfully reproduce the original call. For historical DB-only
|
||||
tasks, the endpoint is looked up from ``_RETRY_ROUTE_MAP`` by task name.
|
||||
"""
|
||||
tm = request.state.task_manager
|
||||
|
||||
# --- Strategy 1: in-memory task with metadata ---
|
||||
new_task = tm.retry_task(task_id)
|
||||
if new_task is not None:
|
||||
meta = new_task.metadata or {}
|
||||
endpoint = meta.get("endpoint")
|
||||
body = meta.get("body", {})
|
||||
if endpoint:
|
||||
base_url = f"http://{request.url.hostname}:{request.url.port}"
|
||||
url = f"{base_url}{endpoint}"
|
||||
auth_header = request.headers.get("authorization")
|
||||
headers: dict[str, str] = {}
|
||||
if auth_header:
|
||||
headers["authorization"] = auth_header
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||
|
||||
# Metadata present but no endpoint — fall through to DB strategy
|
||||
# (the new task was already created; caller can track it)
|
||||
return {"task_id": new_task.id, "status": "retried"}
|
||||
|
||||
# --- Strategy 2: DB-only historical task (no in-memory record) ---
|
||||
loop = asyncio.get_event_loop()
|
||||
task = await loop.run_in_executor(
|
||||
None, lambda: db_schema.query_task_by_id(task_id),
|
||||
@@ -142,18 +169,18 @@ async def retry_task(
|
||||
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}"
|
||||
url = f"{base_url}{endpoint}"
|
||||
|
||||
# Forward the Authorization header so the processing endpoint can
|
||||
# authenticate the request.
|
||||
auth_header = request.headers.get("authorization")
|
||||
headers: dict[str, str] = {}
|
||||
headers = {}
|
||||
if auth_header:
|
||||
headers["authorization"] = auth_header
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, headers=headers)
|
||||
|
||||
return resp.json()
|
||||
return {"task_id": new_task.id, "status": "retried", "original_response": resp.json()}
|
||||
|
||||
@@ -28,9 +28,10 @@ class Task:
|
||||
result_files: List[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
log_lines: List[str] = field(default_factory=list)
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
d = {
|
||||
"task_id": self.id,
|
||||
"name": self.name,
|
||||
"status": self.status.value,
|
||||
@@ -40,6 +41,9 @@ class Task:
|
||||
"error": self.error,
|
||||
"log_lines": self.log_lines[-100:],
|
||||
}
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
return d
|
||||
|
||||
|
||||
class TaskManager:
|
||||
@@ -135,6 +139,21 @@ class TaskManager:
|
||||
)
|
||||
self._schedule(self._broadcast(task_id))
|
||||
|
||||
def retry_task(self, task_id: str) -> Optional[Task]:
|
||||
"""Create a new task to retry a failed task with its original parameters.
|
||||
|
||||
Returns the new task if the original was failed and retryable, else None.
|
||||
The caller is responsible for dispatching the actual work based on
|
||||
``new_task.metadata``.
|
||||
"""
|
||||
original = self._tasks.get(task_id)
|
||||
if not original or original.status != TaskStatus.FAILED:
|
||||
return None
|
||||
new_task = self.create_task(original.name)
|
||||
if original.metadata:
|
||||
new_task.metadata = dict(original.metadata)
|
||||
return new_task
|
||||
|
||||
def set_failed(self, task_id: str, error: str):
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
export interface TaskInfo {
|
||||
@@ -13,44 +13,64 @@ export interface TaskInfo {
|
||||
log_lines: string[]
|
||||
}
|
||||
|
||||
interface TaskConnection {
|
||||
ws: WebSocket | null
|
||||
reconnectAttempts: number
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
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 logs = ref<string[]>([])
|
||||
const taskSource = ref<string>('')
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectAttempts = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let currentTaskId: string | null = null
|
||||
// --- Per-task WebSocket management ---
|
||||
const taskConnections = new Map<string, TaskConnection>()
|
||||
const MAX_RECONNECT = 5
|
||||
|
||||
function connectWebSocket(taskId: string) {
|
||||
disconnectWebSocket()
|
||||
currentTaskId = taskId
|
||||
reconnectAttempts = 0
|
||||
disconnectTaskWS(taskId)
|
||||
taskConnections.set(taskId, { ws: null, reconnectAttempts: 0, reconnectTimer: null })
|
||||
doConnect(taskId)
|
||||
}
|
||||
|
||||
function doConnect(taskId: string) {
|
||||
const conn = taskConnections.get(taskId)
|
||||
if (!conn) return
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const url = `${protocol}//${host}/ws/task/${taskId}?token=${token}`
|
||||
|
||||
ws = new WebSocket(url)
|
||||
const socket = new WebSocket(url)
|
||||
conn.ws = socket
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0
|
||||
socket.onopen = () => {
|
||||
conn.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.error) return // ignore error messages from ws
|
||||
currentTask.value = data
|
||||
logs.value = data.log_lines || []
|
||||
if (data.error) return
|
||||
|
||||
// Update activeTasks map
|
||||
activeTasks.value.set(data.task_id, data)
|
||||
|
||||
// Legacy: update tasks list
|
||||
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
|
||||
if (idx >= 0) {
|
||||
tasks.value[idx] = data
|
||||
@@ -58,30 +78,33 @@ export const useProcessingStore = defineStore('processing', () => {
|
||||
tasks.value.unshift(data)
|
||||
}
|
||||
|
||||
// Legacy: update logs for the current (most recent) task
|
||||
if (currentTask.value?.task_id === data.task_id) {
|
||||
logs.value = data.log_lines || []
|
||||
}
|
||||
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
setTimeout(() => disconnectWebSocket(), 2000)
|
||||
setTimeout(() => disconnectTaskWS(data.task_id), 2000)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
socket.onerror = () => {
|
||||
// Error will be followed by onclose, which handles reconnection
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
ws = null
|
||||
// Auto-reconnect if task is still running and not manually disconnected
|
||||
const task = currentTask.value
|
||||
socket.onclose = () => {
|
||||
conn.ws = null
|
||||
const task = activeTasks.value.get(taskId)
|
||||
if (
|
||||
currentTaskId === taskId &&
|
||||
task &&
|
||||
(task.status === 'pending' || task.status === 'running') &&
|
||||
reconnectAttempts < MAX_RECONNECT
|
||||
conn.reconnectAttempts < MAX_RECONNECT
|
||||
) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
|
||||
reconnectAttempts++
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (currentTaskId === taskId) {
|
||||
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 10000)
|
||||
conn.reconnectAttempts++
|
||||
conn.reconnectTimer = setTimeout(() => {
|
||||
if (taskConnections.has(taskId)) {
|
||||
doConnect(taskId)
|
||||
}
|
||||
}, delay)
|
||||
@@ -89,24 +112,58 @@ export const useProcessingStore = defineStore('processing', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectTaskWS(taskId: string) {
|
||||
const conn = taskConnections.get(taskId)
|
||||
if (!conn) return
|
||||
conn.reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
||||
if (conn.reconnectTimer) {
|
||||
clearTimeout(conn.reconnectTimer)
|
||||
conn.reconnectTimer = null
|
||||
}
|
||||
if (conn.ws) {
|
||||
conn.ws.close()
|
||||
conn.ws = null
|
||||
}
|
||||
taskConnections.delete(taskId)
|
||||
}
|
||||
|
||||
/** Disconnect all task WebSockets (backward compat) */
|
||||
function disconnectWebSocket() {
|
||||
currentTaskId = null
|
||||
reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
for (const taskId of Array.from(taskConnections.keys())) {
|
||||
disconnectTaskWS(taskId)
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
function removeTask(taskId: string) {
|
||||
disconnectTaskWS(taskId)
|
||||
activeTasks.value.delete(taskId)
|
||||
const idx = tasks.value.findIndex(t => t.task_id === taskId)
|
||||
if (idx >= 0) tasks.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function retryTask(taskId: string) {
|
||||
const res = await api.post(`/api/tasks/${taskId}/retry`)
|
||||
const newTaskId: string = res.data.task_id
|
||||
const taskInfo: TaskInfo = {
|
||||
task_id: newTaskId,
|
||||
name: res.data.message || '',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: '',
|
||||
result_files: [],
|
||||
error: null,
|
||||
log_lines: [],
|
||||
}
|
||||
activeTasks.value.set(newTaskId, taskInfo)
|
||||
connectWebSocket(newTaskId)
|
||||
return newTaskId
|
||||
}
|
||||
|
||||
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
|
||||
const res = await api.post(endpoint, body || {})
|
||||
const taskId = res.data.task_id
|
||||
taskSource.value = source
|
||||
currentTask.value = {
|
||||
const taskInfo: TaskInfo = {
|
||||
task_id: taskId,
|
||||
name: res.data.message || '',
|
||||
status: 'pending',
|
||||
@@ -116,10 +173,23 @@ export const useProcessingStore = defineStore('processing', () => {
|
||||
error: null,
|
||||
log_lines: [],
|
||||
}
|
||||
activeTasks.value.set(taskId, taskInfo)
|
||||
logs.value = []
|
||||
connectWebSocket(taskId)
|
||||
return taskId
|
||||
}
|
||||
|
||||
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask }
|
||||
return {
|
||||
activeTasks,
|
||||
activeTaskList,
|
||||
currentTask,
|
||||
tasks,
|
||||
logs,
|
||||
taskSource,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
startTask,
|
||||
removeTask,
|
||||
retryTask,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,27 +35,34 @@
|
||||
<div class="main-grid">
|
||||
<!-- Left column: Progress + Logs -->
|
||||
<div class="col-left">
|
||||
<!-- Progress -->
|
||||
<!-- Active tasks list -->
|
||||
<div class="card progress-card animate-in animate-in-delay-1">
|
||||
<div class="card-head">
|
||||
<h3>处理进度</h3>
|
||||
<el-tag v-if="currentTask" :type="statusType" size="small" effect="dark">
|
||||
{{ statusText }}
|
||||
<el-tag v-if="visibleTasks.length > 0" size="small" effect="dark">
|
||||
{{ visibleTasks.length }} 个任务
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTask" class="progress-area">
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-track">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:style="{ width: currentTask.progress + '%', background: statusColor }"
|
||||
></div>
|
||||
<div v-if="visibleTasks.length > 0" class="task-cards">
|
||||
<div v-for="task in visibleTasks" :key="task.task_id" class="task-card-item">
|
||||
<div class="task-card-header">
|
||||
<span class="task-name">{{ task.name }}</span>
|
||||
<el-tag :type="statusTagType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
|
||||
</div>
|
||||
<el-progress v-if="task.status === 'running' || task.status === 'pending'" :percentage="task.progress" :stroke-width="8" />
|
||||
<div v-if="task.message" class="task-message">{{ task.message }}</div>
|
||||
<!-- Error display -->
|
||||
<el-alert v-if="task.status === 'failed' && task.error" :title="task.error" type="error" show-icon :closable="false" class="task-error" />
|
||||
<!-- Actions -->
|
||||
<div class="task-card-actions">
|
||||
<el-button v-if="task.status === 'failed'" type="warning" size="small" @click="handleRetry(task.task_id)">重试</el-button>
|
||||
<el-button v-if="task.status === 'completed' || task.status === 'failed'" size="small" @click="handleDismiss(task.task_id)">关闭</el-button>
|
||||
</div>
|
||||
<!-- Log lines for this task -->
|
||||
<div v-if="task.log_lines?.length" class="task-logs">
|
||||
<div v-for="(log, i) in task.log_lines.slice(-50)" :key="i" class="log-line">{{ log }}</div>
|
||||
</div>
|
||||
<div class="progress-meta">
|
||||
<span class="progress-pct" :style="{ color: statusColor }">{{ currentTask.progress }}%</span>
|
||||
<span class="progress-msg">{{ currentTask.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
@@ -71,8 +78,11 @@
|
||||
<div class="card log-card animate-in animate-in-delay-2">
|
||||
<div class="card-head">
|
||||
<h3>处理日志</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<el-button size="small" link @click="$router.push('/tasks')">查看全部日志</el-button>
|
||||
<el-button size="small" link @click="clearLogs">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="logBox" class="log-box">
|
||||
<div v-if="logs.length === 0" class="empty-state small">
|
||||
<p>暂无日志</p>
|
||||
@@ -207,41 +217,10 @@ const detailedStats = ref({
|
||||
total_processed: 0,
|
||||
})
|
||||
|
||||
const currentTask = computed(() => {
|
||||
if (ps.taskSource !== 'sync') return ps.currentTask
|
||||
return null
|
||||
})
|
||||
const logs = computed(() => ps.logs.slice(0, 10))
|
||||
|
||||
const statusType = computed(() => {
|
||||
const m: Record<string, string> = {
|
||||
pending: 'info',
|
||||
running: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger',
|
||||
}
|
||||
return m[currentTask.value?.status || ''] || 'info'
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const m: Record<string, string> = {
|
||||
pending: '#a1a1aa',
|
||||
running: '#f97316',
|
||||
completed: '#22c55e',
|
||||
failed: '#ef4444',
|
||||
}
|
||||
return m[currentTask.value?.status || ''] || '#a1a1aa'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const m: Record<string, string> = {
|
||||
pending: '等待中',
|
||||
running: '运行中',
|
||||
completed: '已完成',
|
||||
failed: '已失败',
|
||||
}
|
||||
return m[currentTask.value?.status || ''] || ''
|
||||
})
|
||||
const visibleTasks = computed(() =>
|
||||
ps.taskSource !== 'sync' ? ps.activeTaskList : []
|
||||
)
|
||||
const logs = computed(() => ps.logs.slice(0, 50))
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
@@ -290,6 +269,29 @@ function clearLogs(): void {
|
||||
ps.logs.splice(0)
|
||||
}
|
||||
|
||||
function statusTagType(status: string): string {
|
||||
const map: Record<string, string> = { pending: 'info', running: '', completed: 'success', failed: 'danger' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { pending: '等待中', running: '运行中', completed: '已完成', failed: '失败' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
async function handleRetry(taskId: string): Promise<void> {
|
||||
try {
|
||||
await ps.retryTask(taskId)
|
||||
ElMessage.success('已重新提交任务')
|
||||
} catch {
|
||||
ElMessage.error('重试失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss(taskId: string): void {
|
||||
ps.removeTask(taskId)
|
||||
}
|
||||
|
||||
async function refreshStats(): Promise<void> {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
@@ -400,11 +402,11 @@ const runPipeline = () => doAction('/processing/pipeline')
|
||||
const runOcr = () => doAction('/processing/ocr-batch')
|
||||
const runExcel = () => doAction('/processing/excel')
|
||||
|
||||
// Auto-refresh stats when task completes
|
||||
// Auto-refresh stats when any task completes or fails
|
||||
watch(
|
||||
() => currentTask.value?.status,
|
||||
(status) => {
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
() => visibleTasks.value.map(t => t.status),
|
||||
(statuses) => {
|
||||
if (statuses.some(s => s === 'completed' || s === 'failed')) {
|
||||
refreshStats()
|
||||
}
|
||||
}
|
||||
@@ -693,6 +695,75 @@ onMounted(() => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Task cards ── */
|
||||
.task-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-card-item {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
background: #fafafa;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.task-card-item:hover {
|
||||
border-color: #d4d4d8;
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-message {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.task-logs {
|
||||
margin-top: 10px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #09090b;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-logs .log-line {
|
||||
color: #a1a1aa;
|
||||
padding: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Progress area ── */
|
||||
.progress-card {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside :width="isCollapse ? '72px' : '240px'" class="sidebar">
|
||||
<el-aside v-show="!isMobile" :width="isCollapse ? '72px' : '240px'" class="sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
|
||||
<div class="logo-mark">
|
||||
@@ -83,6 +83,9 @@
|
||||
<!-- Header -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button v-if="isMobile" class="hamburger-btn" @click="mobileDrawer = true">
|
||||
<el-icon :size="22"><MenuIcon /></el-icon>
|
||||
</button>
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
@@ -122,6 +125,82 @@
|
||||
</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="/logs">
|
||||
<el-icon><Notebook /></el-icon>
|
||||
<template #title>日志中心</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/memory">
|
||||
<el-icon><Memo /></el-icon>
|
||||
<template #title>记忆库</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/barcodes">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<template #title>条码映射</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/config">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<template #title>系统配置</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/sync">
|
||||
<el-icon><Cloudy /></el-icon>
|
||||
<template #title>云端同步</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Change password dialog -->
|
||||
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="70px">
|
||||
@@ -143,12 +222,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, FolderOpened,
|
||||
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
|
||||
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight, Menu as MenuIcon
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api'
|
||||
@@ -158,6 +237,8 @@ const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isCollapse = ref(false)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const mobileDrawer = ref(false)
|
||||
const showPwd = ref(false)
|
||||
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||
const pwdFormRef = ref<FormInstance>()
|
||||
@@ -187,18 +268,35 @@ const isOnline = ref(navigator.onLine)
|
||||
function updateOnlineStatus() {
|
||||
isOnline.value = navigator.onLine
|
||||
}
|
||||
|
||||
// Track viewport for mobile drawer
|
||||
function updateMobileState() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
// Close drawer on route change
|
||||
watch(() => route.path, () => {
|
||||
mobileDrawer.value = false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
window.addEventListener('resize', updateMobileState)
|
||||
await authStore.fetchUser()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
window.removeEventListener('resize', updateMobileState)
|
||||
})
|
||||
|
||||
const filesMenuOpen = ['/files']
|
||||
|
||||
function onMenuSelect() {
|
||||
mobileDrawer.value = false
|
||||
}
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': '处理中心',
|
||||
'/files/orders': '采购单',
|
||||
@@ -416,6 +514,18 @@ async function changePassword() {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
@@ -423,6 +533,50 @@ async function changePassword() {
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ── Hamburger button (mobile) ── */
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── Mobile drawer ── */
|
||||
.mobile-drawer :deep(.el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawer-sidebar {
|
||||
background: #09090b;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drawer-sidebar .sidebar-logo {
|
||||
padding: 20px 20px 24px;
|
||||
}
|
||||
|
||||
.drawer-sidebar .sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0 12px;
|
||||
border-right: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -506,4 +660,29 @@ async function changePassword() {
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.content {
|
||||
padding: 8px;
|
||||
}
|
||||
.topbar {
|
||||
height: 52px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -306,4 +306,35 @@ onUnmounted(cancelSearch)
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.stat-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
.card-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -453,4 +453,35 @@ onUnmounted(cancelSearch)
|
||||
|
||||
.log-line.err { color: #f87171; }
|
||||
.log-line.ok { color: #34d399; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.stat-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
.card-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,19 +92,20 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||
<div class="preview-body">
|
||||
<div 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="70%" :close-on-click-modal="false" top="5vh">
|
||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="showDetailDlg = false">
|
||||
<div class="preview-body">
|
||||
<div class="detail-logs">
|
||||
<div 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>
|
||||
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
@@ -366,4 +367,20 @@ onMounted(loadData)
|
||||
.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>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
</el-table>
|
||||
|
||||
<!-- Preview dialog -->
|
||||
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||
<div class="preview-body">
|
||||
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
||||
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
|
||||
@@ -97,14 +97,13 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||
<div class="preview-body">
|
||||
<div class="detail-logs">
|
||||
<div 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>
|
||||
<template #footer>
|
||||
<el-button @click="showDetailDlg = false">关闭</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
@@ -335,4 +334,20 @@ onMounted(loadData)
|
||||
.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>
|
||||
|
||||
@@ -88,19 +88,20 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||
<el-dialog v-model="showPreview" title="文件预览" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
|
||||
<div class="preview-body">
|
||||
<div 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="70%" :close-on-click-modal="false" top="5vh">
|
||||
<el-dialog v-model="showDetailDlg" title="处理详情" width="75%" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||
<div class="preview-body">
|
||||
<div class="detail-logs">
|
||||
<div 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>
|
||||
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
@@ -337,4 +338,20 @@ onMounted(loadData)
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user