feat: 物料页面接入批量导入与模板下载

- 顶部加「下载模板」「批量导入」两个按钮和隐藏的 xlsx 文件输入
- 上传走保存互斥,飞行期间禁止切换公司;importing 标志挡住重复选文件
- 发起请求前清空 input.value,否则连续选同一个文件不会再触发 change
- 校验失败(400 且 data 带 errors)弹结果弹窗,其余错误走兜底提示
- 成功后刷新到第一页并提示导入条数

测试里挂载后先把 fetchMaterials mockClear 掉:onMounted 已经拉过一次列表,
不清空的话「未被调用」必然失败、成功用例的断言也没有区分度;成功那条改成断言
具体的 (1, '', 'all') 参数。
This commit is contained in:
文睿 张 2026-10-10 16:30:27 +08:00
parent 326cf77010
commit 7f2b296339
2 changed files with 162 additions and 1 deletions

View File

@ -20,6 +20,11 @@ const materialsStore = vi.hoisted(() => ({
getCategoryDisplay: vi.fn(() => '-')
}))
const materialsApi = vi.hoisted(() => ({
importFile: vi.fn(),
downloadImportTemplate: vi.fn()
}))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
@ -42,6 +47,7 @@ vi.mock('../api/masterData', () => ({
unitsApi: { list: vi.fn() },
codeRulesApi: { next: vi.fn() }
}))
vi.mock('../api/materials', () => ({ materialsApi }))
// 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded",
// 这里用替身直接派发 change,模拟用户选中某个分类。
@ -607,3 +613,61 @@ describe('物料图片上传的保存互斥', () => {
wrapper.unmount()
})
})
describe('批量导入', () => {
beforeEach(() => {
materialsApi.importFile.mockReset()
materialsApi.downloadImportTemplate.mockReset()
materialsStore.fetchMaterials.mockReset()
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
})
test('批量导入成功后提示成功并刷新列表', async () => {
materialsApi.importFile.mockResolvedValue({
data: { total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] }
})
const wrapper = mountMaterials()
await flushPromises()
// 挂载时 onMounted 已经拉过一次列表,清掉才能断言“导入后又拉了一次”
materialsStore.fetchMaterials.mockClear()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
})
await flushPromises()
expect(materialsApi.importFile).toHaveBeenCalled()
expect(materialsStore.fetchMaterials).toHaveBeenCalledWith(1, '', 'all')
expect(ElMessage.success).toHaveBeenCalledWith('成功导入 2 条物料')
wrapper.unmount()
})
test('批量导入失败时展示错误明细且不刷新列表', async () => {
const error = new Error('校验未通过')
error.response = {
data: {
code: 400,
message: '有 1 行校验未通过,本次未导入任何数据',
data: {
total_rows: 1, success_rows: 0, failed_rows: 1,
errors: [{ row: 2, data: { name: '', spec: '', unit: '', major: '', minor: '' }, reason: '物料名称不能为空' }]
}
}
}
materialsApi.importFile.mockRejectedValue(error)
const wrapper = mountMaterials()
await flushPromises()
materialsStore.fetchMaterials.mockClear()
await setupState(wrapper).handleImportFile({
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
})
await flushPromises()
expect(setupState(wrapper).importResult.failed_rows).toBe(1)
expect(materialsStore.fetchMaterials).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@ -2,7 +2,18 @@
<div class="materials-page">
<div class="page-header">
<h2>物料主数据</h2>
<div class="header-actions">
<el-button @click="handleDownloadTemplate">下载模板</el-button>
<el-button @click="triggerImport">批量导入</el-button>
<el-button type="primary" @click="showAddModal">新增物料</el-button>
<input
ref="importInput"
type="file"
accept=".xlsx"
style="display: none"
@change="handleImportFile"
/>
</div>
</div>
<div class="search-bar">
@ -162,6 +173,10 @@
:on-confirm="confirmDelete"
/>
<el-dialog v-model="importDialogVisible" title="批量导入结果" width="900px" @closed="handleImportDialogClosed">
<MaterialImportResult v-if="importResult" :result="importResult" @close="importDialogVisible = false" />
</el-dialog>
<AlertTip :message="alertMessage" :type="alertType" />
</div>
</template>
@ -175,9 +190,11 @@ import { useSavingGuard } from '../composables/useSavingGuard'
import BaseTable from '../components/business/BaseTable.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import AlertTip from '../components/business/AlertTip.vue'
import MaterialImportResult from '../components/business/MaterialImportResult.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createMaterialForm, toMaterialPayload } from './materialForm'
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
import { materialsApi } from '../api/materials'
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
import { searchMaterialOptions } from '../utils/materialSearch'
import { errorText } from '../utils/errorText'
@ -212,6 +229,10 @@ const alertMessage = ref('')
const alertType = ref('success')
const imagePreview = ref('')
const imageInput = ref(null)
const importInput = ref(null)
const importDialogVisible = ref(false)
const importResult = ref(null)
const importing = ref(false)
const categories = ref([])
const units = ref([])
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
@ -253,6 +274,78 @@ function handleImageUpload(e) {
}
}
// 批量导入入口:上传期间再点不该重新弹选择框
function triggerImport() {
if (importing.value) return
importInput.value?.click()
}
async function handleImportFile(event) {
const file = event.target.files?.[0]
// 清空 input,否则连续选同一个文件不会再触发 change
event.target.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
ElMessage.warning('只支持 .xlsx 文件')
return
}
if (file.size > 5 * 1024 * 1024) {
ElMessage.warning('文件不能超过 5MB')
return
}
const formData = new FormData()
formData.append('file', file)
importing.value = true
try {
// 导入是写请求:包进保存互斥,飞行期间禁止切换公司
const res = await runWithSaving(() => materialsApi.importFile(formData))
const result = res.data
await materialsStore.fetchMaterials(1, '', 'all')
searchKeyword.value = ''
ElMessage.success(`成功导入 ${result.success_rows} 条物料`)
} catch (e) {
// 校验失败时响应里带逐行明细,弹窗展示;其余错误走兜底提示
const detail = e?.response?.data?.data
if (detail && Array.isArray(detail.errors)) {
importResult.value = detail
importDialogVisible.value = true
} else if (!e?.response) {
console.error('批量导入失败:', e)
ElMessage.error(errorText(e, '批量导入失败'))
}
} finally {
importing.value = false
}
}
function handleImportDialogClosed() {
importResult.value = null
}
async function handleDownloadTemplate() {
try {
const res = await runWithSaving(() => materialsApi.downloadImportTemplate())
if (!res.ok) {
ElMessage.error('模板下载失败')
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '物料导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (e) {
console.error('下载模板失败:', e)
ElMessage.error('模板下载失败')
}
}
const form = reactive(createMaterialForm())
onMounted(async () => {
@ -507,6 +600,10 @@ useOrganizationSwitchGuard(() => hasUnsavedChanges.value)
align-items: center;
margin-bottom: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
.search-bar {
display: flex;
width: 100%;