From e441ac82a86629db321a16be5b768eddf303e59e Mon Sep 17 00:00:00 2001 From: houhuan Date: Tue, 12 May 2026 18:37:23 +0800 Subject: [PATCH] Refactor processing logic and enhance error handling - Cleaned up code in processing.py by removing inline semicolons and improving readability. - Updated upsert_file_relation calls to ensure consistent handling of file relations. - Enhanced query_file_relations in db_schema.py to support filtering by file existence. - Improved API error handling in index.ts with user-friendly messages for 401 and 403 errors. - Added online/offline status tracking in Layout.vue. - Implemented debounced search functionality across multiple views to optimize performance. - Introduced loading skeletons in Dashboard.vue for better user experience during data fetching. - Enhanced file preview cleanup logic in Images.vue, Orders.vue, and Tables.vue to prevent memory leaks. - Updated global styles to include new loading and notification animations. --- web/backend/routers/memory.py | 7 ++ web/backend/routers/processing.py | 25 +++--- web/backend/services/db_schema.py | 36 ++++++--- web/frontend/src/App.vue | 13 --- web/frontend/src/api/index.ts | 21 ++++- web/frontend/src/router/index.ts | 10 +++ web/frontend/src/stores/processing.ts | 42 +++++++++- web/frontend/src/styles/global.css | 103 +++++++++++++++++++++++- web/frontend/src/views/Barcodes.vue | 21 ++++- web/frontend/src/views/Config.vue | 23 +++++- web/frontend/src/views/Dashboard.vue | 37 ++++++--- web/frontend/src/views/Layout.vue | 42 +++++++++- web/frontend/src/views/Login.vue | 24 ++++++ web/frontend/src/views/Logs.vue | 18 ++++- web/frontend/src/views/Memory.vue | 43 ++++++++-- web/frontend/src/views/Sync.vue | 9 ++- web/frontend/src/views/Tasks.vue | 18 ++++- web/frontend/src/views/files/Images.vue | 13 ++- web/frontend/src/views/files/Orders.vue | 13 ++- web/frontend/src/views/files/Tables.vue | 13 ++- 20 files changed, 455 insertions(+), 76 deletions(-) diff --git a/web/backend/routers/memory.py b/web/backend/routers/memory.py index f04b4dc..c221473 100644 --- a/web/backend/routers/memory.py +++ b/web/backend/routers/memory.py @@ -53,6 +53,7 @@ class MemoryListResponse(BaseModel): total: int page: int page_size: int + stats: Optional[Dict] = None def _get_db(): @@ -96,11 +97,17 @@ async def list_memory( start = (page - 1) * page_size page_items = results[start:start + page_size] + # Compute confidence stats from all results (not just current page) + high = sum(1 for r in results if r.get("confidence", 0) > 50) + medium = sum(1 for r in results if 10 <= r.get("confidence", 0) <= 50) + low = sum(1 for r in results if r.get("confidence", 0) < 10) + return MemoryListResponse( items=[_row_to_item(r) for r in page_items], total=total, page=page, page_size=page_size, + stats={"high": high, "medium": medium, "low": low, "total": total}, ) diff --git a/web/backend/routers/processing.py b/web/backend/routers/processing.py index 69f316c..dcb3988 100644 --- a/web/backend/routers/processing.py +++ b/web/backend/routers/processing.py @@ -265,7 +265,7 @@ async def ocr_batch( for ext in ['.xlsx', '.xls']: candidate = _output_dir / f"{out_stem}{ext}" if candidate.exists(): - upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name) + upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done') _add_result_file(candidate.name) break tm.add_log(task.id, f"[OCR] 完成: {f.name}") @@ -317,7 +317,8 @@ async def process_excel( result_path = _result_dir / result_name if result_path.exists(): tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}") - upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name) + upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') + _add_result_file(result_name) continue tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}") @@ -326,7 +327,7 @@ async def process_excel( svc.process_excel(str(f)) # Find result file if result_path.exists(): - upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name) + upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') _add_result_file(result_name) tm.add_log(task.id, f"[Excel] 完成: {f.name}") # Learn products into memory from purchase order result @@ -430,7 +431,8 @@ async def full_pipeline( for ext in ['.xlsx', '.xls']: candidate = _output_dir / f"{out_stem}{ext}" if candidate.exists(): - upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name) + upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done') + _add_result_file(candidate.name) break tm.add_log(task.id, f"[OCR] 完成: {f.name}") out_file = _output_dir / f"{out_stem}.xlsx" @@ -456,7 +458,8 @@ async def full_pipeline( result_path = _result_dir / result_name if result_path.exists(): tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}") - upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name) + upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') + _add_result_file(result_name) tm.update_progress(task.id, pct, f"跳过: {f.name}") continue @@ -464,7 +467,8 @@ async def full_pipeline( try: order_svc.process_excel(str(f)) if result_path.exists(): - upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name) + upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done') + _add_result_file(result_name) tm.add_log(task.id, f"[Excel] 完成: {f.name}") if result_path.exists(): _learn_products_from_excel(result_path, tm, task.id, source='ocr') @@ -515,7 +519,8 @@ async def ocr_single( for ext in ['.xlsx', '.xls']: candidate = _output_dir / f"{stem}{ext}" if candidate.exists(): - upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name) + upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done') + _add_result_file(candidate.name) break tm.add_log(task.id, f"[OCR] 完成: {body.filename}") result_files = list(getattr(_tlocal, 'result_files', [])) @@ -598,13 +603,15 @@ async def pipeline_single( if out_xlsx.exists() or out_xls.exists(): out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}") - upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done'); _add_result_file(out_name) + upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done') + _add_result_file(out_name) else: ocr_svc.process_image(str(file_path)) for ext in ['.xlsx', '.xls']: candidate = _output_dir / f"{stem}{ext}" if candidate.exists(): - upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name) + upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done') + _add_result_file(candidate.name) break tm.add_log(task.id, f"[OCR] 完成") diff --git a/web/backend/services/db_schema.py b/web/backend/services/db_schema.py index dc559e8..5975366 100644 --- a/web/backend/services/db_schema.py +++ b/web/backend/services/db_schema.py @@ -476,16 +476,21 @@ def upsert_file_relation(input_image: str = None, output_excel: str = None, def query_file_relations(view: str = None, status: str = None, page: int = 1, page_size: int = 50, - sort_by: str = None, sort_order: str = "desc") -> tuple[list[dict], int]: + sort_by: str = None, sort_order: str = "desc", + exists_only: bool = True) -> tuple[list[dict], int]: """Query file relations with optional view filter and pagination. view='orders': only rows with result_purchase, sorted by result_purchase view='tables': only rows with output_excel, sorted by output_excel view='images': only rows with input_image, sorted by input_image view=None: all rows + exists_only=True: for a given view, only return rows where the primary file + still exists on disk (input_image for images, + output_excel for tables, result_purchase for orders) Returns (items, total). """ + project_root = Path(__file__).resolve().parent.parent.parent.parent conn = sqlite3.connect(_db_path) conn.row_factory = sqlite3.Row try: @@ -516,22 +521,13 @@ def query_file_relations(view: str = None, status: str = None, sort_col = order_by.split()[0] if order_by else 'id' sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC' - # Count - row = conn.execute( - f"SELECT COUNT(*) as cnt FROM file_relations{where}", params - ).fetchone() - total = row[0] if row else 0 - - # Page - offset = (page - 1) * page_size - params.extend([page_size, offset]) + # Fetch all matching rows (existence filter happens in Python) rows = conn.execute( - f"SELECT * FROM file_relations{where} ORDER BY {sort_col} {sort_dir} LIMIT ? OFFSET ?", + f"SELECT * FROM file_relations{where} ORDER BY {sort_col} {sort_dir}", params, ).fetchall() items = [] - project_root = Path(__file__).resolve().parent.parent.parent.parent for r in rows: d = dict(r) # Check file existence @@ -547,8 +543,24 @@ def query_file_relations(view: str = None, status: str = None, d['result_exists'] = (project_root / 'data' / 'result' / d['result_purchase']).exists() else: d['result_exists'] = False + + # Filter: when exists_only is True, only keep rows whose primary file exists + if exists_only: + if view == 'images' and not d['input_exists']: + continue + if view == 'tables' and not d['output_exists']: + continue + if view == 'orders' and not d['result_exists']: + continue + items.append(d) + total = len(items) + + # Page (Python-side after existence filtering) + start = (page - 1) * page_size + items = items[start:start + page_size] + return items, total finally: conn.close() diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index 4767500..d883686 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -7,16 +7,3 @@ - - diff --git a/web/frontend/src/api/index.ts b/web/frontend/src/api/index.ts index 424e49e..8aa6850 100644 --- a/web/frontend/src/api/index.ts +++ b/web/frontend/src/api/index.ts @@ -1,11 +1,12 @@ import axios from 'axios' +import { ElMessage } from 'element-plus' const api = axios.create({ baseURL: '/api', timeout: 30000, }) -// Request interceptor: attach JWT token +// Request interceptor: attach JWT token + AbortController support api.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { @@ -14,13 +15,27 @@ api.interceptors.request.use((config) => { return config }) -// Response interceptor: handle 401 +// Response interceptor: handle 401 gracefully +let isRedirecting = false api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { localStorage.removeItem('token') - window.location.href = '/login' + // Prevent redirect loops: only redirect if not already on login page + if (!window.location.pathname.startsWith('/login') && !isRedirecting) { + isRedirecting = true + ElMessage.warning('登录已过期,请重新登录') + // Use a small delay to allow the current UI to settle + setTimeout(() => { + window.location.href = '/login' + isRedirecting = false + }, 500) + } + } else if (error.response?.status === 403) { + ElMessage.error('没有权限执行此操作') + } else if (error.response?.status >= 500) { + ElMessage.error('服务器错误,请稍后重试') } return Promise.reject(error) } diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts index 19b1c03..4eebc0f 100644 --- a/web/frontend/src/router/index.ts +++ b/web/frontend/src/router/index.ts @@ -83,4 +83,14 @@ router.beforeEach((to, from, next) => { } }) +// Track route loading state for page transitions +let routeLoadingTimer: ReturnType | null = null + +router.afterEach(() => { + if (routeLoadingTimer) clearTimeout(routeLoadingTimer) + routeLoadingTimer = setTimeout(() => { + routeLoadingTimer = null + }, 300) +}) + export default router diff --git a/web/frontend/src/stores/processing.ts b/web/frontend/src/stores/processing.ts index 010fd21..b81eacd 100644 --- a/web/frontend/src/stores/processing.ts +++ b/web/frontend/src/stores/processing.ts @@ -20,10 +20,19 @@ export const useProcessingStore = defineStore('processing', () => { const taskSource = ref('') let ws: WebSocket | null = null + let reconnectAttempts = 0 + let reconnectTimer: ReturnType | null = null + let currentTaskId: string | null = null + const MAX_RECONNECT = 5 function connectWebSocket(taskId: string) { disconnectWebSocket() + currentTaskId = taskId + reconnectAttempts = 0 + doConnect(taskId) + } + function doConnect(taskId: string) { const token = localStorage.getItem('token') const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host @@ -31,13 +40,17 @@ export const useProcessingStore = defineStore('processing', () => { ws = new WebSocket(url) + ws.onopen = () => { + reconnectAttempts = 0 + } + ws.onmessage = (event) => { try { const data = JSON.parse(event.data) + if (data.error) return // ignore error messages from ws currentTask.value = data logs.value = data.log_lines || [] - // Update in tasks list const idx = tasks.value.findIndex(t => t.task_id === data.task_id) if (idx >= 0) { tasks.value[idx] = data @@ -45,7 +58,6 @@ export const useProcessingStore = defineStore('processing', () => { tasks.value.unshift(data) } - // Auto-disconnect on completion if (data.status === 'completed' || data.status === 'failed') { setTimeout(() => disconnectWebSocket(), 2000) } @@ -53,15 +65,37 @@ export const useProcessingStore = defineStore('processing', () => { } ws.onerror = () => { - console.error('WebSocket error') + // Error will be followed by onclose, which handles reconnection } - ws.onclose = () => { + ws.onclose = (event) => { ws = null + // Auto-reconnect if task is still running and not manually disconnected + const task = currentTask.value + if ( + currentTaskId === taskId && + task && + (task.status === 'pending' || task.status === 'running') && + reconnectAttempts < MAX_RECONNECT + ) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000) + reconnectAttempts++ + reconnectTimer = setTimeout(() => { + if (currentTaskId === taskId) { + doConnect(taskId) + } + }, delay) + } } } function disconnectWebSocket() { + currentTaskId = null + reconnectAttempts = MAX_RECONNECT // prevent reconnect + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } if (ws) { ws.close() ws = null diff --git a/web/frontend/src/styles/global.css b/web/frontend/src/styles/global.css index 2c78ecf..790bcd8 100644 --- a/web/frontend/src/styles/global.css +++ b/web/frontend/src/styles/global.css @@ -3,7 +3,15 @@ Clean · Minimal · Zinc palette ═══════════════════════════════════════════ */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +/* Use system fonts with fallbacks — avoids blocking render on Google Fonts */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: local('Inter'), local('InterVariable'), + url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2') format('woff2'); +} :root { /* ── Backgrounds ── */ @@ -26,6 +34,19 @@ --danger-light: #fef2f2; --info: #18181b; + /* ── Extended palette ── */ + --indigo-500: #6366f1; + --indigo-400: #818cf8; + --indigo-100: rgba(99,102,241,0.1); + --emerald-500: #10b981; + --emerald-100: rgba(16,185,129,0.1); + --amber-400: #fbbf24; + --amber-500: #f59e0b; + --amber-600: #d97706; + --amber-100: rgba(245,158,11,0.1); + --red-500: #ef4444; + --red-100: rgba(239,68,68,0.1); + /* ── Text ── */ --text-primary: #18181b; --text-secondary: #525252; @@ -51,11 +72,14 @@ /* ── Typography ── */ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; /* ── Transitions ── */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --duration-fast: 0.15s; + --duration-normal: 0.2s; + --duration-slow: 0.3s; } * { @@ -393,3 +417,78 @@ body { .animate-in-delay-2 { animation-delay: 0.1s; } .animate-in-delay-3 { animation-delay: 0.15s; } .animate-in-delay-4 { animation-delay: 0.2s; } + +/* ═══════════════════════════════════════════ + Skeleton Loading + ═══════════════════════════════════════════ */ + +.skeleton { + background: linear-gradient(90deg, #f4f4f5 25%, #e4e4e7 50%, #f4f4f5 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-sm); +} + +.skeleton-text { + height: 14px; + margin-bottom: 8px; + width: 100%; +} + +.skeleton-text.short { + width: 60%; +} + +.skeleton-circle { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.skeleton-card { + height: 80px; + border-radius: var(--radius); +} + +/* ═══════════════════════════════════════════ + Loading Overlay + ═══════════════════════════════════════════ */ + +.loading-overlay { + position: relative; + pointer-events: none; + opacity: 0.7; +} + +.loading-overlay::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(255,255,255,0.5); + border-radius: inherit; + z-index: 1; +} + +/* ═══════════════════════════════════════════ + Toast / Notification Transitions + ═══════════════════════════════════════════ */ + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} diff --git a/web/frontend/src/views/Barcodes.vue b/web/frontend/src/views/Barcodes.vue index ed886df..7b86292 100644 --- a/web/frontend/src/views/Barcodes.vue +++ b/web/frontend/src/views/Barcodes.vue @@ -42,7 +42,7 @@ placeholder="搜索条码..." clearable style="width: 220px" - @keyup.enter="loadData" + @input="debouncedSearch" @clear="loadData" > @@ -89,7 +89,7 @@ placeholder="搜索条码..." clearable style="width: 220px" - @keyup.enter="loadData" + @input="debouncedSearch" @clear="loadData" > @@ -181,16 +181,30 @@ diff --git a/web/frontend/src/views/Dashboard.vue b/web/frontend/src/views/Dashboard.vue index 2fc7aa8..959c6ff 100644 --- a/web/frontend/src/views/Dashboard.vue +++ b/web/frontend/src/views/Dashboard.vue @@ -3,6 +3,19 @@
+
+
+
+
+
+
+
() const logBox = ref() +const statsLoading = ref(true) const detailedStats = ref({ input_images: 0, @@ -260,12 +274,6 @@ const stats = computed(() => [ }, ]) -function fmtSize(b: number): string { - if (b < 1024) return b + ' B' - if (b < 1048576) return (b / 1024).toFixed(1) + ' KB' - return (b / 1048576).toFixed(1) + ' MB' -} - function fmtTime(i: number): string { const d = new Date() d.setSeconds(d.getSeconds() - (logs.value.length - i)) @@ -283,11 +291,14 @@ function clearLogs(): void { } async function refreshStats(): Promise { + statsLoading.value = true try { const res = await api.get('/files/stats/detailed') detailedStats.value = res.data } catch { // silent + } finally { + statsLoading.value = false } } @@ -328,6 +339,7 @@ async function upload(files: File[]): Promise { uploading.value = true uploadPct.value = 0 const uploadedFiles: { name: string; type: string }[] = [] + const failedFiles: string[] = [] for (let i = 0; i < files.length; i++) { const file = files[i] uploadingName.value = file.name @@ -344,9 +356,8 @@ async function upload(files: File[]): Promise { }) const typeLabel = getFileTypeLabel(file.name) uploadedFiles.push({ name: file.name, type: typeLabel }) - ElMessage.success(`${file.name} → ${typeLabel === 'OCR' ? '全流程处理队列' : 'Excel处理队列'}`) } catch (err: any) { - ElMessage.error(`上传失败: ${file.name}`) + failedFiles.push(file.name) } } uploading.value = false @@ -354,15 +365,21 @@ async function upload(files: File[]): Promise { uploadPct.value = 0 refreshStats() + // Show upload results + if (uploadedFiles.length > 0) { + ElMessage.success(`${uploadedFiles.length} 个文件上传成功`) + } + if (failedFiles.length > 0) { + ElMessage.error(`${failedFiles.length} 个文件上传失败: ${failedFiles.join(', ')}`) + } + // Auto-process: pipeline for images, excel for Excel files if (uploadedFiles.length > 0) { const hasImages = uploadedFiles.some(f => f.type === 'OCR') const hasExcel = uploadedFiles.some(f => f.type === 'Excel') if (hasImages) { - ElMessage.info('自动启动一键全流程...') await doAction('/processing/pipeline') } else if (hasExcel) { - ElMessage.info('自动启动Excel处理...') await doAction('/processing/excel') } } diff --git a/web/frontend/src/views/Layout.vue b/web/frontend/src/views/Layout.vue index b2e642e..6118ec6 100644 --- a/web/frontend/src/views/Layout.vue +++ b/web/frontend/src/views/Layout.vue @@ -86,6 +86,11 @@

{{ pageTitle }}

+ +
+ + 离线 +
{{ (authStore.username || 'U')[0].toUpperCase() }}
@@ -135,7 +140,7 @@