This commit is contained in:
2026-09-13 21:10:05 +08:00
parent 19fed894d9
commit a0e46a05e5
18 changed files with 1451 additions and 227 deletions
@@ -8,16 +8,33 @@ 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, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import OrderReferenceText from './OrderReferenceText.vue'
type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number }
interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] }
type VehicleSpec = {
value: string
label: string
capacity: number
maxWeightLb: number
}
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 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)
@@ -29,10 +46,14 @@ const activeOrderKey = ref('')
const containers = ref<LoadingContainer[]>([])
const busyPalletIds = ref<string[]>([])
const draftToken = ref('')
const finalized = ref(false)
const vehicleDialogOpen = ref(false)
const selectedVehicle = ref('')
const query = reactive({ orderNo: '', orderType: '', sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED', fbaWarehouseCode: '' })
const query = reactive({
orderNo: '',
orderType: '',
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED',
fbaWarehouseCode: '',
})
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL')
const orderTypeOptions = [
@@ -44,22 +65,26 @@ const orderTypeOptions = [
]
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 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 }))
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))
@@ -67,95 +92,219 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
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 vehicleSpecs: VehicleSpec[] = [
{ value: 'LARGE_30', label: '大车(30托)', capacity: 30, maxWeightLb: 40000 },
{ value: 'SMALL_15', label: '小车(15托)', capacity: 15, maxWeightLb: 10000 },
{ value: 'SMALL_14', label: '小车(14托)', capacity: 14, maxWeightLb: 10000 },
{ value: 'SMALL_12', label: '小车(12托)', capacity: 12, maxWeightLb: 10000 },
{
value: 'LARGE_30',
label: '大车(30托)',
capacity: 30,
maxWeightLb: 40000,
},
{
value: 'SMALL_15',
label: '小车(15托)',
capacity: 15,
maxWeightLb: 10000,
},
{
value: 'SMALL_14',
label: '小车(14托)',
capacity: 14,
maxWeightLb: 10000,
},
{
value: 'SMALL_12',
label: '小车(12托)',
capacity: 12,
maxWeightLb: 10000,
},
]
const selectedVehicleSpec = computed(() => vehicleSpecs.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) }
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 cartonMarks(item: DispatchPalletCandidateDto) { return String(item.cartonMarks || '').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 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])) }
palletSnapshots.value = {
...palletSnapshots.value,
...Object.fromEntries(items.map((item) => [palletId(item), item])),
}
}
async function hydrateDraftPallets() {
const restored = await listDispatchCandidates({ draftToken: ensureDraftToken() })
const restored = await listDispatchCandidates({
draftToken: ensureDraftToken(),
})
rememberPallets(restored)
}
async function load() {
loading.value = true; loadError.value = null
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() })
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 = ''
candidates.value = []
activeOrderKey.value = ''
loadError.value = getApiErrorDetails(error, '可排车入仓单加载失败,请重试')
} finally { loading.value = false }
} finally {
loading.value = false
}
}
function reset() {
query.orderNo = ''
query.orderType = ''
query.sourceStage = 'RECEIVED'
query.fbaWarehouseCode = ''
warehouseTab.value = 'ALL'
void load()
}
function reset() { query.orderNo = ''; query.orderType = ''; query.sourceStage = 'RECEIVED'; query.fbaWarehouseCode = ''; warehouseTab.value = 'ALL'; void load() }
function addContainer() { selectedVehicle.value = ''; vehicleDialogOpen.value = true }
function addContainer() {
selectedVehicle.value = ''
vehicleDialogOpen.value = true
}
async function confirmAddContainer() {
const spec = selectedVehicleSpec.value
if (!spec) { ElMessage.warning('请选择车型'); return }
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 || [] })
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, '添加装车车型失败,请重试') }
} 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 }
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 { 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) }
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 { 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) }
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) }
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 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)
@@ -163,31 +312,77 @@ function dropInto(event: DragEvent, containerId: string) {
}
async function close(value = false) {
const token = ensureDraftToken()
if (!value && token && !finalized.value) {
try { await releaseDispatchDraft(token) } catch { /* server-side expiry remains the fallback */ }
}
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 }
saving.value = true; mutationError.value = null
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, draftToken: ensureDraftToken() })
finalized.value = true; emit('update:modelValue', false); emit('created'); ElMessage.success('预排车创建成功')
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 }
} catch (error) {
mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试')
} finally {
saving.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
draftToken.value = makeDraftToken(); finalized.value = false; 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 = vehicleSpecs.find((item) => item.capacity === c.capacity) || vehicleSpecs[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(
() => 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 = vehicleSpecs.find((item) => item.capacity === c.capacity) || vehicleSpecs[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 || ''
@@ -206,38 +401,111 @@ watch([warehouseTab, () => query.orderType], () => {
</section>
<nav class="warehouse-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>
<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>
<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"><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>
<header>
<div>
<h3>
出库单列表 <span>({{ orderGroups.length }}单)</span>
</h3>
<div class="order-type-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="订单类型" 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-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>
<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 class="carton-marks"><el-tag v-for="mark in cartonMarks(item).slice(0, 4)" :key="mark" size="small" effect="plain">{{ mark }}</el-tag><span v-if="cartonMarks(item).length > 4">+{{ cartonMarks(item).length - 4 }}</span><span v-if="!cartonMarks(item).length">暂无箱号</span></div></div>
<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 text type="primary" :loading="busyPalletIds.includes(palletId(item))">移入 <ArrowRight :size="14" /></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>
<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="点击左侧入仓单查看托盘和箱号" />
@@ -245,12 +513,41 @@ watch([warehouseTab, () => query.orderType], () => {
</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>
<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 text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
<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 text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></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 text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
<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 text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
</div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
</div>
</div>
@@ -259,7 +556,10 @@ watch([warehouseTab, () => query.orderType], () => {
</aside>
</div>
<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>
<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>
</AppDialog>
<el-dialog v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
<el-radio-group v-model="selectedVehicle" class="vehicle-options">
@@ -270,41 +570,274 @@ watch([warehouseTab, () => query.orderType], () => {
</template>
<style scoped>
.dispatch-query { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #e6eaf2; border-radius: 8px; background: #f7f8fb; }
.dispatch-query :deep(.el-input), .dispatch-query :deep(.el-select) { width: 190px; }
.warehouse-tabs { display: flex; gap: 10px; margin-top: 16px; }
.warehouse-tab, .order-type-tabs button { border: 1px solid #dfe4ed; background: #fff; color: #667085; cursor: pointer; transition: all .18s ease; }
.warehouse-tab { min-width: 92px; padding: 9px 20px; border-radius: 7px; font-size: 14px; }
.warehouse-tab:hover, .order-type-tabs button:hover { border-color: #7654d6; color: #6941c6; }
.warehouse-tab.active, .order-type-tabs button.active { border-color: #6f42d8; background: #6f42d8; color: #fff; }
.dispatch-workbench { display: grid; grid-template-columns: minmax(550px, 1.5fr) minmax(330px, .85fr) minmax(390px, 1fr); gap: 16px; margin-top: 16px; }
.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: 10px; }
.detail-order-reference { display: flex; min-width: 0; max-width: 240px; align-items: baseline; gap: 3px; }
h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.order-type-tabs { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; }
.order-type-tabs button { flex: 0 0 auto; padding: 5px 12px; border-radius: 16px; font-size: 12px; }
.order-table-wrap { min-height: 520px; }
.detail-pane, .container-pane { border-left: 1px solid var(--cc-border); padding-left: 16px; }
.pallet-list, .container-list { height: 520px; overflow-y: auto; border: 1px solid var(--cc-border); border-radius: 6px; background: #fafbfc; }
.pallet-row { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; min-height: 94px; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; cursor: grab; transition: background .18s ease; }
.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: 4px; }.pallet-copy > span, .carton-marks > span { color: var(--cc-muted); font-size: 12px; }
.carton-marks { display: flex; min-width: 0; flex-wrap: wrap; gap: 4px; }
.container-pane > .el-input { margin-bottom: 10px; }.container-list { height: 460px; padding: 8px; }
.loading-container { margin-bottom: 10px; border: 1px dashed #bcc6d5; border-radius: 6px; 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: 4px; color: var(--el-color-danger); font-style: normal; font-weight: 700; }
.container-head { display: grid; grid-template-columns: 1fr auto 32px; min-height: 44px; align-items: center; gap: 8px; padding: 0 8px 0 12px; background: #f4f6fa; }
.container-head span { color: var(--cc-muted); font-size: 12px; }.container-head b { color: var(--el-color-primary); }
.container-body { min-height: 76px; padding: 8px; }.drop-hint { display: grid; min-height: 60px; place-items: center; color: var(--cc-muted); font-size: 12px; }
.loaded-pallet { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px; 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: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px -20px -20px; padding: 14px 20px; border-top: 1px solid var(--cc-border); background: #fafbfc; }
.dialog-footer > span { color: var(--cc-muted); font-size: 12px; }
.vehicle-options { display: grid; gap: 14px; }
@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; } }
.dispatch-query {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #f7f8fb;
}
.dispatch-query :deep(.el-input),
.dispatch-query :deep(.el-select) {
width: 190px;
}
.warehouse-tabs {
display: flex;
gap: 10px;
margin-top: 16px;
}
.warehouse-tab,
.order-type-tabs button {
border: 1px solid #dfe4ed;
background: #fff;
color: #667085;
cursor: pointer;
transition: all 0.18s ease;
}
.warehouse-tab {
min-width: 92px;
padding: 9px 20px;
border-radius: 7px;
font-size: 14px;
}
.warehouse-tab:hover,
.order-type-tabs button:hover {
border-color: #7654d6;
color: #6941c6;
}
.warehouse-tab.active,
.order-type-tabs button.active {
border-color: #6f42d8;
background: #6f42d8;
color: #fff;
}
.dispatch-workbench {
display: grid;
grid-template-columns: minmax(550px, 1.5fr) minmax(330px, 0.85fr) minmax(390px, 1fr);
gap: 16px;
margin-top: 16px;
}
.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: 10px;
}
.detail-order-reference {
display: flex;
min-width: 0;
max-width: 240px;
align-items: baseline;
gap: 3px;
}
h3,
p {
margin: 0;
}
h3 {
font-size: 16px;
}
h3 span,
header > span,
p {
color: var(--cc-muted);
font-size: 12px;
font-weight: 400;
}
.order-type-tabs {
display: flex;
gap: 6px;
margin-top: 10px;
overflow-x: auto;
}
.order-type-tabs button {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 16px;
font-size: 12px;
}
.order-table-wrap {
min-height: 520px;
}
.detail-pane,
.container-pane {
border-left: 1px solid var(--cc-border);
padding-left: 16px;
}
.pallet-list,
.container-list {
height: 520px;
overflow-y: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
background: #fafbfc;
}
.pallet-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
min-height: 94px;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--cc-border);
background: #fff;
cursor: grab;
transition: background 0.18s ease;
}
.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: 4px;
}
.pallet-copy > span,
.carton-marks > span {
color: var(--cc-muted);
font-size: 12px;
}
.carton-marks {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.carton-mark-line {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: var(--cc-muted);
font-size: 12px;
}
.carton-mark-line > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-pane > .el-input {
margin-bottom: 10px;
}
.container-list {
height: 460px;
padding: 8px;
}
.loading-container {
margin-bottom: 10px;
border: 1px dashed #bcc6d5;
border-radius: 6px;
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: 4px;
color: var(--el-color-danger);
font-style: normal;
font-weight: 700;
}
.container-head {
display: grid;
grid-template-columns: 1fr auto 32px;
min-height: 44px;
align-items: center;
gap: 8px;
padding: 0 8px 0 12px;
background: #f4f6fa;
}
.container-head span {
color: var(--cc-muted);
font-size: 12px;
}
.container-head b {
color: var(--el-color-primary);
}
.container-body {
min-height: 76px;
padding: 8px;
}
.drop-hint {
display: grid;
min-height: 60px;
place-items: center;
color: var(--cc-muted);
font-size: 12px;
}
.loaded-pallet {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 8px;
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: 12px;
}
.loaded-pallet strong {
color: var(--cc-text);
font-size: 13px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 16px -20px -20px;
padding: 14px 20px;
border-top: 1px solid var(--cc-border);
background: #fafbfc;
}
.dialog-footer > span {
color: var(--cc-muted);
font-size: 12px;
}
.vehicle-options {
display: grid;
gap: 14px;
}
@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;
}
}
</style>