This commit is contained in:
2026-10-19 17:56:31 +08:00
parent 5a46198bf0
commit 5c28cd7c79
84 changed files with 3356 additions and 3630 deletions
@@ -1,743 +1,127 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { appointmentReturnTarget, appointmentReturnQuery } from '@/modules/appointment-calendar'
import { TripPreparationDialog, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto, type ExternalFreightPreview } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import { fullyCoveredServices, coveredServiceLabels, type FeeCoveragePreview, type FeeCoverageSelection } from '../feeCoverageTypes'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter()
const routeOpen = ref(false)
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
function openRoute(row: DispatchListItem) {
routeOrigin.value = { kind: 'warehouse', label: row.overseasWarehouse, warehouseId: dispatchSnapshots.get(row.dispatchNo)?.overseasWarehouseId || undefined }
// The service's route field is a comma-separated set of destination warehouse codes, not a stop sequence.
routeDestinations.value = [...new Set(row.route.split(',').map(code => code.trim()).filter(Boolean))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code }))
routeOpen.value = true
}
const access = useAccessPreviewStore()
const rows = ref<DispatchListItem[]>([])
const selectedDispatches = ref<DispatchListItem[]>([]), prepareOpen = ref(false), prepareWarehouse = ref(''), prepareWarehouseName = ref(''), prepareNumbers = ref<string[]>([])
const canPrepare = computed(() => access.can('platform:fleet:trip:edit') && access.can('platform:fleet:trip:list'))
function beginPreparation() {
const selected = selectedDispatches.value
if (!selected.length || selected.some(row => row.statusCode !== 'PENDING_DEPARTURE' || row.workflow)) { ElMessage.warning('请选择尚未关联车次的待发运排车单'); return }
const warehouses = new Set(selected.map(row => dispatchSnapshots.get(row.dispatchNo)?.overseasWarehouseId))
if (warehouses.size !== 1 || ![...warehouses][0]) { ElMessage.warning('请选择同一起运仓的排车单'); return }
prepareWarehouse.value = String([...warehouses][0]); prepareWarehouseName.value = selected[0]!.overseasWarehouse
prepareNumbers.value = selected.map(row => row.dispatchNo); prepareOpen.value = true
}
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const stage = ref<'' | 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'>('')
const stages = [
{ value: '', label: '全部' }, { value: 'BEFORE_LOADING', label: '装车前' },
{ value: 'LOADING', label: '装车中' }, { value: 'AFTER_LOADING', label: '装车后' },
{ value: 'CANCELLED', label: '已取消' },
] as const
const section = ref<'vehicle' | 'dispatchReview' | 'loadingReview'>('vehicle')
const sections = [
{ value: 'vehicle', label: '车辆信息' },
{ value: 'dispatchReview', label: '调度审核' },
{ value: 'loadingReview', label: '理货员审核' },
] as const
const visibleSections = computed(() => stage.value === 'BEFORE_LOADING' ? sections : sections.filter(item => item.value === 'vehicle'))
const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
let loadSequence = 0
function switchStage(value: typeof stage.value) {
if (stage.value === value) return
stage.value = value
if (value !== 'BEFORE_LOADING') section.value = 'vehicle'
filters.value.status = ''
page.value = 1
void load()
}
function openTrip(row: DispatchListItem, tab: 'overview' | 'loading') {
if (!row.workflow || !access.can('platform:fleet:trip:list')) return
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab, ...appointmentReturnQuery(route.query) } })
}
const filters = ref<QueryModel>({
dispatchNo: typeof route.query.dispatchNo === 'string' ? route.query.dispatchNo : '',
vehiclePlate: '',
status: '',
fbaAppointmentNo: '',
})
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const transferIdempotencyKey = ref('')
const externalFreight = ref<ExternalFreightPreview | null>(null)
const coveragePreview = ref<FeeCoveragePreview | null>(null)
const coverageSelection = ref<FeeCoverageSelection[]>([])
const coveredServices = computed(() => fullyCoveredServices(coveragePreview.value, coverageSelection.value, externalFreight.value?.quotes.map(row => row.incomingNo) || []))
const requiresDeliveryFee = computed(() => !!externalFreight.value?.requiresDeliveryFee && !coveredServices.value.includes('DELIVERY_FEE'))
const requiresLoadingFee = computed(() => needsLoadingFee.value && !coveredServices.value.includes('LOADING_FEE'))
const requiresAppointmentFee = computed(() => isBuyAppointment.value && !coveredServices.value.includes('APPOINTMENT_FEE'))
const freightLoading = ref(false)
let freightGeneration = 0
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
const editSaving = ref(false)
const editItems = ref<DispatchItemDto[]>([])
const removingItemId = ref('')
const bolLoadingNo = ref('')
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({
vehiclePlate: '',
driverName: '',
driverPhone: '',
carrierName: '',
route: '',
departAt: '',
fbaAppointmentNo: '',
fbaAppointmentStatus: '',
vehicleAppointmentStatus: '',
estimatedArrivalAt: '',
})
const transferForm = reactive({
plannedDeliveryAt: '',
deliveryMethod: '',
waybillNo: '',
carrierName: '',
pickupMethod: '',
loadingType: '',
vehicleType: '',
vehicleSize: '',
driverName: '',
driverPhone: '',
vehiclePlate: '',
appointmentAccount: '',
sellerName: '',
deliveryFee: undefined as number | undefined,
loadingFee: undefined as number | undefined,
appointmentFee: undefined as number | undefined,
remark: '',
})
const deliveryMethodOptions = [
{ label: 'FBA自提', value: 'FBA_PICKUP' },
{ label: '客户自提', value: 'CUSTOMER_PICKUP' },
{ label: '卡派打托', value: 'TRUCK_PALLET' },
{ label: '卡派地板', value: 'TRUCK_FLOOR' },
{ label: '快递', value: 'EXPRESS' },
{ label: '外配', value: 'OUTSOURCED' },
{ label: '专车直送', value: 'DEDICATED_TRUCK' },
{ label: '零担派送', value: 'LTL' },
]
const pickupMethodOptions = [
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' },
{ label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' },
{ label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
{ label: '仓库自提', value: 'WAREHOUSE_PICKUP' },
]
const loadingTypeOptions = [
{ label: '托盘', value: 'PALLET' },
{ label: '地板', value: 'FLOOR' },
{ label: '混装', value: 'MIXED' },
]
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
const vehicleSizeOptions = dispatchVehicleSizeOptions
const appointmentAccountOptions = [
{ label: '4号账号(美西)', value: '4' },
{ label: '8号账号(美东)', value: '8' },
{ label: '10号账号(美中)', value: '10' },
{ label: '12号账号(加拿大)', value: '12' },
{ label: '买约', value: 'BUY_APPOINTMENT' },
]
const isBuyAppointment = computed(() => transferForm.appointmentAccount === 'BUY_APPOINTMENT')
const needsLoadingFee = computed(() => ['FLOOR', 'MIXED'].includes(transferForm.loadingType))
const statusOptions = [
{ label: '待发车', value: 'PENDING_DEPARTURE' },
{ label: '拣货中', value: 'PICKING' },
{ label: '待发车(拣货完成)', value: 'READY_TO_DEPART' },
{ label: '待发车(部分完成)', value: 'PARTIAL_READY_TO_DEPART' },
{ label: '已发车', value: 'DEPARTED' },
{ label: '已发车(部分完成)', value: 'PARTIAL_DEPARTED' },
{ label: '已取消', value: 'CANCELLED' },
]
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE', reviewStatus: '' })
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && model.value.status === 'ACTIVE')
const fields = computed<QueryField[]>(() => [
{
key: 'dispatchNo',
type: 'input',
label: '排车单号',
placeholder: '请输入排车单号',
width: 220,
},
{
key: 'vehiclePlate',
type: 'input',
label: '车牌号',
placeholder: '请输入车牌号',
width: 180,
},
{
key: 'status',
type: 'select',
label: '车辆状态',
placeholder: '全部状态',
options: statusOptions,
width: 180,
},
{
key: 'fbaAppointmentNo',
type: 'input',
label: 'FBA预约号',
placeholder: '请输入FBA预约号',
width: 220,
},
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'reviewStatus', label: '审核结果', type: 'select' as const, width: 140, options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })) }] : []),
])
const columns: DataTableColumn[] = [
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 220, fixed: 'left', singleLine: false },
{ key: 'workflow', label: '关联车次', minWidth: 230, slotName: 'workflow', singleLine: false },
{ key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false },
{ key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 150, singleLine: false },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 150, singleLine: false },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
{
key: 'carrierName',
prop: 'carrierName',
label: '托车公司名称',
minWidth: 160,
},
{ key: 'orderNos', prop: 'orderNos', label: '关联订单', minWidth: 260, singleLine: false },
{ key: 'route', prop: 'route', label: '路线', minWidth: 180 },
{
key: 'overseasWarehouse',
prop: 'overseasWarehouse',
label: '海外仓',
minWidth: 160,
},
{ key: 'departAt', prop: 'departAt', label: '发车时间', minWidth: 170 },
{
key: 'fbaAppointment',
prop: 'fbaAppointment',
label: 'FBA预约',
minWidth: 180,
},
{
key: 'vehicleAppointment',
prop: 'vehicleAppointment',
label: '车辆预约',
minWidth: 130,
},
{
key: 'estimatedArrivalAt',
prop: 'estimatedArrivalAt',
label: '预计到达时间',
minWidth: 170,
},
{
key: 'palletCount',
prop: 'palletCount',
label: '托盘数',
width: 90,
align: 'right',
},
{
key: 'packageCount',
prop: 'packageCount',
label: '箱数',
width: 80,
align: 'right',
},
{
key: 'status',
prop: 'status',
label: '车辆状态',
width: 150,
slotName: 'status',
},
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
const visibleColumns = computed(() => {
if (section.value === 'vehicle') {
const visible = columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
if (stage.value === 'AFTER_LOADING') visible.splice(visible.findIndex(column => column.key === 'status'), 0,
{ key: 'routeMap', label: '路线地图', width: 120, slotName: 'routeMap' })
return visible
}
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
return keys.map(key => columns.find(column => column.key === key)!)
})
const preparationColumns = computed<DataTableColumn[]>(() => canPrepare.value ? [visibleColumns.value[0]!, { key: 'selection', type: 'selection', width: 45, selectable: (row: DispatchListItem) => row.statusCode === 'PENDING_DEPARTURE' && !row.workflow }, ...visibleColumns.value.slice(1)] : visibleColumns.value)
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
function text(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || '—'
}
function number(value: unknown) {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : 0
}
function statusLabel(value: unknown) {
const status = String(value || '')
return statusOptions.find((option) => option.value === status)?.label || text(value)
}
function appointmentStatusLabel(value: unknown) {
const code = String(value || '')
const labels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
return labels[code] || text(value)
}
function appointmentLabel(numberValue: unknown, statusValue: unknown) {
const appointmentNo = text(numberValue)
const appointmentStatus = appointmentStatusLabel(statusValue)
if (appointmentNo === '—') return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : `${appointmentNo} · ${appointmentStatus}`
}
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
return {
actions: dto.actions,
workflow: dto.workflow,
id: String(dto.id),
dispatchNo: text(dto.dispatchNo),
vehiclePlate: text(dto.vehiclePlate),
driverName: text(dto.driverName),
driverPhone: text(dto.driverPhone),
carrierName: text(dto.carrierName),
orderNos: text(dto.orderNos),
route: text(dto.route),
overseasWarehouse: text(dto.overseasWarehouseName || '仓库名称待补充'),
departAt: formatOverseasDateTime(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: appointmentStatusLabel(dto.vehicleAppointmentStatus),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
statusCode: String(dto.status || ''),
palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount),
createdAt: formatOverseasDateTime(dto.createdAt),
}
}
function dispatchError(error: unknown) {
const details = getApiErrorDetails(error, '排车列表加载失败,请重试')
return details.code === 'HTTP_404'
? {
...details,
message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布',
}
: details
}
async function load(model: QueryModel = filters.value) {
const sequence = ++loadSequence
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage' },
...(stage.value === 'AFTER_LOADING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
])
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
let sequence = 0
async function load() {
const current = ++sequence
loading.value = true; loadError.value = null
try {
const result = await listDispatches({
page: page.value,
size: pageSize.value,
stage: stage.value || undefined,
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
status: String(filters.value.status || '').trim() || undefined,
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
})
if (sequence !== loadSequence) return
dispatchSnapshots.clear()
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
rows.value = result.records.map(toListItem)
total.value = result.total
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
total.value = 0
loadError.value = dispatchError(error)
} finally {
if (sequence === loadSequence) loading.value = false
}
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value ? { beforeLoadingSection: section.value, ...(section.value !== 'VEHICLE_INFO' && model.value.reviewStatus ? { reviewStatus: String(model.value.reviewStatus) as ReviewStatus } : {}) } : {}) })
if (current !== sequence) return
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
finally { if (current === sequence) loading.value = false }
}
function search(model: QueryModel) {
page.value = 1
void load(model)
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; model.value.reviewStatus = ''; page.value = 1; void load() }
function switchSection(value: BeforeLoadingSection) { section.value = value; model.value.reviewStatus = ''; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
function rowActions(row: DispatchListRow): RowActionItem[] {
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
}
function reset(model: QueryModel) {
page.value = 1
void load(model)
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (value === 'bol') return void openBol(row)
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
operation.value = undefined
const requested = value as DispatchAction
if (!canDispatchAction(row.actions, requested, access.can)) return
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
}
function openDetail(row: DispatchListItem) {
void router.push({
name: 'overseas-dispatch-detail',
params: { dispatchNo: row.dispatchNo },
})
function recoverPending() {
const command = recovery.pending.value
if (!command) return
if (command.action === 'CREATE') { createOpen.value = true; return }
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
}
async function openBol(row: DispatchListItem) {
async function openBol(row: DispatchListRow) {
if (bolLoadingNo.value) return
const pdfWindow = window.open('', '_blank')
if (!pdfWindow) { ElMessage.warning('浏览器拦截了 BOL PDF 窗口,请允许本站打开新窗口'); return }
const windowHandle = window.open('', '_blank')
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
bolLoadingNo.value = row.dispatchNo
mutationError.value = null
try {
const pdf = await getDispatchBolPdf(row.dispatchNo)
const url = URL.createObjectURL(pdf)
pdfWindow.location.href = url
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
pdfWindow.close()
mutationError.value = getApiErrorDetails(cause, 'BOL PDF 生成失败,请重试')
} finally {
bolLoadingNo.value = ''
}
try { const pdf = await getDispatchBolPdf(row.dispatchNo); const url = URL.createObjectURL(pdf); windowHandle.location.href = url; window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (cause) { windowHandle.close(); mutationError.value = getApiErrorDetails(cause, 'BOL读取失败') }
finally { bolLoadingNo.value = '' }
}
function canEdit(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && row.actions?.canEdit === true
async function openRoute(row: DispatchListRow) {
try { const data = await getDispatch(row.dispatchNo); if (data.dispatchNo !== row.dispatchNo) throw new Error('路线排车号不一致'); routeOrigin.value = { kind: 'warehouse', label: row.warehouseName, warehouseId: row.warehouseId }; routeDestinations.value = [...new Set(data.orderDetails.map(item => item.destinationFbaWarehouse).filter((code): code is string => Boolean(code)))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })); routeOpen.value = true }
catch (cause) { mutationError.value = getApiErrorDetails(cause, '运输路线读取失败') }
}
function canCancel(row: DispatchListItem) {
return access.can(permission.dispatchCancel) && row.actions?.canCancel === true
}
function canTransfer(row: DispatchListItem) {
return access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true
}
function canRemovePallet(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true
}
function actionBlockReasons(row: DispatchListItem) {
const actions = row.actions
if (!actions) return ['操作资格暂未返回,请刷新列表']
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
}
function openOutbound(row: DispatchListItem) {
if (!row.actions?.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: row.actions.outboundNo } })
}
function openEdit(row: DispatchListItem) {
if (!canEdit(row)) return
const source = dispatchSnapshots.get(row.dispatchNo)
editRow.value = row
editItems.value = [...(source?.items || [])]
Object.assign(editForm, {
vehiclePlate: source?.vehiclePlate || '',
driverName: source?.driverName || '',
driverPhone: source?.driverPhone || '',
carrierName: source?.carrierName || '',
route: source?.route || '',
departAt: source?.departAt || '',
fbaAppointmentNo: source?.fbaAppointmentNo || '',
fbaAppointmentStatus: source?.fbaAppointmentStatus || '',
vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '',
estimatedArrivalAt: source?.estimatedArrivalAt || '',
})
mutationError.value = null
editOpen.value = true
}
async function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
transferIdempotencyKey.value = `web-transfer-${row.dispatchNo}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`
Object.assign(transferForm, {
plannedDeliveryAt: '',
deliveryMethod: '',
waybillNo: '',
carrierName: row.carrierName === '—' ? '' : row.carrierName,
pickupMethod: '',
loadingType: '',
vehicleType: '',
vehicleSize: '',
driverName: row.driverName === '—' ? '' : row.driverName,
driverPhone: row.driverPhone === '—' ? '' : row.driverPhone,
vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate,
appointmentAccount: '',
sellerName: '',
deliveryFee: undefined,
loadingFee: undefined,
appointmentFee: undefined,
remark: '',
})
mutationError.value = null
transferOpen.value = true
externalFreight.value = null
coveragePreview.value = null
coverageSelection.value = []
freightLoading.value = true
const generation = ++freightGeneration
try {
const [preview, coverage] = await Promise.all([getDispatchExternalFreight(row.dispatchNo), getDispatchFeeCoverage(row.dispatchNo)])
if (generation === freightGeneration && transferOpen.value) { externalFreight.value = preview; coveragePreview.value = coverage }
} catch (cause) {
if (generation === freightGeneration && transferOpen.value) mutationError.value = getApiErrorDetails(cause, '约定运费读取失败,请重新打开转派送')
} finally {
if (generation === freightGeneration) freightLoading.value = false
}
}
async function submitEdit() {
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
editSaving.value = true
mutationError.value = null
try {
await updateDispatch(editRow.value.dispatchNo, {
vehiclePlate: editForm.vehiclePlate.trim() || null,
driverName: editForm.driverName.trim() || null,
driverPhone: editForm.driverPhone.trim() || null,
carrierName: editForm.carrierName.trim() || null,
departAt: editForm.departAt || null,
fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null,
fbaAppointmentStatus: editForm.fbaAppointmentStatus || null,
vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null,
estimatedArrivalAt: editForm.estimatedArrivalAt || null,
})
editOpen.value = false
ElMessage.success('排车信息已保存')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试')
} finally {
editSaving.value = false
}
}
async function removePallet(itemValue: unknown) {
const item = itemValue as DispatchItemDto
if (!editRow.value || !canRemovePallet(editRow.value) || removingItemId.value) return
try {
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', { customClass: 'admin-density-confirm',
confirmButtonText: '确认移除',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
removingItemId.value = String(item.id)
mutationError.value = null
try {
const updated = await removeDispatchItem(editRow.value.dispatchNo, String(item.id))
editItems.value = [...(updated.items || [])]
editForm.route = updated.route || ''
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
if (row) {
Object.assign(row, toListItem(updated))
editRow.value = row
}
ElMessage.success('托盘已移除,可重新排车')
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '托盘移除失败,请重试')
} finally {
removingItemId.value = ''
}
}
async function cancel(row: DispatchListItem) {
if (!canCancel(row)) return
try {
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { customClass: 'admin-density-confirm',
confirmButtonText: '确认取消',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
mutationError.value = null
try {
await cancelDispatch(row.dispatchNo)
ElMessage.success('排车单已取消并释放占用,可从入仓单或换标单重新预排')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
}
}
function rowActions(row: DispatchListItem): RowActionItem[] {
if (section.value !== 'vehicle') {
const reviewPermission = section.value === 'dispatchReview' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'
return [
{ key: 'review', label: access.can(reviewPermission) ? '前往审核' : '查看审核', priority: 400, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
{ key: 'detail', label: '查看', priority: 300 },
]
}
return [
{ key: 'detail', label: '查看', priority: 300 },
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
{ key: 'trip', label: '处理原车次', priority: 150, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
{ key: 'outbound', label: '查看原出库', priority: 150, visible: !!row.actions?.outboundNo && access.can(permission.outboundList) },
{ key: 'edit', label: '编辑', priority: 100, visible: canEdit(row) },
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success', visible: canTransfer(row) },
{ key: 'cancel', label: '取消', tone: 'danger', divided: true, visible: canCancel(row) },
]
}
function handleRowAction(command: string, row: DispatchListItem) {
if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading')
else if (command === 'detail') openDetail(row)
else if (command === 'bol') void openBol(row)
else if (command === 'trip') openTrip(row, 'overview')
else if (command === 'outbound') openOutbound(row)
else if (command === 'edit') openEdit(row)
else if (command === 'transfer') openTransfer(row)
else if (command === 'cancel') void cancel(row)
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (transferSaving.value || freightLoading.value || !externalFreight.value) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && transferForm.appointmentFee == null)) {
ElMessage.warning('买约时请填写卖家姓名和买约费用')
return
}
const feeInputs = [
['DELIVERY_FEE', requiresDeliveryFee.value ? transferForm.deliveryFee : undefined, '派送费用'],
['LOADING_FEE', requiresLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', requiresAppointmentFee.value ? transferForm.appointmentFee : undefined, '买约费用'],
] as const
const fees = feeInputs
.filter(([, amount]) => amount != null)
.map(([feeType, amount, feeName]) => ({
feeType,
feeName,
direction: 'RECEIVABLE' as const,
currency: 'USD' as const,
amount: Number(amount),
remark: feeName,
}))
transferSaving.value = true
try {
const result = await transferDispatchToDelivery(transferRow.value.dispatchNo, {
idempotencyKey: transferIdempotencyKey.value,
plannedDeliveryAt: transferForm.plannedDeliveryAt,
deliveryMethod: transferForm.deliveryMethod,
waybillNo: transferForm.waybillNo.trim() || undefined,
carrierName: transferForm.carrierName.trim() || undefined,
pickupMethod: transferForm.pickupMethod,
loadingType: transferForm.loadingType,
vehicleType: transferForm.vehicleType || undefined,
vehicleSize: transferForm.vehicleSize || undefined,
driverName: transferForm.driverName.trim() || undefined,
driverPhone: transferForm.driverPhone.trim() || undefined,
vehiclePlate: transferForm.vehiclePlate.trim() || undefined,
appointmentAccount: transferForm.appointmentAccount || undefined,
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
externalFreightHash: externalFreight.value.snapshotHash,
feeCoverageCargoHash: coverageSelection.value.length ? coveragePreview.value?.cargoHash : undefined,
feeCoverage: coverageSelection.value,
})
transferOpen.value = false
if (result.outboundStatus === 'CANCELLED') {
ElMessage.warning(`原出库单 ${result.outboundNo} 已取消,本次未生成新单据;请取消原排车后重新预排`)
} else if (result.replayed) {
ElMessage.info(`已存在出库单 ${result.outboundNo} 及关联派送单,本次未重复生成`)
} else {
ElMessage.success('已生成出库单和派送单')
}
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试')
} finally {
transferSaving.value = false
}
}
onMounted(load)
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" /><TripPreparationDialog v-model="prepareOpen" :warehouse-id="prepareWarehouse" :warehouse-name="prepareWarehouseName" :dispatch-nos="prepareNumbers" @saved="load()" />
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="preparationColumns" @selection-change="selectedDispatches = $event" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template><template #toolbar-end><el-button v-if="canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation">安排车次</el-button></template>
<template #filters><div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="排车执行阶段">
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
</div>
<div class="density-status-tabs" role="tablist" aria-label="排车业务分区">
<button v-for="item in visibleSections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value">{{ item.label }}</button>
</div>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></div></template>
<OperationErrorToast :details="mutationError" />
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}结果尚待核对。<el-button link :disabled="recovery.busy.value" @click="recoverPending">核对原请求</el-button></p>
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
<template #filters><div class="integrated-query-group"><div class="density-status-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /><p v-for="reason in actionBlockReasons(row as DispatchListItem)" :key="String(reason)">{{ reason }}</p></template>
<template #workflow="{ row }">
<el-button v-if="row.workflow && access.can('platform:fleet:trip:list')" link type="primary" @click="openTrip(row as DispatchListItem, 'overview')">{{ row.workflow.tripNo }}</el-button>
<span v-else>{{ row.workflow?.tripNo || '未关联车次' }}</span>
</template>
<template v-for="review in (['dispatchReview', 'loadingReview'] as const)" :key="review" #[review]="{ row }">
<template v-if="(row as DispatchListItem).workflow">
<div>{{ reviewLabels[(row as DispatchListItem).workflow![review].status] }}</div>
<div v-if="row.workflow[review].status === 'REJECTED'">{{ row.workflow[review].reason || '—' }}</div>
<div v-if="row.workflow[review].reviewedAt">{{ formatOverseasDateTime(row.workflow[review].reviewedAt) }}</div>
</template>
<span v-else>未关联车次</span>
</template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #routeMap="{ row }"><el-button v-if="row.dispatchNo" link type="primary" @click="openRoute(row as DispatchListItem)">查看路线</el-button></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
</DataTable>
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;托盘移除资格以当前服务端校验结果为准。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><p v-if="editRow?.actions?.removePalletBlockReason">{{ editRow.actions.removePalletBlockReason }}</p><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId) || !editRow || !canRemovePallet(editRow)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</ListPageLayout></AdminPage>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="load" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template>
<style scoped>
.transfer-grid,
.edit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 var(--admin-space-5);
}
.transfer-grid :deep(.el-select),
.transfer-grid :deep(.el-date-editor),
.transfer-grid :deep(.el-input-number),
.edit-grid :deep(.el-select),
.edit-grid :deep(.el-date-editor) {
width: 100%;
}
h3 {
margin: var(--admin-space-1) 0 var(--admin-space-5);
}
@media (max-width: 900px) {
.transfer-grid,
.edit-grid {
grid-template-columns: 1fr;
}
}
</style>