diff --git a/frontend/src/components/business/DeleteConfirm.test.js b/frontend/src/components/business/DeleteConfirm.test.js new file mode 100644 index 00000000..4ee23775 --- /dev/null +++ b/frontend/src/components/business/DeleteConfirm.test.js @@ -0,0 +1,90 @@ +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { describe, expect, test, vi } from 'vitest' +import DeleteConfirm from './DeleteConfirm.vue' + +const CONFIRM_TEXT = '确认作废' + +function mountConfirm(onConfirm) { + return mount(DeleteConfirm, { + props: { modelValue: true, confirmText: CONFIRM_TEXT, onConfirm }, + global: { + plugins: [ElementPlus], + stubs: { teleport: true } + } + }) +} + +function confirmButton(wrapper) { + return wrapper.findAll('button').find(button => button.text().includes(CONFIRM_TEXT)) +} + +// 弹窗关闭体现为子组件把 modelValue 置回 false +function closedByEmit(wrapper) { + return (wrapper.emitted('update:modelValue') || []).some(args => args[0] === false) +} + +describe('DeleteConfirm 的关闭时机', () => { + test('父操作成功后关闭弹窗', async () => { + const onConfirm = vi.fn().mockResolvedValue() + const wrapper = mountConfirm(onConfirm) + await flushPromises() + + await confirmButton(wrapper).trigger('click') + await flushPromises() + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(closedByEmit(wrapper)).toBe(true) + wrapper.unmount() + }) + + test('父操作失败时弹窗保持打开,让用户能直接重试', async () => { + const onConfirm = vi.fn().mockRejectedValue(new Error('接口失败')) + const wrapper = mountConfirm(onConfirm) + await flushPromises() + + await confirmButton(wrapper).trigger('click') + await flushPromises() + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(closedByEmit(wrapper)).toBe(false) + wrapper.unmount() + }) + + test('失败后原地点一次即可重试,不必重走确认流程', async () => { + const onConfirm = vi.fn() + .mockRejectedValueOnce(new Error('接口失败')) + .mockResolvedValueOnce() + const wrapper = mountConfirm(onConfirm) + await flushPromises() + + await confirmButton(wrapper).trigger('click') + await flushPromises() + expect(closedByEmit(wrapper)).toBe(false) + + // 弹窗还开着,直接再点一次 + await confirmButton(wrapper).trigger('click') + await flushPromises() + + expect(onConfirm).toHaveBeenCalledTimes(2) + expect(closedByEmit(wrapper)).toBe(true) + wrapper.unmount() + }) + + test('操作进行中按钮转 loading,避免重复提交', async () => { + let resolveRequest + const onConfirm = vi.fn(() => new Promise((resolve) => { resolveRequest = resolve })) + const wrapper = mountConfirm(onConfirm) + await flushPromises() + + await confirmButton(wrapper).trigger('click') + await flushPromises() + + expect(confirmButton(wrapper).classes()).toContain('is-loading') + + resolveRequest() + await flushPromises() + expect(confirmButton(wrapper).classes()).not.toContain('is-loading') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/business/DeleteConfirm.vue b/frontend/src/components/business/DeleteConfirm.vue index 830f38e4..011b9866 100644 --- a/frontend/src/components/business/DeleteConfirm.vue +++ b/frontend/src/components/business/DeleteConfirm.vue @@ -24,10 +24,17 @@ const props = defineProps({ content: { type: String, default: '确定要删除此数据吗?此操作不可恢复。' + }, + // 确认后执行的实际操作,由父组件传入并返回 Promise。 + // 这里刻意不用 emit('confirm'):emit 是同步的,拿不到父组件异步操作的结果, + // 会退化成"请求刚发出去弹窗就关了",失败时用户得把确认流程重走一遍。 + onConfirm: { + type: Function, + required: true } }) -const emit = defineEmits(['update:modelValue', 'confirm']) +const emit = defineEmits(['update:modelValue']) const visible = ref(props.modelValue) const loading = ref(false) @@ -43,8 +50,11 @@ watch(visible, (val) => { async function handleConfirm() { loading.value = true try { - emit('confirm') + await props.onConfirm() + // 只有父组件的操作成功返回才关闭弹窗:失败时留着,用户可以直接再点一次 visible.value = false + } catch (e) { + // 失败原因已由 axios 拦截器统一提示,这里只负责别把弹窗关掉 } finally { loading.value = false } diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index 73aef9c5..ea6f0404 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -125,7 +125,7 @@ - + diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 19fc1038..5103889f 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -144,7 +144,7 @@ title="确认作废该物料?" content="作废后该物料不能再用于新业务;已有业务记录仍会保留。" confirm-text="确认作废" - @confirm="confirmDelete" + :on-confirm="confirmDelete" /> @@ -394,13 +394,11 @@ function handleDelete(id) { deleteDialogVisible.value = true } +// 这里不能吞异常:DeleteConfirm 靠它判断该不该关弹窗,吞掉会让失败时误关。 +// 错误提示本来由 axios 拦截器统一负责,不吞也不会漏给用户看。 async function confirmDelete() { - try { - await materialsStore.obsoleteMaterial(deleteId.value) - ElMessage.success('物料已作废') - } catch (e) { - // 错误已由 API 层处理 - } + await materialsStore.obsoleteMaterial(deleteId.value) + ElMessage.success('物料已作废') } async function handleRestore(id) { diff --git a/frontend/src/views/Outbound.vue b/frontend/src/views/Outbound.vue index f14d4c4c..af51327c 100644 --- a/frontend/src/views/Outbound.vue +++ b/frontend/src/views/Outbound.vue @@ -79,7 +79,7 @@ - +
diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index c2d218e3..a3f4b946 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -80,7 +80,7 @@ - +