Files
admin-web/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
T

594 lines
34 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, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
ContainerBookingResource,
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
} from '../types'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const access = useAccessPreviewStore()
const router = useRouter()
const containers = ref<ContainerBookingResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const selectedContainerId = ref('')
const activeChannelCode = ref('')
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const selectionLoading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const selectionError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string }>()
for (const item of containers.value) {
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
})
}
return [...channels.values()]
})
const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null)
const editable = computed(() => plan.value?.status === 'EDITING')
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.1)
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.1)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
])
const filteredContainers = computed(() => containers.value.filter((item) => (
['WAIT_LOADING', 'EDITING'].includes(item.planStatus)
&& Boolean(activeChannelCode.value)
&& item.channelCode === activeChannelCode.value
)))
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
return candidates.value.filter((item) => {
if (customerId && item.accountId !== customerId) return false
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
})
})
function preferredContainerId() {
const visible = filteredContainers.value
return visible.find((item) => item.planStatus === 'EDITING')?.id
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
}
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 105, slotName: 'unitWeight' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
]
function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function adjustmentMax(source: object) {
const item = itemRow(source)
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
return Math.max(1, item.packageCount + remaining)
}
function displayCabinetNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
}
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function displayActualContainerNo(container: ContainerBookingResource | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待录入'
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
for (const item of next.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
if (container) {
container.planId = next.id
if (syncContainerStatus) container.planStatus = next.status
}
}
function clearSelectionData() {
plan.value = null
candidates.value = []
noDraft.value = false
selectionError.value = null
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
}
async function loadSelectedData(preserved: Record<string, number> = {}) {
const container = selectedContainer.value
const request = ++selectionRequest
clearSelectionData()
if (!container) return
if (container.planStatus === 'COMPLETED') return
selectionLoading.value = true
try {
const [candidateResult, planResult] = await Promise.all([
listSmartLoadingCandidates(container.id, container.warehouseId),
getSmartLoadingPlanByBooking(container.id).then((value) => ({ value, missing: false })).catch((error) => {
const details = getApiErrorDetails(error)
if (details.code === 'A0404') return { value: null, missing: true }
throw error
}),
])
if (request !== selectionRequest) return
candidates.value = candidateResult
if (planResult.value) acceptPlan(planResult.value, preserved, false)
else noDraft.value = planResult.missing
} catch (error) {
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
} finally {
if (request === selectionRequest) selectionLoading.value = false
}
}
function openList(status: 'LOADING' | 'COMPLETED') {
const routeName = status === 'LOADING' ? 'loading-plans' : 'loading-lists'
const query = selectedContainer.value
? { planId: selectedContainer.value.planId || undefined, warehouseId: selectedContainer.value.warehouseId }
: undefined
void router.push({ name: routeName, query })
}
async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true
loadError.value = null
try {
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!channelTabs.value.some((item) => item.code === activeChannelCode.value)) {
activeChannelCode.value = channelTabs.value[0]?.code || ''
}
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedData(preserved)
} catch (error) {
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
} finally {
loading.value = false
}
}
async function load() { await loadAll() }
async function loadCustomers() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customers.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customers.value = []
} finally {
customersLoading.value = false
}
}
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
await loadSelectedData()
}
async function selectChannel(channelCode: string) {
if (activeChannelCode.value === channelCode) return
activeChannelCode.value = channelCode
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
}
async function createPlan() {
const container = selectedContainer.value
if (!container) return
await mutate(async () => acceptPlan(await createSmartLoadingPlan(container.id, null)), '创建装柜计划失败')
}
function openAssign(source: object) {
const candidate = candidateRow(source)
selectedCandidate.value = candidate
assignForm.packageCount = Math.min(candidate.availablePackageCount, 1)
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
return
}
await mutate(async () => {
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
inboundId: selectedCandidate.value!.inboundId,
inboundDetailId: selectedCandidate.value!.inboundDetailId,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
await loadSelectedData()
ElMessage.success('货物已加入当前柜次')
}, '分配货物失败', { preserveAssign: true })
}
async function recommend() {
if (!plan.value) return
await mutate(async () => {
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
await loadSelectedData()
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value) return
const count = Number(quantityDrafts[item.id])
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
const max = adjustmentMax(item)
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
await loadSelectedData()
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
await loadSelectedData()
ElMessage.success('装柜明细已移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
async function removeAll() {
if (!plan.value?.items.length) return
const itemCount = plan.value.items.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的全部 ${itemCount} 条装柜明细吗?`, '全部移除', {
type: 'warning', confirmButtonText: '全部移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
let current = plan.value!
for (const original of [...current.items]) {
const item = current.items.find((value) => value.id === original.id)
if (!item) continue
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
acceptPlan(current)
}
await loadSelectedData()
ElMessage.success(`已移除全部 ${itemCount} 条装柜明细`)
}, '全部移除失败', { pickingConflict: true })
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
}, '推送预装清单失败')
}
async function mutate(
action: () => Promise<void>,
fallback: string,
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) {
mutating.value = true
try {
await action()
} catch (error) {
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
await loadAll(recovery.preserveDrafts)
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
? '该订单在拣货中不许删除'
: null
const message = pickingMessage || '方案已被其他人修改,已刷新最新版本,请重新确认后提交'
if (recovery.preserveAssign) assignOpen.value = true
if (pickingMessage) ElMessage.error(pickingMessage)
else ElMessage.warning(message)
} else {
ElMessage.error(details.message)
}
} finally {
mutating.value = false
}
}
onMounted(() => { void load(); void loadCustomers() })
</script>
<template>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page">
<div v-if="channelTabs.length" class="channel-summary-row">
<el-tabs :model-value="activeChannelCode" class="channel-tabs" aria-label="物流渠道" @tab-change="selectChannel(String($event))">
<el-tab-pane v-for="channel in channelTabs" :key="channel.code" :label="channel.name" :name="channel.code" />
</el-tabs>
<dl class="compact-summary" aria-label="当前渠道装柜统计">
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
<div><dt>空柜</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query-v2"
:search-on-reset="false"
@search="applyFilters"
/>
<LoadErrorState v-if="loadError" title="智能装柜工作台加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
:loading="loading || selectionLoading || mutating"
stripe
preference-key="global-smart-loading-candidates"
column-settings-mode="hide"
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
>
<template #actions="{ row }">
<el-button
v-if="canAssign && editable"
size="small"
type="primary"
plain
:disabled="assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1"
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
</template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>当前渠道空柜</strong><small>仅显示待装柜和预装编辑中的柜次</small></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
v-for="item in filteredContainers"
:key="item.id"
type="button"
class="container-option"
:class="{ selected: item.id === selectedContainerId }"
@click="selectContainer(item.id)"
>
<span class="container-option__top">
<strong>{{ hasRealContainerNo(item) ? displayActualContainerNo(item) : '模拟柜次' }}</strong>
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
{{ hasRealContainerNo(item) ? '已录入集装箱号' : '空柜' }}
</el-tag>
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span v-if="!hasRealContainerNo(item)" class="container-option__hint">可先排货,开始装柜前录入集装箱号</span>
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong><small>{{ selectedContainer ? `柜次号:${displayCabinetNo(selectedContainer)} · 集装箱号:${displayActualContainerNo(selectedContainer)}` : '尚未选择柜次' }}</small></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canDelete && editable && plan?.items.length" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />全部移除</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div>
</header>
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedData()">
<template #details><ApiErrorMeta :details="selectionError" /></template>
</LoadErrorState>
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
<section v-else-if="selectedPlanStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成柜次">
<el-tag type="success">装柜完成</el-tag>
<strong>该柜次已完成装柜</strong>
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
</section>
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan">
<el-alert
class="preload-readiness-alert"
type="info"
:closable="false"
show-icon
title="推送预装仅生成预装清单,不会直接开放 PDA"
description="后续依次完成:补集装箱号、关联并放舱真实订舱、确认预装;全部满足后才生成 PDA 任务。"
/>
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
</DataTable>
</template>
</section>
</section>
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
<p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
</section>
</component>
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.embedded-smart-loading { min-width: 0; }
.channel-summary-row { display: grid; grid-template-columns: minmax(240px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tabs { min-width: 0; }
.channel-tabs :deep(.el-tabs__header) { margin: 0 0 -2px; }
.channel-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.channel-tabs :deep(.el-tabs__item) { overflow: hidden; max-width: 240px; height: 48px; padding-inline: 18px; text-overflow: ellipsis; white-space: nowrap; }
.channel-tabs :deep(.el-tabs__content) { display: none; }
.compact-summary { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 16px; margin: 0; padding: 6px 12px; white-space: nowrap; }
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
.container-pane { background: #fafafa; }
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
.container-option { display: grid; width: 100%; min-height: 112px; height: auto; align-content: start; gap: 6px; padding: 14px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.4; text-align: left; cursor: pointer; }
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
.plan-header { flex-wrap: wrap; }
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
.plan-actions,.row-actions { flex-wrap: wrap; }
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.plan-pane :deep(.el-input-number) { width: 120px; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 1180px) {
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.embedded-smart-loading .dispatch-grid { height: auto; }
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }
.container-pane { max-height: min(58vh, 460px); }
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: none; }
}
@media (max-width: 720px) {
.channel-summary-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
.compact-summary { min-height: 0; justify-content: flex-start; flex-wrap: wrap; gap: 6px 14px; padding: 8px 12px; white-space: normal; }
.capacity-grid { grid-template-columns: 1fr; }
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
.container-list { grid-template-columns: 1fr; }
.channel-tabs :deep(.el-tabs__item) { max-width: 180px; padding-inline: 12px; }
}
</style>