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
@@ -1,7 +1,10 @@
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardView from './DashboardView.vue'
import { getTodayNewOrderCount } from '../api/dashboard'
import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard'
const access = vi.hoisted(() => ({ permissions: new Set<string>() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
vi.mock('../api/dashboard', () => ({
getTodayNewOrderCount: vi.fn().mockResolvedValue(12),
@@ -18,7 +21,28 @@ vi.mock('../api/dashboard', () => ({
enableAutoUnmount(afterEach)
describe('DashboardView', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
access.permissions = new Set(['platform:order:list', 'platform:customer:list', 'platform:firstleg:container:list', 'platform:firstleg:customs:list'])
})
it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => {
access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel'])
const wrapper = mount(DashboardView); await flushPromises()
expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0)
expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务')
expect(wrapper.text()).not.toContain('指标加载失败'); expect(wrapper.text()).not.toContain('刷新数据')
})
it('requests only granted metric sources and requires both container and customs permissions', async () => {
access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list'])
const wrapper = mount(DashboardView); await flushPromises()
expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户'])
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises()
expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
})
it('renders only the four live metrics', async () => {
const wrapper = mount(DashboardView)
+20 -13
View File
@@ -7,6 +7,7 @@ import {
RefreshCw,
} from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import {
getActiveCustomerCount,
getDashboardOperationalMetrics,
@@ -22,6 +23,11 @@ const todayNewOrderCount = ref<number | null>(null)
const activeCustomerCount = ref<number | null>(null)
const operationalMetrics = ref<DashboardOperationalMetrics | null>(null)
const metricsUpdatedAt = ref<string | null>(null)
const access = useAccessPreviewStore()
const canReadOrders = computed(() => access.can('platform:order:list'))
const canReadCustomers = computed(() => access.can('platform:customer:list'))
const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list'))
const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value)
function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) {
if (metricsLoading.value) return '…'
@@ -30,17 +36,18 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
}
const metrics = computed(() => [
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value },
{
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
note: operationalMetrics.value
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
: '已开船且尚未到仓',
tone: 'green',
visible: canReadOperations.value,
},
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
])
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', visible: canReadOperations.value },
].filter(metric => metric.visible))
async function toggleFullscreen() {
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
@@ -55,17 +62,17 @@ async function loadMetrics() {
metricsLoading.value = true
metricsError.value = false
const [orders, customers, operations] = await Promise.allSettled([
getTodayNewOrderCount(),
getActiveCustomerCount(),
getDashboardOperationalMetrics(),
canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null),
canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null),
canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null),
])
todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null
activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null
operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null
metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected')
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf
? operations.value.asOf
: dayjs().format('YYYY-MM-DDTHH:mm:ss')
: hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null
metricsLoading.value = false
}
@@ -80,10 +87,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<section ref="dashboardRef" class="dashboard-view">
<AdminPage as="div" spacing="normal">
<section class="dashboard-context" aria-label="数据看板说明">
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
<div class="dashboard-head-actions">
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<RefreshCw :size="16" aria-hidden="true" />
刷新数据
</button>
@@ -95,7 +102,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
</div>
</section>
<div class="dashboard-metrics" aria-label="核心业务指标">
<div v-if="hasMetrics" class="dashboard-metrics" aria-label="核心业务指标">
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
<span class="dashboard-metric-mark" aria-hidden="true" />
<p>{{ metric.label }}</p>
@@ -75,6 +75,9 @@ describe('overseas business mounted pages', () => {
expect(document.body.textContent).toContain('池来源订单按实际运输履约推进')
expect(document.body.textContent).toContain('FB-TEST'); expect(document.body.textContent).toContain('— / — / —')
expect(listUsTruckOrders).toHaveBeenCalledOnce(); expect(getUsTruckOrder).not.toHaveBeenCalled()
const dialog = wrapper.findComponent(AppDialog)
await dialog.findAll('.app-dialog__footer button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
})
it('keeps a pool order read-only even when an old edit deep link is used', async () => {
routing.route.params = { orderId: 'TR-200' }; routing.route.query = { customerId: truck.customerId }
@@ -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">
@@ -25,11 +25,11 @@ function mount(component: object, props: Record<string, unknown> = {}) {
const pinia = createPinia()
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><slot/></div>' },
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmText'], emits: ['update:modelValue', 'confirm'], template: '<div v-if="modelValue"><slot/></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmText', 'beforeClose'], emits: ['update:modelValue', 'confirm'], template: '<div v-if="modelValue"><slot/></div>' },
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }, ElForm: layout, ElFormItem: layout,
ElInput: { ...field, name: 'ElInput' }, ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: { ...field, name: 'ElDatePicker' }, ElOption: true, ApiErrorMeta: true, LoadErrorState: true,
} } }); wrappers.push(wrapper); return { wrapper, pinia }
@@ -40,6 +40,7 @@ afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('待调度核对')
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
@@ -81,8 +82,9 @@ it('protects X/Esc/cancel model-close requests and does not emit close when disc
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: false }); await flushPromises()
wrapper.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '改名'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('update:modelValue', false); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledOnce(); expect(wrapper.emitted('update:modelValue')).toBeUndefined()
const done = vi.fn()
await wrapper.findComponent({ name: 'AppDialog' }).props('beforeClose')(done); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledOnce(); expect(done).not.toHaveBeenCalled(); expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('retains an uncertain request and route protection after closing and reopening its form', async () => {
vi.mocked(api.saveCapacityInquiry).mockRejectedValue(new Error('network'))
@@ -91,7 +91,7 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequenc
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
<template #times="{ row }"><div>{{ offerTime(row.plannedDepartureAt) }}</div><div>截止 {{ offerTime(row.cutoffAt) }}</div></template>
<template #loads="{ row }"><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可确认承运' : '暂不可确认' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
</DataTable>
@@ -2,21 +2,22 @@ import { computed, getCurrentInstance, reactive, ref, type Ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
/** All dialog close paths use requestClose; the shared registry also protects route/tab exits. */
export function useFleetDraftGuard(key: string, open: Ref<boolean>, model: object, busy: Ref<boolean>, uncertain: Ref<boolean>, close: () => void) {
/** Veto native closing before Element Plus changes its internal visibility; route/tab exits use the shared registry. */
export function useFleetDraftGuard(key: string, open: Ref<boolean>, model: object, busy: Ref<boolean>, uncertain: Ref<boolean>) {
const baseline = ref(''), checking = ref(false)
const snapshot = computed(() => JSON.stringify(model))
const registration = useUnsavedWorkRegistration({ key: `${key}:${getCurrentInstance()?.uid}`, label: computed(() => '拼车表单'), isOpen: computed(() => open.value || uncertain.value), model: reactive({ form: model, uncertain }), requiredPermission: computed(() => null) })
function capture() { baseline.value = snapshot.value; if (!uncertain.value) registration.captureBaseline() }
async function requestClose() {
if (busy.value || checking.value) return
async function mayClose() {
if (busy.value || checking.value) return false
if (snapshot.value !== baseline.value || uncertain.value) {
checking.value = true
try { await ElMessageBox.confirm(uncertain.value ? '上次提交结果尚未确定,原请求将保留。关闭后可重新打开并核对或重试,是否关闭?' : '表单有未保存内容,确认放弃本次修改?', '关闭表单', { customClass: 'admin-density-confirm', confirmButtonText: '确认关闭', cancelButtonText: '继续填写' }) }
catch { return }
catch { return false }
finally { checking.value = false }
}
close()
return true
}
return { capture, requestClose, dirty: computed(() => snapshot.value !== baseline.value) }
async function beforeClose(done: () => void) { if (await mayClose()) done() }
return { capture, beforeClose, dirty: computed(() => snapshot.value !== baseline.value) }
}
+16 -1
View File
@@ -1,6 +1,6 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import AppDialog from './AppDialog.vue'
@@ -13,6 +13,7 @@ const ElDialogStub = defineComponent({
closeOnClickModal: Boolean,
closeOnPressEscape: Boolean,
fullscreen: Boolean,
beforeClose: Function,
},
template: '<section data-testid="dialog"><h2 v-if="!$slots.header" data-testid="native-title">{{ title }}</h2><slot name="header" /><slot /><slot name="footer" /></section>',
})
@@ -62,6 +63,20 @@ describe('AppDialog', () => {
expect(cancelButton.attributes('disabled')).toBeDefined()
})
it('applies the same opt-in beforeClose hook to native close and the default cancel action', async () => {
let allowClose: (() => void) | undefined
const beforeClose = vi.fn((done: () => void) => { allowClose = done })
const wrapper = mountDialog({ beforeClose })
expect(wrapper.getComponent(ElDialogStub).props('beforeClose')).toBe(beforeClose)
await wrapper.findAll('button')[0]!.trigger('click')
expect(beforeClose).toHaveBeenCalledOnce()
expect(wrapper.emitted('cancel')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
allowClose!()
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('can render a content-only dialog without footer actions', () => {
const wrapper = mountDialog({ showFooter: false })
expect(wrapper.findAll('button')).toHaveLength(0)
+6
View File
@@ -25,6 +25,7 @@ const props = withDefaults(defineProps<{
lockWhileSubmitting?: boolean
fullscreenable?: boolean
defaultFullscreen?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>(), {
width: 'min(560px, calc(100vw - 28px))',
loading: false,
@@ -63,8 +64,12 @@ function toggleFullscreen() {
function closeDialog() {
if (submittingLocked.value) return
const done = () => {
open.value = false
emit('cancel')
}
if (props.beforeClose) void props.beforeClose(done)
else done()
}
</script>
@@ -79,6 +84,7 @@ function closeDialog() {
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
:before-close="beforeClose"
:fullscreen="fullscreenable && fullscreen"
:class="{ 'has-native-fullscreen-control': fullscreenable && !$slots.header }"
>