Compare commits
16 Commits
e441ac82a8
...
13ef605481
| Author | SHA1 | Date | |
|---|---|---|---|
| 13ef605481 | |||
| ec8d0d7db6 | |||
| 17c45cab3f | |||
| 3a49780d8d | |||
| 3f8e34c07f | |||
| d94e416202 | |||
| fa43a9770e | |||
| 1a4522bd02 | |||
| 7e15431937 | |||
| 7e63dda522 | |||
| 26f6275f4e | |||
| 2d79c05cf1 | |||
| 50ee6ac5bd | |||
| 2a2b4c639e | |||
| beaf7c6203 | |||
| 7c3616ff98 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
|||||||
|
# 前端 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 调用
|
||||||
@@ -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,34 @@ async def delete_file(
|
|||||||
return {"message": f"已删除 {filename}"}
|
return {"message": f"已删除 {filename}"}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
@@ -224,6 +252,10 @@ class RelationDeleteRequest(BaseModel):
|
|||||||
ids: List[int]
|
ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeleteRequest(BaseModel):
|
||||||
|
files: list[dict]
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_relation_for_deleted_file(directory: str, filename: str):
|
def _cleanup_relation_for_deleted_file(directory: str, filename: str):
|
||||||
"""Clean up relation table when a file is deleted."""
|
"""Clean up relation table when a file is deleted."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -269,9 +301,12 @@ async def get_file_relations(
|
|||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
sort_by: Optional[str] = None,
|
sort_by: Optional[str] = None,
|
||||||
sort_order: str = "desc",
|
sort_order: str = "desc",
|
||||||
|
sync: bool = Query(True, description="Auto-sync file relations before querying"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Query file relations with optional view filter."""
|
"""Query file relations with optional view filter."""
|
||||||
|
if sync:
|
||||||
|
sync_file_relations()
|
||||||
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size,
|
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size,
|
||||||
sort_by=sort_by, sort_order=sort_order)
|
sort_by=sort_by, sort_order=sort_order)
|
||||||
return {"items": items, "total": total}
|
return {"items": items, "total": total}
|
||||||
@@ -294,6 +329,24 @@ async def sync_relations(
|
|||||||
return {"message": "文件关系表已重建"}
|
return {"message": "文件关系表已重建"}
|
||||||
|
|
||||||
|
|
||||||
|
class ResetCacheRequest(BaseModel):
|
||||||
|
files: list[dict] # [{input_image, output_excel, result_purchase}, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-cache")
|
||||||
|
async def reset_cache(
|
||||||
|
req: ResetCacheRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete output/result files and reset status to pending for reprocessing.
|
||||||
|
|
||||||
|
Each item in files should have: {input_image?, output_excel?, result_purchase?}
|
||||||
|
The corresponding files on disk are deleted, and the relation status is reset.
|
||||||
|
"""
|
||||||
|
result = reset_file_cache(req.files)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/relations")
|
@router.delete("/relations")
|
||||||
async def delete_relations(
|
async def delete_relations(
|
||||||
body: RelationDeleteRequest,
|
body: RelationDeleteRequest,
|
||||||
|
|||||||
@@ -653,6 +653,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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const debounced = (...args: Parameters<T>) => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => fn(...args), delay)
|
||||||
|
}
|
||||||
|
const cancel = () => { if (timer) clearTimeout(timer) }
|
||||||
|
return { debounced, cancel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
export function useFilePreview() {
|
||||||
|
const showPreview = ref(false)
|
||||||
|
const previewType = ref<'image' | 'excel' | ''>('')
|
||||||
|
const previewSrc = ref('')
|
||||||
|
const previewRows = ref<string[][]>([])
|
||||||
|
|
||||||
|
async function openPreview(dir: string, fname: string) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
const ct = resp.headers.get('content-type') || ''
|
||||||
|
if (ct.includes('image')) {
|
||||||
|
previewType.value = 'image'
|
||||||
|
const blob = await resp.blob()
|
||||||
|
previewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.type === 'excel') {
|
||||||
|
previewType.value = 'excel'
|
||||||
|
previewRows.value = data.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showPreview.value = true
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('预览失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPreview() {
|
||||||
|
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewSrc.value)
|
||||||
|
}
|
||||||
|
previewSrc.value = ''
|
||||||
|
previewType.value = ''
|
||||||
|
previewRows.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showPreview, previewType, previewSrc, previewRows,
|
||||||
|
openPreview, cleanupPreview
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function statusType(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
done: 'success', merged: 'success', excel_done: 'warning',
|
||||||
|
ocr_done: 'info', pending: 'info'
|
||||||
|
}
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusText(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
done: '已完成', merged: '已合并', excel_done: '已处理',
|
||||||
|
ocr_done: '已OCR', pending: '待处理'
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(t: string): string {
|
||||||
|
if (!t) return '--'
|
||||||
|
return t.replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
import './styles/global.css'
|
import './styles/global.css'
|
||||||
@@ -10,6 +10,11 @@ import router from './router'
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, _instance, info) => {
|
||||||
|
console.error('Vue error:', err, info)
|
||||||
|
ElMessage.error('操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus, { locale: zhCn })
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|||||||
@@ -83,14 +83,4 @@ router.beforeEach((to, from, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track route loading state for page transitions
|
|
||||||
let routeLoadingTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
router.afterEach(() => {
|
|
||||||
if (routeLoadingTimer) clearTimeout(routeLoadingTimer)
|
|
||||||
routeLoadingTimer = setTimeout(() => {
|
|
||||||
routeLoadingTimer = null
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -121,10 +121,5 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
return taskId
|
return taskId
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollTaskStatus(taskId: string) {
|
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask }
|
||||||
const res = await api.get(`/processing/status/${taskId}`)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -183,19 +183,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
|
import { Search, Refresh, Connection, Right, Setting } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
// Debounce helper
|
|
||||||
function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const debounced = (...args: Parameters<T>) => {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => fn(...args), delay)
|
|
||||||
}
|
|
||||||
const cancel = () => { if (timer) clearTimeout(timer) }
|
|
||||||
return { debounced, cancel }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -347,7 +337,9 @@ async function deleteItem(row: any) {
|
|||||||
await api.delete(`/barcodes/${row.barcode}`)
|
await api.delete(`/barcodes/${row.barcode}`)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
|
|||||||
@@ -124,13 +124,16 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -142,7 +145,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, reactive, onMounted, onUnmounted } 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, Notebook, FolderOpened,
|
||||||
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
|
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
|
||||||
@@ -156,32 +159,44 @@ const authStore = useAuthStore()
|
|||||||
|
|
||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
const showPwd = ref(false)
|
const showPwd = ref(false)
|
||||||
const pwdForm = reactive({ old_password: '', new_password: '' })
|
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||||
const isOnline = ref(navigator.onLine !== false)
|
const pwdFormRef = ref<FormInstance>()
|
||||||
|
const pwdRules: FormRules = {
|
||||||
|
old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirm_password: [
|
||||||
|
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: string, callback: any) => {
|
||||||
|
if (value !== pwdForm.new_password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
|
||||||
// Track online/offline status
|
// Track online/offline status
|
||||||
function updateOnlineStatus() {
|
function updateOnlineStatus() {
|
||||||
isOnline.value = navigator.onLine !== false
|
isOnline.value = navigator.onLine
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
window.addEventListener('online', updateOnlineStatus)
|
window.addEventListener('online', updateOnlineStatus)
|
||||||
window.addEventListener('offline', updateOnlineStatus)
|
window.addEventListener('offline', updateOnlineStatus)
|
||||||
|
await authStore.fetchUser()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('online', updateOnlineStatus)
|
window.removeEventListener('online', updateOnlineStatus)
|
||||||
window.removeEventListener('offline', updateOnlineStatus)
|
window.removeEventListener('offline', updateOnlineStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
const navItems: { path: string; label: string; icon: any; badge?: string }[] = [
|
|
||||||
{ path: '/', label: '处理中心', icon: HomeFilled },
|
|
||||||
{ path: '/tasks', label: '任务历史', icon: Timer },
|
|
||||||
{ path: '/logs', label: '日志中心', icon: Notebook },
|
|
||||||
{ path: '/memory', label: '记忆库', icon: Memo },
|
|
||||||
{ path: '/barcodes', label: '条码映射', icon: Connection },
|
|
||||||
{ path: '/config', label: '系统配置', icon: Setting },
|
|
||||||
{ path: '/sync', label: '云端同步', icon: Cloudy },
|
|
||||||
]
|
|
||||||
|
|
||||||
const filesMenuOpen = ['/files']
|
const filesMenuOpen = ['/files']
|
||||||
|
|
||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
@@ -208,19 +223,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
|
||||||
try {
|
await pwdFormRef.value.validate(async (valid) => {
|
||||||
await api.post('/auth/change-password', pwdForm)
|
if (!valid) return
|
||||||
ElMessage.success('密码修改成功')
|
try {
|
||||||
showPwd.value = false
|
await api.post('/auth/change-password', {
|
||||||
} catch (err: any) {
|
old_password: pwdForm.old_password,
|
||||||
ElMessage.error(err.response?.data?.detail || '修改失败')
|
new_password: pwdForm.new_password
|
||||||
}
|
})
|
||||||
|
ElMessage.success('密码修改成功')
|
||||||
|
showPwd.value = false
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.response?.data?.detail || '修改失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -103,16 +103,7 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const debounced = (...args: Parameters<T>) => {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => fn(...args), delay)
|
|
||||||
}
|
|
||||||
const cancel = () => { if (timer) clearTimeout(timer) }
|
|
||||||
return { debounced, cancel }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchPath = ref('')
|
const searchPath = ref('')
|
||||||
@@ -161,7 +152,9 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get('/logs/stats')
|
const res = await api.get('/logs/stats')
|
||||||
Object.assign(logStats, res.data)
|
Object.assign(logStats, res.data)
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('加载统计数据失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -161,17 +161,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
// Debounce utility
|
|
||||||
function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const debounced = (...args: Parameters<T>) => {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => fn(...args), delay)
|
|
||||||
}
|
|
||||||
const cancel = () => { if (timer) clearTimeout(timer) }
|
|
||||||
return { debounced, cancel }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -218,11 +208,11 @@ async function loadData() {
|
|||||||
confidenceStats.low = res.data.stats.low || 0
|
confidenceStats.low = res.data.stats.low || 0
|
||||||
confidenceStats.total = res.data.stats.total || 0
|
confidenceStats.total = res.data.stats.total || 0
|
||||||
} else {
|
} else {
|
||||||
// Fallback: compute from current page items
|
// Fallback: stats not available from server, default to 0
|
||||||
confidenceStats.high = items.value.filter(i => i.confidence > 50).length
|
confidenceStats.high = 0
|
||||||
confidenceStats.medium = items.value.filter(i => i.confidence >= 10 && i.confidence <= 50).length
|
confidenceStats.medium = 0
|
||||||
confidenceStats.low = items.value.filter(i => i.confidence < 10).length
|
confidenceStats.low = 0
|
||||||
confidenceStats.total = total.value
|
confidenceStats.total = 0
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error('加载失败')
|
||||||
@@ -298,7 +288,9 @@ async function deleteItem(row: any) {
|
|||||||
await api.delete(`/memory/${row.barcode}`)
|
await api.delete(`/memory/${row.barcode}`)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reimport() {
|
async function reimport() {
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ async function checkStatus() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get('/sync/status')
|
const res = await api.get('/sync/status')
|
||||||
syncStatus.value = res.data
|
syncStatus.value = res.data
|
||||||
} catch {}
|
} catch {
|
||||||
|
ElMessage.error('检查同步状态失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doPush() {
|
async function doPush() {
|
||||||
|
|||||||
@@ -140,16 +140,7 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import { useDebounce } from '../composables/useDebounce'
|
||||||
function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const debounced = (...args: Parameters<T>) => {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => fn(...args), delay)
|
|
||||||
}
|
|
||||||
const cancel = () => { if (timer) clearTimeout(timer) }
|
|
||||||
return { debounced, cancel }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -204,7 +195,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) {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
||||||
批量删除
|
批量删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
<input
|
<input
|
||||||
ref="uploadInput"
|
ref="uploadInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -121,9 +124,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../../stores/processing'
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -135,65 +141,28 @@ const sortBy = ref('created_at')
|
|||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
const uploadInput = ref<HTMLInputElement>()
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
const showPreview = ref(false)
|
|
||||||
const previewType = ref('')
|
|
||||||
const previewSrc = ref('')
|
|
||||||
const previewRows = ref<string[][]>([])
|
|
||||||
const showDetailDlg = ref(false)
|
const showDetailDlg = ref(false)
|
||||||
const detailLogs = ref<string[]>([])
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
|
||||||
return m[s] || 'info'
|
|
||||||
}
|
|
||||||
function statusText(s: string) {
|
|
||||||
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
|
|
||||||
return m[s] || s
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(t: string): string {
|
|
||||||
if (!t) return '--'
|
|
||||||
return t.replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {
|
||||||
loading.value = false
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
async function previewFile(row: any) {
|
async function previewFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const fname = row.input_image || row.output_excel || row.result_purchase
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
|
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
|
||||||
try {
|
await openPreview(dir, fname)
|
||||||
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
||||||
const ct = resp.headers.get('content-type') || ''
|
|
||||||
if (ct.includes('image')) {
|
|
||||||
previewType.value = 'image'
|
|
||||||
const blob = await resp.blob()
|
|
||||||
previewSrc.value = URL.createObjectURL(blob)
|
|
||||||
} else {
|
|
||||||
const data = await resp.json()
|
|
||||||
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
|
|
||||||
}
|
|
||||||
showPreview.value = true
|
|
||||||
} catch { ElMessage.error('预览失败') }
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupPreview() {
|
|
||||||
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(previewSrc.value)
|
|
||||||
}
|
|
||||||
previewSrc.value = ''
|
|
||||||
previewType.value = ''
|
|
||||||
previewRows.value = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -269,7 +238,9 @@ async function deleteFile(row: any) {
|
|||||||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchPipeline() {
|
async function batchPipeline() {
|
||||||
@@ -304,17 +275,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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -121,9 +124,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../../stores/processing'
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -134,76 +140,29 @@ const selected = ref<any[]>([])
|
|||||||
const sortBy = ref('created_at')
|
const sortBy = ref('created_at')
|
||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
|
|
||||||
// Preview
|
|
||||||
const showPreview = ref(false)
|
|
||||||
const previewType = ref('')
|
|
||||||
const previewSrc = ref('')
|
|
||||||
const previewRows = ref<string[][]>([])
|
|
||||||
|
|
||||||
// Detail
|
// Detail
|
||||||
const showDetailDlg = ref(false)
|
const showDetailDlg = ref(false)
|
||||||
const detailLogs = ref<string[]>([])
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
|
||||||
return m[s] || 'info'
|
|
||||||
}
|
|
||||||
function statusText(s: string) {
|
|
||||||
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
|
|
||||||
return m[s] || s
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(t: string): string {
|
|
||||||
if (!t) return '--'
|
|
||||||
return t.replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {
|
||||||
loading.value = false
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
async function previewFile(row: any) {
|
async function previewFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const fname = row.result_purchase || row.output_excel || row.input_image
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
|
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
|
||||||
const url = `/api/files/preview/${dir}/${encodeURIComponent(fname)}`
|
await openPreview(dir, fname)
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
||||||
const ct = resp.headers.get('content-type') || ''
|
|
||||||
|
|
||||||
if (ct.includes('image')) {
|
|
||||||
previewType.value = 'image'
|
|
||||||
const blob = await resp.blob()
|
|
||||||
previewSrc.value = URL.createObjectURL(blob)
|
|
||||||
} else {
|
|
||||||
const data = await resp.json()
|
|
||||||
if (data.type === 'excel') {
|
|
||||||
previewType.value = 'excel'
|
|
||||||
previewRows.value = data.rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showPreview.value = true
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('预览失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupPreview() {
|
|
||||||
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(previewSrc.value)
|
|
||||||
}
|
|
||||||
previewSrc.value = ''
|
|
||||||
previewType.value = ''
|
|
||||||
previewRows.value = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -239,7 +198,9 @@ async function deleteFile(row: any) {
|
|||||||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchMerge() {
|
async function batchMerge() {
|
||||||
@@ -265,17 +226,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)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<el-button :disabled="!selected.length" @click="batchDelete">
|
<el-button :disabled="!selected.length" @click="batchDelete">
|
||||||
批量删除
|
批量删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button :disabled="!selected.length" @click="resetCache">
|
||||||
|
清除处理缓存
|
||||||
|
</el-button>
|
||||||
<el-button type="danger" @click="clearAll">
|
<el-button type="danger" @click="clearAll">
|
||||||
删除全部
|
删除全部
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -117,9 +120,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
import { useProcessingStore } from '../../stores/processing'
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
|
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||||||
|
import { useFilePreview } from '../../composables/useFilePreview'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
const processingStore = useProcessingStore()
|
const processingStore = useProcessingStore()
|
||||||
|
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -131,65 +137,28 @@ const sortBy = ref('created_at')
|
|||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
const uploadInput = ref<HTMLInputElement>()
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
const showPreview = ref(false)
|
|
||||||
const previewType = ref('')
|
|
||||||
const previewSrc = ref('')
|
|
||||||
const previewRows = ref<string[][]>([])
|
|
||||||
const showDetailDlg = ref(false)
|
const showDetailDlg = ref(false)
|
||||||
const detailLogs = ref<string[]>([])
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
|
||||||
return m[s] || 'info'
|
|
||||||
}
|
|
||||||
function statusText(s: string) {
|
|
||||||
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
|
|
||||||
return m[s] || s
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(t: string): string {
|
|
||||||
if (!t) return '--'
|
|
||||||
return t.replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {
|
||||||
loading.value = false
|
ElMessage.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
async function previewFile(row: any) {
|
async function previewFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const fname = row.output_excel || row.result_purchase || row.input_image
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
||||||
try {
|
await openPreview(dir, fname)
|
||||||
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
||||||
const ct = resp.headers.get('content-type') || ''
|
|
||||||
if (ct.includes('image')) {
|
|
||||||
previewType.value = 'image'
|
|
||||||
const blob = await resp.blob()
|
|
||||||
previewSrc.value = URL.createObjectURL(blob)
|
|
||||||
} else {
|
|
||||||
const data = await resp.json()
|
|
||||||
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
|
|
||||||
}
|
|
||||||
showPreview.value = true
|
|
||||||
} catch { ElMessage.error('预览失败') }
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupPreview() {
|
|
||||||
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(previewSrc.value)
|
|
||||||
}
|
|
||||||
previewSrc.value = ''
|
|
||||||
previewType.value = ''
|
|
||||||
previewRows.value = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(row: any) {
|
function showDetail(row: any) {
|
||||||
@@ -247,7 +216,9 @@ async function deleteFile(row: any) {
|
|||||||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchProcess() {
|
async function batchProcess() {
|
||||||
@@ -263,17 +234,35 @@ async function batchProcess() {
|
|||||||
async function batchDelete() {
|
async function batchDelete() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
||||||
for (const row of selected.value) {
|
const files = selected.value
|
||||||
if (row.output_excel) {
|
.filter(r => r.output_excel)
|
||||||
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
|
.map(r => ({ directory: 'output', filename: r.output_excel }))
|
||||||
}
|
const res = await api.post('/files/batch-delete', { files })
|
||||||
if (row.id) {
|
if (res.data.errors?.length) {
|
||||||
await api.delete('/files/relations', { data: { ids: [row.id] } })
|
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||||||
}
|
} else {
|
||||||
|
ElMessage.success('批量删除完成')
|
||||||
}
|
}
|
||||||
ElMessage.success('批量删除完成')
|
|
||||||
loadData()
|
loadData()
|
||||||
} catch {}
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('批量删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetCache() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定清除选中的 ${selected.value.length} 个文件的处理缓存?删除后可重新处理。`, '确认')
|
||||||
|
const files = selected.value.map(r => ({
|
||||||
|
input_image: r.input_image,
|
||||||
|
output_excel: r.output_excel,
|
||||||
|
result_purchase: r.result_purchase,
|
||||||
|
}))
|
||||||
|
const res = await api.post('/files/reset-cache', { files })
|
||||||
|
ElMessage.success(`已清除 ${res.data.deleted_files} 个缓存文件`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error('清除缓存失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearAll() {
|
async function clearAll() {
|
||||||
@@ -283,7 +272,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user