fix(appointments): unify calendar layout and cabinet numbers
This commit is contained in:
@@ -3,6 +3,19 @@ import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
|
||||
describe('appointment calendar interactions', () => {
|
||||
it('keeps creation beside the title and record actions beside data notices', () => {
|
||||
const wrapper = mount(AppointmentCalendarPanel, {
|
||||
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
|
||||
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
|
||||
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
|
||||
expect(actions.text()).toContain('未定时安排')
|
||||
expect(actions.text()).toContain('取消记录')
|
||||
expect(actions.get('summary').text()).toContain('数据提示')
|
||||
wrapper.unmount()
|
||||
})
|
||||
const render = () => mount(AppointmentCalendarPanel, {
|
||||
props: { initialDate: '2026-09-24', canCreate: true, events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
|
||||
|
||||
@@ -84,10 +84,9 @@ defineExpose({ selectedDate, mode })
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图">
|
||||
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
|
||||
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
|
||||
@@ -97,6 +96,10 @@ defineExpose({ selectedDate, mode })
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
|
||||
<slot name="toolbar-actions" />
|
||||
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-options">
|
||||
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
|
||||
@@ -149,6 +152,8 @@ defineExpose({ selectedDate, mode })
|
||||
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.calendar-label > svg { color: #617ce9; }
|
||||
.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
|
||||
|
||||
@@ -40,7 +40,7 @@ function render(openCalendar = true) {
|
||||
const unregister = register(() => !routing.containerPending)
|
||||
onBeforeUnmount(unregister)
|
||||
}, template: '<div>整柜跟进工作区</div>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /></div>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /><slot name="toolbar-actions" /></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '<div v-if="modelValue"><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
@@ -70,6 +70,19 @@ describe('appointment calendar data integration', () => {
|
||||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||||
})
|
||||
|
||||
it('displays the existing D cabinet number in both arrival and pickup events', async () => {
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0007', arrivalAt: '2026-10-09T08:00:00', arrivalType: 'ESTIMATED' }])
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [{ containerBookingId: '1', cabinetNo: 'D0007', containerNo: null, destinationPortName: null, destinationWarehouseCode: null, fleetSupplierName: null, pickupAppointmentAt: '2026-10-09T09:00:00', actualPickupAt: null }], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
|
||||
const wrapper = render(); await flushPromises()
|
||||
const events = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')
|
||||
expect(events).toHaveLength(2)
|
||||
for (const event of events) {
|
||||
expect(event.fields).toContainEqual({ label: '柜次号', value: 'D0007' })
|
||||
expect(event.title).toContain('D0007')
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens a dated calendar appointment read-only for list-only users', async () => {
|
||||
routing.canWrite=false
|
||||
const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]}
|
||||
@@ -90,6 +103,7 @@ describe('appointment calendar data integration', () => {
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper=render(); await flushPromises()
|
||||
await wrapper.get('[data-unscheduled]').trigger('click')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
|
||||
@@ -97,13 +111,13 @@ describe('appointment calendar data integration', () => {
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => {
|
||||
it('loads cancellation records only after opening the dialog and opens cancelled appointments read-only', async () => {
|
||||
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
|
||||
const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')!
|
||||
;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle')
|
||||
const records=wrapper.get('[data-cancellations]')
|
||||
await records.trigger('click')
|
||||
await wrapper.get('[data-history-view]').trigger('click');await flushPromises()
|
||||
expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
@@ -119,8 +133,8 @@ describe('appointment calendar data integration', () => {
|
||||
it('refreshes an expanded cancellation list after a successful cancellation', async () => {
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}])
|
||||
const wrapper=render();await flushPromises()
|
||||
const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')!
|
||||
;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle')
|
||||
const records=wrapper.get('[data-cancellations]')
|
||||
await records.trigger('click')
|
||||
const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm
|
||||
wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises()
|
||||
wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises()
|
||||
@@ -150,6 +164,7 @@ describe('appointment calendar data integration', () => {
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 })
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-unscheduled]').trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!
|
||||
@@ -179,7 +194,7 @@ describe('appointment calendar data integration', () => {
|
||||
expect(wrapper.text()).not.toContain('缩小日期范围')
|
||||
})
|
||||
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',cabinetNo: 'D0001',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||||
const wrapper=render(); await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})])
|
||||
@@ -196,7 +211,7 @@ describe('appointment calendar data integration', () => {
|
||||
})
|
||||
it('keeps successful arrival data visible when the pickup source fails', async () => {
|
||||
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',cabinetNo:'D0001',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
|
||||
const wrapper=render(); await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
|
||||
@@ -248,7 +263,7 @@ describe('appointment calendar data integration', () => {
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
|
||||
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('查询可预约货物')
|
||||
expect(wrapper.find('details').attributes('open')).toBeUndefined()
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
|
||||
@@ -283,6 +298,8 @@ describe('appointment calendar data integration', () => {
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('未定时安排(1)')
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||||
expect(wrapper.findComponent({name:'DataTable'}).exists()).toBe(false)
|
||||
await wrapper.get('[data-unscheduled]').trigger('click')
|
||||
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
|
||||
})
|
||||
|
||||
@@ -335,7 +352,7 @@ describe('appointment calendar data integration', () => {
|
||||
|
||||
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
|
||||
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0001', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||||
|
||||
@@ -31,6 +31,7 @@ import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, typ
|
||||
import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar'
|
||||
|
||||
const cancellationTarget = ref<FbaAppointmentDto | null>(null)
|
||||
const unscheduledOpen = ref(false)
|
||||
const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false)
|
||||
const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment))
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -130,22 +131,22 @@ const columns: DataTableColumn[] = [
|
||||
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
|
||||
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
|
||||
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
|
||||
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
|
||||
title: `${arrival.voyageNo || arrival.cabinetNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
|
||||
fields: [
|
||||
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
|
||||
{ label: '目的港', value: arrival.destinationPortName },
|
||||
{ label: '已装货柜号', value: arrival.containerNo },
|
||||
{ label: '柜次号', value: arrival.bookingNo },
|
||||
{ label: '柜次号', value: arrival.cabinetNo },
|
||||
{ label: '货物去向', value: arrival.destinationWarehouseCode },
|
||||
],
|
||||
})), ...pickups.value.map(pickup => ({
|
||||
key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const,
|
||||
date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16),
|
||||
title: `${pickup.containerNo || pickup.bookingNo || '未关联柜号'} 预约提柜`,
|
||||
title: `${pickup.containerNo || pickup.cabinetNo || '未关联柜号'} 预约提柜`,
|
||||
sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`,
|
||||
subtitle: '美国目的仓相关提柜安排',
|
||||
fields: [
|
||||
{ label: '柜次号', value: pickup.bookingNo }, { label: '目的港', value: pickup.destinationPortName },
|
||||
{ label: '柜次号', value: pickup.cabinetNo }, { label: '目的港', value: pickup.destinationPortName },
|
||||
{ label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName },
|
||||
{ label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' },
|
||||
{ label: '时间口径', value: '原提柜业务时间,港口时区待核实' },
|
||||
@@ -416,13 +417,23 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
<div v-if="calendarVisited" v-show="workView === 'calendar'">
|
||||
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
|
||||
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details>
|
||||
<details class="appointment-extra" @toggle="cancellationsOpen = ($event.target as HTMLDetailsElement).open"><summary>取消记录</summary><AppointmentCancellationRecords v-if="cancellationsOpen" :key="cancellationsRevision" @view="openEdit({key: $event}, true)" /></details>
|
||||
<AppointmentCalendarPanel :notices="[pickupNotice, showTripLayer ? tripNotice : ''].filter(Boolean)" :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @view="openEdit($event, true)" @edit="openEdit" @delete="remove" @open="openSource">
|
||||
<template #toolbar-actions>
|
||||
<el-button aria-haspopup="dialog" data-unscheduled @click="unscheduledOpen = true">未定时安排({{ unscheduledError ? '加载失败' : unscheduledTotal }})</el-button>
|
||||
<el-button aria-haspopup="dialog" data-cancellations @click="cancellationsOpen = true">取消记录</el-button>
|
||||
</template>
|
||||
<template #notice-actions><el-button v-if="showTripLayer && unresolvedTrips.length" link type="primary" @click="unresolvedDialogOpen = true">查看待核实车次({{ unresolvedTrips.length }})</el-button></template>
|
||||
</AppointmentCalendarPanel>
|
||||
</div>
|
||||
<AppDialog v-if="unscheduledOpen" v-model="unscheduledOpen" title="未定时安排" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
|
||||
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
|
||||
<DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" :results-visible="!unscheduledError" row-key="appointmentNo" empty-text="暂无未定时安排" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()">
|
||||
<template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="cancellationsOpen" v-model="cancellationsOpen" title="取消记录" width="min(1160px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
|
||||
<AppointmentCancellationRecords :key="cancellationsRevision" @view="openEdit({key: $event}, true)" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="unresolvedDialogOpen" v-model="unresolvedDialogOpen" :title="`待核实车次(${unresolvedTrips.length})`" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog">
|
||||
<p>这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。</p>
|
||||
<DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1">
|
||||
@@ -470,7 +481,5 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
|
||||
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
|
||||
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.appointment-extra { margin-bottom: var(--admin-space-5); }
|
||||
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
|
||||
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
|
||||
</style>
|
||||
|
||||
@@ -7,15 +7,38 @@ vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSou
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
function render() {
|
||||
return mount(AppointmentCargoPanel, { global: { stubs: {
|
||||
function render(attach = false) {
|
||||
return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true, RowActions: true,
|
||||
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '<section class="integrated-query-group"><slot name="query-tabs"/></section>' },
|
||||
} } })
|
||||
}
|
||||
describe('appointment planning cargo query', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
|
||||
it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => {
|
||||
const wrapper = render(true); await flushPromises()
|
||||
const tabs = () => wrapper.findAll('[role="tab"]')
|
||||
expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物'])
|
||||
expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true)
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
await tabs()[1]!.trigger('click')
|
||||
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true)
|
||||
await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(tabs()[0]!.element)
|
||||
expect(tabs()[1]!.attributes('tabindex')).toBe('-1')
|
||||
await tabs()[0]!.trigger('keydown', { key: 'End' })
|
||||
await flushPromises()
|
||||
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(tabs()[1]!.element)
|
||||
await tabs()[1]!.trigger('keydown', { key: 'Home' })
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses one server-page request per entry, page change and reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
|
||||
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -149,11 +150,8 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="group" aria-label="预约货源类型">
|
||||
<el-button :type="candidateMode === 'firstleg' ? 'primary' : 'default'" @click="candidateMode = 'firstleg'">头程货物</el-button>
|
||||
<el-button :type="candidateMode === 'external' ? 'primary' : 'default'" @click="candidateMode = 'external'">外接货物</el-button>
|
||||
</div>
|
||||
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
|
||||
<template #query-tabs><AppointmentCargoTabs v-model="candidateMode" /></template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
@@ -173,7 +171,12 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
|
||||
<template #filters>
|
||||
<section class="integrated-query-group" aria-label="头程货物类型与筛选">
|
||||
<AppointmentCargoTabs v-if="candidateMode === 'firstleg'" v-model="candidateMode" />
|
||||
<QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||
</section>
|
||||
</template>
|
||||
<template #notices>
|
||||
<div v-if="externalSource" class="cargo-note">
|
||||
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mode = defineModel<'firstleg' | 'external'>({ required: true })
|
||||
const tabs = [{ key: 'firstleg', label: '头程货物' }, { key: 'external', label: '外接货物' }] as const
|
||||
async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
let next: number
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
|
||||
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = tabs.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
const key = tabs[next]!.key
|
||||
mode.value = key
|
||||
await nextTick()
|
||||
document.getElementById(`appointment-cargo-tab-${key}`)?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="预约货源类型">
|
||||
<button v-for="(tab, index) in tabs" :id="`appointment-cargo-tab-${tab.key}`" :key="tab.key" type="button" role="tab"
|
||||
:aria-selected="mode === tab.key" :tabindex="mode === tab.key ? 0 : -1"
|
||||
:class="{ 'is-active': mode === tab.key }"
|
||||
@click="mode = tab.key" @keydown="handleKeydown($event, index)">{{ tab.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,7 +44,12 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #toolbar-actions><slot name="toolbar-actions" /></template>
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
|
||||
<template #filters>
|
||||
<section class="integrated-query-group" aria-label="外接货物类型与筛选">
|
||||
<slot name="query-tabs" />
|
||||
<QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||
</section>
|
||||
</template>
|
||||
<template #notices>
|
||||
<slot name="notices" />
|
||||
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
|
||||
@@ -32,9 +32,13 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.data-table__filters [role="tablist"]').text()).toContain('头程货物')
|
||||
await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
|
||||
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('.data-table__filters [role="tablist"]')).toHaveLength(1)
|
||||
expect(wrapper.get('.data-table__filters [role="tablist"]').isVisible()).toBe(true)
|
||||
expect(wrapper.get('.data-table__filters [role="tab"][aria-selected="true"]').text()).toBe('外接货物')
|
||||
expect(wrapper.text()).toContain('B-1')
|
||||
await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
| 页面/操作 | 原型承载与布局 | 实现约束 |
|
||||
|---|---|---|
|
||||
| 补充计划时段 | 用户2026-10-12新增预约截图:开始、结束时段输入框各占整行过长 | 共用 FbaAppointmentFields 的补充区域沿用三列网格,计划开始、结束时段同排各占一列,与主字段对齐;夏令时说明整行、两个时差输入同排。日历新增/编辑、合批安排、入仓单预约同步生效;字段语义及保存不变,待人工截图确认 |
|
||||
| 预约补充记录入口 | 用户2026-10-12截图1:原顶部未定时安排、取消记录两行默认折叠文字要求优化 | 用户2026-10-12后续确认覆盖页内展开方案:入口并入日历工具栏,与数据提示同组;未定时安排1000px、取消记录1160px居中弹框,保留独立分页及错误重试,取消记录仅打开时加载。新增预约紧邻日历标题,月/周及日期操作连续排列 |
|
||||
| 送仓与排车货源页签 | 用户2026-10-12截图2、3:参考尾程订单下划线页签 | 头程货物、外接货物通过同一局部页签组件复用 density-status-tabs,两种模式均放入表格 filters 的 integrated-query-group 内并紧邻搜索;点击及方向键/Home/End切换,保留跨货源选择;新增、编辑、取消预约沿用原承载方式。待人工1920×1080、100%截图确认 |
|
||||
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
|
||||
| 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
|
||||
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
|
||||
|
||||
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface ArrivalCalendarEntry {
|
||||
containerBookingId: string
|
||||
bookingNo: string
|
||||
cabinetNo: string
|
||||
containerNo?: string | null
|
||||
vesselName?: string | null
|
||||
voyageNo?: string | null
|
||||
@@ -14,7 +14,7 @@ export interface ArrivalCalendarEntry {
|
||||
|
||||
export interface PickupCalendarEntry {
|
||||
containerBookingId: string
|
||||
bookingNo: string
|
||||
cabinetNo: string
|
||||
containerNo: string | null
|
||||
destinationPortName: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
|
||||
Reference in New Issue
Block a user