280 lines
19 KiB
Vue
280 lines
19 KiB
Vue
<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>
|