2026-08-10 15:36:53 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
2026-08-11 11:15:44 +08:00
|
|
|
|
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
|
2026-08-10 15:36:53 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import {
|
|
|
|
|
|
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
|
|
|
|
|
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
|
|
|
|
|
|
recommendSmartLoading, removeSmartLoadingItem,
|
|
|
|
|
|
} from '../api/platformContainerLoading'
|
|
|
|
|
|
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingItem, SmartLoadingPlan } from '../types'
|
|
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const containerId = computed(() => String(route.params.containerId || ''))
|
|
|
|
|
|
const container = ref<ContainerBookingResource | null>(null)
|
|
|
|
|
|
const plan = ref<SmartLoadingPlan | null>(null)
|
|
|
|
|
|
const candidates = ref<SmartLoadingCandidate[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const mutating = ref(false)
|
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const mutationError = 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 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'))
|
2026-08-11 11:15:44 +08:00
|
|
|
|
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
2026-08-11 11:59:39 +08:00
|
|
|
|
const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING')
|
2026-08-10 15:36:53 +08:00
|
|
|
|
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
|
|
|
|
|
|
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(container.value?.capacityCbm || 0) * 1.1)
|
|
|
|
|
|
const weightLimit = computed(() => Number(container.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)
|
2026-08-11 20:02:38 +08:00
|
|
|
|
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
|
|
|
|
|
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
|
|
|
|
|
const itemColumns: DataTableColumn[] = [
|
|
|
|
|
|
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
|
|
|
|
|
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
|
|
|
|
|
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
|
|
|
|
|
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
|
|
|
|
|
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
|
|
|
|
|
|
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
|
|
|
|
|
|
{ key: 'volumeCbm', label: '体积', width: 120, slotName: 'volume' },
|
|
|
|
|
|
{ key: 'grossWeightKg', label: '毛重', width: 130, slotName: 'weight' },
|
|
|
|
|
|
{ key: 'destination', label: '目的仓', minWidth: 160, slotName: 'destination' },
|
|
|
|
|
|
]
|
|
|
|
|
|
const candidateColumns: DataTableColumn[] = [
|
|
|
|
|
|
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
|
|
|
|
|
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
|
|
|
|
|
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
|
|
|
|
|
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 180 },
|
|
|
|
|
|
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
|
|
|
|
|
{ key: 'unitVolumeCbm', label: '单箱体积', width: 120, slotName: 'unitVolume' },
|
|
|
|
|
|
{ key: 'unitWeightKg', label: '单箱重量', width: 120, slotName: 'unitWeight' },
|
|
|
|
|
|
{ key: 'route', label: '目的仓匹配', minWidth: 170, slotName: 'route' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-08-11 20:02:38 +08:00
|
|
|
|
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 }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
|
|
|
|
|
function itemRow(source: object) { return source as SmartLoadingItem }
|
|
|
|
|
|
function routeMatches(source: object) {
|
|
|
|
|
|
const candidate = candidateRow(source)
|
|
|
|
|
|
const target = container.value?.destinationWarehouseCode
|
|
|
|
|
|
return !target || target === candidate.destinationWarehouseCode
|
|
|
|
|
|
}
|
|
|
|
|
|
function explainCandidate(source: object) {
|
|
|
|
|
|
const candidate = candidateRow(source)
|
|
|
|
|
|
if (!routeMatches(candidate)) return `目的仓不一致(候选:${candidate.destinationWarehouseCode || '未设置'})`
|
|
|
|
|
|
const volume = Number(candidate.unitVolumeCbm || 0)
|
|
|
|
|
|
const weight = Number(candidate.unitWeightKg || 0)
|
|
|
|
|
|
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
|
|
|
|
|
|
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
|
|
|
|
|
|
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
|
|
|
|
|
|
}
|
|
|
|
|
|
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
|
|
|
|
|
|
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true; loadError.value = null; noDraft.value = false
|
|
|
|
|
|
try {
|
|
|
|
|
|
container.value = await getContainer(containerId.value)
|
2026-08-11 11:59:39 +08:00
|
|
|
|
if (container.value.planStatus === 'COMPLETED') {
|
2026-08-11 11:15:44 +08:00
|
|
|
|
plan.value = null
|
|
|
|
|
|
candidates.value = []
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
|
|
|
|
|
|
catch (error) {
|
|
|
|
|
|
const detail = getApiErrorDetails(error)
|
|
|
|
|
|
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
|
|
|
|
|
|
else throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates.value = await listSmartLoadingCandidates(container.value.warehouseId)
|
|
|
|
|
|
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
|
|
|
|
|
|
finally { loading.value = false }
|
|
|
|
|
|
}
|
2026-08-11 11:15:44 +08:00
|
|
|
|
function openOperationalList() {
|
|
|
|
|
|
if (!container.value || container.value.planStatus === 'EDITING') return
|
|
|
|
|
|
void router.push({
|
|
|
|
|
|
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
|
|
|
|
|
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
async function createPlan() {
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
|
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
|
2026-08-11 11:15:44 +08:00
|
|
|
|
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
finally { mutating.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, inboundDetailId: selectedCandidate.value.inboundDetailId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }))
|
|
|
|
|
|
assignOpen.value = false; ElMessage.success('货物已分配')
|
2026-08-11 11:15:44 +08:00
|
|
|
|
} catch (error) { await handleMutationError(error, '分配失败') }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
finally { mutating.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function recommend() {
|
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
|
try { acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)); ElMessage.success('智能建议已生成,请人工核对后再推送') }
|
2026-08-11 11:15:44 +08:00
|
|
|
|
catch (error) { await handleMutationError(error, '智能推荐失败') }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
finally { mutating.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
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 }
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
|
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
|
2026-08-11 11:15:44 +08:00
|
|
|
|
catch (error) { await handleMutationError(error, '调整失败') }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
finally { mutating.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function remove(source: object) {
|
|
|
|
|
|
const item = itemRow(source)
|
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
|
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
|
|
|
|
|
if (!confirmed || !plan.value) return
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
|
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
|
2026-08-11 11:59:39 +08:00
|
|
|
|
catch (error) { await handleMutationError(error, '移除失败', true) }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
finally { mutating.value = false }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
async function pushPreload() {
|
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
|
const confirmed = await ElMessageBox.confirm('推送后计划进入装柜流程,不能继续调整排柜明细。确认推送预装清单?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
|
|
|
|
|
|
if (!confirmed || !plan.value) return
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
|
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
|
2026-08-11 11:15:44 +08:00
|
|
|
|
catch (error) { await handleMutationError(error, '推送失败') }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
finally { mutating.value = false }
|
|
|
|
|
|
}
|
2026-08-11 11:59:39 +08:00
|
|
|
|
async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
|
2026-08-10 15:36:53 +08:00
|
|
|
|
const details = getApiErrorDetails(error, fallback)
|
2026-08-11 11:15:44 +08:00
|
|
|
|
if (details.code === 'A0409') {
|
|
|
|
|
|
const preserved = { ...quantityDrafts }
|
|
|
|
|
|
await load()
|
|
|
|
|
|
for (const item of plan.value?.items || []) {
|
|
|
|
|
|
if (preserved[item.id] != null) quantityDrafts[item.id] = preserved[item.id]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-11 11:59:39 +08:00
|
|
|
|
const pickingMessage = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
|
2026-08-11 11:52:30 +08:00
|
|
|
|
? '该订单在拣货中不许删除'
|
2026-08-11 11:59:39 +08:00
|
|
|
|
: null
|
|
|
|
|
|
mutationError.value = { ...details, message: pickingMessage || (details.code === 'A0409' ? '计划已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' : details.message) }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
ElMessage.error(mutationError.value.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(load)
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<AdminPage>
|
|
|
|
|
|
<section class="business-page workbench">
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
|
|
|
|
|
|
<div class="workbench-identity">
|
|
|
|
|
|
<span>当前柜次</span>
|
|
|
|
|
|
<strong>{{ container?.cabinetNo || '正在加载' }}</strong>
|
|
|
|
|
|
<small>集装箱号 {{ container?.containerNo || '待录入' }}</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="header-actions">
|
|
|
|
|
|
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
|
|
|
|
|
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
2026-08-11 11:59:39 +08:00
|
|
|
|
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
2026-08-11 11:59:39 +08:00
|
|
|
|
<section v-if="container?.planStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成状态">
|
|
|
|
|
|
<el-tag type="success">装柜完成</el-tag>
|
|
|
|
|
|
<strong>该柜次已完成装柜</strong>
|
|
|
|
|
|
<p>智能装柜方案已锁定,不能继续调整。请进入装柜清单查看完整记录。</p>
|
|
|
|
|
|
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
|
2026-08-11 11:15:44 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
<template v-else>
|
2026-08-11 11:52:30 +08:00
|
|
|
|
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '拣货中' : plan ? '预装柜中' : '待装柜' }}</strong></div></section>
|
2026-08-11 20:02:38 +08:00
|
|
|
|
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</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>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<el-empty v-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">
|
2026-08-11 20:02:38 +08:00
|
|
|
|
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" :show-column-settings="false" empty-text="暂无已分配货物">
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<template #toolbar-start><div><h2>装柜方案</h2><p>计划 {{ plan.planNo }} · 版本 {{ plan.version }};智能建议必须经人工核对。</p></div></template>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></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" :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>
|
|
|
|
|
|
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
|
|
|
|
|
|
</DataTable>
|
2026-08-11 20:02:38 +08:00
|
|
|
|
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" preference-key="smart-loading-candidates" :show-column-settings="false" empty-text="当前仓库暂无可排柜货物">
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于仓库、目的仓、可用箱数及 110% 容量上限校验。</p></div></template>
|
|
|
|
|
|
<template #actions="{ row }"><el-button v-if="canAssign && editable" link type="primary" :disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
|
|
|
|
|
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
|
|
|
|
|
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
|
|
|
|
|
|
<template #route="{ row }"><el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '冲突' }}</el-tag><small>{{ explainCandidate(row) }}</small></template>
|
|
|
|
|
|
</DataTable>
|
|
|
|
|
|
</template>
|
2026-08-11 11:15:44 +08:00
|
|
|
|
</template>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<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.orderNo || '—' }} · 可用 {{ 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><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.workbench { display: grid; gap: 16px; min-width: 0; }
|
2026-08-10 16:24:54 +08:00
|
|
|
|
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
|
|
|
|
|
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
|
|
|
|
|
|
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
|
|
|
|
|
|
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
|
|
|
|
|
|
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
.workbench h2,.workbench h2 + p { margin: 0; }
|
|
|
|
|
|
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
|
|
|
|
|
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
|
|
|
|
|
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
|
|
|
|
|
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
|
|
|
|
|
|
.summary-band span { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
2026-08-11 11:15:44 +08:00
|
|
|
|
.readonly-status-card { display: grid; justify-items: start; gap: 10px; padding: 24px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
|
|
|
|
|
.readonly-status-card strong { font-size: 16px; }
|
|
|
|
|
|
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
|
|
|
|
|
.row-actions { min-width: 150px; }
|
|
|
|
|
|
.workbench :deep(.el-input-number) { width: 130px; }
|
|
|
|
|
|
.assign-dialog p { margin: 0 0 10px; }
|
|
|
|
|
|
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
2026-08-10 16:24:54 +08:00
|
|
|
|
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</style>
|