diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index 753a569b..38fc7982 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -1,120 +1,69 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' +import { scheduleTypes, type ScheduleEvent } from './schedule' -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': '' }, - global: { stubs: { ElButton: { template: '' } }, 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 event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] }) +const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events }, + global: { stubs: { ElButton: { template: '' } } } }) +describe('four-type appointment calendar', () => { + it('shows all four types by default with a calendar and persistent daily detail', () => { + const wrapper = render(scheduleTypes.map(type => event(type))) + expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4) + expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓') + expect(wrapper.findAll('.detail-event')).toHaveLength(4) + expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排') + expect(wrapper.findAll('.calendar-day')).toHaveLength(42) + expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/) }) - 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 }, - ] }, - global: { stubs: { ElButton: { template: '' } }, directives: { loading: {} } }, - }) - - it('opens a readable appointment without exposing write actions', async () => { - const wrapper = render() - const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false} - await wrapper.setProps({events:[event]}) - const actions=wrapper.get('.detail-event footer') - expect(actions.text()).toBe('查看预约') - await actions.get('button').trigger('click') - expect(wrapper.emitted('view')?.[0]).toEqual([event]) - expect(wrapper.emitted('edit')).toBeUndefined() - wrapper.unmount() - }) - it('hides optional references by default and counts only appointments after enabling them', async () => { - const wrapper = render() - await wrapper.setProps({ events: [ - { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] }, - { key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] }, - { key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] }, - ] }) + it('filters locally without requesting another range', async () => { + const wrapper = render([event('arrival'), event('dispatch')]) + await wrapper.get('[aria-label="安排类型"] input').setValue(false) expect(wrapper.findAll('.detail-event')).toHaveLength(1) - const switches = wrapper.get('[aria-label="参考图层"]').findAll('input') - await switches[0]!.setValue(true); await switches[1]!.setValue(true) - expect(wrapper.findAll('.detail-event')).toHaveLength(3) - expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约') - expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划') + await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing') + expect(wrapper.text()).toContain('当天暂无安排') expect(wrapper.emitted('range')).toBeUndefined() }) - it('selects an already loaded date without requesting data again', async () => { + it('changes the selected date locally and requests one range per mode or period change', async () => { const wrapper = render() - await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click') + await wrapper.get('[data-date="2026-10-20"]').trigger('click') expect(wrapper.emitted('range')).toBeUndefined() - expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约') - }) - - it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => { - const wrapper = render() - await wrapper.setProps({ events: [ - { key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] }, - { key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] }, - ] }) - expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true) - expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true) - expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓') - expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束') - expect(wrapper.emitted('range')).toBeUndefined() - }) - - it('requests exactly one range for a view switch and navigates seven days', async () => { - const wrapper = render() + expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click') - expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]]) + expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]]) await wrapper.get('[aria-label="下一周"]').trigger('click') - expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }]) + expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }]) }) - - it('keeps busy days to three previews in both views and exposes the full count', async () => { - const wrapper = render() - await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({ - key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00', - title: `船舶 ${index}`, fields: [], - })) }) - await wrapper.get('[aria-label="参考图层"] input').setValue(true) - for (const view of ['month', 'week']) { - if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click') - const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]') - expect(day.findAll('.event-chip')).toHaveLength(3) - expect(day.attributes('title')).toContain('共 233 条安排') - expect(day.get('.event-more').text()).toBe('还有 230 条') - expect(wrapper.findAll('.detail-event')).toHaveLength(233) - } + it('limits previews to three while retaining every event in the daily details', () => { + const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index))) + expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3) + expect(wrapper.get('.event-more').text()).toBe('还有 20 条') + expect(wrapper.findAll('.detail-event')).toHaveLength(23) }) - - it('uses the selected day for creation and emits edit/delete only from detail actions', async () => { - const wrapper = render() - await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click') - expect(wrapper.emitted('create')).toEqual([['2026-09-24']]) - await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click') - await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click') - expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' }) - expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' }) + it('keeps undated records outside date cells and exposes them explicitly', async () => { + const wrapper = render([{ ...event('warehouse'), date: null }]) + expect(wrapper.findAll('.event-chip')).toHaveLength(0) + await wrapper.get('.undated-button').trigger('click') + expect(wrapper.findAll('.detail-event')).toHaveLength(1) + expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定') }) - - it('renders many inbound codes outside the two-column fields with only three previews', async () => { + it('retains business links and exposes editing only with authoritative eligibility', async () => { + const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true } + const wrapper = render([row]) + await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click') + await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click') + expect(wrapper.emitted('open')).toEqual([[row]]) + expect(wrapper.emitted('edit')).toEqual([[row]]) + await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] }) + expect(wrapper.find('.detail-event footer').exists()).toBe(false) + }) + it('distinguishes loading and partial failure from a complete empty day', async () => { const wrapper = render() - await wrapper.setProps({ events: [{ - key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓', - fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }], - inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true, - }] }) - const card = wrapper.get('.detail-event') - expect(card.get('dl').text()).not.toContain('WT-INBOUND') - expect(card.findAll('.inbound-codes li')).toHaveLength(3) - expect(card.get('.inbound-codes').text()).toContain('20 个') - expect(card.get('footer').text()).toContain('编辑') + await wrapper.setProps({ loading: true }) + expect(wrapper.text()).toContain('正在读取安排') + expect(wrapper.text()).not.toContain('当天暂无安排') + await wrapper.setProps({ loading: false, incomplete: true }) + expect(wrapper.text()).toContain('部分数据不可用') + expect(wrapper.text()).not.toContain('当天暂无安排') }) }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index 013de53d..fd4bc039 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -1,82 +1,40 @@ - - diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index f3facbfd..49f1f7e3 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -1,235 +1,118 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { inject, onBeforeUnmount } from 'vue' -import { ElDialog } from 'element-plus' -import type { LeaveGuard } from './useWorkDraft' +import { onBeforeRouteLeave } from 'vue-router' +import { ElMessageBox } from 'element-plus' +import { ApiResponseError } from '@/shared/api/http' import AppointmentCalendarView from './AppointmentCalendarView.vue' -import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi' -import { listArrivalCalendar } from './api' -import { workCalendar, workItem, workSummary, followAction } from './work.test-support' -const routing = vi.hoisted(() => ({ query: {} as Record, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true })) +import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi' +import { listTripCalendar } from './tripCalendar' +import type { ScheduleEvent } from './schedule' +const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record, push: vi.fn(), replace: vi.fn(), denied: new Set() })) vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) -vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) })) -vi.mock('./workApi', async original => ({ ...await original(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() })) -vi.mock('./api', async original => ({ ...await original(), listArrivalCalendar: vi.fn() })) -function render(realDialog = false) { - return mount(AppointmentCalendarView, { attachTo: document.body, global: { components: { ElDialog }, directives: { loading: {} }, stubs: { - teleport: true, - ElTooltip: { template: '' }, - ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '
' }, +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) })) +vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() })) +vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) +const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true } +const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true } +export function renderSchedule() { + return mount(AppointmentCalendarView, { global: { stubs: { AdminPage: { template: '
' }, - ElButton: { props: ['type'], template: '' }, - WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '
' }, - WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() { - const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')! - const unregister = register(async () => routing.allowLeave) - onBeforeUnmount(unregister) - }, template: '
{{ itemKey }}
' }, - SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '
' }, - LoadErrorState: { props: ['title', 'message'], template: '
{{ title }} {{ message }}
' }, - ApiErrorMeta: true, + AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '
' }, + AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '
' }, + WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true, + LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '
{{ title }} {{ message }}
' }, } } }) } -describe('appointment work calendar', () => { - beforeEach(() => { - vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true - routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined) - vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar) - vi.mocked(getWorkSummary).mockResolvedValue(workSummary) - vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 }) - vi.mocked(listArrivalCalendar).mockResolvedValue([]) - }) - it('retains the old batch and page when a resolved later batch continues into its vehicle work', async () => { - routing.query = { workAnchor: '2026-10-15', workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'old-batch-work' } - const wrapper = render(); await flushPromises() - wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-batch-work'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work') - wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-vehicle-work', followAction); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-vehicle-work') - await wrapper.get('.panel-nav button').trigger('click'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work') - await wrapper.get('.panel-nav button').trigger('click'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('old-batch-work') - await wrapper.get('.panel-nav button').trigger('click'); await flushPromises() - expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ view: 'UNDATED', page: 3 }), expect.any(AbortSignal)) +beforeEach(() => { + vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear() + vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }) + vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse]) + vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] }) + vi.mocked(saveWarehouseArrival).mockResolvedValue({}) +}) +describe('simplified appointment entry', () => { + it('keeps unsaved input when the operator declines to leave', async () => { + vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') + const wrapper = renderSchedule(); await flushPromises() + wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() + await wrapper.get('input').setValue('2026-10-22T09:30') + await wrapper.get('[data-close]').trigger('click'); await flushPromises() + expect(ElMessageBox.confirm).toHaveBeenCalled() + expect(wrapper.find('[data-dialog]').exists()).toBe(true) + expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30') wrapper.unmount() }) - it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => { - const wrapper = render(); await flushPromises() - expect(wrapper.text()).toContain('预约工作日历') - expect(wrapper.get('[data-calendar] .work-inbox').attributes('aria-label')).toBe('全局待办') - expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal)) - expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled() + it('loads original sources and real trips without loading work summaries or generated work items', async () => { + const wrapper = renderSchedule(); await flushPromises() + expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal)) + const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) + expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']]) + expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/) wrapper.unmount() }) - it('keeps day/type selection and pagination context in the overlay and clears selection on close', async () => { - vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 30, pages: 4 }) - const wrapper = render(); await flushPromises() - const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' }) - calendar.vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises() - expect(calendar.props()).toMatchObject({ selectedDate: '2026-10-15', selectedType: 'PICKUP' }) - expect(wrapper.get('.panel-header').text()).toContain('2026-10-15 · 提柜') - expect(wrapper.find('.panel-body .work-pagination').exists()).toBe(false) - expect(wrapper.get('.panel-footer').text()).toContain('共 30 项') - await wrapper.get('.panel-footer .work-pagination button:last-child').trigger('click'); await flushPromises() - expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-10-15', types: ['PICKUP'], page: 2 }), expect.any(AbortSignal)) - await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises() - expect(wrapper.find('.work-panel').exists()).toBe(false) - expect(calendar.props('selectedDate')).toBeUndefined() + it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => { + vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline')) + const wrapper = renderSchedule(); await flushPromises() + expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival']) + expect(wrapper.text()).toContain('计划到仓读取失败') + expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true) wrapper.unmount() }) - it('expands the existing detail without remounting or bypassing the unsaved close guard', async () => { - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises() - await wrapper.get('.work-row-action button').trigger('click'); await flushPromises() - const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm - routing.allowLeave = false - await wrapper.get('[aria-label="展开全屏"]').trigger('click') - expect(wrapper.get('.el-dialog').classes()).toContain('is-fullscreen') - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail) - await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises() - expect(wrapper.find('.work-panel').exists()).toBe(true) - routing.allowLeave = true - await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises() - expect(wrapper.find('.work-panel').exists()).toBe(false) + it('does not request unreadable domains and describes the incomplete scope', async () => { + routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list') + const wrapper = renderSchedule(); await flushPromises() + expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('缺少车次读取权限') + expect(wrapper.text()).toContain('缺少尾程订单读取权限') wrapper.unmount() }) - it.each(['mask', 'escape'] as const)('closes from %s through the real dialog without bypassing draft protection', async method => { - const wrapper = render(true); await flushPromises() - await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises() - await wrapper.get('.work-row-action button').trigger('click'); await flushPromises() - await wrapper.get('.panel-body').trigger('click') - expect(wrapper.find('[data-detail]').exists()).toBe(true) - const dismiss = async () => { - if (method === 'mask') { - const mask = wrapper.get('.el-overlay-dialog') - await mask.trigger('mousedown'); await mask.trigger('mouseup'); await mask.trigger('click') - } else { - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true })) - } - await flushPromises() - } - routing.allowLeave = false - await dismiss() - expect(wrapper.find('[data-detail]').exists()).toBe(true) - routing.allowLeave = true - await dismiss() - expect(wrapper.find('.work-panel').exists()).toBe(false) + it('ignores stale calendar responses when the date range changes', async () => { + let resolveOld!: (value: Awaited>) => void + vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) + const wrapper = renderSchedule(); await flushPromises() + wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises() + resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises() + expect(wrapper.text()).not.toContain('缺少还柜读取权限') + expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0) wrapper.unmount() }) - it.each(['VIEW_DETAIL', 'VIEW_HISTORY', 'SAVE_FOLLOWUP'] as const)('keeps %s navigation intact while distinguishing read-only actions', async code => { - const nextAction = { ...followAction, code, label: '服务端动作文案' } - vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ nextAction })], page: 1, size: 8, total: 1, pages: 1 }) - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises() - const button = wrapper.get('.work-row-action button') - expect(button.attributes('data-variant')).toBe(code === 'SAVE_FOLLOWUP' ? 'primary' : 'default') - expect(wrapper.get('.item-schedule').text()).toContain('作业日期未定') - expect(wrapper.get('.item-schedule').text()).toContain('America/Los_Angeles') - expect(wrapper.get('.item-context').text()).toContain('地点:ONT8') - expect(wrapper.get('.item-context').text()).toContain('负责人:测试调度') - expect(wrapper.get('.item-context').text()).toContain('跟进:2026-10-15') - await button.trigger('click'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(nextAction) + it('retries an unknown save with the same payload and key, then only reloads reads', async () => { + vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({}) + const wrapper = renderSchedule(); await flushPromises() + wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() + await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30') + await wrapper.get('[data-save]').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('提交结果尚未确认') + expect(wrapper.get('input').attributes('disabled')).toBeDefined() + const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise + expect(await guard()).toBe(false) + await wrapper.get('[data-save]').trigger('click'); await flushPromises() + expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0]) + expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String)) + expect(wrapper.find('[data-dialog]').exists()).toBe(false) wrapper.unmount() }) - it('sends an explicit safe return context with external business navigation', async () => { - routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' } - const wrapper = render(); await flushPromises() - wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises() - expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) }) - expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } }) + it('requires an explicit reread after a conflict and retains the entered date', async () => { + vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409)) + const wrapper = renderSchedule(); await flushPromises() + wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() + await wrapper.get('input').setValue('2026-10-22T09:30') + await wrapper.get('[data-save]').trigger('click'); await flushPromises() + expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined() + vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }]) + await wrapper.get('[data-retry]').trigger('click'); await flushPromises() + expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30') + await wrapper.get('[data-save]').trigger('click'); await flushPromises() + expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String)) wrapper.unmount() }) - it('keeps a successful save in the current detail and retains the original global page on return', async () => { - routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3' } - const wrapper = render(); await flushPromises() - const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm - detail.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail) - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal)) - await wrapper.get('.panel-nav button').trigger('click'); await flushPromises() - expect(wrapper.find('[data-detail]').exists()).toBe(false) - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal)) + it('keeps the selected date and view in the source return link', async () => { + const wrapper = renderSchedule(); await flushPromises() + const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) + panel.vm.$emit('select', '2026-10-21', 'week') + panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises() + expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } }) wrapper.unmount() }) - it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => { - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises() - const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' }) - calendar.vm.$emit('anchor', 1); await flushPromises() - calendar.vm.$emit('flow', 'DIRECT'); await flushPromises() - const calls = vi.mocked(getWorkCalendar).mock.calls.length - calendar.vm.$emit('types', []); await flushPromises() - expect(getWorkCalendar).toHaveBeenCalledTimes(calls) - expect(getWorkSummary).toHaveBeenCalledTimes(1) - expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7') - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal)) - expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型') - wrapper.unmount() - }) - it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => { - const wrapper = render(); await flushPromises() - wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises() - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal)) - await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises() - expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED') - await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises() - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal)) - wrapper.unmount() - }) - it('shows a failed source as an error, not zero work or all complete', async () => { - vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable')) - vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable')) - vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable')) - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises() - expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败') - expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true) - expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项') - wrapper.unmount() - }) - it('ignores a late response after the global scope changes', async () => { - let resolveOld!: (value: Awaited>) => void - vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve })) - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="OVERDUE"]').trigger('click') - await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises() - resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises() - expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001') - wrapper.unmount() - }) - it('returns from a prerequisite to the original item and preserves the original undated list context', async () => { - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises() - await wrapper.get('.work-row-action button').trigger('click'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction) - wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises() - expect(wrapper.text()).toContain('返回原事项') - wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises() - expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1') - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal)) - wrapper.unmount() - }) - it('goes directly to the current container unfinished list after route confirmation', async () => { - const wrapper = render(); await flushPromises() - await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises() - await wrapper.get('.work-row-action button').trigger('click'); await flushPromises() - wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises() - expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false) - expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办') - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal)) - expect(getWorkSummary).toHaveBeenCalledTimes(2) - wrapper.unmount() - }) - it('opens explicit source verification without forging an appointment or guessing a customer', async () => { - routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' } - const wrapper = render(); await flushPromises() - expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }) - wrapper.unmount() - routing.canReadSource = false - const denied = render(); await flushPromises() - expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false) - expect(denied.text()).toContain('没有货源读取权限'); denied.unmount() - }) }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 81175a5a..85bba439 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -1,303 +1,192 @@ diff --git a/src/modules/appointment-calendar/AppointmentRepair.test.ts b/src/modules/appointment-calendar/AppointmentRepair.test.ts index 89a7a755..64f008cc 100644 --- a/src/modules/appointment-calendar/AppointmentRepair.test.ts +++ b/src/modules/appointment-calendar/AppointmentRepair.test.ts @@ -1,86 +1,31 @@ import { flushPromises, mount } from '@vue/test-utils' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ApiResponseError } from '@/shared/api/http' -import { defineComponent, h, inject, KeepAlive, onBeforeUnmount, ref, type Ref } from 'vue' -import { ElDialog, ElPopover } from 'element-plus' -import type { LeaveGuard } from './useWorkDraft' +import { defineComponent, h, KeepAlive, ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentCalendarView from './AppointmentCalendarView.vue' -import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi' -import { listArrivalCalendar } from './api' -import { workCalendar, workItem, workSummary, followAction } from './work.test-support' -const routing = vi.hoisted(() => ({ query: {} as Record, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true })) -vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) -vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) })) -vi.mock('./workApi', async original => ({ ...await original(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() })) -vi.mock('./api', async original => ({ ...await original(), listArrivalCalendar: vi.fn() })) -function render(realDialog = false, active?: Ref) { - const host = active ? defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div', { 'data-other-page': '' }) }) }) : AppointmentCalendarView - return mount(host, { attachTo: document.body, global: { components: { ElDialog, ElPopover }, directives: { loading: {} }, stubs: { - teleport: !active, - ElTooltip: active ? false : { template: '' }, - ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '
' }, - AdminPage: { template: '
' }, - QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields', 'loading'], emits: ['update:modelValue', 'search'], template: '
' }, - DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'page', 'pageSize'], emits: ['update:page'], template: `
{{ column.label }}
` }, - ElButton: { props: ['type'], template: '' }, - WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '
' }, - WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() { - const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')! - const unregister = register(async () => routing.allowLeave) - onBeforeUnmount(unregister) - }, template: '
{{ itemKey }}
' }, - SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '
' }, - LoadErrorState: { props: ['title', 'message'], template: '
{{ title }} {{ message }}
' }, - ApiErrorMeta: true, - } } }) -} -describe('appointment repair regression', () => { - afterEach(() => vi.useRealTimers()) +import { getCalendarSources, getWarehouseArrivals } from './scheduleApi' +import { listTripCalendar } from './tripCalendar' +vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() })) +vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() })) +describe('calendar cache lifecycle', () => { beforeEach(() => { - vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true - routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined) - vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar) - vi.mocked(getWorkSummary).mockResolvedValue(workSummary) - vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 }) - vi.mocked(listArrivalCalendar).mockResolvedValue([]) + vi.clearAllMocks() + vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true }) + vi.mocked(getWarehouseArrivals).mockResolvedValue([]) + vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] }) }) - it('waits for synchronization before restoring calendar, summary and current work without showing a failed save', async () => { - vi.useFakeTimers() - routing.query = { workScope: 'global', workView: 'ALL' } - const pending = new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409) - vi.mocked(getWorkCalendar).mockRejectedValueOnce(pending) - vi.mocked(getWorkSummary).mockRejectedValueOnce(pending) - vi.mocked(getWorkItems).mockRejectedValueOnce(pending) - const wrapper = render(); await flushPromises() - expect(wrapper.text()).toContain('同步中') - expect(wrapper.find('[role="alert"]').exists()).toBe(false) - expect(wrapper.text()).not.toContain('加载失败') - await vi.advanceTimersByTimeAsync(2000); await flushPromises() - expect(getWorkCalendar).toHaveBeenCalledTimes(2) - expect(getWorkSummary).toHaveBeenCalledTimes(2) - expect(getWorkItems).toHaveBeenCalledTimes(2) - expect(wrapper.text()).not.toContain('同步中') - wrapper.unmount() - }) - it('cancels synchronization polling when leaving the cached calendar', async () => { - vi.useFakeTimers() - vi.mocked(getWorkSummary).mockRejectedValue(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)) - const active = ref(true), wrapper = render(false, active); await flushPromises() - expect(getWorkSummary).toHaveBeenCalledTimes(1) + it('aborts reads when cached and reloads on return without creating background work polling', async () => { + const active = ref(true) + const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) }) + const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '
' }, AppointmentCalendarPanel: { template: '
' }, AppDialog: true } } }) + await flushPromises() + expect(getCalendarSources).toHaveBeenCalledTimes(1) + const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]! active.value = false; await flushPromises() - await vi.advanceTimersByTimeAsync(30000) - expect(getWorkSummary).toHaveBeenCalledTimes(1) - wrapper.unmount() - }) - it('removes the teleported dialog while a cached appointment page is inactive and restores its context', async () => { - routing.query = { workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'work:1' } - const active = ref(true), wrapper = render(true, active); await flushPromises() - expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1') - active.value = false; await flushPromises() - expect(document.body.querySelector('.work-dialog')).toBeNull() + expect(signal.aborted).toBe(true) active.value = true; await flushPromises() - expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1') - expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal)) + expect(getCalendarSources).toHaveBeenCalledTimes(2) wrapper.unmount() }) }) diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md index 2bd91bd1..43202134 100644 --- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -1,5 +1,24 @@ # 预约工作日历逐操作矩阵 +## 2026-10-19 四类日历恢复(当前结论) + +依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。 + +| 页面 | 操作 | 当前确认承载与布局 | 实现结论 | +|---|---|---|---| +| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 | +| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 | +| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 | +| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 | +| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 | +| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 | + +2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。 + +发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。 + +本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。 + 本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。 本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。 diff --git a/src/modules/appointment-calendar/schedule.test.ts b/src/modules/appointment-calendar/schedule.test.ts new file mode 100644 index 00000000..1e696e18 --- /dev/null +++ b/src/modules/appointment-calendar/schedule.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { projectContainerSchedule } from './schedule' +import type { CalendarSources, WarehouseArrival } from './scheduleApi' +const range = { from: '2026-11-01', to: '2026-12-01' } +const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true } +const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true } +describe('four-type schedule projection', () => { + it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => { + const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true) + expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' }) + }) + it('adds two local calendar days across months without creating a time', () => { + const events = projectContainerSchedule(sources, [warehouse], range, true) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' }) + }) + it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => { + const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] } + expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04') + expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' }) + }) + it('keeps manually moved containers visible when their arrival is outside this month', () => { + const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true) + expect(events).toHaveLength(1) + expect(events[0]?.date).toBe('2026-11-08') + }) + it('does not replace the planned date with PDA actual arrival', () => { + const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]! + expect(event.date).toBe('2026-11-05') + expect(event.canEdit).toBe(false) + expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' }) + }) + it('shows missing dates explicitly and never uses internal IDs as display numbers', () => { + const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false) + expect(events.map(row => row.date)).toEqual([null, null]) + expect(events.every(row => row.sourcePath === undefined)).toBe(true) + expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓']) + }) + it('does not invent default plans when the authoritative warehouse query failed', () => { + expect(projectContainerSchedule(sources, null, range, true)).toEqual([]) + }) + it('uses the actual return date after completion while preserving the deadline in details', () => { + const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true) + expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' }) + }) +}) diff --git a/src/modules/appointment-calendar/schedule.ts b/src/modules/appointment-calendar/schedule.ts new file mode 100644 index 00000000..0f02b5bb --- /dev/null +++ b/src/modules/appointment-calendar/schedule.ts @@ -0,0 +1,70 @@ +import { addDays } from './calendar' +import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi' + +export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const +export type ScheduleType = typeof scheduleTypes[number] +export const scheduleLabels: Record = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' } +export interface ScheduleEvent { + key: string + type: ScheduleType + date: string | null + time: string + title: string + subtitle?: string + fields: Array<{ label: string; value?: string | null }> + sourcePath?: string + warehouse?: WarehouseArrival + canEdit?: boolean +} +const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to +const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—' + +export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) { + const events: ScheduleEvent[] = [] + const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || []) + for (const row of sources.containers) { + const label = row.containerNo || row.cabinetNo || '—' + const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo }, + { label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }] + const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined + const date = row.arrivalAt?.slice(0, 10) || null + if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date, + time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath, + subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定', + fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] }) + // 读取失败时不能用默认值遮掉无法读取的人工安排。 + if (warehouses === null) continue + const warehouse = warehouseMap.get(row.containerBookingId) + warehouseMap.delete(row.containerBookingId) + const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0 + const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null) + if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned, + time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse, + canEdit: warehouse?.canEdit === true, + subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实', + fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode }, + { label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned }, + { label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] }) + } + // 人工改期可能跨月,与当前月份的到港记录无关。 + for (const row of warehouseMap.values()) { + const date = row.plannedAt?.slice(0, 10) || null + if (!date || !inRange(date, range)) continue + events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '', + title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit, + sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined, + fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode }, + { label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] }) + } + for (const row of sources.emptyReturns) { + const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt + if (!at || !inRange(at.slice(0, 10), range)) continue + events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16), + title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`, + subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒', + sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined, + fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName }, + { label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] }) + } + return events +} diff --git a/src/modules/appointment-calendar/scheduleApi.ts b/src/modules/appointment-calendar/scheduleApi.ts new file mode 100644 index 00000000..edc3ad83 --- /dev/null +++ b/src/modules/appointment-calendar/scheduleApi.ts @@ -0,0 +1,50 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface CalendarContainer { + containerBookingId: string + cabinetNo: string | null + containerNo: string | null + vesselName: string | null + voyageNo: string | null + destinationPortName: string | null + destinationWarehouseCode: string | null + arrivalAt: string | null + arrivalType: 'ACTUAL' | 'ESTIMATED' +} +export interface CalendarReturn { + containerBookingId: string + containerNo: string | null + fleetSupplierName: string | null + actualEmptyReturnAt: string | null + emptyReturnWarning: { status: string; deadlineAt: string | null } +} +export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean } +export interface WarehouseArrival { + sourceContainerBookingId: string + containerNo: string | null + warehouseName: string | null + warehouseCode: string | null + plannedAt: string | null + actualAt: string | null + version: number + planCount: number + canEdit: boolean +} +export interface ScheduleRange { from: string; to: string } +export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) { + return unwrapApiResponse(await http.get, ApiEnvelope>( + '/platform/v1/terminal-drayages/calendar-sources', { params: range, signal }, + )) +} +export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) { + return unwrapApiResponse(await http.post, ApiEnvelope>( + '/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal }, + )) +} +export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number } +export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) { + return unwrapApiResponse(await http.put, ApiEnvelope>( + `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body, + { headers: { 'Idempotency-Key': key } }, + )) +} diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts index 13e8b1e3..5d3a9ea2 100644 --- a/src/modules/appointment-calendar/tripCalendar.test.ts +++ b/src/modules/appointment-calendar/tripCalendar.test.ts @@ -7,6 +7,13 @@ function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'Amer return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] } } describe('location-based trip calendar', () => { + it('counts a shared trip once and excludes cancelled arrangements', () => { + const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z') + const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' } + const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range) + expect(result.events).toHaveLength(1) + expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' }) + }) it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => { const localRange = {from:'2026-09-28',to:'2026-11-09'} expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'}) diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts index b28b8fee..0115bf4c 100644 --- a/src/modules/appointment-calendar/tripCalendar.ts +++ b/src/modules/appointment-calendar/tripCalendar.ts @@ -1,5 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { CalendarEvent } from './AppointmentCalendarPanel.vue' +import type { ScheduleEvent } from './schedule' export interface TripCalendarEntry { sourceType: 'TRIP_PLAN' @@ -54,9 +54,10 @@ function localStamp(value: string | null, zone: string | null) { } catch { return null } } export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) { - const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords] + const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords] const labels: Record = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' } - for (const row of result.records) { + for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) { + if (row.status === 'CANCELLED') continue // Executed work stays on its actual local date even if the plan changes later. const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone) if (row.originCountryCode !== 'US' || !occurrence) { @@ -65,8 +66,8 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s if (occurrence.date < range.from || occurrence.date >= range.to) continue const actual = localStamp(row.departedAt, row.originTimezone) const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone) - events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time, - title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认', + events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time, + title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排', sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`, fields: [ { label: '车次状态', value: labels[row.status] || '待核实' }, diff --git a/src/modules/overseas-master-data/components/PickupDemandPanel.vue b/src/modules/overseas-master-data/components/PickupDemandPanel.vue index 95225533..9fa200fa 100644 --- a/src/modules/overseas-master-data/components/PickupDemandPanel.vue +++ b/src/modules/overseas-master-data/components/PickupDemandPanel.vue @@ -61,12 +61,12 @@ watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ }) - +

已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}

- +
diff --git a/src/modules/overseas-master-data/components/TripPickupPanel.vue b/src/modules/overseas-master-data/components/TripPickupPanel.vue index c2121c8d..950ccf99 100644 --- a/src/modules/overseas-master-data/components/TripPickupPanel.vue +++ b/src/modules/overseas-master-data/components/TripPickupPanel.vue @@ -119,12 +119,12 @@ onBeforeUnmount(() => { sequence++ }) - +

{{ selected?.transferNo }} · {{ selected?.pickupAddress }}

- +

{{ file.fileName }}

- +
diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue index 5ff810f9..74fa0e02 100644 --- a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue +++ b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue @@ -159,7 +159,7 @@ onBeforeUnmount(() => { generation++ }) - +

保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。

取消确认增加
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index 12ec2621..d572d6ee 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -284,11 +284,12 @@ onMounted(load)
{{ field.label }}
{{ field.value }}
- - +
diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts index 581a84ba..7e197daa 100644 --- a/src/styles/adminDensity.test.ts +++ b/src/styles/adminDensity.test.ts @@ -180,31 +180,26 @@ describe('approved Admin density ownership', () => { expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }') expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__footer { border-top: 0;') }) - it('keeps appointment dialog content scrollable with fixed controls and unclipped calendar days', () => { - const declarations = (file: string, selector: string) => { + it('keeps the simplified calendar in two columns with bounded previews and scrollable daily details', () => { + const view = parse(read('modules/appointment-calendar/AppointmentCalendarView.vue')).descriptor + const panel = parse(read('modules/appointment-calendar/AppointmentCalendarPanel.vue')).descriptor + expect(view.template!.content).toContain(' { const values: Record = {} - const css = parse(read(`modules/appointment-calendar/${file}`)).descriptor.styles.map(style => style.content).join('\n') - cssRoot(css).walkRules(selector, rule => { rule.walkDecls(decl => { values[decl.prop] = decl.value }) }) + cssRoot(panel.styles.map(style => style.content).join('\n')).walkRules(selector, rule => { + if (rule.parent?.type !== 'root') return + rule.walkDecls(decl => { values[decl.prop] = decl.value }) + }) return values } - const panel = declarations('AppointmentCalendarView.vue', '.work-panel') - expect(panel.position).toBeUndefined() - expect(panel['min-height']).toBe('0') - expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog.el-dialog:not(.is-fullscreen))').height).toBe('88dvh') - expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog .el-dialog__body)').overflow).toBe('hidden') - expect(declarations('AppointmentCalendarView.vue', '.work-layout')['grid-template-columns']).toBeUndefined() - expect(declarations('AppointmentCalendarView.vue', '.panel-body')).toMatchObject({ 'overflow-y': 'auto', 'min-height': '0' }) - expect(declarations('AppointmentCalendarView.vue', '.panel-header').flex).toBe('none') - expect(declarations('AppointmentCalendarView.vue', '.panel-footer').flex).toBe('none') - expect(declarations('AppointmentCalendarView.vue', '.work-row-action .el-button')).toMatchObject({ 'line-height': '20px', 'white-space': 'normal', height: 'auto' }) - expect(declarations('AppointmentCalendarView.vue', '.work-row-action :deep(.el-button > span)')).toMatchObject({ 'line-height': 'inherit', 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0', 'max-width': '100%' }) - expect(declarations('WorkCalendarPanel.vue', '.calendar-toolbar')['justify-content']).toBe('flex-start') - expect(declarations('AppointmentCalendarView.vue', '.work-inbox').border).toBeUndefined() - const day = declarations('WorkCalendarPanel.vue', '.work-day') - expect(day.height).toBe('146px') - expect(day['min-height']).toBe(day.height) - expect(day['max-height']).toBe(day.height) - expect(day.overflow).not.toBe('hidden') + expect(declarations('.appointment-calendar')['grid-template-columns']).toBe('minmax(0, 1fr) 360px') + expect(declarations('.calendar-day').height).toBe('146px') + expect(declarations('.week-grid .calendar-day').height).toBe('270px') + expect(declarations('.detail-events')['overflow-y']).toBe('auto') + expect(declarations('.detail-event dd')['overflow-wrap']).toBe('anywhere') + expect(panel.template!.content).toContain('.slice(0, 3)') + expect(panel.template!.content).not.toContain('新增预约') }) it('shares list status and category tab styles across registered list consumers', () => { for (const file of [ @@ -372,7 +367,7 @@ describe('approved Admin density ownership', () => { visit(node.children) } } visit(ast.children) - expect(panes).toBe(file === 'LastMileOrderDetailView' ? 6 : 4) + expect(panes).toBe(file === 'LastMileOrderDetailView' ? 7 : 4) } })