ec8d0d7db6
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
322 lines
11 KiB
Vue
322 lines
11 KiB
Vue
<template>
|
||
<div class="file-page animate-in">
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<h3>表格处理</h3>
|
||
<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>
|
||
<el-button :disabled="!selected.length" @click="batchDelete">
|
||
批量删除
|
||
</el-button>
|
||
<el-button type="danger" @click="clearAll">
|
||
删除全部
|
||
</el-button>
|
||
<input
|
||
ref="uploadInput"
|
||
type="file"
|
||
multiple
|
||
accept=".xls,.xlsx"
|
||
hidden
|
||
@change="handleUpload"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
:data="items"
|
||
v-loading="loading"
|
||
@selection-change="onSelect"
|
||
@sort-change="onSortChange"
|
||
stripe
|
||
style="width: 100%"
|
||
>
|
||
<el-table-column type="selection" width="45" />
|
||
<el-table-column label="Excel处理文件" min-width="200">
|
||
<template #default="{ row }">
|
||
<span class="file-name primary">{{ row.output_excel || '--' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="" width="40" align="center">
|
||
<template #default="{ row }">
|
||
<el-icon :color="row.result_exists ? '#52C41A' : '#d1d5db'" :size="16">
|
||
<Right />
|
||
</el-icon>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="采购单文件" min-width="180">
|
||
<template #default="{ row }">
|
||
<span class="file-name secondary">{{ row.result_purchase || '--' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="" width="40" align="center">
|
||
<template #default="{ row }">
|
||
<el-icon :color="row.input_exists ? '#52C41A' : '#d1d5db'" :size="16">
|
||
<Right />
|
||
</el-icon>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="Input图片" min-width="180">
|
||
<template #default="{ row }">
|
||
<span class="file-name secondary">{{ row.input_image || '--' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
|
||
<template #default="{ row }">
|
||
<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" @close="cleanupPreview">
|
||
<div class="preview-body">
|
||
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
|
||
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
|
||
</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"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
layout="total, prev, pager, next"
|
||
@current-change="loadData"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Right } from '@element-plus/icons-vue'
|
||
import { useProcessingStore } from '../../stores/processing'
|
||
import { statusType, statusText, fmtTime } from '../../composables/useFileUtils'
|
||
import { useFilePreview } from '../../composables/useFilePreview'
|
||
import api from '../../api'
|
||
|
||
const processingStore = useProcessingStore()
|
||
const { showPreview, previewType, previewSrc, previewRows, openPreview, cleanupPreview } = useFilePreview()
|
||
|
||
const items = ref<any[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = 50
|
||
const loading = ref(true)
|
||
const selected = ref<any[]>([])
|
||
const sortBy = ref('created_at')
|
||
const sortOrder = ref('desc')
|
||
const uploadInput = ref<HTMLInputElement>()
|
||
|
||
const showDetailDlg = ref(false)
|
||
const detailLogs = ref<string[]>([])
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
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 {
|
||
ElMessage.error('加载文件列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function onSelect(rows: any[]) { selected.value = rows }
|
||
|
||
async function previewFile(row: any) {
|
||
const fname = row.output_excel || row.result_purchase || row.input_image
|
||
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
||
await openPreview(dir, fname)
|
||
}
|
||
|
||
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 })
|
||
ElMessage.success(`处理任务已创建: ${res.data.task_id}`)
|
||
} catch (err: any) {
|
||
ElMessage.error(err.response?.data?.detail || '处理失败')
|
||
}
|
||
}
|
||
|
||
async function deleteFile(row: any) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除 ${row.output_excel}?`, '确认')
|
||
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
|
||
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
|
||
ElMessage.success('已删除')
|
||
loadData()
|
||
} catch (err: any) {
|
||
if (err !== 'cancel') ElMessage.error('删除失败')
|
||
}
|
||
}
|
||
|
||
async function batchProcess() {
|
||
try {
|
||
const filenames = selected.value.map(r => r.output_excel).filter(Boolean)
|
||
const res = await api.post('/processing/excel', { files: filenames })
|
||
ElMessage.success(`批量处理任务已创建: ${res.data.task_id}`)
|
||
} catch (err: any) {
|
||
ElMessage.error(err.response?.data?.detail || '处理失败')
|
||
}
|
||
}
|
||
|
||
async function batchDelete() {
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
|
||
const files = selected.value
|
||
.filter(r => r.output_excel)
|
||
.map(r => ({ directory: 'output', filename: r.output_excel }))
|
||
const res = await api.post('/files/batch-delete', { files })
|
||
if (res.data.errors?.length) {
|
||
ElMessage.warning(`删除完成,${res.data.errors.length} 个文件失败`)
|
||
} else {
|
||
ElMessage.success('批量删除完成')
|
||
}
|
||
loadData()
|
||
} catch (err: any) {
|
||
if (err !== 'cancel') ElMessage.error('批量删除失败')
|
||
}
|
||
}
|
||
|
||
async function clearAll() {
|
||
try {
|
||
await ElMessageBox.confirm('确定清空所有 Excel 处理文件?此操作不可恢复。', '确认')
|
||
await api.post('/files/clear/output')
|
||
await api.post('/files/relations/sync')
|
||
ElMessage.success('已清空')
|
||
loadData()
|
||
} catch (err: any) {
|
||
if (err !== 'cancel') ElMessage.error('清空失败')
|
||
}
|
||
}
|
||
|
||
onMounted(loadData)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.file-page {
|
||
width: 100%;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.header-left h3 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.file-name.primary {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
.file-name.secondary {
|
||
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>
|