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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(/\.[^.]+$/, '')
|
||||
|
||||
@@ -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(/\.[^.]+$/, '')
|
||||
|
||||
@@ -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(/\.[^.]+$/, '')
|
||||
|
||||
Reference in New Issue
Block a user