feat: 物料导入结果弹窗组件
- 摘要条按成败两种文案;失败时展示逐行错误表格、分页与导出按钮 - 分页复用现成的 BasePagination,每页 20 行 - 导出走 exportCsv,文件名带本地时间后缀 - downloadCsv 经 inject 注入,测试里可替换掉真实下载动作 - 测试等两轮 nextTick 再断言表格内容:el-table 先注册列、下一轮才填单元格, 只等一个 tick 拿到的是空 tr,断言会以看不出原因的方式失败
This commit is contained in:
parent
f5e9029c46
commit
c3835fb28d
|
|
@ -0,0 +1,63 @@
|
|||
import { mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import MaterialImportResult from './MaterialImportResult.vue'
|
||||
|
||||
const failedResult = {
|
||||
total_rows: 3,
|
||||
success_rows: 0,
|
||||
failed_rows: 2,
|
||||
errors: [
|
||||
{ row: 2, data: { name: '', spec: 'M8x30', unit: '个', major: '机械结构类', minor: '标准件' }, reason: '物料名称不能为空' },
|
||||
{ row: 4, data: { name: '螺栓', spec: '', unit: '吨', major: '机械结构类', minor: '标准件' }, reason: '单位「吨」不存在或已停用' }
|
||||
]
|
||||
}
|
||||
|
||||
// el-table 分两轮渲染:先注册列,下一轮才把单元格填上。只等一个 tick 的话
|
||||
// 拿到的是空 <tr>,断言文本会莫名其妙地失败(实测过一次),所以统一等两轮。
|
||||
async function mountResult(result, options = {}) {
|
||||
const wrapper = mount(MaterialImportResult, {
|
||||
props: { result },
|
||||
global: { plugins: [ElementPlus], stubs: { teleport: true }, ...options }
|
||||
})
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('MaterialImportResult', () => {
|
||||
test('失败时展示行数摘要与每行错误', async () => {
|
||||
const wrapper = await mountResult(failedResult)
|
||||
|
||||
expect(wrapper.text()).toContain('共 3 行')
|
||||
expect(wrapper.text()).toContain('2 行未通过')
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2)
|
||||
expect(rows[0].text()).toContain('2')
|
||||
expect(rows[0].text()).toContain('物料名称不能为空')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('导出错误明细时把行号与原因写进 CSV', async () => {
|
||||
const download = vi.fn()
|
||||
const wrapper = await mountResult(failedResult, { provide: { downloadCsv: download } })
|
||||
|
||||
await wrapper.find('[data-test="export-errors"]').trigger('click')
|
||||
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
const [fileName, csv] = download.mock.calls[0]
|
||||
expect(fileName).toContain('导入错误明细')
|
||||
expect(csv).toContain('物料名称不能为空')
|
||||
expect(csv).toContain('行号')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('全部成功时只展示成功摘要,不显示错误表格', async () => {
|
||||
const wrapper = await mountResult({ total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] })
|
||||
|
||||
expect(wrapper.text()).toContain('成功导入 2 条')
|
||||
expect(wrapper.find('[data-test="error-table"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
117
frontend/src/components/business/MaterialImportResult.vue
Normal file
117
frontend/src/components/business/MaterialImportResult.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class="import-result">
|
||||
<el-alert
|
||||
v-if="result.failed_rows === 0"
|
||||
:title="`共 ${result.total_rows} 行,成功导入 ${result.success_rows} 条`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
:title="`共 ${result.total_rows} 行,${result.failed_rows} 行未通过,本次未导入任何数据`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<template v-if="result.failed_rows > 0">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" plain data-test="export-errors" @click="exportErrors">导出错误明细</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
data-test="error-table"
|
||||
:data="pagedErrors"
|
||||
size="small"
|
||||
border
|
||||
max-height="320"
|
||||
>
|
||||
<el-table-column prop="row" label="行号" width="80" />
|
||||
<el-table-column label="物料名称" width="140">
|
||||
<template #default="{ row }">{{ row.data.name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格" width="120">
|
||||
<template #default="{ row }">{{ row.data.spec || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="90">
|
||||
<template #default="{ row }">{{ row.data.unit || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大类" width="140">
|
||||
<template #default="{ row }">{{ row.data.major || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小类" width="120">
|
||||
<template #default="{ row }">{{ row.data.minor || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="失败原因" min-width="220" />
|
||||
</el-table>
|
||||
|
||||
<BasePagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="result.errors.length"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="footer">
|
||||
<el-button @click="$emit('close')">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import BasePagination from './BasePagination.vue'
|
||||
import { buildCsv, downloadCsv as defaultDownloadCsv } from '../../utils/exportCsv'
|
||||
|
||||
const props = defineProps({
|
||||
result: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['close'])
|
||||
|
||||
// 注入只用于测试替换下载动作,生产走默认实现。
|
||||
const downloadCsv = inject('downloadCsv', defaultDownloadCsv)
|
||||
|
||||
const pageSize = 20
|
||||
const currentPage = ref(1)
|
||||
|
||||
const pagedErrors = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return props.result.errors.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
function handlePageChange(page) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function exportErrors() {
|
||||
const rows = [['行号', '物料名称', '规格', '单位', '大类', '小类', '失败原因']]
|
||||
for (const item of props.result.errors) {
|
||||
rows.push([
|
||||
item.row,
|
||||
item.data?.name || '',
|
||||
item.data?.spec || '',
|
||||
item.data?.unit || '',
|
||||
item.data?.major || '',
|
||||
item.data?.minor || '',
|
||||
item.reason
|
||||
])
|
||||
}
|
||||
downloadCsv(`导入错误明细_${timestampSuffix()}.csv`, buildCsv(rows))
|
||||
}
|
||||
|
||||
// 文件名后缀用本地时间,不依赖 datetime.js 的展示格式
|
||||
function timestampSuffix() {
|
||||
const now = new Date()
|
||||
const pad = value => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||||
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-result { display: flex; flex-direction: column; gap: 12px; }
|
||||
.toolbar { display: flex; justify-content: flex-end; }
|
||||
.footer { display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user