Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchCargoBoard.vue
T
2026-10-21 15:49:35 +08:00

280 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, shallowRef, useId, useSlots, watch } from 'vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import type { Candidate, DispatchDraft, DraftContainer } from '../dispatchContracts'
import { selectionGroupBlockReason } from '../dispatchContracts'
const props = defineProps<{
candidates: Candidate[]
draft: DispatchDraft | null
activeContainerId: string
capacity: number
disabled: boolean
warehouseSelected: boolean
page: number
size: number
total: number
}>()
const emit = defineEmits<{
add: [source: Candidate, containerId: string]
selectContainer: [id: string]
removeCargo: [source: Candidate, containerId: string]
removeContainer: [id: string]
addVehicle: []
changePage: [page: number]
changeSize: [size: number]
}>()
const selectedOrderKey = ref('')
const slots = useSlots()
const vehicleRadioName = `smart-dispatch-active-vehicle-${useId()}`
const draggedSource = shallowRef<Candidate | null>(null)
const dropTarget = ref<string | null>(null)
function orderKey(source: Candidate) {
return JSON.stringify([source.customerId, source.selection.sourceType, source.orderNo || source.inboundCode || source.planNo || source.selection.relabelNo || source.sourceKey])
}
function businessNo(source: Candidate) {
return source.orderNo || source.inboundCode || source.planNo || source.selection.relabelNo || '—'
}
function sourceLabel(source: Candidate) {
return source.selection.sourceType === 'RELABEL' ? '换标' : source.selection.sourceType === 'RETURN_RECEIPT' ? '退回实收' : source.selection.warehousePalletId ? '已收货托盘' : '在途计划'
}
function destinationLabel(source: Candidate) {
return source.destination.warehouseCode || source.destination.warehouseName || source.destination.address || '—'
}
function uniqueGroups(sources: Candidate[]) {
const seen = new Set<string>()
return sources.filter(source => {
const key = source.selectionGroup?.groupKey || source.sourceKey
if (seen.has(key)) return false
seen.add(key)
return true
})
}
const orders = computed(() => {
const grouped = new Map<string, Candidate[]>()
for (const source of props.candidates) {
const key = orderKey(source)
const existing = grouped.get(key)
if (existing) existing.push(source)
else grouped.set(key, [source])
}
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
})
const selectedOrder = computed(() => orders.value.find(order => order.key === selectedOrderKey.value))
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []).filter(source => !assignedVehicle(source)))
const vehicles = computed<DraftContainer[]>(() => props.draft?.containers.length ? props.draft.containers : [{ draftContainerId: '', sequenceNo: 1, capacity: props.capacity, sourceKeys: [] }])
function assignedVehicle(source: Candidate) {
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
return props.draft?.containers.find(vehicle => vehicle.sourceKeys.some(key => keys.has(key)))
}
function canAdd(source: Candidate) {
return !props.disabled && props.warehouseSelected && props.candidates.includes(source) && !assignedVehicle(source) && !selectionGroupBlockReason(source)
}
function validTarget(id: string) {
if (slots.vehicles) return props.warehouseSelected && Boolean(id) && id === props.activeContainerId
return vehicles.value.some(vehicle => vehicle.draftContainerId === id)
}
function add(source: Candidate, id: string) {
if (!canAdd(source) || !validTarget(id)) return
emit('add', source, id)
}
function clearDrag() { draggedSource.value = null; dropTarget.value = null }
function dragStart(event: DragEvent, source: Candidate) {
clearDrag()
if (!canAdd(source)) { event.preventDefault(); return }
draggedSource.value = source
if (event.dataTransfer) {
// Transfer only a harmless marker. The live component reference is the authority.
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
event.dataTransfer.effectAllowed = 'move'
}
}
function dragOver(event: DragEvent, id: string) {
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
event.preventDefault()
dropTarget.value = id
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function drop(event: DragEvent, id: string) {
event.preventDefault()
const source = draggedSource.value
clearDrag()
if (source) add(source, id)
}
function chooseOrder(key: string) {
if (props.disabled) return
clearDrag()
selectedOrderKey.value = key
}
function changePage(page: number) {
if (props.disabled) return
selectedOrderKey.value = ''
clearDrag()
emit('changePage', page)
}
function changeSize(size: number) {
if (props.disabled) return
selectedOrderKey.value = ''
clearDrag()
emit('changeSize', size)
}
function vehicleSources(vehicle: DraftContainer) {
const keys = new Set(vehicle.sourceKeys)
return uniqueGroups((props.draft?.sources || []).filter(source => keys.has(source.sourceKey)))
}
function physicalPalletCount(sources: Candidate[]): number | null {
const keys = new Set<string>()
for (const source of sources) {
if (!source.selectionGroup?.pallets.length) return null
for (const pallet of source.selectionGroup.pallets) {
if (!pallet.warehousePalletId && (!source.selectionGroup.groupKey || !pallet.palletNo)) return null
const key = pallet.warehousePalletId
? JSON.stringify(['physical', pallet.warehousePalletId])
: JSON.stringify(['planned', source.selectionGroup.groupKey, pallet.palletNo])
keys.add(key)
}
}
return keys.size
}
function vehiclePalletCount(vehicle: DraftContainer) {
if (vehicle.sourceKeys.some(key => !props.draft?.sources.some(source => source.sourceKey === key))) return null
return physicalPalletCount(vehicleSources(vehicle))
}
function groupPackages(source: Candidate) {
const packages = source.selectionGroup?.pallets.map(pallet => pallet.packageCount)
return packages?.length && packages.every(value => Number.isSafeInteger(value) && value >= 0) ? packages.reduce((sum, value) => sum + value, 0) : null
}
function palletLabel(source: Candidate) {
return source.selectionGroup?.pallets.map(pallet => pallet.palletNo || '托盘号未提供').join('、') || source.palletNo || '—'
}
watch(() => [props.page, props.size], () => { selectedOrderKey.value = ''; clearDrag() }, { flush: 'sync' })
watch(() => props.candidates, () => {
clearDrag()
// A same-page reload temporarily empties candidates. Keep the selected key;
// selectedOrder naturally becomes undefined if the refreshed page no longer has it.
}, { deep: true, flush: 'sync' })
watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeContainerId], clearDrag, { deep: true, flush: 'sync' })
</script>
<template>
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span></header>
<div class="cargo-board-order-content">
<div class="cargo-board-table-wrap">
<table class="density-table cargo-board-table">
<thead><tr><th class="cargo-board-operation">操作</th><th>订单号 / 来源</th><th>客户</th><th>关联托数</th><th>目的仓</th></tr></thead>
<tbody>
<tr v-for="order in orders" :key="order.key" :class="{ 'is-selected': selectedOrderKey === order.key }" @click="chooseOrder(order.key)">
<td><el-button link :disabled="disabled" :aria-pressed="selectedOrderKey === order.key" @click.stop="chooseOrder(order.key)">查看托盘</el-button></td>
<td><span class="cargo-board-business-no">{{ businessNo(order.source) }}</span><small class="cargo-board-muted">{{ sourceLabel(order.source) }}{{ !order.source.orderNo ? ' · 未关联订单' : '' }}</small></td>
<td>{{ order.source.customerName || '—' }}</td>
<td>{{ physicalPalletCount(order.sources) ?? '—' }}</td>
<td>{{ [...new Set(order.sources.map(destinationLabel))].join('、') }}</td>
</tr>
<tr v-if="!orders.length"><td colspan="5" class="cargo-board-empty">{{ warehouseSelected ? '暂无可排货物' : '请先选择起运仓' }}</td></tr>
</tbody>
</table>
</div>
<footer class="cargo-board-pagination"><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
</div>
</section>
<section class="cargo-board-panel cargo-board-pallets" aria-label="托盘明细" aria-live="polite">
<header class="cargo-board-heading"><h3>托盘明细</h3><span class="cargo-board-order-caption">订单号:{{ selectedOrder ? businessNo(selectedOrder.source) : '未选择订单' }}<small v-if="selectedOrder && !selectedOrder.source.orderNo" class="cargo-board-muted">未关联订单</small></span></header>
<div class="cargo-board-pallet-list">
<table class="density-table cargo-board-pallet-table">
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
<tbody>
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<td>
<el-button class="cargo-board-pallet-move" link :disabled="!canAdd(source) || !validTarget(activeContainerId)" :aria-label="`将托盘 ${palletLabel(source)} 移入选中车辆`" @click="add(source, activeContainerId)">{{ palletLabel(source) }}</el-button>
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
<details v-if="selectionGroupBlockReason(source)" class="cargo-board-block"><summary>暂不可排车</summary><p>{{ selectionGroupBlockReason(source) }}</p></details>
</td>
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
</tr>
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">暂无待装托盘</td></tr>
</tbody>
</table>
</div>
</section>
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击托盘号。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
<div class="cargo-board-vehicle-content">
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<div class="cargo-board-vehicle-list">
<article v-for="vehicle in vehicles" :key="vehicle.draftContainerId" class="cargo-board-vehicle" :class="{ 'is-current': vehicle.draftContainerId === activeContainerId, 'is-drop-target': dropTarget === vehicle.draftContainerId }" :aria-label="`第 ${vehicle.sequenceNo} 辆车`" @dragover="dragOver($event, vehicle.draftContainerId)" @dragleave.self="dropTarget = null" @drop="drop($event, vehicle.draftContainerId)">
<header class="cargo-board-vehicle-heading"><label><input type="radio" :name="vehicleRadioName" :checked="activeContainerId === vehicle.draftContainerId" :disabled="disabled" :aria-label="`选择第 ${vehicle.sequenceNo} 辆车`" @change="emit('selectContainer', vehicle.draftContainerId)" /> 第 {{ vehicle.sequenceNo }} 辆车 · {{ vehicle.capacity }} 托位</label><span>托数 {{ vehiclePalletCount(vehicle) ?? '—' }}/{{ vehicle.capacity }}</span></header>
<div class="cargo-board-vehicle-body">
<p v-if="!vehicle.sourceKeys.length" class="cargo-board-empty">拖入托盘</p>
<div v-for="source in vehicleSources(vehicle)" :key="source.selectionGroup.groupKey" class="cargo-board-loaded-pallet">
<div><strong>{{ palletLabel(source) }}</strong><small class="cargo-board-muted">{{ businessNo(source) }} · {{ groupPackages(source) ?? '—' }} 箱</small><details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details></div>
<el-button link :disabled="disabled" :aria-label="`移回托盘 ${palletLabel(source)}`" title="移回托盘" @click="emit('removeCargo', source, vehicle.draftContainerId)">×</el-button>
</div>
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
</div>
<footer v-if="vehicle.draftContainerId && !vehicle.sourceKeys.length" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
</article>
</div>
</slot>
</div>
</section>
</div>
</template>
<style scoped>
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 44fr) minmax(0, 22fr) minmax(0, 34fr); grid-template-rows: auto auto; align-items: stretch; gap: var(--admin-space-5) var(--admin-space-6); }
.cargo-board-panel { display: grid; grid-row: 1 / span 2; grid-template-rows: subgrid; align-items: start; min-width: 0; background: var(--admin-surface); }
.cargo-board-orders { grid-column: 1; }
.cargo-board-pallets { grid-column: 2; }
.cargo-board-vehicles { grid-column: 3; }
.cargo-board-order-content, .cargo-board-vehicle-content { min-width: 0; }
.cargo-board-heading { align-self: stretch; display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); box-sizing: border-box; margin: 0; flex-wrap: wrap; }
.cargo-board-heading h3 { margin: 0; font-size: var(--admin-font-section); }
.cargo-board-vehicle-tools { display: flex; align-items: center; gap: var(--admin-space-3); margin-left: auto; flex-wrap: wrap; }
.cargo-board-vehicle-tools > .el-button { margin-left: 0; }
.cargo-board-muted { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-table-wrap { overflow: auto; max-height: min(390px, 40vh); border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
.cargo-board-table { width: 100%; table-layout: fixed; }
.cargo-board-table td { overflow-wrap: anywhere; }
.cargo-board-table th:nth-child(1) { width: 82px; }
.cargo-board-table th:nth-child(2) { width: 32%; }
.cargo-board-table th:nth-child(4) { width: 72px; }
.cargo-board-table small, .cargo-board-loaded-pallet small { display: block; }
.cargo-board-table tbody tr { cursor: pointer; }
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
.cargo-board-business-no { font-weight: 600; }
.cargo-board-pagination { display: flex; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; }
.cargo-board-order-caption { margin-left: auto; max-width: 65%; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-order-caption small { display: block; }
.cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; }
.cargo-board-pallet-list { max-height: min(500px, 48vh); overflow-y: auto; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
.cargo-board-pallet-table { width: 100%; table-layout: fixed; }
.cargo-board-pallet-table .cargo-board-pallet-count { width: 72px; text-align: right; white-space: nowrap; }
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
.cargo-board-pallet-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
.cargo-board-pallet.is-draggable { cursor: grab; }
.cargo-board-pallet-move { max-width: 100%; height: auto; white-space: normal; text-align: left; justify-content: flex-start; }
.cargo-board-pallet.is-draggable { user-select: none; }
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.cargo-board-group-details summary { cursor: pointer; }
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
.cargo-board-block { color: var(--admin-action-danger); font-size: var(--admin-font-caption); margin: var(--admin-space-2) 0; }
.cargo-board-vehicle-list { display: flex; flex-direction: column; gap: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.cargo-board-vehicle { border: 2px dashed var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface); }
.cargo-board-vehicle.is-current { border-color: var(--admin-action-primary-border); }
.cargo-board-vehicle.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
.cargo-board-vehicle-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; padding: var(--admin-space-5); background: var(--admin-dialog-source-bg); }
.cargo-board-vehicle-heading label { display: flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
.cargo-board-vehicle-body { min-height: 90px; padding: var(--admin-space-3); }
.cargo-board-loaded-pallet { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-3); padding: var(--admin-space-3); border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.cargo-board-loaded-pallet > div { min-width: 0; }
.cargo-board-loaded-pallet > .el-button { flex: none; }
.cargo-board-vehicle-footer { display: flex; justify-content: flex-end; padding: 0 var(--admin-space-5) var(--admin-space-3); }
</style>