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:
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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] 完成")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
// 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