Compare commits

..

16 Commits

Author SHA1 Message Date
houhuan 13ef605481 feat: auto-sync file list + clear processing cache
- Auto-sync file_relations on every query (files appear immediately)
- Add POST /api/files/reset-cache endpoint to delete output/result
  files and reset status to pending for reprocessing
- Add "清除处理缓存" button to all 3 file views

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:06:05 +08:00
19 changed files with 1723 additions and 267 deletions
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 调用
+54 -1
View File
@@ -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,
+65
View File
@@ -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)
}
+6 -1
View File
@@ -1,6 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus, { ElMessage } from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './styles/global.css' import './styles/global.css'
@@ -10,6 +10,11 @@ import router from './router'
const app = createApp(App) const app = createApp(App)
app.config.errorHandler = (err, _instance, info) => {
console.error('Vue error:', err, info)
ElMessage.error('操作失败,请稍后重试')
}
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(ElementPlus, { locale: zhCn }) app.use(ElementPlus, { locale: zhCn })
-10
View File
@@ -83,14 +83,4 @@ router.beforeEach((to, from, next) => {
} }
}) })
// Track route loading state for page transitions
let routeLoadingTimer: ReturnType<typeof setTimeout> | null = null
router.afterEach(() => {
if (routeLoadingTimer) clearTimeout(routeLoadingTimer)
routeLoadingTimer = setTimeout(() => {
routeLoadingTimer = null
}, 300)
})
export default router export default router
+1 -6
View File
@@ -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 }
}) })
+5 -13
View File
@@ -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)
+42 -20
View File
@@ -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
await pwdFormRef.value.validate(async (valid) => {
if (!valid) return
try { try {
await api.post('/auth/change-password', pwdForm) await api.post('/auth/change-password', {
old_password: pwdForm.old_password,
new_password: pwdForm.new_password
})
ElMessage.success('密码修改成功') ElMessage.success('密码修改成功')
showPwd.value = false showPwd.value = false
} catch (err: any) { } catch (err: any) {
ElMessage.error(err.response?.data?.detail || '修改失败') ElMessage.error(err.response?.data?.detail || '修改失败')
} }
})
} }
</script> </script>
+4 -11
View File
@@ -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(() => {
+9 -17
View File
@@ -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() {
+3 -1
View File
@@ -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() {
+4 -11
View File
@@ -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) {
+41 -52
View File
@@ -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 {
ElMessage.error('加载文件列表失败')
} finally {
loading.value = false loading.value = false
}
} }
function onSelect(rows: any[]) { selected.value = rows } function onSelect(rows: any[]) { selected.value = rows }
async function previewFile(row: any) { async function previewFile(row: any) {
const token = localStorage.getItem('token')
const fname = row.input_image || row.output_excel || row.result_purchase const fname = row.input_image || row.output_excel || row.result_purchase
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result' const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
try { await openPreview(dir, fname)
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
const ct = resp.headers.get('content-type') || ''
if (ct.includes('image')) {
previewType.value = 'image'
const blob = await resp.blob()
previewSrc.value = URL.createObjectURL(blob)
} else {
const data = await resp.json()
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
}
showPreview.value = true
} catch { ElMessage.error('预览失败') }
}
function cleanupPreview() {
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
URL.revokeObjectURL(previewSrc.value)
}
previewSrc.value = ''
previewType.value = ''
previewRows.value = []
} }
function showDetail(row: any) { function showDetail(row: any) {
@@ -269,7 +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)
+41 -62
View File
@@ -15,6 +15,9 @@
<el-button type="danger" :disabled="!selected.length" @click="batchDelete"> <el-button type="danger" :disabled="!selected.length" @click="batchDelete">
批量删除 批量删除
</el-button> </el-button>
<el-button :disabled="!selected.length" @click="resetCache">
清除处理缓存
</el-button>
</div> </div>
</div> </div>
@@ -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 {
ElMessage.error('加载文件列表失败')
} finally {
loading.value = false loading.value = false
}
} }
function onSelect(rows: any[]) { selected.value = rows } function onSelect(rows: any[]) { selected.value = rows }
async function previewFile(row: any) { async function previewFile(row: any) {
const token = localStorage.getItem('token')
const fname = row.result_purchase || row.output_excel || row.input_image const fname = row.result_purchase || row.output_excel || row.input_image
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input' const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
const url = `/api/files/preview/${dir}/${encodeURIComponent(fname)}` await openPreview(dir, fname)
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
const ct = resp.headers.get('content-type') || ''
if (ct.includes('image')) {
previewType.value = 'image'
const blob = await resp.blob()
previewSrc.value = URL.createObjectURL(blob)
} else {
const data = await resp.json()
if (data.type === 'excel') {
previewType.value = 'excel'
previewRows.value = data.rows
}
}
showPreview.value = true
} catch {
ElMessage.error('预览失败')
}
}
function cleanupPreview() {
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
URL.revokeObjectURL(previewSrc.value)
}
previewSrc.value = ''
previewType.value = ''
previewRows.value = []
} }
function showDetail(row: any) { function showDetail(row: any) {
@@ -239,7 +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)
+44 -53
View File
@@ -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 {
ElMessage.error('加载文件列表失败')
} finally {
loading.value = false loading.value = false
}
} }
function onSelect(rows: any[]) { selected.value = rows } function onSelect(rows: any[]) { selected.value = rows }
async function previewFile(row: any) { async function previewFile(row: any) {
const token = localStorage.getItem('token')
const fname = row.output_excel || row.result_purchase || row.input_image const fname = row.output_excel || row.result_purchase || row.input_image
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input' const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
try { await openPreview(dir, fname)
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
const ct = resp.headers.get('content-type') || ''
if (ct.includes('image')) {
previewType.value = 'image'
const blob = await resp.blob()
previewSrc.value = URL.createObjectURL(blob)
} else {
const data = await resp.json()
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
}
showPreview.value = true
} catch { ElMessage.error('预览失败') }
}
function cleanupPreview() {
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
URL.revokeObjectURL(previewSrc.value)
}
previewSrc.value = ''
previewType.value = ''
previewRows.value = []
} }
function showDetail(row: any) { function showDetail(row: any) {
@@ -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)