From 7f2b296339733e06d681aee4c4555d3e43578276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Sat, 10 Oct 2026 16:30:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=A9=E6=96=99=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶部加「下载模板」「批量导入」两个按钮和隐藏的 xlsx 文件输入 - 上传走保存互斥,飞行期间禁止切换公司;importing 标志挡住重复选文件 - 发起请求前清空 input.value,否则连续选同一个文件不会再触发 change - 校验失败(400 且 data 带 errors)弹结果弹窗,其余错误走兜底提示 - 成功后刷新到第一页并提示导入条数 测试里挂载后先把 fetchMaterials mockClear 掉:onMounted 已经拉过一次列表, 不清空的话「未被调用」必然失败、成功用例的断言也没有区分度;成功那条改成断言 具体的 (1, '', 'all') 参数。 --- frontend/src/views/Materials.test.js | 64 ++++++++++++++++++ frontend/src/views/Materials.vue | 99 +++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 70287966..0bbad1b2 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -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() + }) +}) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 3a5c24b4..3d4c6b03 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -2,7 +2,18 @@
@@ -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%;