fix: sync/barcode/memory overhaul + detailed logs + preview + result tracking

- Sync: fix GiteaSync constructor + add push()/pull() methods
- Barcode: two-tab layout matching GUI (mapping + special rules)
- Memory: spec→specification unification, manual add, confidence/price tracking
- Processing: TaskLogHandler captures detailed logs (barcode mapping, unit conversion)
- Preview: fullscreen dialog for file preview (image/Excel) in Orders/Tables/Images
- Detail: per-file log filtering in file pages
- Tasks: result files now per-task, add copy path button
- Config: reactive edited state + save_config fix
- Dashboard: sync task isolation, log limit 10

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:37:10 +08:00
parent c18039f790
commit 81bafaf557
20 changed files with 1610 additions and 502 deletions
+4 -2
View File
@@ -17,6 +17,7 @@ export const useProcessingStore = defineStore('processing', () => {
const currentTask = ref<TaskInfo | null>(null)
const tasks = ref<TaskInfo[]>([])
const logs = ref<string[]>([])
const taskSource = ref<string>('')
let ws: WebSocket | null = null
@@ -67,9 +68,10 @@ export const useProcessingStore = defineStore('processing', () => {
}
}
async function startTask(endpoint: string, body?: any) {
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
const res = await api.post(endpoint, body || {})
const taskId = res.data.task_id
taskSource.value = source
currentTask.value = {
task_id: taskId,
name: res.data.message || '',
@@ -90,5 +92,5 @@ export const useProcessingStore = defineStore('processing', () => {
return res.data
}
return { currentTask, tasks, logs, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
})
+289 -111
View File
@@ -7,98 +7,207 @@
<el-icon :size="20" color="#6366f1"><Connection /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ items.length }}</span>
<span class="stat-label">映射规则</span>
<span class="stat-value">{{ mappingItems.length + specialItems.length }}</span>
<span class="stat-label">规则</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
<el-icon :size="20" color="#10b981"><Right /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ mappingItems.length }}</span>
<span class="stat-label">条码映射</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Setting /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ specialItems.length }}</span>
<span class="stat-label">特殊处理</span>
</div>
</div>
</div>
<!-- Main table card -->
<!-- Two-tab layout matching GUI -->
<div class="card animate-in animate-in-delay-1">
<div class="card-head">
<h3>条码映射管理</h3>
<div class="card-actions">
<el-input
v-model="search"
placeholder="搜索条码..."
clearable
style="width: 200px"
@keyup.enter="loadData"
@clear="loadData"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="primary" @click="openAdd" :icon="Plus">新增映射</el-button>
</div>
</div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- Tab 1: 条码映射 -->
<el-tab-pane label="条码映射" name="mapping">
<div class="tab-toolbar">
<el-input
v-model="search"
placeholder="搜索条码..."
clearable
style="width: 220px"
@keyup.enter="loadData"
@clear="loadData"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="tab-actions">
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="primary" @click="openMappingAdd">新增映射</el-button>
</div>
</div>
<el-table :data="items" v-loading="loading" stripe max-height="600" size="small" class="barcode-table">
<el-table-column prop="barcode" label="原始条码" width="200">
<template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column label="映射" width="60" align="center">
<template #default>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</template>
</el-table-column>
<el-table-column prop="target" label="目标条码" width="200">
<template #default="{ row }">
<span class="barcode-cell target">{{ row.target }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-table :data="mappingItems" v-loading="loading" stripe max-height="500" size="small">
<el-table-column prop="barcode" label="条码" width="200">
<template #default="{ row }">
<span class="barcode-cell">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</template>
</el-table-column>
<el-table-column label="目标条码" width="200">
<template #default="{ row }">
<span class="barcode-cell target">{{ row.target }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editMapping(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 2: 特殊处理 -->
<el-tab-pane label="特殊处理" name="special">
<div class="tab-toolbar">
<el-input
v-model="search"
placeholder="搜索条码..."
clearable
style="width: 220px"
@keyup.enter="loadData"
@clear="loadData"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="tab-actions">
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="primary" @click="openSpecialAdd">新增特殊处理</el-button>
</div>
</div>
<el-table :data="specialItems" v-loading="loading" stripe max-height="500" size="small">
<el-table-column prop="barcode" label="条码" width="180">
<template #default="{ row }">
<span class="barcode-cell special-type">{{ row.barcode }}</span>
</template>
</el-table-column>
<el-table-column prop="multiplier" label="乘数" width="70" align="center">
<template #default="{ row }">
<span class="multiplier-badge">{{ row.multiplier }}</span>
</template>
</el-table-column>
<el-table-column prop="target_unit" label="目标单位" width="90" align="center" />
<el-table-column prop="fixed_price" label="固定单价" width="100" align="center">
<template #default="{ row }">
<span v-if="row.fixed_price != null" class="price-cell">{{ row.fixed_price.toFixed(4) }}</span>
<span v-else class="text-muted">--</span>
</template>
</el-table-column>
<el-table-column prop="specification" label="规格" width="90" align="center" />
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editSpecial(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<!-- Add/Edit dialog -->
<el-dialog v-model="showAdd" :title="isEdit ? '编辑映射' : '新增映射'" width="450px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px">
<el-form-item label="原始条码">
<el-input v-model="form.barcode" :disabled="isEdit" placeholder="输入原始条码" />
<!-- Mapping add/edit dialog -->
<el-dialog v-model="showMapping" :title="mappingEdit ? '编辑条码映射' : '新增条码映射'" width="450px" :close-on-click-modal="false">
<el-form :model="mappingForm" label-width="80px">
<el-form-item label="条码">
<el-input v-model="mappingForm.barcode" :disabled="mappingEdit" placeholder="输入原始条码" />
</el-form-item>
<el-form-item label="目标条码">
<el-input v-model="form.target" placeholder="输入目标条码" />
<el-input v-model="mappingForm.target" placeholder="输入目标条码" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" placeholder="映射说明(可选" />
<el-input v-model="mappingForm.description" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdd = false">取消</el-button>
<el-button @click="showMapping = false">取消</el-button>
<el-button type="primary" @click="saveMapping">保存</el-button>
</template>
</el-dialog>
<!-- Special rule add/edit dialog -->
<el-dialog v-model="showSpecial" :title="specialEdit ? '编辑特殊处理' : '新增特殊处理'" width="480px" :close-on-click-modal="false">
<el-form :model="specialForm" label-width="80px">
<el-form-item label="条码">
<el-input v-model="specialForm.barcode" :disabled="specialEdit" placeholder="输入条码" />
</el-form-item>
<el-form-item label="乘数">
<el-input-number v-model="specialForm.multiplier" :min="1" :step="1" style="width: 100%" placeholder="如: 10" />
</el-form-item>
<el-form-item label="目标单位">
<el-input v-model="specialForm.targetUnit" placeholder="如: 瓶、个、对" />
</el-form-item>
<el-form-item label="固定单价">
<el-input-number v-model="specialForm.fixedPrice" :precision="4" :step="0.01" :min="0" style="width: 100%" placeholder="可选" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="specialForm.specification" placeholder="如: 1*30" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="specialForm.description" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSpecial = false">取消</el-button>
<el-button type="primary" @click="saveSpecial">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Connection } from '@element-plus/icons-vue'
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
import api from '../api'
const loading = ref(false)
const search = ref('')
const items = ref<any[]>([])
const showAdd = ref(false)
const isEdit = ref(false)
const rawItems = ref<any[]>([])
const activeTab = ref('mapping')
const form = reactive({
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
const specialItems = computed(() => rawItems.value.filter(r => r.multiplier))
// ── Mapping form ──
const showMapping = ref(false)
const mappingEdit = ref(false)
const mappingForm = reactive({ barcode: '', target: '', description: '' })
// ── Special form ──
const showSpecial = ref(false)
const specialEdit = ref(false)
const specialForm = reactive({
barcode: '',
target: '',
multiplier: null as number | null,
targetUnit: '',
fixedPrice: null as number | null,
specification: '',
description: '',
})
@@ -106,7 +215,7 @@ async function loadData() {
loading.value = true
try {
const res = await api.get('/barcodes', { params: { search: search.value } })
items.value = res.data.items
rawItems.value = res.data.items
} catch {
ElMessage.error('加载失败')
} finally {
@@ -114,53 +223,113 @@ async function loadData() {
}
}
function openAdd() {
resetForm()
showAdd.value = true
function onTabChange() {
// Keep search across tabs
}
function editItem(row: any) {
isEdit.value = true
form.barcode = row.barcode
form.target = row.target
form.description = row.description || ''
showAdd.value = true
// ── Mapping CRUD ──
function openMappingAdd() {
mappingEdit.value = false
mappingForm.barcode = ''
mappingForm.target = ''
mappingForm.description = ''
showMapping.value = true
}
function resetForm() {
form.barcode = ''
form.target = ''
form.description = ''
isEdit.value = false
function editMapping(row: any) {
mappingEdit.value = true
mappingForm.barcode = row.barcode
mappingForm.target = row.target
mappingForm.description = row.description || ''
showMapping.value = true
}
async function saveMapping() {
if (!form.barcode || !form.target) {
ElMessage.warning('请填写条码和目标')
if (!mappingForm.barcode || !mappingForm.target) {
ElMessage.warning('请填写条码和目标条码')
return
}
try {
if (isEdit.value) {
await api.put(`/barcodes/${form.barcode}`, {
target: form.target,
description: form.description,
if (mappingEdit.value) {
await api.put(`/barcodes/${mappingForm.barcode}`, {
target: mappingForm.target,
description: mappingForm.description,
})
ElMessage.success('已更新')
} else {
await api.post('/barcodes', form)
await api.post('/barcodes', {
barcode: mappingForm.barcode,
target: mappingForm.target,
description: mappingForm.description,
})
ElMessage.success('已创建')
}
showAdd.value = false
resetForm()
showMapping.value = false
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
// ── Special CRUD ──
function openSpecialAdd() {
specialEdit.value = false
specialForm.barcode = ''
specialForm.multiplier = null
specialForm.targetUnit = ''
specialForm.fixedPrice = null
specialForm.specification = ''
specialForm.description = ''
showSpecial.value = true
}
function editSpecial(row: any) {
specialEdit.value = true
specialForm.barcode = row.barcode
specialForm.multiplier = row.multiplier
specialForm.targetUnit = row.target_unit || ''
specialForm.fixedPrice = row.fixed_price ?? null
specialForm.specification = row.specification || ''
specialForm.description = row.description || ''
showSpecial.value = true
}
async function saveSpecial() {
if (!specialForm.barcode) {
ElMessage.warning('请填写条码')
return
}
if (!specialForm.multiplier) {
ElMessage.warning('请填写乘数')
return
}
try {
const body: any = {
multiplier: specialForm.multiplier,
target_unit: specialForm.targetUnit || null,
fixed_price: specialForm.fixedPrice ?? null,
specification: specialForm.specification || null,
description: specialForm.description,
}
if (specialEdit.value) {
await api.put(`/barcodes/${specialForm.barcode}`, body)
ElMessage.success('已更新')
} else {
await api.post('/barcodes', { barcode: specialForm.barcode, ...body })
ElMessage.success('已创建')
}
showSpecial.value = false
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
// ── Shared ──
async function deleteItem(row: any) {
try {
await ElMessageBox.confirm(`确定删除映射 ${row.barcode}${row.target}`, '确认')
const desc = row.target ? `${row.barcode}${row.target}` : `${row.barcode}`
await ElMessageBox.confirm(`确定删除规则 ${desc}`, '确认')
await api.delete(`/barcodes/${row.barcode}`)
ElMessage.success('已删除')
loadData()
@@ -172,16 +341,16 @@ onMounted(loadData)
<style scoped>
.barcodes-page {
max-width: 1200px;
width: 100%;
}
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
max-width: 300px;
max-width: 560px;
}
.stat-card {
@@ -230,38 +399,23 @@ onMounted(loadData)
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-head {
/* ── Tab toolbar ── */
.tab-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 14px;
}
.card-head h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-actions {
.tab-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* ── Table ── */
.barcode-table {
border-radius: 10px;
overflow: hidden;
}
/* ── Barcode cells ── */
.barcode-cell {
font-family: var(--font-mono);
font-size: 13px;
@@ -271,4 +425,28 @@ onMounted(loadData)
.barcode-cell.target {
color: var(--success);
}
.barcode-cell.special-type {
color: var(--warning);
}
.multiplier-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
background: rgba(245,158,11,0.1);
color: var(--warning);
font-weight: 600;
font-family: var(--font-mono);
}
.price-cell {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.text-muted {
color: var(--text-muted);
}
</style>
+1 -1
View File
@@ -64,7 +64,7 @@ const loading = ref(false)
const saving = ref(false)
const activeTab = ref('')
const config = ref<Record<string, Record<string, string>>>({})
const edited: Record<string, Record<string, string>> = {}
const edited = reactive<Record<string, Record<string, string>>>({})
const sectionLabels: Record<string, string> = {
API: 'API 配置',
+9 -6
View File
@@ -193,8 +193,11 @@ const detailedStats = ref({
total_processed: 0,
})
const currentTask = computed(() => ps.currentTask)
const logs = computed(() => ps.logs)
const currentTask = computed(() => {
if (ps.taskSource !== 'sync') return ps.currentTask
return null
})
const logs = computed(() => ps.logs.slice(0, 10))
const statusType = computed(() => {
const m: Record<string, string> = {
@@ -341,7 +344,7 @@ 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' ? 'OCR识别队列' : 'Excel处理队列'}`)
ElMessage.success(`${file.name}${typeLabel === 'OCR' ? '全流程处理队列' : 'Excel处理队列'}`)
} catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`)
}
@@ -351,13 +354,13 @@ async function upload(files: File[]): Promise<void> {
uploadPct.value = 0
refreshStats()
// Auto-process: run pipeline for images, excel for Excel files
// 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('自动启动OCR识别...')
await doAction('/processing/ocr-batch')
ElMessage.info('自动启动一键全流程...')
await doAction('/processing/pipeline')
} else if (hasExcel) {
ElMessage.info('自动启动Excel处理...')
await doAction('/processing/excel')
+101 -28
View File
@@ -8,7 +8,7 @@
</div>
<div class="stat-info">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">总记录</span>
<span class="stat-label">总记录</span>
</div>
</div>
<div class="stat-card">
@@ -17,16 +17,25 @@
</div>
<div class="stat-info">
<span class="stat-value">{{ highConfidence }}</span>
<span class="stat-label">置信度</span>
<span class="stat-label">可信 (&gt;50)</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ mediumConfidence }}</span>
<span class="stat-label">中可信 (10~50)</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
<el-icon :size="20" color="#ef4444"><Warning /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ lowConfidence }}</span>
<span class="stat-label">置信度</span>
<span class="stat-label">可信 (&lt;10)</span>
</div>
</div>
</div>
@@ -49,6 +58,7 @@
</template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="primary" @click="openAdd">新增记录</el-button>
<el-button size="small" type="warning" plain @click="reimport">重新导入</el-button>
</div>
</div>
@@ -67,22 +77,35 @@
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="120" />
<el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="price" label="价" width="100">
<el-table-column label="价" width="100">
<template #default="{ row }">
<span class="price-cell">{{ row.price != null ? row.price.toFixed(4) : '-' }}</span>
<span class="price-cell">{{ row.avg_price != null ? row.avg_price.toFixed(4) : '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="100">
<el-table-column label="价格范围" width="140">
<template #default="{ row }">
<span v-if="row.price_count > 0" class="price-range">
{{ row.min_price?.toFixed(2) }}~{{ row.max_price?.toFixed(2) }}
</span>
<span v-else class="text-muted">--</span>
</template>
</el-table-column>
<el-table-column label="记录次数" width="90" align="center">
<template #default="{ row }">
<span :class="row.price_count > 3 ? 'count-high' : 'count-low'">{{ row.price_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="可信度" width="90" align="center">
<template #default="{ row }">
<span class="confidence-badge" :class="confCls(row.confidence)">
{{ row.confidence }}
</span>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="80" />
<el-table-column prop="use_count" label="使用次数" width="90" />
<el-table-column prop="source" label="来源" width="75" align="center" />
<el-table-column prop="use_count" label="出现" width="60" align="center" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
@@ -104,16 +127,16 @@
</div>
<!-- Edit dialog -->
<el-dialog v-model="showEdit" title="编辑记忆记录" width="480px" :close-on-click-modal="false">
<el-dialog v-model="showEdit" :title="isAdd ? '新增记忆记录' : '编辑记忆记录'" width="480px" :close-on-click-modal="false">
<el-form :model="editForm" label-width="80px">
<el-form-item label="条码">
<el-input :model-value="editForm.barcode" disabled />
<el-input v-model="editForm.barcode" :disabled="!isAdd" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="editForm.spec" />
<el-input v-model="editForm.specification" />
</el-form-item>
<el-form-item label="单位">
<el-input v-model="editForm.unit" />
@@ -146,22 +169,24 @@ const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const highConfidence = computed(() => items.value.filter(i => i.confidence >= 80).length)
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 50).length)
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)
const showEdit = ref(false)
const isAdd = ref(false)
const editForm = reactive({
barcode: '',
name: '',
spec: '',
specification: '',
unit: '',
price: 0,
confidence: 0,
confidence: 50,
})
function confCls(c: number) {
if (c >= 80) return 'high'
if (c >= 50) return 'mid'
if (c > 50) return 'high'
if (c >= 10) return 'mid'
return 'low'
}
@@ -180,10 +205,22 @@ async function loadData() {
}
}
function openAdd() {
isAdd.value = true
editForm.barcode = ''
editForm.name = ''
editForm.specification = ''
editForm.unit = ''
editForm.price = 0
editForm.confidence = 50
showEdit.value = true
}
function editItem(row: any) {
isAdd.value = false
editForm.barcode = row.barcode
editForm.name = row.name || ''
editForm.spec = row.spec || ''
editForm.specification = row.specification || ''
editForm.unit = row.unit || ''
editForm.price = row.price || 0
editForm.confidence = row.confidence || 0
@@ -191,15 +228,31 @@ function editItem(row: any) {
}
async function saveEdit() {
if (!editForm.barcode) {
ElMessage.warning('请输入条码')
return
}
try {
await api.put(`/memory/${editForm.barcode}`, {
name: editForm.name,
spec: editForm.spec,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
ElMessage.success('保存成功')
if (isAdd.value) {
await api.post('/memory', {
barcode: editForm.barcode,
name: editForm.name,
specification: editForm.specification,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
ElMessage.success('添加成功')
} else {
await api.put(`/memory/${editForm.barcode}`, {
name: editForm.name,
specification: editForm.specification,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
ElMessage.success('保存成功')
}
showEdit.value = false
loadData()
} catch (err: any) {
@@ -243,7 +296,7 @@ onMounted(loadData)
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
@@ -363,6 +416,26 @@ onMounted(loadData)
color: #ef4444;
}
.price-range {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.count-high {
font-weight: 600;
color: var(--success);
}
.count-low {
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
/* ── Pagination ── */
.pagination-bar {
display: flex;
+2 -2
View File
@@ -126,7 +126,7 @@ async function checkStatus() {
async function doPush() {
syncing.value = true
try {
await processingStore.startTask('/sync/push')
await processingStore.startTask('/sync/push', undefined, 'sync')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '推送失败')
} finally {
@@ -137,7 +137,7 @@ async function doPush() {
async function doPull() {
syncing.value = true
try {
await processingStore.startTask('/sync/pull')
await processingStore.startTask('/sync/pull', undefined, 'sync')
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '拉取失败')
} finally {
+25 -8
View File
@@ -114,7 +114,10 @@
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
<h4>结果文件</h4>
<div v-for="f in detailTask.result_files" :key="f" class="file-chip">{{ f }}</div>
<div v-for="f in detailTask.result_files" :key="f" class="file-row-detail">
<span class="file-path-text">{{ f }}</span>
<el-button size="small" @click="copyPath(f)">复制路径</el-button>
</div>
</div>
<div class="detail-logs">
@@ -196,6 +199,15 @@ function showDetail(row: any) {
showDetailDialog.value = true
}
async function copyPath(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制路径')
} catch {
ElMessage.error('复制失败')
}
}
async function retryTask(row: any) {
try {
await api.post(`/tasks/${row.id}/retry`)
@@ -394,15 +406,20 @@ onMounted(() => {
margin-bottom: 8px;
}
.file-chip {
display: inline-block;
padding: 4px 10px;
background: rgba(16,185,129,0.08);
.file-row-detail {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #f9fafb;
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 12px;
margin-bottom: 6px;
}
.file-path-text {
font-size: 13px;
font-family: var(--font-mono);
color: var(--success);
margin: 0 4px 4px 0;
color: var(--text-primary);
}
.log-box {
+133 -11
View File
@@ -6,6 +6,7 @@
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button @click="triggerUpload">上传图片</el-button>
<el-button type="primary" :disabled="!selected.length" @click="batchPipeline">
批量生成采购单 ({{ selected.length }})
</el-button>
@@ -18,6 +19,14 @@
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
<input
ref="uploadInput"
type="file"
multiple
accept=".jpg,.jpeg,.png,.bmp"
hidden
@change="handleUpload"
/>
</div>
</div>
@@ -25,6 +34,7 @@
:data="items"
v-loading="loading"
@selection-change="onSelect"
@sort-change="onSortChange"
stripe
style="width: 100%"
>
@@ -63,21 +73,37 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="pipelineFile(row)">
生成采购单
</el-button>
<el-button link type="primary" size="small" @click="ocrFile(row)">
仅OCR
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="pipelineFile(row)">生成采购单</el-button>
<el-button link type="primary" size="small" @click="ocrFile(row)">仅OCR</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
<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>
</div>
</el-dialog>
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
<div class="detail-logs">
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
</div>
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
</el-dialog>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
@@ -94,14 +120,27 @@
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import { useProcessingStore } from '../../stores/processing'
import api from '../../api'
const processingStore = useProcessingStore()
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
const uploadInput = ref<HTMLInputElement>()
const showPreview = ref(false)
const previewType = ref('')
const previewSrc = ref('')
const previewRows = ref<string[][]>([])
const showDetailDlg = ref(false)
const detailLogs = ref<string[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
@@ -112,10 +151,15 @@ function statusText(s: string) {
return m[s] || s
}
function fmtTime(t: string): string {
if (!t) return '--'
return t.replace('T', ' ').slice(0, 19)
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize } })
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
items.value = res.data.items
total.value = res.data.total
} catch {}
@@ -124,6 +168,64 @@ async function loadData() {
function onSelect(rows: any[]) { selected.value = rows }
async function previewFile(row: any) {
const token = localStorage.getItem('token')
const fname = row.input_image || row.output_excel || row.result_purchase
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
try {
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
const ct = resp.headers.get('content-type') || ''
if (ct.includes('image')) {
previewType.value = 'image'
const blob = await resp.blob()
previewSrc.value = URL.createObjectURL(blob)
} else {
const data = await resp.json()
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
}
showPreview.value = true
} catch { ElMessage.error('预览失败') }
}
function showDetail(row: any) {
const fname = row.input_image || row.output_excel || row.result_purchase
const stem = fname.replace(/\.[^.]+$/, '')
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
showDetailDlg.value = true
}
function onSortChange({ prop, order }: any) {
if (prop === 'created_at') {
sortBy.value = 'created_at'
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
sortBy.value = ''
sortOrder.value = 'desc'
}
loadData()
}
function triggerUpload() {
uploadInput.value?.click()
}
async function handleUpload(e: Event) {
const el = e.target as HTMLInputElement
if (!el.files || !el.files.length) return
for (const file of Array.from(el.files)) {
const fd = new FormData()
fd.append('file', file)
try {
await api.post('/files/upload?target=input', fd)
ElMessage.success(`已上传: ${file.name}`)
} catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`)
}
}
el.value = ''
loadData()
}
async function pipelineFile(row: any) {
try {
const res = await api.post('/processing/pipeline-single', { filename: row.input_image })
@@ -241,9 +343,29 @@ onMounted(loadData)
color: var(--text-muted);
font-size: 13px;
}
.time-text {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
.preview-table tr:nth-child(even) { background:#fafafa }
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
.detail-line { color:#a1a1aa }
.detail-line.err { color:#ef4444 }
.detail-line.ok { color:#22c55e }
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
</style>
+146 -8
View File
@@ -22,6 +22,7 @@
:data="items"
v-loading="loading"
@selection-change="onSelect"
@sort-change="onSortChange"
stripe
style="width: 100%"
>
@@ -60,18 +61,49 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadFile(row)">
下载
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="downloadFile(row)">下载</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Preview dialog -->
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
<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>
<div v-else style="text-align:center;color:var(--text-muted);padding:40px">暂无可预览内容</div>
</div>
</el-dialog>
<!-- Detail dialog -->
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
<div class="detail-logs">
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
</div>
<template #footer>
<el-button @click="showDetailDlg = false">关闭</el-button>
</template>
</el-dialog>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
@@ -88,14 +120,29 @@
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import { useProcessingStore } from '../../stores/processing'
import api from '../../api'
const processingStore = useProcessingStore()
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
// Preview
const showPreview = ref(false)
const previewType = ref('')
const previewSrc = ref('')
const previewRows = ref<string[][]>([])
// Detail
const showDetailDlg = ref(false)
const detailLogs = ref<string[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
@@ -106,10 +153,15 @@ function statusText(s: string) {
return m[s] || s
}
function fmtTime(t: string): string {
if (!t) return '--'
return t.replace('T', ' ').slice(0, 19)
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize } })
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
items.value = res.data.items
total.value = res.data.total
} catch {}
@@ -118,6 +170,54 @@ async function loadData() {
function onSelect(rows: any[]) { selected.value = rows }
async function previewFile(row: any) {
const token = localStorage.getItem('token')
const fname = row.result_purchase || row.output_excel || row.input_image
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
const url = `/api/files/preview/${dir}/${encodeURIComponent(fname)}`
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
const ct = resp.headers.get('content-type') || ''
if (ct.includes('image')) {
previewType.value = 'image'
const blob = await resp.blob()
previewSrc.value = URL.createObjectURL(blob)
} else {
const data = await resp.json()
if (data.type === 'excel') {
previewType.value = 'excel'
previewRows.value = data.rows
}
}
showPreview.value = true
} catch {
ElMessage.error('预览失败')
}
}
function showDetail(row: any) {
const fname = row.result_purchase || row.output_excel || row.input_image
const stem = fname.replace(/\.[^.]+$/, '')
// Filter logs from the processing store that mention this file
detailLogs.value = (processingStore.logs || []).filter(
(l: string) => l.includes(fname) || l.includes(stem)
)
showDetailDlg.value = true
}
function onSortChange({ prop, order }: any) {
if (prop === 'created_at') {
sortBy.value = 'created_at'
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
sortBy.value = ''
sortOrder.value = 'desc'
}
loadData()
}
async function downloadFile(row: any) {
const token = localStorage.getItem('token')
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
@@ -204,9 +304,47 @@ onMounted(loadData)
color: var(--text-muted);
font-size: 13px;
}
.time-text {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.preview-table td {
border: 1px solid var(--border-light);
padding: 4px 8px;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-table tr:nth-child(even) { background: #fafafa; }
.preview-table tr:first-child { background: #f0f0f0; font-weight: 600; }
.detail-logs {
max-height: 60vh;
overflow-y: auto;
background: #09090b;
border-radius: 8px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.8;
}
.detail-line { color: #a1a1aa; }
.detail-line.err { color: #ef4444; }
.detail-line.ok { color: #22c55e; }
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex; flex-direction:column; width:96vw; height:94vh; margin:3vh 2vw; border-radius:12px!important }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1; min-height:0; padding:8px 16px 16px; overflow:hidden; display:flex; flex-direction:column }
.preview-body { flex:1; min-height:0; display:flex; flex-direction:column }
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
</style>
+132 -8
View File
@@ -6,6 +6,7 @@
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button @click="triggerUpload">上传Excel</el-button>
<el-button type="primary" :disabled="!selected.length" @click="batchProcess">
批量处理 ({{ selected.length }})
</el-button>
@@ -15,6 +16,14 @@
<el-button type="danger" @click="clearAll">
删除全部
</el-button>
<input
ref="uploadInput"
type="file"
multiple
accept=".xls,.xlsx"
hidden
@change="handleUpload"
/>
</div>
</div>
@@ -22,6 +31,7 @@
:data="items"
v-loading="loading"
@selection-change="onSelect"
@sort-change="onSortChange"
stripe
style="width: 100%"
>
@@ -60,18 +70,36 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="processFile(row)">
处理
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="processFile(row)">处理</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
<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>
</div>
</el-dialog>
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
<div class="detail-logs">
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
</div>
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
</el-dialog>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
@@ -88,14 +116,27 @@
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import { useProcessingStore } from '../../stores/processing'
import api from '../../api'
const processingStore = useProcessingStore()
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
const sortBy = ref('created_at')
const sortOrder = ref('desc')
const uploadInput = ref<HTMLInputElement>()
const showPreview = ref(false)
const previewType = ref('')
const previewSrc = ref('')
const previewRows = ref<string[][]>([])
const showDetailDlg = ref(false)
const detailLogs = ref<string[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
@@ -106,10 +147,15 @@ function statusText(s: string) {
return m[s] || s
}
function fmtTime(t: string): string {
if (!t) return '--'
return t.replace('T', ' ').slice(0, 19)
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize } })
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
items.value = res.data.items
total.value = res.data.total
} catch {}
@@ -118,6 +164,64 @@ async function loadData() {
function onSelect(rows: any[]) { selected.value = rows }
async function previewFile(row: any) {
const token = localStorage.getItem('token')
const fname = row.output_excel || row.result_purchase || row.input_image
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
try {
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
const ct = resp.headers.get('content-type') || ''
if (ct.includes('image')) {
previewType.value = 'image'
const blob = await resp.blob()
previewSrc.value = URL.createObjectURL(blob)
} else {
const data = await resp.json()
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
}
showPreview.value = true
} catch { ElMessage.error('预览失败') }
}
function showDetail(row: any) {
const fname = row.output_excel || row.result_purchase || row.input_image
const stem = fname.replace(/\.[^.]+$/, '')
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
showDetailDlg.value = true
}
function onSortChange({ prop, order }: any) {
if (prop === 'created_at') {
sortBy.value = 'created_at'
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
sortBy.value = ''
sortOrder.value = 'desc'
}
loadData()
}
function triggerUpload() {
uploadInput.value?.click()
}
async function handleUpload(e: Event) {
const el = e.target as HTMLInputElement
if (!el.files || !el.files.length) return
for (const file of Array.from(el.files)) {
const fd = new FormData()
fd.append('file', file)
try {
await api.post('/files/upload?target=output', fd)
ElMessage.success(`已上传: ${file.name}`)
} catch (err: any) {
ElMessage.error(`上传失败: ${file.name}`)
}
}
el.value = ''
loadData()
}
async function processFile(row: any) {
try {
const res = await api.post('/processing/excel-single', { filename: row.output_excel })
@@ -208,9 +312,29 @@ onMounted(loadData)
color: var(--text-muted);
font-size: 13px;
}
.time-text {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
.preview-table tr:nth-child(even) { background:#fafafa }
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
.detail-line { color:#a1a1aa }
.detail-line.err { color:#ef4444 }
.detail-line.ok { color:#22c55e }
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
</style>