diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index e8898056..2e3062cd 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -242,6 +242,62 @@ describe('admin auth session', () => { expect(localStorage.getItem('cc-admin-token')).toBeNull() }) + it('rejects an old refresh after another tab has switched to a different account', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'admin-refresh') + localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]') + let finishRefresh!: (value: ReturnType) => void + mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve })) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + const listeners = vi.spyOn(window, 'addEventListener') + const auth = useAuthStore() + const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void + listeners.mockRestore() + const refreshing = auth.refreshSession() + await Promise.resolve() + const salesToken = token({ user_id: '2000002', username: 'sales' }) + localStorage.setItem('cc-admin-token', salesToken) + localStorage.setItem('cc-admin-refresh-token', 'sales-refresh') + localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]') + onStorage(new StorageEvent('storage', { key: 'cc-admin-token' })) + expect(auth.user?.account).toBe('sales') + finishRefresh(loginResponse(token(), 'admin-rotated')) + await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代') + expect(auth.user?.account).toBe('sales') + expect(auth.token).toBe(salesToken) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh') + expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list']) + }) + + it('discards late permission results after another tab switches accounts without clearing the new session', async () => { + mocks.post.mockResolvedValue(loginResponse()) + let finishPermissions!: (value: string[]) => void + let permissionsStarted!: () => void + const started = new Promise(resolve => { permissionsStarted = resolve }) + mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => { + permissionsStarted() + return new Promise(resolve => { finishPermissions = resolve }) + }) + const listeners = vi.spyOn(window, 'addEventListener') + const auth = useAuthStore() + const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void + listeners.mockRestore() + const login = auth.login('platform_admin', 'password') + await started + const salesToken = token({ user_id: '2000002', username: 'sales' }) + localStorage.setItem('cc-admin-token', salesToken) + localStorage.setItem('cc-admin-refresh-token', 'sales-refresh') + localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]') + onStorage(new StorageEvent('storage', { key: 'cc-admin-token' })) + finishPermissions(['platform:warehouse:list']) + await expect(login).rejects.toThrow('登录会话已被后续操作替代') + expect(auth.user?.account).toBe('sales') + expect(auth.token).toBe(salesToken) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh') + expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list']) + expect(mocks.post).toHaveBeenCalledTimes(1) + }) + it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => { const previous = token() const rotated = token({ token_version: 2 }) diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 2c206da9..d8072e09 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -144,6 +144,10 @@ export const useAuthStore = defineStore('admin-auth', () => { try { const session = sessionFromToken(storedToken) const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown + // A different account in another tab supersedes this tab's pending login/refresh responses. + if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) { + sessionGeneration += 1 + } token.value = storedToken refreshToken.value = storedRefreshToken user.value = session.user diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index 8cfe2150..41d43a17 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -46,7 +46,7 @@ describe('appointment calendar interactions', () => { 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') + 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' }) }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index dd418631..f7ae284b 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -109,7 +109,7 @@ defineExpose({ selectedDate, mode })
编辑 - 删除 + 取消预约
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index a9e92753..5cc60ff4 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentCalendarView from './AppointmentCalendarView.vue' import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse' import { listArrivalCalendar } from './api' -import { resolvePlanningSource, saveFollowup } from './planningApi' +import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi' const routing = vi.hoisted(() => ({ query: {} as Record, canSourceRead: true })) -vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn() })) +vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() })) import { listPlatformWarehouses } from '@/modules/warehouses' vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() })) @@ -14,7 +14,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'source-read' || routing.canSourceRead }) })) -vi.mock('@/modules/overseas-warehouse', () => ({ +vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal()), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' }, @@ -22,8 +22,8 @@ vi.mock('@/modules/overseas-warehouse', () => ({ vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) const candidates = [ - { lineId: '9007199254740993', customerId: 'A', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' }, - { lineId: '9007199254740994', customerId: 'B', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' }, + { lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' }, + { lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' }, ] function render() { return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: { @@ -48,9 +48,10 @@ describe('appointment calendar data integration', () => { routing.query = {}; routing.canSourceRead = true vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 }) vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([]) + vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0}) vi.mocked(listArrivalCalendar).mockResolvedValue([]) vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 }) - vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) + vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) }) it('creates one shared appointment for two customers without sending a fabricated header customer', async () => { @@ -64,13 +65,12 @@ describe('appointment calendar data integration', () => { await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await flushPromises() await wrapper.get('[data-select]').trigger('click') - await wrapper.get('input[placeholder="填写仓库提供的预约编号"]').setValue('APT-AB') - await wrapper.findAll('[data-date]')[1]!.setValue('2026-10-09T07:00:00') + expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false) await wrapper.get('[data-save]').trigger('click') await flushPromises() expect(createFbaAppointment).toHaveBeenCalledTimes(1) expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({ - appointmentNo: 'APT-AB', lineIds: ['9007199254740993', '9007199254740994'], + originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}), })) expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId') expect(listInboundOrders).toHaveBeenCalledTimes(1) @@ -88,6 +88,27 @@ describe('appointment calendar data integration', () => { await flushPromises() expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' }) }) + it('keeps a pending unknown save retry on the same immutable command', async () => { + vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost')) + const wrapper = render(); await flushPromises() + await wrapper.get('[data-create]').trigger('click'); await flushPromises() + await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8'); await flushPromises() + await wrapper.get('[data-select]').trigger('click') + await wrapper.get('[data-save]').trigger('click'); await flushPromises() + const first = vi.mocked(createFbaAppointment).mock.calls[0]![0] + await wrapper.get('[data-save]').trigger('click'); await flushPromises() + expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first) + expect(first.details.appointmentAt).toBeNull() + expect(first.details.confirmationNo).toBeNull() + expect(first.idempotencyKey).toBeTruthy() + }) + it('keeps undated arrangements outside calendar time slots', async () => { + vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 }) + const wrapper=render(); await flushPromises() + expect(wrapper.text()).toContain('未定时安排(1)') + expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) + expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt') + }) it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => { const wrapper = render() @@ -96,8 +117,7 @@ describe('appointment calendar data integration', () => { await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await flushPromises() await wrapper.get('[data-select]').trigger('click') - await wrapper.get('input[placeholder="填写仓库提供的预约编号"]').setValue('APT-AB') - await wrapper.findAll('[data-date]')[1]!.setValue('2026-10-09T07:00:00') + expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false) await wrapper.get('select[aria-label="预约仓库"]').setValue('') await wrapper.get('[data-save]').trigger('click') expect(createFbaAppointment).not.toHaveBeenCalled() @@ -148,7 +168,7 @@ describe('appointment calendar data integration', () => { it('keeps delivery appointments when the arrival source fails', async () => { vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用')) - vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }]) + vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }]) const wrapper = render() await flushPromises() const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index aedd3f55..2a0ce7a6 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -2,7 +2,7 @@ import '@/styles/adminDensity.css' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute } from 'vue-router' -import { resolvePlanningSource, type ResolvedPlanningSource } from './planningApi' +import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource } from './planningApi' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' @@ -15,7 +15,8 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import { createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment, overseasWarehousePermissions as permission, - type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto, + FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, + type FbaAppointmentDto, type FbaAppointmentDetailsPayload, type InboundOrderSummaryDto, } from '@/modules/overseas-warehouse' import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue' @@ -65,8 +66,18 @@ let calendarController: AbortController | undefined let calendarRequest = 0 let candidateRequest = 0 let detailRequest = 0 -const form = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'PENDING' as BatchFbaAppointmentPayload['appointmentStatus'], appointmentRemark: '' }) -const statusLabels: Record = { PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } +const form = reactive(createAppointmentForm()) +const pendingSave = ref<{ appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: FbaAppointmentDetailsPayload; idempotencyKey: string } | null>(null) +const unscheduled = ref([]), unscheduledTotal = ref(0), unscheduledPage = ref(1), unscheduledSize = ref(10) +const unscheduledError = ref(null) +const unscheduledColumns: DataTableColumn[] = [{key:'actions',label:'操作',slotName:'actions',width:100,fixed:'left'},{prop:'appointmentNo',label:'系统预约单号',minWidth:200},{prop:'fbaWarehouseCode',label:'目的仓',width:100},{prop:'selectedCount',label:'票数',width:80}] +let unscheduledRequest = 0 +async function loadUnscheduled() { + const current = ++unscheduledRequest; unscheduledError.value = null + try { const result = await listUnscheduledAppointments(unscheduledPage.value, unscheduledSize.value); if(current === unscheduledRequest) {unscheduled.value = result.records; unscheduledTotal.value = result.total} } + catch(cause) {if(current === unscheduledRequest) unscheduledError.value = getApiErrorDetails(cause, '未定时安排加载失败')} +} +const statusLabels: Record = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' } const columns: DataTableColumn[] = [ { key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) }, { prop: 'inboundCode', label: '入仓码', minWidth: 150 }, @@ -89,15 +100,17 @@ const events = computed(() => [...arrivals.value.map(arrival => { label: '货物去向', value: arrival.destinationWarehouseCode }, ], })), ...appointments.value.map(appointment => ({ - key: appointment.appointmentNo, type: 'delivery' as const, date: appointment.appointmentAt.slice(0, 10), - time: appointment.appointmentAt.slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} 预约送仓`, + key: appointment.appointmentNo, type: 'delivery' as const, date: (appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '').slice(0, 10), + time: (appointment.appointmentAt || appointment.plannedAt || '').slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} ${appointment.appointmentStatus === 'SUCCESS' ? '已确认送仓' : '计划送仓'}`, fields: [ { label: '预约仓库', value: appointment.fbaWarehouseCode }, - { label: '预约编号', value: appointment.appointmentNo }, + { label: '系统单号', value: appointment.appointmentNo }, + { label: '外部约号', value: appointment.confirmationNo }, + { label: '地点时区', value: appointment.timezone || '待核实' }, { label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' }, { label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` }, { label: '预约备注', value: appointment.appointmentRemark }, - ], + ].filter(field => field.value != null && field.value !== ''), inboundCodes: appointment.items.map(item => item.inboundCode), canEdit: access.can(permission.inboundOrderAppointment), canDelete: access.can('platform:appointment:delete'), @@ -144,16 +157,17 @@ function openCreate(date: string) { candidateTotal.value = 0 candidateError.value = null dialogError.value = null - Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' }) + Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null dialogOpen.value = true detailReady.value = true void searchWarehouses('') } -async function openEdit(event: CalendarEvent) { +async function openEdit(event: Pick) { const request = ++detailRequest dialogError.value = null - editing.value = appointments.value.find(item => item.appointmentNo === event.key) || null + editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null + pendingSave.value = null if (!editing.value) return dialogOpen.value = true detailLoading.value = true @@ -164,9 +178,7 @@ async function openEdit(event: CalendarEvent) { editing.value = detail detailReady.value = true warehouse.value = detail.fbaWarehouseCode - Object.assign(form, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt, - expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus, - appointmentRemark: detail.appointmentRemark || '' }) + Object.assign(form, createAppointmentForm(detail)) } catch (error) { if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败') } finally { if (request === detailRequest) detailLoading.value = false } @@ -222,13 +234,13 @@ async function loadCandidates() { async function save() { if (saving.value || detailLoading.value || !detailReady.value) return - if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) { - ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return - } + const validation = appointmentFormError(form) + if (!pendingSave.value && validation) { ElMessage.warning(validation); return } if (!editing.value) { if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return } if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return } if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return } + if (selected.value.some(item => !item.overseasWarehouseId) || new Set(selected.value.map(item => item.overseasWarehouseId)).size !== 1) { ElMessage.warning('请选择同一接仓点的货物'); return } if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) { ElMessage.warning('同一预约的货物必须送往同一目的仓'); return } @@ -236,14 +248,15 @@ async function save() { saving.value = true dialogError.value = null try { - const common = { appointmentEndAt: form.appointmentEndAt || null, appointmentAt: form.appointmentAt, expectedLoadingAt: form.expectedLoadingAt, - appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || undefined } - if (editing.value) await updateFbaAppointment(editing.value.appointmentNo, { ...common, version: editing.value.version }) - else await createFbaAppointment({ ...common, appointmentNo: form.appointmentNo.trim(), lineIds: selected.value.map(item => item.lineId) }) + pendingSave.value ||= {appointmentNo: editing.value?.appointmentNo || null, version: editing.value?.version || 0, originWarehouseId: String(selected.value[0]?.overseasWarehouseId || ''), fbaWarehouseCode: searchedWarehouse.value, lineIds: selected.value.map(item => item.lineId), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID()} + const payload = pendingSave.value + if (payload.appointmentNo) await updateFbaAppointment(payload.appointmentNo, { details: payload.details, version: payload.version, idempotencyKey: payload.idempotencyKey }) + else await createFbaAppointment({ originWarehouseId: payload.originWarehouseId, fbaWarehouseCode: payload.fbaWarehouseCode, details: payload.details, idempotencyKey: payload.idempotencyKey, lineIds: payload.lineIds }) + pendingSave.value = null dialogOpen.value = false ElMessage.success('预约已保存') - await load() - } catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') } + await Promise.all([load(), loadUnscheduled()]) + } catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(dialogError.value.code || '')) pendingSave.value = null } finally { saving.value = false } } @@ -255,17 +268,17 @@ async function remove(event: CalendarEvent) { const appointment = appointments.value.find(item => item.appointmentNo === event.key) if (!appointment || loading.value) return try { - await ElMessageBox.confirm(`确认删除预约 ${appointment.appointmentNo}?该操作会解除全部 ${appointment.selectedCount} 票货物的预约关联。`, '删除预约', - { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', customClass: 'admin-density-confirm' }) + await ElMessageBox.confirm(`确认取消预约 ${appointment.appointmentNo}?将释放全部 ${appointment.selectedCount} 票货物的占用,历史仍保留。`, '取消预约', + { type: 'warning', confirmButtonText: '确认取消预约', cancelButtonText: '返回', customClass: 'admin-density-confirm' }) } catch { return } loading.value = true try { await deleteAppointment(appointment.appointmentNo, appointment.version) - ElMessage.success('预约已删除') + ElMessage.success('预约已取消,历史仍保留') await load() } catch (error) { - errorTitle.value = '预约删除失败' - loadError.value = getApiErrorDetails(error, '预约删除失败') + errorTitle.value = '预约取消失败' + loadError.value = getApiErrorDetails(error, '预约取消失败') } finally { loading.value = false } } @@ -291,8 +304,8 @@ async function resolveLink() { } watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true }) -onMounted(() => load()) -onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() }) +onMounted(() => { void load(); void loadUnscheduled() }) +onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })