From 20b4d1425b8057f91e5d9e23a9b3821cfb7f9913 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 11 Oct 2026 21:09:30 +0800 Subject: [PATCH] fix(appointments): ignore stale impact history responses --- .../AppointmentImpactDialog.test.ts | 45 +++++++++++++++++++ .../AppointmentImpactDialog.vue | 14 +++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts b/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts index a408f60b..f58855c8 100644 --- a/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts +++ b/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts @@ -11,6 +11,51 @@ const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI- const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '
' } } } }) describe('appointment impact handling', () => { beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) }) + function deferred() { + let resolve!: (value: ImpactDetail) => void, reject!: (reason: Error) => void + const promise=new Promise((yes,no)=>{resolve=yes;reject=no}) + return {promise,resolve,reject} + } + const pageDetail=(label:string):ImpactDetail=>({...detail,history:{total:60,records:[{version:1,action:'RECORD',operatorName:null,result:label,nextAction:null,followupDate:null,createdAt:'2026-10-11T00:00:00'}]}}) + it('keeps the latest history page when responses finish in reverse order',async()=>{ + vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页')) + const wrapper=render();await flushPromises() + const second=deferred(),third=deferred() + vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise) + const pager=wrapper.findComponent({name:'ElPagination'}) + pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2) + pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3) + await flushPromises() + third.resolve(pageDetail('第三页最新'));await flushPromises() + second.resolve(pageDetail('第二页过期'));await flushPromises() + expect(wrapper.text()).toContain('第三页最新');expect(wrapper.text()).not.toContain('第二页过期') + wrapper.unmount() + }) + it('ignores an obsolete failure and keeps loading until the current request finishes',async()=>{ + vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页')) + const wrapper=render();await flushPromises() + const second=deferred(),third=deferred() + vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise) + const pager=wrapper.findComponent({name:'ElPagination'}) + pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2) + pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3) + second.reject(new Error('obsolete page failure'));await flushPromises() + expect(wrapper.text()).not.toContain('obsolete page failure') + expect(pager.props('disabled')).toBe(true) + expect(wrapper.findAll('button').find(b=>b.text()==='认领跟进')!.attributes('disabled')).toBeDefined() + wrapper.findAllComponents({name:'ElButton'}).find(b=>b.text()==='认领跟进')!.vm.$emit('click') + await flushPromises();expect(handleImpact).not.toHaveBeenCalled() + third.resolve(pageDetail('第三页最新'));await flushPromises() + expect(pager.props('disabled')).toBe(false);wrapper.unmount() + }) + it('does not let a closed dialog response replace a newly opened impact',async()=>{ + const old=deferred();vi.mocked(getImpact).mockReturnValueOnce(old.promise) + const first=render();await flushPromises();first.unmount() + vi.mocked(getImpact).mockResolvedValueOnce({...pageDetail('新影响历史'),impact:{...detail.impact,impactNo:'AI-NEW'}}) + const next=render();await flushPromises() + old.resolve(pageDetail('已关闭历史'));await flushPromises() + expect(next.text()).toContain('AI-NEW');expect(next.text()).not.toContain('已关闭历史');next.unmount() + }) it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => { const wrapper = render(false); await flushPromises() expect(wrapper.text()).toContain('2026-10-15T09:00:00') diff --git a/src/modules/appointment-calendar/AppointmentImpactDialog.vue b/src/modules/appointment-calendar/AppointmentImpactDialog.vue index fcc57bdc..5b42d60f 100644 --- a/src/modules/appointment-calendar/AppointmentImpactDialog.vue +++ b/src/modules/appointment-calendar/AppointmentImpactDialog.vue @@ -12,14 +12,16 @@ const router = useRouter(), detail = ref(null), loading = r const error = ref(null), pending = ref(null) const form = reactive({ result: '', nextAction: '', followupDate: '' }) const actions: Record = { OBSERVED: '船期核对', CLAIM: '认领', RECORD: '记录处理', RESOLVE: '完成核对' } +let loadGeneration = 0 async function load() { + const generation = ++loadGeneration loading.value = true; error.value = null - try { detail.value = await getImpact(props.containerId, historyPage.value) } - catch (cause) { error.value = getApiErrorDetails(cause, '影响详情加载失败') } - finally { loading.value = false } + try { const result = await getImpact(props.containerId, historyPage.value); if (generation === loadGeneration) detail.value = result } + catch (cause) { if (generation === loadGeneration) error.value = getApiErrorDetails(cause, '影响详情加载失败') } + finally { if (generation === loadGeneration) loading.value = false } } async function save(action: ImpactCommand['action']) { - if (!detail.value || saving.value || !props.canWrite) return + if (!detail.value || loading.value || saving.value || !props.canWrite) return if (!pending.value && action !== 'CLAIM' && !form.result.trim()) { ElMessage.warning('请填写实际处理结果'); return } pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null } saving.value = true; error.value = null @@ -33,7 +35,7 @@ function beforeClose(done: () => void) { if (canLeave()) done() } function openTrip(tripNo: string) { if (canLeave()) void router.push(`/overseas-master-data/trips/${encodeURIComponent(tripNo)}`) } const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined) const unregister = register?.(canLeave) -onBeforeUnmount(() => unregister?.()) +onBeforeUnmount(() => { ++loadGeneration; unregister?.() }) onBeforeRouteLeave(canLeave) onBeforeRouteUpdate(canLeave) onMounted(load) @@ -65,7 +67,7 @@ onMounted(load) 完成本次核对 重试本次处理 -
观察及处理历史({{ detail.history.total }} 条)

{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC
{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}

+
观察及处理历史({{ detail.history.total }} 条)

{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC
{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}