Release internal fleet capacity pool to gray #14
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
open.value = false
|
||||
emit('cancel')
|
||||
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 }"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user