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
+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(/\.[^.]+$/, '')