773 lines
28 KiB
Vue
773 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
import { GripVertical, Plus } from '@lucide/vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||
import type { InboundOrderListItem } from '../types'
|
||
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
|
||
import OrderReferenceText from './OrderReferenceText.vue'
|
||
|
||
interface LoadingContainer {
|
||
id: string
|
||
capacity: number
|
||
maxWeightLb: number
|
||
vehicleLabel: string
|
||
palletIds: string[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
orders: InboundOrderListItem[]
|
||
}>()
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: boolean]
|
||
created: []
|
||
}>()
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||
const candidates = ref<DispatchPalletCandidateDto[]>([])
|
||
const palletSnapshots = ref<Record<string, DispatchPalletCandidateDto>>({})
|
||
const activeOrderKey = ref('')
|
||
const containers = ref<LoadingContainer[]>([])
|
||
const busyPalletIds = ref<string[]>([])
|
||
const draftToken = ref('')
|
||
const vehicleDialogOpen = ref(false)
|
||
const selectedVehicle = ref('')
|
||
const query = reactive({
|
||
orderNo: '',
|
||
orderType: '',
|
||
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED',
|
||
fbaWarehouseCode: '',
|
||
})
|
||
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL')
|
||
|
||
const orderTypeOptions = [
|
||
{ value: 'FIRST_LEG', label: '头程订单' },
|
||
{ value: 'RELABEL_APPLICATION', label: '换标下单' },
|
||
{ value: 'RETURN_RELABEL', label: '退货换标' },
|
||
{ value: 'LOCAL_LOOSEBOARD', label: '本土散板' },
|
||
{ value: 'WAREHOUSE_TRANSFER', label: '仓储转运' },
|
||
]
|
||
|
||
const planIds = computed(() => [...new Set(props.orders.map((item) => item.planId).filter(Boolean))])
|
||
const visibleCandidates = computed(() =>
|
||
candidates.value.filter((item) => {
|
||
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
|
||
if (!typeMatches) return false
|
||
if (warehouseTab.value === 'ALL') return true
|
||
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
|
||
return warehouseTab.value === 'LAX' ? /lax|洛杉矶|los angeles/.test(warehouse) : /nyc|jfk|纽约|new york/.test(warehouse)
|
||
}),
|
||
)
|
||
const orderGroups = computed(() => {
|
||
const groups = new Map<string, DispatchPalletCandidateDto[]>()
|
||
for (const item of visibleCandidates.value) {
|
||
const key = String(item.orderId || item.orderNo || item.planId)
|
||
groups.set(key, [...(groups.get(key) || []), item])
|
||
}
|
||
return [...groups.entries()].map(([key, pallets]) => ({
|
||
key,
|
||
first: pallets[0]!,
|
||
pallets,
|
||
}))
|
||
})
|
||
const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || [])
|
||
const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds))
|
||
const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
|
||
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
|
||
const weightLb = (item?: DispatchPalletCandidateDto) => Number(item?.volumeWeightKg || 0) * 2.2046226218
|
||
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
|
||
|
||
function palletId(item: DispatchPalletCandidateDto) {
|
||
return String(item.planPalletId || item.warehousePalletId || '')
|
||
}
|
||
function text(value: unknown) {
|
||
const normalized = String(value ?? '').trim()
|
||
return normalized || '—'
|
||
}
|
||
function number(value: unknown, digits = 0) {
|
||
const normalized = Number(value)
|
||
return Number.isFinite(normalized) ? normalized.toFixed(digits) : '0'
|
||
}
|
||
function dateTime(value: unknown) {
|
||
return formatOverseasDateTime(value)
|
||
}
|
||
function orderType(value: unknown) {
|
||
return (
|
||
(
|
||
{
|
||
FIRST_LEG: '头程订单',
|
||
RELABEL_APPLICATION: '换标下单',
|
||
RETURN_RELABEL: '退货换标',
|
||
LOCAL_LOOSEBOARD: '本土散板',
|
||
WAREHOUSE_TRANSFER: '仓储转运',
|
||
ONE_CLICK_DROPSHIP: '一件代发',
|
||
REJECTED: '拒收订单',
|
||
EXPRESS: '快递订单',
|
||
} as Record<string, string>
|
||
)[String(value || '')] || text(value)
|
||
)
|
||
}
|
||
function relabelCartonMarks(item: DispatchPalletCandidateDto) {
|
||
return String(item.relabelCartonMarks || '')
|
||
.split(',')
|
||
.map((value) => value.trim())
|
||
.filter(Boolean)
|
||
}
|
||
function containerFor(id: string) {
|
||
return containers.value.find((item) => item.palletIds.includes(id))
|
||
}
|
||
function palletSnapshot(id: string) {
|
||
return palletSnapshots.value[id]
|
||
}
|
||
function makeDraftToken() {
|
||
return globalThis.crypto?.randomUUID?.() || `dispatch-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||
}
|
||
function ensureDraftToken() {
|
||
if (!draftToken.value.trim()) draftToken.value = makeDraftToken()
|
||
return draftToken.value
|
||
}
|
||
function rememberPallets(items: DispatchPalletCandidateDto[]) {
|
||
palletSnapshots.value = {
|
||
...palletSnapshots.value,
|
||
...Object.fromEntries(items.map((item) => [palletId(item), item])),
|
||
}
|
||
}
|
||
async function hydrateDraftPallets() {
|
||
const restored = await listDispatchCandidates({
|
||
draftToken: ensureDraftToken(),
|
||
})
|
||
rememberPallets(restored)
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
loadError.value = null
|
||
try {
|
||
candidates.value = await listDispatchCandidates({
|
||
planIds: planIds.value.length ? planIds.value.join(',') : undefined,
|
||
orderNo: query.orderNo.trim() || undefined,
|
||
fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined,
|
||
sourceStage: query.sourceStage || undefined,
|
||
draftToken: ensureDraftToken(),
|
||
})
|
||
rememberPallets(candidates.value)
|
||
activeOrderKey.value = orderGroups.value[0]?.key || ''
|
||
} catch (error) {
|
||
candidates.value = []
|
||
activeOrderKey.value = ''
|
||
loadError.value = getApiErrorDetails(error, '可排车入仓单加载失败,请重试')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
function reset() {
|
||
query.orderNo = ''
|
||
query.orderType = ''
|
||
query.sourceStage = 'RECEIVED'
|
||
query.fbaWarehouseCode = ''
|
||
warehouseTab.value = 'ALL'
|
||
void load()
|
||
}
|
||
|
||
function addContainer() {
|
||
selectedVehicle.value = ''
|
||
vehicleDialogOpen.value = true
|
||
}
|
||
async function confirmAddContainer() {
|
||
const spec = selectedVehicleSpec.value
|
||
if (!spec) {
|
||
ElMessage.warning('请选择车型')
|
||
return
|
||
}
|
||
try {
|
||
const created = await createDispatchDraftContainer(ensureDraftToken(), spec.capacity)
|
||
containers.value.push({
|
||
id: created.id,
|
||
capacity: created.capacity,
|
||
maxWeightLb: spec.maxWeightLb,
|
||
vehicleLabel: spec.label,
|
||
palletIds: created.palletIds || [],
|
||
})
|
||
vehicleDialogOpen.value = false
|
||
} catch (error) {
|
||
mutationError.value = getApiErrorDetails(error, '添加装车车型失败,请重试')
|
||
}
|
||
}
|
||
|
||
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
|
||
const id = palletId(item)
|
||
const target = containers.value.find((container) => container.id === containerId)
|
||
if (!target || target.palletIds.includes(id)) return
|
||
if (!String(item.overseasWarehouseId || '').trim()) {
|
||
ElMessage.warning('该托盘尚未关联海外仓,不能排车')
|
||
return
|
||
}
|
||
if (containerFor(id)) {
|
||
ElMessage.warning('该托盘已在其他装车托盘中')
|
||
return
|
||
}
|
||
if (target.palletIds.length >= target.capacity) {
|
||
ElMessage.warning('该装车托盘已达到设置的托数')
|
||
return
|
||
}
|
||
const nextWeight = containerWeightLb(target) + weightLb(item)
|
||
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
|
||
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
|
||
const anchor = assigned.value[0]
|
||
if (anchor?.customerId && anchor.customerId !== item.customerId) {
|
||
ElMessage.warning('一次排车只能装同一客户的托盘')
|
||
return
|
||
}
|
||
if (anchor?.overseasWarehouseId && anchor.overseasWarehouseId !== item.overseasWarehouseId) {
|
||
ElMessage.warning('一次排车只能装同一海外仓的托盘')
|
||
return
|
||
}
|
||
busyPalletIds.value.push(id)
|
||
mutationError.value = null
|
||
try {
|
||
if (item.sourceStage !== 'RELABEL') await lockDispatchDraftPallet(id, ensureDraftToken(), target.id)
|
||
target.palletIds.push(id)
|
||
} catch (error) {
|
||
mutationError.value = getApiErrorDetails(error, '托盘锁定失败,请刷新后重试')
|
||
} finally {
|
||
busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id)
|
||
}
|
||
}
|
||
|
||
async function removePallet(id: string) {
|
||
const owner = containerFor(id)
|
||
if (!owner) return
|
||
busyPalletIds.value.push(id)
|
||
mutationError.value = null
|
||
try {
|
||
if (palletSnapshot(id)?.sourceStage !== 'RELABEL') await unlockDispatchDraftPallet(id, ensureDraftToken())
|
||
owner.palletIds = owner.palletIds.filter((value) => value !== id)
|
||
} catch (error) {
|
||
mutationError.value = getApiErrorDetails(error, '托盘解锁失败,请重试')
|
||
} finally {
|
||
busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id)
|
||
}
|
||
}
|
||
|
||
async function deleteContainer(container: LoadingContainer) {
|
||
for (const id of [...container.palletIds]) {
|
||
await removePallet(id)
|
||
}
|
||
if (!container.palletIds.length) {
|
||
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
|
||
containers.value = containers.value.filter((item) => item.id !== container.id)
|
||
}
|
||
}
|
||
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
|
||
event.dataTransfer?.setData('text/plain', palletId(item))
|
||
}
|
||
function dropInto(event: DragEvent, containerId: string) {
|
||
const id = event.dataTransfer?.getData('text/plain') || ''
|
||
const item = candidates.value.find((candidate) => palletId(candidate) === id)
|
||
if (item) void movePallet(item, containerId)
|
||
}
|
||
|
||
async function close(value = false) {
|
||
emit('update:modelValue', value)
|
||
}
|
||
|
||
async function submit() {
|
||
if (!assignedIds.value.length) {
|
||
ElMessage.warning('请至少移入一个托盘')
|
||
return
|
||
}
|
||
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) {
|
||
ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘')
|
||
return
|
||
}
|
||
const relabelOrders = [...new Set(assigned.value.filter((item) => item.sourceStage === 'RELABEL')
|
||
.map((item) => String(item.orderNo || '').trim()).filter(Boolean))]
|
||
if (relabelOrders.length && (relabelOrders.length !== 1 || assigned.value.some((item) => item.sourceStage !== 'RELABEL'))) {
|
||
ElMessage.warning('一张换标单的托盘需要单独排车')
|
||
return
|
||
}
|
||
saving.value = true
|
||
mutationError.value = null
|
||
try {
|
||
await preArrangeDispatch({
|
||
planPalletIds: assignedIds.value,
|
||
...(relabelOrders.length ? { relabelNo: relabelOrders[0] } : { draftToken: ensureDraftToken() }),
|
||
})
|
||
emit('update:modelValue', false)
|
||
emit('created')
|
||
ElMessage.success('排车明细已同步到预排列表')
|
||
await router.push({ name: 'overseas-dispatches' })
|
||
} catch (error) {
|
||
mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(open) => {
|
||
if (!open) return
|
||
draftToken.value = makeDraftToken()
|
||
containers.value = []
|
||
palletSnapshots.value = {}
|
||
query.orderNo = ''
|
||
query.orderType = ''
|
||
query.sourceStage = 'RECEIVED'
|
||
query.fbaWarehouseCode = ''
|
||
warehouseTab.value = 'ALL'
|
||
mutationError.value = null
|
||
void (async () => {
|
||
const drafts = await listDispatchDrafts().catch(() => [])
|
||
const draft = drafts[0]
|
||
if (draft?.draftToken) {
|
||
draftToken.value = draft.draftToken
|
||
containers.value = (draft.containers || []).map((c) => {
|
||
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
|
||
return {
|
||
id: c.id,
|
||
capacity: c.capacity,
|
||
maxWeightLb: spec.maxWeightLb,
|
||
vehicleLabel: spec.label,
|
||
palletIds: c.palletIds || [],
|
||
}
|
||
})
|
||
await hydrateDraftPallets().catch(() => undefined)
|
||
}
|
||
await load()
|
||
})()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch([warehouseTab, () => query.orderType], () => {
|
||
activeOrderKey.value = orderGroups.value[0]?.key || ''
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<OperationErrorToast :details="mutationError" />
|
||
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" @update:model-value="close($event)">
|
||
<section class="dispatch-query" aria-label="智能排车筛选">
|
||
<el-input v-model="query.orderNo" clearable placeholder="订单号" @keyup.enter="load" />
|
||
<el-select v-model="query.orderType" clearable placeholder="全部订单类型"><el-option v-for="option in orderTypeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
|
||
<el-select v-model="query.sourceStage" clearable placeholder="全部状态"><el-option label="入库完成托盘" value="RECEIVED" /><el-option label="计划托盘" value="PLAN" /></el-select>
|
||
<el-input v-model="query.fbaWarehouseCode" clearable placeholder="FBA仓库" @keyup.enter="load" />
|
||
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
|
||
</section>
|
||
|
||
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
|
||
<button
|
||
v-for="tab in [
|
||
{ value: 'ALL', label: '全部' },
|
||
{ value: 'LAX', label: '洛杉矶' },
|
||
{ value: 'NYC', label: '纽约' },
|
||
]"
|
||
:key="tab.value"
|
||
type="button"
|
||
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
|
||
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'"
|
||
>
|
||
{{ tab.label }}
|
||
</button>
|
||
</nav>
|
||
|
||
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
|
||
><template #details><ApiErrorMeta :details="loadError" /></template
|
||
></LoadErrorState>
|
||
<div v-else class="dispatch-workbench">
|
||
<section class="order-pane" aria-label="可排车入仓单">
|
||
<header>
|
||
<div>
|
||
<h3>
|
||
出库单列表 <span>({{ orderGroups.length }}单)</span>
|
||
</h3>
|
||
<div class="order-type-tabs density-tabs">
|
||
<button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">
|
||
{{ option.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div v-loading="loading" class="order-table-wrap">
|
||
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
|
||
<el-table-column label="订单号" min-width="170"
|
||
><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template
|
||
></el-table-column>
|
||
<el-table-column label="换标单号" min-width="180"
|
||
><template #default="{ row }">{{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}</template
|
||
></el-table-column>
|
||
<el-table-column label="原入仓码" min-width="150"
|
||
><template #default="{ row }">{{ text(row.first.inboundCode) }}</template
|
||
></el-table-column>
|
||
<el-table-column label="订单类型" width="100"
|
||
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
|
||
>
|
||
<el-table-column label="重量(lb)" width="100" align="right"
|
||
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column
|
||
>
|
||
<el-table-column label="托盘数量" width="90" align="right"
|
||
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
|
||
>
|
||
<el-table-column label="目的地FBA" width="110"
|
||
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
|
||
>
|
||
<el-table-column label="体积(m³)" width="100" align="right"
|
||
><template #default="{ row }">{{
|
||
number(
|
||
row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0),
|
||
2,
|
||
)
|
||
}}</template></el-table-column
|
||
>
|
||
<el-table-column label="货柜号" min-width="125"
|
||
><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column
|
||
>
|
||
<el-table-column label="预约送仓时间" min-width="145"
|
||
><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column
|
||
>
|
||
<el-table-column label="预计装车时间" min-width="145"
|
||
><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column
|
||
>
|
||
</el-table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
|
||
<header>
|
||
<h3>
|
||
托盘明细
|
||
<span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span>
|
||
</h3>
|
||
<span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span>
|
||
</header>
|
||
<div class="pallet-list">
|
||
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
|
||
<GripVertical :size="16" class="drag-handle" />
|
||
<div class="pallet-copy">
|
||
<strong>{{ text(item.palletNo) }}</strong
|
||
><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(weightLb(item), 0) }}LB</span>
|
||
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
|
||
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
|
||
<span>{{ mark }}</span
|
||
><el-tag size="small" type="warning">换标</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
|
||
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
|
||
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
|
||
<template #dropdown
|
||
><el-dropdown-menu
|
||
><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu
|
||
></template
|
||
>
|
||
</el-dropdown>
|
||
</div>
|
||
<el-empty v-if="!activePallets.length" :image-size="64" description="点击左侧入仓单查看托盘和箱号" />
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="container-pane" aria-label="装车托盘容器">
|
||
<header>
|
||
<div>
|
||
<h3>装车托盘</h3>
|
||
<p>
|
||
托盘 {{ assigned.length }} · 箱数 {{ assignedPackages }} · 立方
|
||
{{ assignedVolume.toFixed(2) }}
|
||
</p>
|
||
</div>
|
||
<el-button type="primary" plain @click="addContainer"><Plus :size="15" />添加装车托盘</el-button>
|
||
</header>
|
||
<div class="container-list">
|
||
<div
|
||
v-for="(container, index) in containers"
|
||
:key="container.id"
|
||
class="loading-container"
|
||
:class="{
|
||
'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1,
|
||
}"
|
||
@dragover.prevent
|
||
@drop.prevent="dropInto($event, container.id)"
|
||
>
|
||
<div class="container-head">
|
||
<strong>{{ container.vehicleLabel }}</strong
|
||
><span
|
||
>托数 <b>{{ container.palletIds.length }}</b
|
||
>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span
|
||
><el-button class="density-action" data-tone="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
|
||
</div>
|
||
<div class="container-body">
|
||
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet">
|
||
<span
|
||
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
|
||
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
|
||
><el-button class="density-action" data-tone="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
|
||
</div>
|
||
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
|
||
</div>
|
||
</div>
|
||
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<template #footer><footer class="dialog-footer">
|
||
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
|
||
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length" @click="submit">预排车</el-button></div>
|
||
</footer></template>
|
||
</AppDialog>
|
||
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
|
||
<el-radio-group v-model="selectedVehicle" class="vehicle-options">
|
||
<el-radio v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.value">{{ spec.label }} · 限重 {{ spec.maxWeightLb }}LB</el-radio>
|
||
</el-radio-group>
|
||
<template #footer><el-button @click="vehicleDialogOpen = false">取消</el-button><el-button type="primary" @click="confirmAddContainer">确定</el-button></template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dispatch-query {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--admin-space-5);
|
||
padding: var(--admin-space-5) var(--admin-space-5);
|
||
border: 1px solid #e6eaf2;
|
||
border-radius: var(--admin-radius);
|
||
background: #f7f8fb;
|
||
}
|
||
.dispatch-query :deep(.el-input),
|
||
.dispatch-query :deep(.el-select) {
|
||
width: 190px;
|
||
}
|
||
.warehouse-tabs {
|
||
margin-top: var(--admin-space-5);
|
||
}
|
||
.dispatch-workbench {
|
||
display: grid;
|
||
grid-template-columns: minmax(550px, 1.5fr) minmax(330px, 0.85fr) minmax(390px, 1fr);
|
||
gap: var(--admin-space-5);
|
||
margin-top: var(--admin-space-5);
|
||
}
|
||
.order-pane,
|
||
.detail-pane,
|
||
.container-pane {
|
||
min-width: 0;
|
||
}
|
||
.order-pane header,
|
||
.detail-pane header,
|
||
.container-pane header {
|
||
display: flex;
|
||
min-height: 74px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--admin-space-4);
|
||
}
|
||
.detail-order-reference {
|
||
display: flex;
|
||
min-width: 0;
|
||
max-width: 240px;
|
||
align-items: baseline;
|
||
gap: 3px;
|
||
}
|
||
h3,
|
||
p {
|
||
margin: 0;
|
||
}
|
||
h3 {
|
||
font-size: var(--admin-font-section);
|
||
}
|
||
h3 span,
|
||
header > span,
|
||
p {
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
font-weight: 400;
|
||
}
|
||
.order-type-tabs {
|
||
margin-top: var(--admin-space-4);
|
||
}
|
||
.order-table-wrap {
|
||
min-height: 520px;
|
||
}
|
||
.detail-pane,
|
||
.container-pane {
|
||
border-left: 1px solid var(--cc-border);
|
||
padding-left: var(--admin-space-5);
|
||
}
|
||
.pallet-list,
|
||
.container-list {
|
||
height: 520px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--cc-border);
|
||
border-radius: var(--admin-radius);
|
||
background: #fafbfc;
|
||
}
|
||
.pallet-row:hover {
|
||
background: #f8f6ff;
|
||
}
|
||
.pallet-row.assigned {
|
||
background: #f2fbf6;
|
||
}
|
||
.drag-handle {
|
||
color: var(--cc-muted);
|
||
}
|
||
.pallet-copy {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: var(--admin-space-1);
|
||
}
|
||
.pallet-copy > span,
|
||
.carton-marks > span {
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
}
|
||
.carton-marks {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: var(--admin-space-1);
|
||
}
|
||
.carton-mark-line {
|
||
display: flex;
|
||
min-width: 0;
|
||
align-items: center;
|
||
gap: var(--admin-space-2);
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
}
|
||
.carton-mark-line > span {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.container-pane > .el-input {
|
||
margin-bottom: var(--admin-space-4);
|
||
}
|
||
.container-list {
|
||
height: 460px;
|
||
padding: var(--admin-space-3);
|
||
}
|
||
.loading-container {
|
||
margin-bottom: var(--admin-space-4);
|
||
border: 1px dashed #bcc6d5;
|
||
border-radius: var(--admin-radius);
|
||
background: #fff;
|
||
}
|
||
.loading-container--overweight {
|
||
border-color: var(--el-color-danger);
|
||
}
|
||
.loading-container--overweight .container-head {
|
||
background: var(--el-color-danger-light-9);
|
||
}
|
||
.loading-container--overweight .container-head span,
|
||
.loading-container--overweight .container-head b {
|
||
color: var(--el-color-danger);
|
||
}
|
||
.container-head em {
|
||
margin-left: var(--admin-space-1);
|
||
color: var(--el-color-danger);
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
}
|
||
.container-head span {
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
}
|
||
.container-head b {
|
||
color: var(--el-color-primary);
|
||
}
|
||
.container-body {
|
||
min-height: 76px;
|
||
padding: var(--admin-space-3);
|
||
}
|
||
.drop-hint {
|
||
display: grid;
|
||
min-height: 60px;
|
||
place-items: center;
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
}
|
||
.loaded-pallet {
|
||
display: flex;
|
||
min-height: 52px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--admin-space-3);
|
||
border-bottom: 1px solid var(--cc-border);
|
||
}
|
||
.loaded-pallet > span {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
}
|
||
.loaded-pallet strong {
|
||
color: var(--cc-text);
|
||
font-size: var(--admin-font-body);
|
||
}
|
||
.dialog-footer > span {
|
||
color: var(--cc-muted);
|
||
font-size: var(--admin-font-caption);
|
||
}
|
||
.vehicle-options {
|
||
display: grid;
|
||
gap: var(--admin-space-5);
|
||
}
|
||
@media (max-width: 1280px) {
|
||
.dispatch-workbench {
|
||
grid-template-columns: minmax(500px, 1.3fr) minmax(320px, 1fr);
|
||
}
|
||
.container-pane {
|
||
grid-column: 1 / -1;
|
||
border-left: 0;
|
||
padding-left: 0;
|
||
}
|
||
.container-list {
|
||
height: 300px;
|
||
}
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--admin-space-5); margin: 0; padding: 0;
|
||
border-top: 1px solid var(--cc-border); background: transparent; border: 0; text-align: left; }
|
||
.container-head {
|
||
display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
|
||
min-height: 44px;
|
||
align-items: center;
|
||
gap: var(--admin-space-3);
|
||
padding: 0 var(--admin-space-3) 0 var(--admin-space-5);
|
||
background: #f4f6fa; }
|
||
.pallet-row {
|
||
display: grid;
|
||
grid-template-columns: 20px minmax(0, 1fr) auto; min-height: var(--admin-empty-height);
|
||
align-items: center;
|
||
gap: var(--admin-space-3);
|
||
padding: var(--admin-space-4) var(--admin-space-5);
|
||
border-bottom: 1px solid var(--cc-border);
|
||
background: #fff;
|
||
cursor: grab;
|
||
transition: background 0.18s ease; }
|
||
.order-pane header, .detail-pane header, .container-pane header { min-height: 60px; }
|
||
|
||
</style>
|