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:
@@ -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