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
|
total: int
|
||||||
page: int
|
page: int
|
||||||
page_size: int
|
page_size: int
|
||||||
|
stats: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
def _get_db():
|
def _get_db():
|
||||||
@@ -96,11 +97,17 @@ async def list_memory(
|
|||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
page_items = results[start:start + 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(
|
return MemoryListResponse(
|
||||||
items=[_row_to_item(r) for r in page_items],
|
items=[_row_to_item(r) for r in page_items],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
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']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{out_stem}{ext}"
|
candidate = _output_dir / f"{out_stem}{ext}"
|
||||||
if candidate.exists():
|
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)
|
_add_result_file(candidate.name)
|
||||||
break
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
||||||
@@ -317,7 +317,8 @@ async def process_excel(
|
|||||||
result_path = _result_dir / result_name
|
result_path = _result_dir / result_name
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
|
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
|
continue
|
||||||
|
|
||||||
tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}")
|
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))
|
svc.process_excel(str(f))
|
||||||
# Find result file
|
# Find result file
|
||||||
if result_path.exists():
|
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)
|
_add_result_file(result_name)
|
||||||
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
||||||
# Learn products into memory from purchase order result
|
# Learn products into memory from purchase order result
|
||||||
@@ -430,7 +431,8 @@ async def full_pipeline(
|
|||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{out_stem}{ext}"
|
candidate = _output_dir / f"{out_stem}{ext}"
|
||||||
if candidate.exists():
|
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
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
||||||
out_file = _output_dir / f"{out_stem}.xlsx"
|
out_file = _output_dir / f"{out_stem}.xlsx"
|
||||||
@@ -456,7 +458,8 @@ async def full_pipeline(
|
|||||||
result_path = _result_dir / result_name
|
result_path = _result_dir / result_name
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
|
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}")
|
tm.update_progress(task.id, pct, f"跳过: {f.name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -464,7 +467,8 @@ async def full_pipeline(
|
|||||||
try:
|
try:
|
||||||
order_svc.process_excel(str(f))
|
order_svc.process_excel(str(f))
|
||||||
if result_path.exists():
|
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}")
|
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
|
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
|
||||||
@@ -515,7 +519,8 @@ async def ocr_single(
|
|||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{stem}{ext}"
|
candidate = _output_dir / f"{stem}{ext}"
|
||||||
if candidate.exists():
|
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
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
|
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
|
||||||
result_files = list(getattr(_tlocal, 'result_files', []))
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
@@ -598,13 +603,15 @@ async def pipeline_single(
|
|||||||
if out_xlsx.exists() or out_xls.exists():
|
if out_xlsx.exists() or out_xls.exists():
|
||||||
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
|
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
|
||||||
tm.add_log(task.id, f"[跳过] 已OCR过 → {out_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:
|
else:
|
||||||
ocr_svc.process_image(str(file_path))
|
ocr_svc.process_image(str(file_path))
|
||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{stem}{ext}"
|
candidate = _output_dir / f"{stem}{ext}"
|
||||||
if candidate.exists():
|
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
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成")
|
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,
|
def query_file_relations(view: str = None, status: str = None,
|
||||||
page: int = 1, page_size: int = 50,
|
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.
|
"""Query file relations with optional view filter and pagination.
|
||||||
|
|
||||||
view='orders': only rows with result_purchase, sorted by result_purchase
|
view='orders': only rows with result_purchase, sorted by result_purchase
|
||||||
view='tables': only rows with output_excel, sorted by output_excel
|
view='tables': only rows with output_excel, sorted by output_excel
|
||||||
view='images': only rows with input_image, sorted by input_image
|
view='images': only rows with input_image, sorted by input_image
|
||||||
view=None: all rows
|
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).
|
Returns (items, total).
|
||||||
"""
|
"""
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
conn = sqlite3.connect(_db_path)
|
conn = sqlite3.connect(_db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
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_col = order_by.split()[0] if order_by else 'id'
|
||||||
sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
|
sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
|
||||||
|
|
||||||
# Count
|
# Fetch all matching rows (existence filter happens in Python)
|
||||||
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])
|
|
||||||
rows = conn.execute(
|
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,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
# Check file existence
|
# 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()
|
d['result_exists'] = (project_root / 'data' / 'result' / d['result_purchase']).exists()
|
||||||
else:
|
else:
|
||||||
d['result_exists'] = False
|
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)
|
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
|
return items, total
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -7,16 +7,3 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
</script>
|
</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 axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor: attach JWT token
|
// Request interceptor: attach JWT token + AbortController support
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -14,13 +15,27 @@ api.interceptors.request.use((config) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// Response interceptor: handle 401
|
// Response interceptor: handle 401 gracefully
|
||||||
|
let isRedirecting = false
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('token')
|
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)
|
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
|
export default router
|
||||||
|
|||||||
@@ -20,10 +20,19 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
const taskSource = ref<string>('')
|
const taskSource = ref<string>('')
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
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) {
|
function connectWebSocket(taskId: string) {
|
||||||
disconnectWebSocket()
|
disconnectWebSocket()
|
||||||
|
currentTaskId = taskId
|
||||||
|
reconnectAttempts = 0
|
||||||
|
doConnect(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doConnect(taskId: string) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const host = window.location.host
|
const host = window.location.host
|
||||||
@@ -31,13 +40,17 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
|
|
||||||
ws = new WebSocket(url)
|
ws = new WebSocket(url)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.error) return // ignore error messages from ws
|
||||||
currentTask.value = data
|
currentTask.value = data
|
||||||
logs.value = data.log_lines || []
|
logs.value = data.log_lines || []
|
||||||
|
|
||||||
// Update in tasks list
|
|
||||||
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
|
const idx = tasks.value.findIndex(t => t.task_id === data.task_id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
tasks.value[idx] = data
|
tasks.value[idx] = data
|
||||||
@@ -45,7 +58,6 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
tasks.value.unshift(data)
|
tasks.value.unshift(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-disconnect on completion
|
|
||||||
if (data.status === 'completed' || data.status === 'failed') {
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
setTimeout(() => disconnectWebSocket(), 2000)
|
setTimeout(() => disconnectWebSocket(), 2000)
|
||||||
}
|
}
|
||||||
@@ -53,15 +65,37 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
console.error('WebSocket error')
|
// Error will be followed by onclose, which handles reconnection
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
ws = null
|
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() {
|
function disconnectWebSocket() {
|
||||||
|
currentTaskId = null
|
||||||
|
reconnectAttempts = MAX_RECONNECT // prevent reconnect
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close()
|
ws.close()
|
||||||
ws = null
|
ws = null
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
Clean · Minimal · Zinc palette
|
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 {
|
:root {
|
||||||
/* ── Backgrounds ── */
|
/* ── Backgrounds ── */
|
||||||
@@ -26,6 +34,19 @@
|
|||||||
--danger-light: #fef2f2;
|
--danger-light: #fef2f2;
|
||||||
--info: #18181b;
|
--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 ── */
|
||||||
--text-primary: #18181b;
|
--text-primary: #18181b;
|
||||||
--text-secondary: #525252;
|
--text-secondary: #525252;
|
||||||
@@ -51,11 +72,14 @@
|
|||||||
|
|
||||||
/* ── Typography ── */
|
/* ── Typography ── */
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--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 ── */
|
/* ── Transitions ── */
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 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-2 { animation-delay: 0.1s; }
|
||||||
.animate-in-delay-3 { animation-delay: 0.15s; }
|
.animate-in-delay-3 { animation-delay: 0.15s; }
|
||||||
.animate-in-delay-4 { animation-delay: 0.2s; }
|
.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="搜索条码..."
|
placeholder="搜索条码..."
|
||||||
clearable
|
clearable
|
||||||
style="width: 220px"
|
style="width: 220px"
|
||||||
@keyup.enter="loadData"
|
@input="debouncedSearch"
|
||||||
@clear="loadData"
|
@clear="loadData"
|
||||||
>
|
>
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
placeholder="搜索条码..."
|
placeholder="搜索条码..."
|
||||||
clearable
|
clearable
|
||||||
style="width: 220px"
|
style="width: 220px"
|
||||||
@keyup.enter="loadData"
|
@input="debouncedSearch"
|
||||||
@clear="loadData"
|
@clear="loadData"
|
||||||
>
|
>
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
@@ -181,16 +181,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
|
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
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 loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const rawItems = ref<any[]>([])
|
const rawItems = ref<any[]>([])
|
||||||
const activeTab = ref('mapping')
|
const activeTab = ref('mapping')
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
|
||||||
|
|
||||||
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
|
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
|
||||||
const specialItems = 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)
|
onMounted(loadData)
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -39,8 +39,12 @@
|
|||||||
v-for="(value, key) in config[activeTab]"
|
v-for="(value, key) in config[activeTab]"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="field-row"
|
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
|
<el-input
|
||||||
:model-value="getEditedValue(activeTab, key, value)"
|
:model-value="getEditedValue(activeTab, key, value)"
|
||||||
@update:model-value="setEditedValue(activeTab, key, $event)"
|
@update:model-value="setEditedValue(activeTab, key, $event)"
|
||||||
@@ -241,5 +245,22 @@ onMounted(loadConfig)
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
word-break: break-all;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
<!-- Top stats row -->
|
<!-- Top stats row -->
|
||||||
<div class="stats-row animate-in">
|
<div class="stats-row animate-in">
|
||||||
<div
|
<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"
|
class="stat-card"
|
||||||
v-for="stat in stats"
|
v-for="stat in stats"
|
||||||
:key="stat.label"
|
:key="stat.label"
|
||||||
@@ -183,6 +196,7 @@ const uploadingName = ref('')
|
|||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const fileInput = ref<HTMLInputElement>()
|
const fileInput = ref<HTMLInputElement>()
|
||||||
const logBox = ref<HTMLElement>()
|
const logBox = ref<HTMLElement>()
|
||||||
|
const statsLoading = ref(true)
|
||||||
|
|
||||||
const detailedStats = ref({
|
const detailedStats = ref({
|
||||||
input_images: 0,
|
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 {
|
function fmtTime(i: number): string {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setSeconds(d.getSeconds() - (logs.value.length - i))
|
d.setSeconds(d.getSeconds() - (logs.value.length - i))
|
||||||
@@ -283,11 +291,14 @@ function clearLogs(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStats(): Promise<void> {
|
async function refreshStats(): Promise<void> {
|
||||||
|
statsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/stats/detailed')
|
const res = await api.get('/files/stats/detailed')
|
||||||
detailedStats.value = res.data
|
detailedStats.value = res.data
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +339,7 @@ async function upload(files: File[]): Promise<void> {
|
|||||||
uploading.value = true
|
uploading.value = true
|
||||||
uploadPct.value = 0
|
uploadPct.value = 0
|
||||||
const uploadedFiles: { name: string; type: string }[] = []
|
const uploadedFiles: { name: string; type: string }[] = []
|
||||||
|
const failedFiles: string[] = []
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i]
|
const file = files[i]
|
||||||
uploadingName.value = file.name
|
uploadingName.value = file.name
|
||||||
@@ -344,9 +356,8 @@ async function upload(files: File[]): Promise<void> {
|
|||||||
})
|
})
|
||||||
const typeLabel = getFileTypeLabel(file.name)
|
const typeLabel = getFileTypeLabel(file.name)
|
||||||
uploadedFiles.push({ name: file.name, type: typeLabel })
|
uploadedFiles.push({ name: file.name, type: typeLabel })
|
||||||
ElMessage.success(`${file.name} → ${typeLabel === 'OCR' ? '全流程处理队列' : 'Excel处理队列'}`)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(`上传失败: ${file.name}`)
|
failedFiles.push(file.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
@@ -354,15 +365,21 @@ async function upload(files: File[]): Promise<void> {
|
|||||||
uploadPct.value = 0
|
uploadPct.value = 0
|
||||||
refreshStats()
|
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
|
// Auto-process: pipeline for images, excel for Excel files
|
||||||
if (uploadedFiles.length > 0) {
|
if (uploadedFiles.length > 0) {
|
||||||
const hasImages = uploadedFiles.some(f => f.type === 'OCR')
|
const hasImages = uploadedFiles.some(f => f.type === 'OCR')
|
||||||
const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
|
const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
|
||||||
if (hasImages) {
|
if (hasImages) {
|
||||||
ElMessage.info('自动启动一键全流程...')
|
|
||||||
await doAction('/processing/pipeline')
|
await doAction('/processing/pipeline')
|
||||||
} else if (hasExcel) {
|
} else if (hasExcel) {
|
||||||
ElMessage.info('自动启动Excel处理...')
|
|
||||||
await doAction('/processing/excel')
|
await doAction('/processing/excel')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@
|
|||||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<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">
|
<el-dropdown @command="handleCommand" trigger="click">
|
||||||
<div class="user-chip">
|
<div class="user-chip">
|
||||||
<div class="user-avatar">{{ (authStore.username || 'U')[0].toUpperCase() }}</div>
|
<div class="user-avatar">{{ (authStore.username || 'U')[0].toUpperCase() }}</div>
|
||||||
@@ -135,7 +140,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
@@ -152,6 +157,20 @@ const authStore = useAuthStore()
|
|||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
const showPwd = ref(false)
|
const showPwd = ref(false)
|
||||||
const pwdForm = reactive({ old_password: '', new_password: '' })
|
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 }[] = [
|
const navItems: { path: string; label: string; icon: any; badge?: string }[] = [
|
||||||
{ path: '/', label: '处理中心', icon: HomeFilled },
|
{ path: '/', label: '处理中心', icon: HomeFilled },
|
||||||
@@ -415,6 +434,27 @@ async function changePassword() {
|
|||||||
color: var(--text-primary);
|
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 ── */
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -223,4 +223,28 @@ async function handleLogin() {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #d4d4d8;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<el-option label="4xx" value="400" />
|
<el-option label="4xx" value="400" />
|
||||||
<el-option label="5xx" value="500" />
|
<el-option label="5xx" value="500" />
|
||||||
</el-select>
|
</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>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
@@ -99,11 +99,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
import { Notebook, Warning, Timer, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
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 loading = ref(false)
|
||||||
const searchPath = ref('')
|
const searchPath = ref('')
|
||||||
const filterMethod = 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 })
|
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) {
|
function formatTime(iso: string) {
|
||||||
if (!iso) return '-'
|
if (!iso) return '-'
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
@@ -155,6 +168,7 @@ onMounted(() => {
|
|||||||
loadData()
|
loadData()
|
||||||
loadStats()
|
loadStats()
|
||||||
})
|
})
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
placeholder="搜索条码或名称..."
|
placeholder="搜索条码或名称..."
|
||||||
clearable
|
clearable
|
||||||
style="width: 240px"
|
style="width: 240px"
|
||||||
@keyup.enter="loadData"
|
@input="debouncedSearch"
|
||||||
@clear="loadData"
|
@clear="loadData"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -157,11 +157,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
import { Search, Refresh, Memo, CircleCheck, Warning } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
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 loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
@@ -169,9 +180,11 @@ const page = ref(1)
|
|||||||
const pageSize = ref(50)
|
const pageSize = ref(50)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const highConfidence = computed(() => items.value.filter(i => i.confidence > 50).length)
|
// Confidence stats from server-side (not just current page)
|
||||||
const mediumConfidence = computed(() => items.value.filter(i => i.confidence >= 10 && i.confidence <= 50).length)
|
const confidenceStats = reactive({ high: 0, medium: 0, low: 0, total: 0 })
|
||||||
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 10).length)
|
const highConfidence = computed(() => confidenceStats.high)
|
||||||
|
const mediumConfidence = computed(() => confidenceStats.medium)
|
||||||
|
const lowConfidence = computed(() => confidenceStats.low)
|
||||||
|
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
const isAdd = ref(false)
|
const isAdd = ref(false)
|
||||||
@@ -198,6 +211,19 @@ async function loadData() {
|
|||||||
})
|
})
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
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) {
|
} catch (err: any) {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error('加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,6 +231,12 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(() => {
|
||||||
|
page.value = 1
|
||||||
|
loadData()
|
||||||
|
}, 400)
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
isAdd.value = true
|
isAdd.value = true
|
||||||
editForm.barcode = ''
|
editForm.barcode = ''
|
||||||
@@ -286,6 +318,7 @@ async function reimport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -98,7 +98,10 @@ const processingStore = useProcessingStore()
|
|||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const syncStatus = ref({ enabled: false, repo_url: '' })
|
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 logs = computed(() => processingStore.logs)
|
||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
@@ -240,7 +243,7 @@ onMounted(checkStatus)
|
|||||||
|
|
||||||
.sync-btn:hover:not(:disabled) {
|
.sync-btn:hover:not(:disabled) {
|
||||||
border-color: var(--amber-400);
|
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);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +315,7 @@ onMounted(checkStatus)
|
|||||||
|
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
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;
|
border-radius: 3px;
|
||||||
transition: width 0.4s var(--ease-out);
|
transition: width 0.4s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<el-option label="Excel处理" value="Excel标准化处理" />
|
<el-option label="Excel处理" value="Excel标准化处理" />
|
||||||
<el-option label="合并采购单" value="合并采购单" />
|
<el-option label="合并采购单" value="合并采购单" />
|
||||||
</el-select>
|
</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>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
@@ -136,11 +136,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
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 loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const filterStatus = ref('')
|
const filterStatus = ref('')
|
||||||
@@ -152,6 +162,9 @@ const total = ref(0)
|
|||||||
|
|
||||||
const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 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 showDetailDialog = ref(false)
|
||||||
const detailTask = ref<any>(null)
|
const detailTask = ref<any>(null)
|
||||||
|
|
||||||
@@ -223,6 +236,7 @@ onMounted(() => {
|
|||||||
loadData()
|
loadData()
|
||||||
loadStats()
|
loadStats()
|
||||||
})
|
})
|
||||||
|
onUnmounted(cancelSearch)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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 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-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>
|
<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 total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
const selected = ref<any[]>([])
|
const selected = ref<any[]>([])
|
||||||
const sortBy = ref('created_at')
|
const sortBy = ref('created_at')
|
||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
@@ -187,6 +187,15 @@ async function previewFile(row: any) {
|
|||||||
} catch { ElMessage.error('预览失败') }
|
} 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) {
|
function showDetail(row: any) {
|
||||||
const fname = row.input_image || row.output_excel || row.result_purchase
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
const stem = fname.replace(/\.[^.]+$/, '')
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- Preview dialog -->
|
<!-- 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 class="preview-body">
|
||||||
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
||||||
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
|
<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 total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
const selected = ref<any[]>([])
|
const selected = ref<any[]>([])
|
||||||
const sortBy = ref('created_at')
|
const sortBy = ref('created_at')
|
||||||
const sortOrder = ref('desc')
|
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) {
|
function showDetail(row: any) {
|
||||||
const fname = row.result_purchase || row.output_excel || row.input_image
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
const stem = fname.replace(/\.[^.]+$/, '')
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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 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-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>
|
<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 total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
const selected = ref<any[]>([])
|
const selected = ref<any[]>([])
|
||||||
const sortBy = ref('created_at')
|
const sortBy = ref('created_at')
|
||||||
const sortOrder = ref('desc')
|
const sortOrder = ref('desc')
|
||||||
@@ -183,6 +183,15 @@ async function previewFile(row: any) {
|
|||||||
} catch { ElMessage.error('预览失败') }
|
} 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) {
|
function showDetail(row: any) {
|
||||||
const fname = row.output_excel || row.result_purchase || row.input_image
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
const stem = fname.replace(/\.[^.]+$/, '')
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
|||||||
Reference in New Issue
Block a user