fix(fleet): preserve drafts before dialog close and respect dashboard permissions

This commit is contained in:
2026-10-07 18:21:03 +08:00
parent 2477667c27
commit 2bc095ecac
11 changed files with 150 additions and 34 deletions
@@ -0,0 +1,58 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia } from 'pinia'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
import type { CapacityOffer } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { listCapacityOffers } from '../api/capacityOffers'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
const wrappers: VueWrapper[] = []
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }) })
afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = '' })
function mountDialog(kind: 'inquiry' | 'settings') {
const pinia = createPinia()
const wrapper = kind === 'inquiry'
? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } })
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus] } })
wrappers.push(wrapper)
return { wrapper, pinia }
}
async function continueEditing() {
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
expect(message).not.toBeNull()
const button = [...message.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续填写')!
expect(button).toBeTruthy(); button.click(); await flushPromises()
await new Promise(resolve => setTimeout(resolve, 50)); await flushPromises()
}
it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited input visible through cancel, Escape and X vetoes', async kind => {
const { wrapper, pinia } = mountDialog(kind)
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
await flushPromises()
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
expect(input.exists()).toBe(true)
await input.setValue('UI-未保存检查'); await flushPromises()
const assertPreserved = () => {
expect(wrapper.find('.app-dialog').isVisible()).toBe(true)
expect(input.element.isConnected).toBe(true)
expect(input.element.value).toBe('UI-未保存检查')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1)
}
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
assertPreserved(); await continueEditing(); assertPreserved()
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises()
assertPreserved(); await continueEditing(); assertPreserved()
await wrapper.get('.el-dialog__headerbtn').trigger('click'); await flushPromises()
assertPreserved(); await continueEditing(); assertPreserved()
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
const accept = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '确认关闭') as HTMLButtonElement
accept.click(); await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
@@ -26,7 +26,7 @@ const writable = computed(() => !props.readonly && (access.can('platform:fleet:c
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
const lock = computed(() => !writable.value || busy.value || !!pending.value)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value), () => { open.value = false })
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
let generation = 0, customerSequence = 0, quoteSequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
function resetQuotes() { quoteSequence++; quoteRows.value = []; quoteSearched.value = false; quoteError.value = null; quoteLoading.value = false; quotePage.value = 1; quotePages.value = 0 }
@@ -78,7 +78,7 @@ async function save() {
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
</script>
<template>
<AppDialog :model-value="open" @update:model-value="!$event && guard.requestClose()" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
@@ -19,7 +19,7 @@ const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark:
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
let generation = 0
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value), () => { open.value = false })
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value))
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
@@ -51,7 +51,7 @@ async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
}
onBeforeUnmount(() => { generation++ })
</script>
<template><AppDialog :model-value="open" @update:model-value="!$event && guard.requestClose()" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">