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 @@
@@ -162,6 +173,10 @@
:on-confirm="confirmDelete"
/>
+
+
+
+
@@ -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%;