diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts index f0d3d1c5..961f1a2c 100644 --- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts +++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts @@ -38,6 +38,7 @@ function summary(raw?: Partial | null) { overCapacity: Boolean(raw?.overCapacity), planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm), bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm), + availableBookedCapacityCbm: raw?.availableBookedCapacityCbm == null ? null : number(raw.availableBookedCapacityCbm), catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm), lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm), snapshotAt: raw?.snapshotAt ?? null, diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts b/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts index 5544ec70..8cc7df33 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts @@ -19,13 +19,20 @@ vi.mock('../api/platformSailingCalendar', () => ({ listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]), previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(), })) -const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false } +const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, availableBookedCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false } const calendar: SailingCalendarResponse = { + nextRefreshAt: '2026-10-01T00:00:00', month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 }, filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] }, records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }], } let wrapper: VueWrapper | undefined +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise }) + return { promise, resolve, reject } +} async function mountView() { wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) await flushPromises() @@ -33,7 +40,7 @@ async function mountView() { } beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(new Date(2026, 8, 30, 12)) + vi.setSystemTime(new Date('2026-09-30T12:00:00+08:00')) vi.clearAllMocks() vi.mocked(api.listSailingTemplates).mockResolvedValue([]) vi.mocked(api.listSailingChannels).mockResolvedValue([]) @@ -43,6 +50,141 @@ beforeEach(() => { afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() }) describe('SailingCalendarView integrated workspace', () => { + it.each(['success', 'failure'] as const)('ignores a stale calendar %s after the next month has loaded', async (outcome) => { + const view = await mountView() + const previous = deferred() + vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise) + const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]') + await keyword.trigger('keyup', { key: 'Enter' }) + const october = { ...calendar, month: '2026-10', records: [{ ...calendar.records[0], id: 'october', sailingNo: 'SL-OCTOBER', etdAt: '2026-10-01T10:00:00' }] } + vi.mocked(listSailingCalendar).mockResolvedValueOnce(october) + await view.get('button[aria-label="下个月"]').trigger('click') + await flushPromises() + if (outcome === 'success') previous.resolve(calendar) + else previous.reject(new Error('旧月份请求失败')) + await flushPromises() + expect(view.get('.detail-header h2').text()).toBe('2026-10-01') + expect(view.get('.detail-panel').text()).toContain('SL-OCTOBER') + expect(view.find('.load-error-state').exists()).toBe(false) + expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false') + }) + + it('keeps the latest calendar request loading when an earlier request finishes first', async () => { + const view = await mountView() + const previous = deferred() + const current = deferred() + vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise) + await view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]').trigger('keyup', { key: 'Enter' }) + await view.get('button[aria-label="下个月"]').trigger('click') + previous.resolve(calendar) + await flushPromises() + expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('true') + current.resolve({ ...calendar, month: '2026-10', records: [] }) + await flushPromises() + expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false') + }) + + it('keeps the copied sailing selected when its date is later in the current month', async () => { + vi.setSystemTime(new Date('2026-09-20T12:00:00+08:00')) + const original = { ...calendar.records[0], etdAt: '2026-09-21T10:00:00', cutoffAt: '2026-09-20T18:00:00' } + const copied = { ...original, id: 'copied', sailingNo: 'SL-COPIED', etdAt: '2026-09-28T10:00:00', cutoffAt: '2026-09-27T18:00:00' } + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original] }) + const view = await mountView() + await view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '21')!.trigger('click') + await view.get('.sailing-card').trigger('click') + vi.mocked(api.copySailing).mockResolvedValueOnce({ sailing: copied, histories: [] }) + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original, copied] }) + await clickAction(view, '复制下一班') + expect(view.get('.detail-header h2').text()).toBe('2026-09-28') + expect(view.get('.sailing-card.active').text()).toContain('SL-COPIED') + expect(view.get('.detail-actions').text()).toContain('订柜建议') + await view.findAll('button').find(button => button.text() === '今天')!.trigger('click') + await flushPromises() + expect(view.get('.detail-header h2').text()).toBe('2026-09-20') + }) + + it('uses the Shanghai calendar date at the UTC day boundary', async () => { + vi.setSystemTime(new Date('2026-09-29T16:00:00Z')) + const view = await mountView() + expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09' })) + expect(view.get('.detail-header h2').text()).toBe('2026-09-30') + expect(view.get('.calendar-note').text()).toContain('仅今天及以后') + }) + + it('refreshes at cutoff so the current cargo reference can roll forward', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T12:00:01' }] }) + const view = await mountView() + await view.get('.sailing-card').trigger('click') + const emptyVolume = { ...volume, actualReceivedCbm: 0, catchableReceivedCbm: 0, recommendedContainerCount: 0, pendingContainerCount: 0 } + vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, summary: { ...emptyVolume, sailingCount: 1 }, records: [{ ...calendar.records[0], volumeSummary: emptyVolume }] }) + await vi.advanceTimersByTimeAsync(1002) + await flushPromises() + expect(listSailingCalendar).toHaveBeenCalledTimes(2) + expect(view.get('.volume-line').text()).toContain('建议 0 柜') + expect(view.find('.detail-actions').exists()).toBe(false) + }) + + it('refreshes across midnight and month end and removes expired selection', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00')) + const view = await mountView() + await view.get('.sailing-card').trigger('click') + vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, month: '2026-10', records: [], summary: { ...calendar.summary, sailingCount: 0 } }) + await vi.advanceTimersByTimeAsync(1001) + await flushPromises() + expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' })) + expect(view.get('.detail-header h2').text()).toBe('2026-10-01') + expect(view.find('.sailing-card').exists()).toBe(false) + expect(view.find('.detail-actions').exists()).toBe(false) + expect(view.get('.calendar-note').text()).toContain('共 0 个船期') + const calls = vi.mocked(listSailingCalendar).mock.calls.length + view.unmount(); wrapper = undefined + await vi.advanceTimersByTimeAsync(86_400_000) + expect(listSailingCalendar).toHaveBeenCalledTimes(calls) + }) + + it('refreshes the next month at the server boundary even when the earlier sailing is hidden', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T13:00:00', etdAt: '2026-09-30T18:00:00' }] }) + const view = await mountView() + const nextMonth = { ...calendar, month: '2026-10', nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], id: 'sailing-2', etdAt: '2026-10-02T10:00:00', cutoffAt: '2026-10-01T18:00:00' }] } + vi.mocked(listSailingCalendar).mockResolvedValue(nextMonth) + await view.get('button[aria-label="下个月"]').trigger('click') + await flushPromises() + expect(listSailingCalendar).toHaveBeenCalledTimes(2) + vi.mocked(listSailingCalendar).mockResolvedValue({ ...nextMonth, nextRefreshAt: '2026-10-01T00:00:00' }) + await vi.advanceTimersByTimeAsync(3_600_002) + await flushPromises() + expect(listSailingCalendar).toHaveBeenCalledTimes(3) + expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' })) + }) + + it('backs off after a refresh failure instead of repeatedly requesting an expired boundary', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' }) + await mountView() + vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('网络错误')) + await vi.advanceTimersByTimeAsync(1002) + await flushPromises() + expect(listSailingCalendar).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(59_000) + expect(listSailingCalendar).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1001) + await flushPromises() + expect(listSailingCalendar).toHaveBeenCalledTimes(3) + }) + + it('reloads today and clears actions when the selected sailing is no longer returned', async () => { + const view = await mountView() + await view.get('.sailing-card').trigger('click') + vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], summary: { ...calendar.summary, sailingCount: 0 } }) + await view.findAll('button').find(button => button.text() === '今天')!.trigger('click') + await flushPromises() + expect(listSailingCalendar).toHaveBeenCalledTimes(2) + expect(view.find('.detail-actions').exists()).toBe(false) + }) + it('keeps one create entry and defaults the sailing to the selected calendar date', async () => { const view = await mountView() expect(view.find('.detail-header button').exists()).toBe(false) @@ -129,6 +271,44 @@ async function editWeeklyTemplate() { } describe('SailingCalendarView dialog workflows', () => { + it.each([ + ['货量明细', false], ['货量明细', true], ['变更历史', false], ['变更历史', true], ['订柜建议', false], ['订柜建议', true], + ] as const)('keeps the latest %s response after reopening; switch sailing: %s', async (label, switchSailing) => { + const nextSailing = { ...calendar.records[0], id: 'sailing-2', sailingNo: 'SAIL-NEXT' } + vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [calendar.records[0], nextSailing] }) + let finishOld!: () => void + if (label === '货量明细') { + const previous = deferred>>() + const result = (code: string): Awaited> => ({ summary: volume, assignments: [{ id: code, inboundCode: code, volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] }) + vi.mocked(api.getSailingVolumeDetail).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY')) + finishOld = () => previous.resolve(result('OLD-ONLY')) + } else if (label === '变更历史') { + const previous = deferred>>() + const result = (reason: string): Awaited> => ({ sailing: nextSailing, histories: [{ id: reason, action: 'UPDATE', reason, createdAt: '2026-09-30T12:00:00' }] }) + vi.mocked(api.getSailing).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY')) + finishOld = () => previous.resolve(result('OLD-ONLY')) + } else { + const previous = deferred>>() + const result = (code: string) => ({ actualReceivedCbm: 12, catchableTotalCbm: 12, planningCapacityCbm: 68, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: code, containerTypeName: code, capacityCbm: 68, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00', catchableReceivedCbm: 12, lateReceivedCbm: 0 }) + vi.mocked(api.getSailingBookingAdvice).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY')) + finishOld = () => previous.resolve(result('OLD-ONLY')) + } + const view = await mountView() + await view.findAll('.sailing-card')[0].trigger('click') + await clickAction(view, label) + const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith(label === '货量明细' ? '货量明细' : label === '变更历史' ? '船期变更历史' : '订柜建议'))! + dialog.vm.$emit('update:modelValue', false) + await flushPromises() + if (switchSailing) await view.findAll('.sailing-card')[1].trigger('click') + await clickAction(view, label) + expect(document.body.textContent).toContain('NEW-ONLY') + finishOld() + await flushPromises() + expect(dialog.props('title')).toContain(switchSailing ? 'SAIL-NEXT' : 'SAIL-TEST') + expect(document.body.textContent).toContain('NEW-ONLY') + expect(document.body.textContent).not.toContain('OLD-ONLY') + }) + it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => { const view = await mountView() await clickAction(view, '新增单个船次') @@ -194,6 +374,49 @@ describe('SailingCalendarView dialog workflows', () => { })) }) + it.each(['cutoff', 'midnight'] as const)('preserves the editing target, changes and version across a %s refresh', async (boundary) => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + if (boundary === 'midnight') vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00')) + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: boundary === 'midnight' ? '2026-10-01T00:00:00' : '2026-09-30T12:00:01.001' }) + const view = await mountView() + await view.get('.sailing-card').trigger('click') + await clickAction(view, '编辑') + const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))! + await dialog.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00') + vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], nextRefreshAt: '2026-10-02T00:00:00' }) + await vi.advanceTimersByTimeAsync(1002) + await flushPromises() + expect(view.find('.detail-actions').exists()).toBe(false) + expect(dialog.props('modelValue')).toBe(true) + expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST') + document.querySelectorAll('.el-dialog__footer button').forEach(button => { + if (button.textContent?.trim() === '保存') button.click() + }) + await flushPromises() + expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ + expectedVersion: 1, cutoffAt: '2026-09-28T16:30:00', + })) + expect(api.createSailing).not.toHaveBeenCalled() + expect(dialog.props('modelValue')).toBe(false) + }) + + it('keeps the edited form when the server rejects a save after automatic refresh', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' }) + const view = await mountView() + await view.get('.sailing-card').trigger('click') + await clickAction(view, '编辑') + const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))! + await vi.advanceTimersByTimeAsync(1002) + await flushPromises() + vi.mocked(api.updateSailing).mockRejectedValueOnce(new Error('版本冲突')) + dialog.vm.$emit('confirm') + await flushPromises() + expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1 })) + expect(dialog.props('modelValue')).toBe(true) + expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST') + }) + it('loads volume, history and booking advice as read-only data for the business-number title', async () => { vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] }) vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] }) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 17a15640..bac8458e 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -2,7 +2,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue' import '@/styles/adminDensity.css' import '@/styles/adminWorkbench.css' -import { computed, onMounted, reactive, ref, watch } from 'vue' +import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { ChevronLeft, ChevronRight, Copy, Eye, Pencil, Plus, RotateCcw, XCircle } from '@lucide/vue' import AdminPage from '@/shared/components/AdminPage.vue' @@ -34,7 +34,13 @@ import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterO interface CalendarCell { date: string; day: number; currentMonth: boolean; records: SailingCalendarEntry[] } const access = useAccessPreviewStore() -const today = new Date() +let today = shanghaiToday() +let refreshTimer: ReturnType | undefined +let disposed = false +let calendarRequest = 0 +let volumeRequest = 0 +let historyRequest = 0 +let adviceRequest = 0 const month = ref(formatMonth(today)) const selectedDate = ref(formatDate(today)) const data = ref(null) @@ -44,6 +50,12 @@ const query = reactive({ warehouseId: '', channelCode: '', departurePortCode: '' const selectedSailing = ref(null) const dialogOpen = ref(false) const dialogMode = ref<'create' | 'edit'>('create') +const editingSailing = ref | null>(null) +const sailingEditorTitle = computed(() => { + if (dialogMode.value === 'create') return '新增船期' + const code = editingSailing.value?.sailingNo?.trim() + return code ? `编辑船期 · ${code}` : '编辑船期' +}) const saving = ref(false) const detailOpen = ref(false) const historyOpen = ref(false) @@ -85,6 +97,11 @@ const templateDialogTitle = computed(() => { const code = templates.value.find(( function formatMonth(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` } function formatDate(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}` } +function shanghaiToday() { + const parts = new Intl.DateTimeFormat('en', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date()) + const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((item) => item.type === type)?.value) + return new Date(part('year'), part('month') - 1, part('day')) +} function addDays(value: string, days: number) { const date = new Date(`${value}T00:00:00`); date.setDate(date.getDate() + days); return formatDate(date) } function resetGenerationRange(from = formatDate(today)) { const todayKey = formatDate(today); generationFrom.value = from && from > todayKey ? from : todayKey; generationTo.value = addDays(generationFrom.value, 56) } function setGenerationWeeks(weeks: number) { generationTo.value = addDays(generationFrom.value || formatDate(today), weeks * 7) } @@ -169,28 +186,64 @@ const canEdit = computed(() => access.can('platform:firstleg:sailing-calendar:ed const canCopy = computed(() => access.can('platform:firstleg:sailing-calendar:create')) const canManageTemplates = computed(() => access.can('platform:firstleg:sailing-template:manage')) +// 月份或筛选快速切换时只接收最后一次请求,旧响应不能覆盖当前日历和刷新节点。 async function load() { + const request = ++calendarRequest + const requestedMonth = month.value + const isCurrent = () => !disposed && request === calendarRequest && requestedMonth === month.value + clearTimeout(refreshTimer) + today = shanghaiToday() loading.value = true error.value = null try { - const result = await listSailingCalendar({ month: month.value, warehouseId: query.warehouseId || undefined, channelCode: query.channelCode || undefined, departurePortCode: query.departurePortCode || undefined, destinationPortCode: query.destinationPortCode || undefined, keyword: query.keyword.trim() || undefined, status: query.status || undefined }) + const result = await listSailingCalendar({ month: requestedMonth, warehouseId: query.warehouseId || undefined, channelCode: query.channelCode || undefined, departurePortCode: query.departurePortCode || undefined, destinationPortCode: query.destinationPortCode || undefined, keyword: query.keyword.trim() || undefined, status: query.status || undefined }) + if (!isCurrent()) return data.value = result const todayKey = formatDate(today) - selectedDate.value = month.value === formatMonth(today) ? todayKey : (selectedDate.value && selectedDate.value.startsWith(month.value) ? selectedDate.value : result.records[0]?.etdAt.slice(0, 10) || `${month.value}-01`) - if (selectedSailing.value) selectedSailing.value = result.records.find((record) => record.id === selectedSailing.value?.id) || null + const isCurrentMonth = requestedMonth === formatMonth(today) + // 保留用户查看或复制定位的有效日期;只有跨月或日期已过期时才重新定位。 + if (!selectedDate.value.startsWith(requestedMonth) || (isCurrentMonth && selectedDate.value < todayKey)) { + selectedDate.value = isCurrentMonth ? todayKey : result.records[0]?.etdAt.slice(0, 10) || `${requestedMonth}-01` + } + if (selectedSailing.value) { + selectedSailing.value = result.records.find((record) => record.id === selectedSailing.value?.id && record.etdAt.slice(0, 10) === selectedDate.value) || null + if (!selectedSailing.value) { detailOpen.value = false; historyOpen.value = false; adviceOpen.value = false } + } } catch (cause) { - error.value = getApiErrorDetails(cause, '船期日历加载失败') + if (isCurrent()) error.value = getApiErrorDetails(cause, '船期日历加载失败') } finally { - loading.value = false + if (isCurrent()) { + loading.value = false + scheduleRefresh() + } } } +function scheduleRefresh() { + if (disposed) return + clearTimeout(refreshTimer) + const nextRefresh = data.value ? new Date(`${data.value.nextRefreshAt}+08:00`).getTime() : Number.NaN + // 服务端统一返回跨月、跨筛选的货量变化节点;失败时延迟重试,避免紧密刷新。 + const delay = !error.value && Number.isFinite(nextRefresh) ? Math.max(1000, nextRefresh - Date.now()) : 60_000 + refreshTimer = setTimeout(() => { + selectedSailing.value = null + detailOpen.value = false; historyOpen.value = false; adviceOpen.value = false + const currentMonth = formatMonth(shanghaiToday()) + if (month.value === formatMonth(today) && month.value !== currentMonth) month.value = currentMonth + else void load() + }, delay) +} function changeMonth(delta: number) { const next = parseMonth(month.value) next.setMonth(next.getMonth() + delta) month.value = formatMonth(next) selectedDate.value = `${month.value}-01` } -function goToday() { month.value = formatMonth(today); selectedDate.value = formatDate(today) } +function goToday() { + today = shanghaiToday() + selectedDate.value = formatDate(today) + if (month.value === formatMonth(today)) void load() + else month.value = formatMonth(today) +} function selectDate(cell: CalendarCell) { selectedDate.value = cell.date; selectedSailing.value = null } function selectSailing(record: SailingCalendarEntry) { selectedSailing.value = record } function resetFilters() { Object.assign(query, { warehouseId: '', channelCode: '', departurePortCode: '', destinationPortCode: '', keyword: '', status: '' }); void load() } @@ -334,7 +387,9 @@ function onChannelChanged(value: string) { syncCutoffFromRule() routeExpanded.value = form.departurePortCode && form.destinationPortCode ? [] : ['route'] } +// 新增表单独立于日历选择,清除上次编辑目标。 async function openCreate() { + editingSailing.value = null dialogMode.value = 'create' cutoffMode.value = 'rule' cutoffDays.value = 3 @@ -349,15 +404,22 @@ async function openCreate() { } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) } finally { formOptionsLoading.value = false } } -async function openEdit(record: SailingCalendarEntry) { try { await ensureWarehouseOptions(); await loadChannelsForWarehouse(record.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +// 编辑目标和版本在打开表单时固定,自动刷新只更新日历,不改变本次提交对象。 +async function openEdit(record: SailingCalendarEntry) { try { await ensureWarehouseOptions(); await loadChannelsForWarehouse(record.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } editingSailing.value = { id: record.id, sailingNo: record.sailingNo }; routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } +// 必须实际调用写接口成功后才关闭弹窗;缺失编辑目标时保留表单并提示。 async function save() { if (!validateForm()) return + const editingId = editingSailing.value?.id + if (dialogMode.value === 'edit' && !editingId) { + ElMessage.error('未找到正在编辑的船期,请关闭后重新打开') + return + } saving.value = true try { if (dialogMode.value === 'create') await createSailing({ ...form, expectedVersion: undefined }) - else if (selectedSailing.value) await updateSailing(selectedSailing.value.id, form) + else await updateSailing(editingId!, { ...form }) ElMessage.success(dialogMode.value === 'create' ? '船期已新增' : '船期已更新') dialogOpen.value = false await load() @@ -393,25 +455,53 @@ async function copySelected() { ElMessage.success(`已定位到下一班:${copied.etdAt.slice(0, 16).replace('T', ' ')}`) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } } +// 详情请求绑定本次船期和弹窗,关闭重开或换船后忽略此前尚未完成的请求。 async function openVolumeDetail() { - if (!selectedSailing.value) return + const sailingId = selectedSailing.value?.id + if (!sailingId) return + const request = ++volumeRequest + const isCurrent = () => !disposed && request === volumeRequest && detailOpen.value && selectedSailing.value?.id === sailingId detailOpen.value = true; detailLoading.value = true; volumeDetail.value = null - try { volumeDetail.value = await getSailingVolumeDetail(selectedSailing.value.id) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '货量明细加载失败').message) } finally { detailLoading.value = false } + try { + const result = await getSailingVolumeDetail(sailingId) + if (isCurrent()) volumeDetail.value = result + } catch (cause) { + if (isCurrent()) ElMessage.error(getApiErrorDetails(cause, '货量明细加载失败').message) + } finally { if (isCurrent()) detailLoading.value = false } } +// 变更历史只展示当前弹窗对应船期的数据,过时响应不再触发提示。 async function openHistory() { - if (!selectedSailing.value) return + const sailingId = selectedSailing.value?.id + if (!sailingId) return + const request = ++historyRequest + const isCurrent = () => !disposed && request === historyRequest && historyOpen.value && selectedSailing.value?.id === sailingId historyOpen.value = true; historyLoading.value = true; histories.value = [] - try { histories.value = (await getSailing(selectedSailing.value.id)).histories || [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '变更历史加载失败').message) } finally { historyLoading.value = false } + try { + const result = await getSailing(sailingId) + if (isCurrent()) histories.value = result.histories || [] + } catch (cause) { + if (isCurrent()) ElMessage.error(getApiErrorDetails(cause, '变更历史加载失败').message) + } finally { if (isCurrent()) historyLoading.value = false } } +// 订柜建议必须与当前选中的船期一致,避免旧船建议覆盖新船。 async function openBookingAdvice() { - if (!selectedSailing.value) return + const sailingId = selectedSailing.value?.id + if (!sailingId) return + const request = ++adviceRequest + const isCurrent = () => !disposed && request === adviceRequest && adviceOpen.value && selectedSailing.value?.id === sailingId adviceOpen.value = true; adviceLoading.value = true; bookingAdvice.value = null - try { bookingAdvice.value = await getSailingBookingAdvice(selectedSailing.value.id) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '订柜建议加载失败').message) } finally { adviceLoading.value = false } + try { + const result = await getSailingBookingAdvice(sailingId) + if (isCurrent()) bookingAdvice.value = result + } catch (cause) { + if (isCurrent()) ElMessage.error(getApiErrorDetails(cause, '订柜建议加载失败').message) + } finally { if (isCurrent()) adviceLoading.value = false } } watch(month, () => void load()) watch(() => [query.warehouseId, query.channelCode, query.departurePortCode, query.destinationPortCode, query.status], () => void load()) onMounted(() => void load()) +onUnmounted(() => { disposed = true; clearTimeout(refreshTimer) })