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.
This commit is contained in:
2026-05-12 18:37:23 +08:00
parent 81bafaf557
commit e441ac82a8
20 changed files with 455 additions and 76 deletions
+7
View File
@@ -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},
)
+16 -9
View File
@@ -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] 完成")
+24 -12
View File
@@ -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()
-13
View File
@@ -7,16 +7,3 @@
<script setup lang="ts">
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>
+17 -2
View File
@@ -1,11 +1,12 @@
import axios from 'axios'
import { 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')
// 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)
}
+10
View File
@@ -83,4 +83,14 @@ 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
+38 -4
View File
@@ -20,10 +20,19 @@ export const useProcessingStore = defineStore('processing', () => {
const taskSource = ref<string>('')
let ws: WebSocket | null = null
let reconnectAttempts = 0
let reconnectTimer: ReturnType<typeof setTimeout> | 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
+101 -2
View File
@@ -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;
}
+18 -3
View File
@@ -42,7 +42,7 @@
placeholder="搜索条码..."
clearable
style="width: 220px"
@keyup.enter="loadData"
@input="debouncedSearch"
@clear="loadData"
>
<template #prefix><el-icon><Search /></el-icon></template>
@@ -89,7 +89,7 @@
placeholder="搜索条码..."
clearable
style="width: 220px"
@keyup.enter="loadData"
@input="debouncedSearch"
@clear="loadData"
>
<template #prefix><el-icon><Search /></el-icon></template>
@@ -181,16 +181,30 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
import api from '../api'
// 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 search = ref('')
const rawItems = ref<any[]>([])
const activeTab = ref('mapping')
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
const specialItems = computed(() => rawItems.value.filter(r => r.multiplier))
@@ -337,6 +351,7 @@ async function deleteItem(row: any) {
}
onMounted(loadData)
onUnmounted(cancelSearch)
</script>
<style scoped>
+22 -1
View File
@@ -39,8 +39,12 @@
v-for="(value, key) in config[activeTab]"
:key="key"
class="field-row"
:class="{ edited: edited[activeTab]?.[key] !== undefined && edited[activeTab][key] !== value }"
>
<label class="field-label">{{ key }}</label>
<label class="field-label">
{{ key }}
<span v-if="edited[activeTab]?.[key] !== undefined && edited[activeTab][key] !== value" class="edited-dot"></span>
</label>
<el-input
:model-value="getEditedValue(activeTab, key, value)"
@update:model-value="setEditedValue(activeTab, key, $event)"
@@ -241,5 +245,22 @@ onMounted(loadConfig)
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
display: flex;
align-items: center;
gap: 6px;
}
.field-row.edited {
background: rgba(99,102,241,0.04);
border-radius: 6px;
padding: 2px 0;
}
.edited-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--indigo-500);
flex-shrink: 0;
}
</style>
+27 -10
View File
@@ -3,6 +3,19 @@
<!-- Top stats row -->
<div class="stats-row animate-in">
<div
v-if="statsLoading"
v-for="n in 4"
:key="'sk'+n"
class="stat-card"
>
<div class="skeleton skeleton-circle" style="width:44px;height:44px;border-radius:12px"></div>
<div class="stat-info" style="gap:4px">
<div class="skeleton skeleton-text" style="width:48px;height:24px"></div>
<div class="skeleton skeleton-text short" style="width:64px;height:14px"></div>
</div>
</div>
<div
v-if="!statsLoading"
class="stat-card"
v-for="stat in stats"
:key="stat.label"
@@ -183,6 +196,7 @@ const uploadingName = ref('')
const processing = ref(false)
const fileInput = ref<HTMLInputElement>()
const logBox = ref<HTMLElement>()
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<void> {
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<void> {
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<void> {
})
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<void> {
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')
}
}
+41 -1
View File
@@ -86,6 +86,11 @@
<h2 class="page-title">{{ pageTitle }}</h2>
</div>
<div class="topbar-right">
<!-- Online indicator -->
<div v-if="!isOnline" class="offline-badge">
<span class="offline-dot"></span>
离线
</div>
<el-dropdown @command="handleCommand" trigger="click">
<div class="user-chip">
<div class="user-avatar">{{ (authStore.username || 'U')[0].toUpperCase() }}</div>
@@ -135,7 +140,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
@@ -152,6 +157,20 @@ const authStore = useAuthStore()
const isCollapse = ref(false)
const showPwd = ref(false)
const pwdForm = reactive({ old_password: '', new_password: '' })
const isOnline = ref(navigator.onLine !== false)
// Track online/offline status
function updateOnlineStatus() {
isOnline.value = navigator.onLine !== false
}
onMounted(() => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
const navItems: { path: string; label: string; icon: any; badge?: string }[] = [
{ path: '/', label: '处理中心', icon: HomeFilled },
@@ -415,6 +434,27 @@ async function changePassword() {
color: var(--text-primary);
}
/* ── Offline badge ── */
.offline-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--radius-sm);
background: rgba(239,68,68,0.1);
color: #ef4444;
font-size: 12px;
font-weight: 600;
}
.offline-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* ── Content ── */
.content {
flex: 1;
+24
View File
@@ -223,4 +223,28 @@ async function handleLogin() {
border-radius: 50%;
background: #d4d4d8;
}
/* ── Ambient background ── */
.bg-grid {
position: absolute;
inset: 0;
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px);
mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
-webkit-mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
pointer-events: none;
}
.bg-glow {
position: absolute;
width: 600px;
height: 600px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(99,102,241,0.06) 0%, transparent 70%);
pointer-events: none;
}
</style>
+16 -2
View File
@@ -58,7 +58,7 @@
<el-option label="4xx" value="400" />
<el-option label="5xx" value="500" />
</el-select>
<el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @keyup.enter="loadData" @clear="loadData">
<el-input v-model="searchPath" placeholder="搜索路径..." clearable size="small" style="width: 180px" @input="debouncedSearch" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
@@ -99,11 +99,21 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
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 searchPath = ref('')
const filterMethod = ref('')
@@ -115,6 +125,9 @@ const total = ref(0)
const logStats = reactive({ today_count: 0, error_count: 0, avg_duration_ms: 0, error_rate: 0 })
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
function formatTime(iso: string) {
if (!iso) return '-'
const d = new Date(iso)
@@ -155,6 +168,7 @@ onMounted(() => {
loadData()
loadStats()
})
onUnmounted(cancelSearch)
</script>
<style scoped>
+38 -5
View File
@@ -50,7 +50,7 @@
placeholder="搜索条码或名称..."
clearable
style="width: 240px"
@keyup.enter="loadData"
@input="debouncedSearch"
@clear="loadData"
>
<template #prefix>
@@ -157,11 +157,22 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
import api from '../api'
// 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 search = ref('')
const items = ref<any[]>([])
@@ -169,9 +180,11 @@ const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const highConfidence = computed(() => items.value.filter(i => i.confidence > 50).length)
const mediumConfidence = computed(() => items.value.filter(i => i.confidence >= 10 && i.confidence <= 50).length)
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 10).length)
// Confidence stats from server-side (not just current page)
const confidenceStats = reactive({ high: 0, medium: 0, low: 0, total: 0 })
const highConfidence = computed(() => confidenceStats.high)
const mediumConfidence = computed(() => confidenceStats.medium)
const lowConfidence = computed(() => confidenceStats.low)
const showEdit = ref(false)
const isAdd = ref(false)
@@ -198,6 +211,19 @@ async function loadData() {
})
items.value = res.data.items
total.value = res.data.total
// Update confidence stats from API response if available
if (res.data.stats) {
confidenceStats.high = res.data.stats.high || 0
confidenceStats.medium = res.data.stats.medium || 0
confidenceStats.low = res.data.stats.low || 0
confidenceStats.total = res.data.stats.total || 0
} else {
// Fallback: compute from current page items
confidenceStats.high = items.value.filter(i => i.confidence > 50).length
confidenceStats.medium = items.value.filter(i => i.confidence >= 10 && i.confidence <= 50).length
confidenceStats.low = items.value.filter(i => i.confidence < 10).length
confidenceStats.total = total.value
}
} catch (err: any) {
ElMessage.error('加载失败')
} finally {
@@ -205,6 +231,12 @@ async function loadData() {
}
}
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(() => {
page.value = 1
loadData()
}, 400)
function openAdd() {
isAdd.value = true
editForm.barcode = ''
@@ -286,6 +318,7 @@ async function reimport() {
}
onMounted(loadData)
onUnmounted(cancelSearch)
</script>
<style scoped>
+6 -3
View File
@@ -98,7 +98,10 @@ const processingStore = useProcessingStore()
const syncing = ref(false)
const syncStatus = ref({ enabled: false, repo_url: '' })
const currentTask = computed(() => processingStore.currentTask)
const currentTask = computed(() => {
if (processingStore.taskSource === 'sync') return processingStore.currentTask
return null
})
const logs = computed(() => processingStore.logs)
const statusType = computed(() => {
@@ -240,7 +243,7 @@ onMounted(checkStatus)
.sync-btn:hover:not(:disabled) {
border-color: var(--amber-400);
box-shadow: 0 0 0 3px rgba(255,193,7,0.08);
box-shadow: 0 0 0 3px rgba(251,191,36,0.08);
transform: translateY(-1px);
}
@@ -312,7 +315,7 @@ onMounted(checkStatus)
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--amber-400), var(--amber-600));
background: linear-gradient(90deg, var(--amber-400), var(--amber-600, #d97706));
border-radius: 3px;
transition: width 0.4s var(--ease-out);
}
+16 -2
View File
@@ -58,7 +58,7 @@
<el-option label="Excel处理" value="Excel标准化处理" />
<el-option label="合并采购单" value="合并采购单" />
</el-select>
<el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @keyup.enter="loadData" @clear="loadData">
<el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @input="debouncedSearch" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
@@ -136,11 +136,21 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
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 search = ref('')
const filterStatus = ref('')
@@ -152,6 +162,9 @@ const total = ref(0)
const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 0 })
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
const showDetailDialog = ref(false)
const detailTask = ref<any>(null)
@@ -223,6 +236,7 @@ onMounted(() => {
loadData()
loadStats()
})
onUnmounted(cancelSearch)
</script>
<style scoped>
+11 -2
View File
@@ -89,7 +89,7 @@
</el-table-column>
</el-table>
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
<div class="preview-body">
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
@@ -129,7 +129,7 @@ const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const loading = ref(true)
const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
@@ -187,6 +187,15 @@ async function previewFile(row: any) {
} 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) {
const fname = row.input_image || row.output_excel || row.result_purchase
const stem = fname.replace(/\.[^.]+$/, '')
+11 -2
View File
@@ -77,7 +77,7 @@
</el-table>
<!-- Preview dialog -->
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
<div class="preview-body">
<div v-if="previewType === 'image'" class="preview-image-wrap">
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
@@ -129,7 +129,7 @@ const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const loading = ref(true)
const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
@@ -197,6 +197,15 @@ async function previewFile(row: any) {
}
}
function cleanupPreview() {
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
URL.revokeObjectURL(previewSrc.value)
}
previewSrc.value = ''
previewType.value = ''
previewRows.value = []
}
function showDetail(row: any) {
const fname = row.result_purchase || row.output_excel || row.input_image
const stem = fname.replace(/\.[^.]+$/, '')
+11 -2
View File
@@ -85,7 +85,7 @@
</el-table-column>
</el-table>
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog" @close="cleanupPreview">
<div class="preview-body">
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
@@ -125,7 +125,7 @@ const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const loading = ref(true)
const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
@@ -183,6 +183,15 @@ async function previewFile(row: any) {
} 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) {
const fname = row.output_excel || row.result_purchase || row.input_image
const stem = fname.replace(/\.[^.]+$/, '')