feat: 物料页面接入批量导入与模板下载
- 顶部加「下载模板」「批量导入」两个按钮和隐藏的 xlsx 文件输入 - 上传走保存互斥,飞行期间禁止切换公司;importing 标志挡住重复选文件 - 发起请求前清空 input.value,否则连续选同一个文件不会再触发 change - 校验失败(400 且 data 带 errors)弹结果弹窗,其余错误走兜底提示 - 成功后刷新到第一页并提示导入条数 测试里挂载后先把 fetchMaterials mockClear 掉:onMounted 已经拉过一次列表, 不清空的话「未被调用」必然失败、成功用例的断言也没有区分度;成功那条改成断言 具体的 (1, '', 'all') 参数。
This commit is contained in:
parent
326cf77010
commit
7f2b296339
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,18 @@
|
|||
<div class="materials-page">
|
||||
<div class="page-header">
|
||||
<h2>物料主数据</h2>
|
||||
<el-button type="primary" @click="showAddModal">新增物料</el-button>
|
||||
<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%;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user