2026-10-09 11:01:41 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import '@/styles/adminDensity.css'
|
2026-10-09 22:05:51 +08:00
|
|
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|
|
|
|
|
import { useRoute } from 'vue-router'
|
|
|
|
|
|
import { resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
|
|
|
|
|
|
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
|
|
|
|
|
|
overseasWarehousePermissions as permission,
|
|
|
|
|
|
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
|
|
|
|
|
|
} from '@/modules/overseas-warehouse'
|
2026-10-09 22:05:51 +08:00
|
|
|
|
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
|
2026-10-09 22:05:51 +08:00
|
|
|
|
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
|
|
|
|
|
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
import { calendarRange, dateKey } from './calendar'
|
|
|
|
|
|
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
|
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
2026-10-09 22:05:51 +08:00
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const linkedSource = ref<ResolvedPlanningSource | null>(null)
|
|
|
|
|
|
const linkError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const linkLoading = ref(false)
|
|
|
|
|
|
let linkRequest = 0
|
|
|
|
|
|
const workView = ref<'calendar' | 'cargo'>('calendar')
|
2026-10-09 11:01:41 +08:00
|
|
|
|
const appointments = ref<FbaAppointmentDto[]>([])
|
|
|
|
|
|
const arrivals = ref<ArrivalCalendarEntry[]>([])
|
|
|
|
|
|
const range = ref(calendarRange(dateKey(new Date()), 'month'))
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
2026-10-09 22:05:51 +08:00
|
|
|
|
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
|
2026-10-09 11:59:29 +08:00
|
|
|
|
const errorTitle = ref('预约日历加载失败')
|
2026-10-09 11:01:41 +08:00
|
|
|
|
const dialogError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const dialogOpen = ref(false)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const detailLoading = ref(false)
|
|
|
|
|
|
const detailReady = ref(false)
|
|
|
|
|
|
const editing = ref<FbaAppointmentDto | null>(null)
|
|
|
|
|
|
const warehouse = ref('')
|
2026-10-09 22:05:51 +08:00
|
|
|
|
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
|
|
|
|
|
|
const warehouseLoading = ref(false)
|
|
|
|
|
|
const warehouseError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const warehouseKeyword = ref('')
|
|
|
|
|
|
const warehouseTotal = ref(0)
|
|
|
|
|
|
let warehouseRequest = 0
|
2026-10-09 11:01:41 +08:00
|
|
|
|
const candidates = ref<InboundOrderSummaryDto[]>([])
|
|
|
|
|
|
const selected = ref<InboundOrderSummaryDto[]>([])
|
|
|
|
|
|
const candidateLoading = ref(false)
|
|
|
|
|
|
const candidateError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const candidatePage = ref(1)
|
|
|
|
|
|
const candidateSize = ref(10)
|
|
|
|
|
|
const candidateTotal = ref(0)
|
|
|
|
|
|
const searchedWarehouse = ref('')
|
|
|
|
|
|
const selectionEpoch = ref(0)
|
|
|
|
|
|
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<string, string> = { 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 },
|
2026-10-10 08:05:21 +08:00
|
|
|
|
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 150 },
|
2026-10-09 11:01:41 +08:00
|
|
|
|
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
|
|
|
|
|
|
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 },
|
|
|
|
|
|
{ prop: 'packageCount', label: '箱数', width: 80 },
|
|
|
|
|
|
{ prop: 'palletCount', label: '托数', width: 80 },
|
|
|
|
|
|
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 },
|
|
|
|
|
|
]
|
|
|
|
|
|
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
|
|
|
|
|
|
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
|
|
|
|
|
|
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
|
|
|
|
|
|
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
|
|
|
|
|
|
fields: [
|
|
|
|
|
|
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
|
|
|
|
|
|
{ label: '目的港', value: arrival.destinationPortName },
|
|
|
|
|
|
{ label: '已装货柜号', value: arrival.containerNo },
|
|
|
|
|
|
{ label: '柜次号', value: arrival.bookingNo },
|
|
|
|
|
|
{ label: '货物去向', value: arrival.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} 预约送仓`,
|
|
|
|
|
|
fields: [
|
|
|
|
|
|
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
|
|
|
|
|
|
{ label: '预约编号', value: appointment.appointmentNo },
|
|
|
|
|
|
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
|
|
|
|
|
|
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
|
|
|
|
|
|
{ label: '预约备注', value: appointment.appointmentRemark },
|
|
|
|
|
|
],
|
2026-10-09 22:05:51 +08:00
|
|
|
|
inboundCodes: appointment.items.map(item => item.inboundCode),
|
2026-10-09 11:01:41 +08:00
|
|
|
|
canEdit: access.can(permission.inboundOrderAppointment),
|
|
|
|
|
|
canDelete: access.can('platform:appointment:delete'),
|
|
|
|
|
|
}))])
|
|
|
|
|
|
|
|
|
|
|
|
async function load(nextRange = range.value) {
|
|
|
|
|
|
range.value = nextRange
|
|
|
|
|
|
const request = ++calendarRequest
|
|
|
|
|
|
calendarController?.abort()
|
|
|
|
|
|
calendarController = new AbortController()
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = null
|
2026-10-09 22:05:51 +08:00
|
|
|
|
sourceErrors.value = []
|
2026-10-09 11:59:29 +08:00
|
|
|
|
errorTitle.value = '预约日历加载失败'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
appointments.value = []
|
|
|
|
|
|
arrivals.value = []
|
|
|
|
|
|
try {
|
2026-10-09 22:05:51 +08:00
|
|
|
|
const [result, arrivalResult] = await Promise.allSettled([
|
2026-10-09 11:01:41 +08:00
|
|
|
|
listFbaAppointmentCalendar(nextRange, calendarController.signal),
|
|
|
|
|
|
listArrivalCalendar(nextRange, calendarController.signal),
|
|
|
|
|
|
])
|
2026-10-09 22:05:51 +08:00
|
|
|
|
if (request !== calendarRequest) return
|
|
|
|
|
|
if (result.status === 'fulfilled') appointments.value = result.value
|
|
|
|
|
|
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
|
|
|
|
|
|
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
|
|
|
|
|
|
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
|
2026-10-09 11:01:41 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
|
|
|
|
|
|
} finally { if (request === calendarRequest) loading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openCreate(date: string) {
|
2026-10-09 22:05:51 +08:00
|
|
|
|
++candidateRequest
|
|
|
|
|
|
++detailRequest
|
|
|
|
|
|
detailLoading.value = false
|
|
|
|
|
|
candidateLoading.value = false
|
|
|
|
|
|
candidatePage.value = 1
|
2026-10-09 11:01:41 +08:00
|
|
|
|
editing.value = null
|
|
|
|
|
|
warehouse.value = ''
|
|
|
|
|
|
searchedWarehouse.value = ''
|
|
|
|
|
|
candidates.value = []
|
|
|
|
|
|
selected.value = []
|
|
|
|
|
|
selectionEpoch.value++
|
|
|
|
|
|
candidateTotal.value = 0
|
|
|
|
|
|
candidateError.value = null
|
|
|
|
|
|
dialogError.value = null
|
|
|
|
|
|
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
|
|
|
|
|
|
dialogOpen.value = true
|
|
|
|
|
|
detailReady.value = true
|
2026-10-09 22:05:51 +08:00
|
|
|
|
void searchWarehouses('')
|
2026-10-09 11:01:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openEdit(event: CalendarEvent) {
|
|
|
|
|
|
const request = ++detailRequest
|
|
|
|
|
|
dialogError.value = null
|
|
|
|
|
|
editing.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
|
|
|
|
|
if (!editing.value) return
|
|
|
|
|
|
dialogOpen.value = true
|
|
|
|
|
|
detailLoading.value = true
|
|
|
|
|
|
detailReady.value = false
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await getFbaAppointment(event.key)
|
|
|
|
|
|
if (request !== detailRequest || !dialogOpen.value) return
|
|
|
|
|
|
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 || '' })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
|
|
|
|
|
|
} finally { if (request === detailRequest) detailLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 22:05:51 +08:00
|
|
|
|
async function searchWarehouses(keyword: string) {
|
|
|
|
|
|
warehouseKeyword.value = keyword
|
|
|
|
|
|
const request = ++warehouseRequest
|
|
|
|
|
|
warehouseLoading.value = true
|
|
|
|
|
|
warehouseError.value = null
|
|
|
|
|
|
warehouseOptions.value = []
|
|
|
|
|
|
warehouseTotal.value = 0
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
|
|
|
|
|
|
if (request !== warehouseRequest || !dialogOpen.value) return
|
|
|
|
|
|
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
|
|
|
|
|
|
warehouseTotal.value = result.total
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
|
|
|
|
|
|
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function changeWarehouse(value: string) {
|
|
|
|
|
|
++candidateRequest
|
|
|
|
|
|
candidateLoading.value = false
|
|
|
|
|
|
candidates.value = []
|
|
|
|
|
|
selected.value = []
|
|
|
|
|
|
candidateTotal.value = 0
|
|
|
|
|
|
candidateError.value = null
|
|
|
|
|
|
candidatePage.value = 1
|
|
|
|
|
|
selectionEpoch.value++
|
|
|
|
|
|
searchedWarehouse.value = value
|
|
|
|
|
|
if (value) void loadCandidates()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadCandidates() {
|
|
|
|
|
|
if (!searchedWarehouse.value) return
|
2026-10-09 11:01:41 +08:00
|
|
|
|
candidateLoading.value = true
|
|
|
|
|
|
candidateError.value = null
|
|
|
|
|
|
const request = ++candidateRequest
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await listInboundOrders({ page: candidatePage.value, size: candidateSize.value, fbaWarehouseCode: searchedWarehouse.value })
|
|
|
|
|
|
if (request !== candidateRequest || !dialogOpen.value) return
|
|
|
|
|
|
candidates.value = result.records
|
|
|
|
|
|
candidateTotal.value = result.total
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (request === candidateRequest) {
|
|
|
|
|
|
candidates.value = []
|
|
|
|
|
|
candidateError.value = getApiErrorDetails(error, '可预约货物加载失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally { if (request === candidateRequest) candidateLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
|
if (saving.value || detailLoading.value || !detailReady.value) return
|
|
|
|
|
|
if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) {
|
|
|
|
|
|
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!editing.value) {
|
2026-10-09 22:05:51 +08:00
|
|
|
|
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 }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
|
|
|
|
|
|
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
|
|
|
|
|
|
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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) })
|
|
|
|
|
|
dialogOpen.value = false
|
|
|
|
|
|
ElMessage.success('预约已保存')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') }
|
|
|
|
|
|
finally { saving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectCandidates(rows: object[]) {
|
|
|
|
|
|
selected.value = rows as InboundOrderSummaryDto[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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' })
|
|
|
|
|
|
} catch { return }
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteAppointment(appointment.appointmentNo, appointment.version)
|
|
|
|
|
|
ElMessage.success('预约已删除')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (error) {
|
2026-10-09 11:59:29 +08:00
|
|
|
|
errorTitle.value = '预约删除失败'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
loadError.value = getApiErrorDetails(error, '预约删除失败')
|
|
|
|
|
|
} finally { loading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 22:05:51 +08:00
|
|
|
|
watch(dialogOpen, open => {
|
|
|
|
|
|
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function resolveLink() {
|
|
|
|
|
|
const request = ++linkRequest
|
|
|
|
|
|
linkedSource.value = null; linkError.value = null; linkLoading.value = false
|
|
|
|
|
|
const { sourceKey, customerId, destinationKey } = route.query
|
|
|
|
|
|
if (!sourceKey && !customerId && !destinationKey) return
|
|
|
|
|
|
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
|
|
|
|
|
|
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
|
|
|
|
|
|
linkLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
|
|
|
|
|
|
if (request === linkRequest) linkedSource.value = result
|
|
|
|
|
|
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
|
|
|
|
|
|
finally { if (request === linkRequest) linkLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
|
|
|
|
|
|
|
2026-10-09 11:01:41 +08:00
|
|
|
|
onMounted(() => load())
|
2026-10-09 22:05:51 +08:00
|
|
|
|
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<AdminPage class="admin-density">
|
2026-10-09 22:05:51 +08:00
|
|
|
|
<p v-if="linkLoading">正在核对预约货源…</p>
|
|
|
|
|
|
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
|
|
|
|
|
|
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" @close="linkedSource = null" />
|
|
|
|
|
|
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
|
|
|
|
|
|
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
|
|
|
|
|
|
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">已开船 FBA 货物</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<AppointmentCargoPanel v-if="workView === 'cargo'" />
|
|
|
|
|
|
<div v-show="workView === 'calendar'">
|
2026-10-09 11:59:29 +08:00
|
|
|
|
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
2026-10-09 22:05:51 +08:00
|
|
|
|
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
|
|
|
|
|
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
|
2026-10-09 11:01:41 +08:00
|
|
|
|
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
|
|
|
|
|
<div v-loading="detailLoading">
|
|
|
|
|
|
<el-form label-position="top" class="calendar-form density-form">
|
2026-10-09 22:05:51 +08:00
|
|
|
|
<el-form-item label="预约仓库" required>
|
|
|
|
|
|
<el-input v-if="editing" :model-value="warehouse" readonly />
|
|
|
|
|
|
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
|
|
|
|
|
|
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="填写仓库提供的预约编号" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
|
|
|
|
|
<el-form-item v-if="editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</el-form>
|
2026-10-09 22:05:51 +08:00
|
|
|
|
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
|
|
|
|
|
|
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
|
|
|
|
|
|
<details :key="editing?.appointmentNo || 'create'" class="appointment-extra">
|
|
|
|
|
|
<summary>补充信息(结束时间、备注{{ editing ? '' : '、状态' }})</summary>
|
|
|
|
|
|
<el-form label-position="top" class="calendar-form density-form">
|
|
|
|
|
|
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
|
|
|
|
|
<el-form-item v-if="!editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
|
|
|
|
|
<el-form-item label="备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="有特殊要求时填写" /></el-form-item>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
<template v-if="!editing && warehouse">
|
2026-10-09 11:01:41 +08:00
|
|
|
|
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
|
2026-10-10 08:05:21 +08:00
|
|
|
|
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
|
2026-10-09 11:01:41 +08:00
|
|
|
|
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
|
|
|
|
|
|
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
|
|
|
|
|
|
</DataTable>
|
|
|
|
|
|
</template>
|
2026-10-09 22:05:51 +08:00
|
|
|
|
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
|
|
|
|
|
|
<div v-else class="appointment-cargo">
|
|
|
|
|
|
<span>{{ editing.selectedCount }} 票货物</span>
|
|
|
|
|
|
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
|
|
|
|
|
|
</div>
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</AppDialog>
|
|
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-10-09 22:05:51 +08:00
|
|
|
|
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
|
|
|
|
|
|
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
|
2026-10-09 22:05:51 +08:00
|
|
|
|
.appointment-extra { margin-bottom: var(--admin-space-5); }
|
|
|
|
|
|
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
|
|
|
|
|
|
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</style>
|