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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 配置',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">高可信 (>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">低可信 (<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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user