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

775 lines
47 KiB
Vue
Raw Normal View History

2026-08-10 17:42:17 +08:00
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
2026-08-10 17:42:17 +08:00
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-14 12:50:42 +08:00
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { batchQueryPlatformCoLoadGroups, type CoLoadGroup } from '@/modules/unified-orders'
2026-08-10 17:42:17 +08:00
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,
assignSmartLoadingGroup,
2026-08-10 17:42:17 +08:00
assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
removeSmartLoadingGroup,
2026-08-10 17:42:17 +08:00
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
ContainerBookingResource,
SmartLoadingCandidate,
SmartLoadingPlan,
} from '../types'
2026-08-24 18:07:38 +08:00
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
2026-08-10 17:42:17 +08:00
2026-08-14 19:02:34 +08:00
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
2026-08-10 17:42:17 +08:00
const access = useAccessPreviewStore()
const router = useRouter()
2026-08-10 17:42:17 +08:00
const containers = ref<ContainerBookingResource[]>([])
2026-08-14 12:50:42 +08:00
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
2026-08-10 17:42:17 +08:00
const selectedContainerId = ref('')
2026-08-14 19:02:34 +08:00
const activeChannelCode = ref('')
2026-08-10 17:42:17 +08:00
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 mutationError = ref<ApiErrorDetails | null>(null)
2026-08-10 17:42:17 +08:00
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
2026-08-10 17:42:17 +08:00
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, CoLoadGroup>())
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
2026-08-10 17:42:17 +08:00
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)
2026-08-17 18:32:41 +08:00
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
2026-08-14 19:02:34 +08:00
const channelTabs = computed(() => {
2026-08-14 19:59:32 +08:00
const channels = new Map<string, { code: string; name: string }>()
2026-08-17 18:32:41 +08:00
for (const item of orchestratableContainers.value) {
if (!item.channelCode) continue
2026-08-14 19:59:32 +08:00
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
2026-08-14 19:02:34 +08:00
code: item.channelCode,
name: item.channelName || item.channelCode,
})
}
return [...channels.values()]
})
const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null)
2026-08-17 16:45:46 +08:00
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
2026-08-21 17:07:37 +08:00
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || member.orderId, inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
function groupOrderCount(group: CoLoadGroup) { return new Set(group.members.map((member) => member.orderId)).size }
function groupMemberLabel(group: CoLoadGroup) {
const orderCount = groupOrderCount(group)
return group.members.length === orderCount ? `${orderCount} 票` : `${orderCount} 票 · ${group.members.length} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const rows: SmartLoadingCandidateRow[] = []
const groups = new Map<string, SmartLoadingCandidate[]>()
for (const candidate of candidates.value) {
if (!candidate.coLoadGroupId) {
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
continue
}
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
}
for (const members of groups.values()) {
const first = members[0]
rows.push({
...first,
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
availablePackageCount: Number(first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0)),
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
sourceCandidates: members,
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(first.memberCount || members.length),
})
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
const grouped = new Map<string, SmartLoadingInboundItem[]>()
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
}
for (const rows of grouped.values()) {
const first = rows[0]
const group = planGroupsByInboundId.value.get(first.inboundId)!
result.push({
...first,
id: `coload:${group.groupId}`,
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
canAdjust: false,
canRemove: rows.every((row) => row.canRemove),
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
})
2026-08-10 17:42:17 +08:00
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)
2026-08-11 20:02:38 +08:00
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
2026-08-10 17:42:17 +08:00
const queryFields = computed<QueryField[]>(() => [
2026-08-14 14:27:03 +08:00
{ 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 } },
2026-08-14 19:59:32 +08:00
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
2026-08-10 17:42:17 +08:00
])
2026-08-14 19:59:32 +08:00
const filteredContainers = computed(() => containers.value.filter((item) => (
2026-08-17 16:45:46 +08:00
['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)
2026-08-14 19:59:32 +08:00
&& Boolean(activeChannelCode.value)
&& item.channelCode === activeChannelCode.value
)))
2026-08-10 17:42:17 +08:00
const filteredCandidates = computed(() => {
2026-08-14 12:50:42 +08:00
const customerId = String(appliedQuery.value.customerId || '')
2026-08-14 19:59:32 +08:00
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
2026-08-17 18:32:41 +08:00
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
return displayedCandidates.value.filter((item) => {
2026-08-17 18:32:41 +08:00
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
return !inboundCode || item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))
2026-08-14 12:50:42 +08:00
})
2026-08-10 17:42:17 +08:00
})
function preferredContainerId() {
const visible = filteredContainers.value
2026-08-11 11:52:30 +08:00
return visible.find((item) => item.planStatus === 'EDITING')?.id
2026-08-17 16:45:46 +08:00
|| visible.find((item) => item.planStatus === 'LOADING')?.id
2026-08-11 11:52:30 +08:00
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
}
2026-08-10 17:42:17 +08:00
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
2026-08-21 17:07:37 +08:00
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
2026-08-10 17:42:17 +08:00
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
2026-08-21 17:07:37 +08:00
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
2026-08-21 16:28:59 +08:00
{ key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 135, slotName: 'unitGrossWeight' },
2026-08-21 17:07:37 +08:00
{ key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 145, slotName: 'averageUnitVolume' },
2026-08-10 17:42:17 +08:00
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
2026-08-24 18:07:38 +08:00
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
2026-08-18 16:09:43 +08:00
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
2026-08-10 17:42:17 +08:00
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
2026-08-18 16:09:43 +08:00
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
2026-08-10 17:42:17 +08:00
]
function formatNumber(value: number, digits = 3) {
2026-08-11 20:02:38 +08:00
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
2026-08-10 17:42:17 +08:00
}
2026-08-11 20:02:38 +08:00
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
2026-08-24 18:07:38 +08:00
function itemRow(source: object) { return source as SmartLoadingInboundItem }
2026-08-14 19:59:32 +08:00
function adjustmentMax(source: object) {
const item = itemRow(source)
2026-08-21 17:07:37 +08:00
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
2026-08-17 16:45:46 +08:00
return Math.max(item.minPackageCount, item.packageCount + remaining)
2026-08-14 19:59:32 +08:00
}
function displayCabinetNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '待生成'
2026-08-15 12:12:38 +08:00
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
2026-08-15 12:12:38 +08:00
}
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() : '待录入'
2026-08-10 17:42:17 +08:00
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
2026-08-24 18:07:38 +08:00
for (const item of groupSmartLoadingItems(next.items || [])) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
2026-08-10 17:42:17 +08:00
}
2026-08-11 14:23:46 +08:00
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
2026-08-10 17:42:17 +08:00
plan.value = next
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
if (container) {
container.planId = next.id
2026-08-11 14:23:46 +08:00
if (syncContainerStatus) container.planStatus = next.status
2026-08-10 17:42:17 +08:00
}
}
async function loadPlanGroups(request = selectionRequest) {
planGroupsByInboundId.value = new Map()
const currentPlan = plan.value
const orderIds = [...new Set((currentPlan?.items || []).map((item) => item.orderId))]
if (!currentPlan || !orderIds.length || !canAssign.value) return
const groups = await batchQueryPlatformCoLoadGroups(orderIds)
if (request !== selectionRequest || plan.value?.id !== currentPlan.id) return
const byInbound = new Map<string, CoLoadGroup>()
for (const info of groups) for (const member of info.group.members) byInbound.set(member.inboundId, info.group)
planGroupsByInboundId.value = byInbound
syncDrafts(currentPlan)
}
2026-08-10 17:42:17 +08:00
function clearSelectionData() {
plan.value = null
candidates.value = []
noDraft.value = false
selectionError.value = null
mutationError.value = null
planGroupsByInboundId.value = new Map()
2026-08-10 17:42:17 +08:00
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
2026-08-10 17:42:17 +08:00
selectionLoading.value = true
try {
const [candidateResult, planResult] = await Promise.all([
listSmartLoadingCandidates(container.id, container.warehouseId),
2026-08-10 17:42:17 +08:00
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)
await loadPlanGroups(request)
}
2026-08-10 17:42:17 +08:00
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> = {}) {
2026-08-10 17:42:17 +08:00
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)
2026-08-14 19:02:34 +08:00
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)
2026-08-10 17:42:17 +08:00
} catch (error) {
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
} finally {
loading.value = false
}
}
async function load() { await loadAll() }
2026-08-14 12:50:42 +08:00
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
}
}
2026-08-10 17:42:17 +08:00
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
await loadSelectedData()
}
2026-08-14 19:02:34 +08:00
async function selectChannel(channelCode: string) {
if (activeChannelCode.value === channelCode) return
activeChannelCode.value = channelCode
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
2026-08-10 17:42:17 +08:00
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
selectedContainerId.value = preferredContainerId()
2026-08-10 17:42:17 +08:00
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)
if (candidateBlocked(candidate)) return
2026-08-10 17:42:17 +08:00
selectedCandidate.value = candidate
assignForm.packageCount = candidate.coLoadGroupId
? Number(candidate.groupTotalBoxes || candidate.availablePackageCount)
: candidate.availablePackageCount
2026-08-10 17:42:17 +08:00
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
2026-08-14 19:59:32 +08:00
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
return
}
2026-08-10 17:42:17 +08:00
await mutate(async () => {
const selected = selectedCandidate.value!
acceptPlan(selected.coLoadGroupId
? await assignSmartLoadingGroup(plan.value!.id, selected.inboundId, plan.value!.version)
: await assignSmartLoadingItem(plan.value!.id, {
inboundId: selected.inboundId,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
2026-08-10 17:42:17 +08:00
assignOpen.value = false
2026-08-14 19:59:32 +08:00
await loadSelectedData()
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
2026-08-18 16:09:43 +08:00
}, '分配明细失败', { preserveAssign: true })
2026-08-10 17:42:17 +08:00
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
return Boolean(candidate.blockReason) || candidate.locked === true
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|| candidate.availablePackageCount < 1
}
2026-08-10 17:42:17 +08:00
async function recommend() {
if (!plan.value) return
await mutate(async () => {
2026-08-22 10:02:13 +08:00
try {
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
} catch (error) {
const details = getApiErrorDetails(error, '智能推荐失败')
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
await loadSelectedData()
if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
}
2026-08-14 19:59:32 +08:00
await loadSelectedData()
2026-08-10 17:42:17 +08:00
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value) return
const count = Number(quantityDrafts[item.id])
2026-08-17 16:45:46 +08:00
if (!Number.isInteger(count) || count < item.minPackageCount) {
ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`)
return
}
2026-08-14 19:59:32 +08:00
const max = adjustmentMax(item)
2026-08-18 16:09:43 +08:00
if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return }
2026-08-10 17:42:17 +08:00
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
2026-08-14 19:59:32 +08:00
await loadSelectedData()
2026-08-10 17:42:17 +08:00
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
async function remove(source: object) {
const item = itemRow(source)
2026-08-17 16:45:46 +08:00
if (!plan.value || !item.canRemove) return
const row = source as SmartLoadingPlanRow
const confirmed = await ElMessageBox.confirm(row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消',
2026-08-10 17:42:17 +08:00
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
if (row.coLoadGroup) {
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await loadSelectedData()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
2026-08-24 18:07:38 +08:00
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
}
2026-08-14 19:59:32 +08:00
await loadSelectedData()
2026-08-24 18:07:38 +08:00
ElMessage.success('该入仓码已从当前柜次移除')
2026-08-11 11:52:30 +08:00
}, '移除装柜明细失败', { pickingConflict: true })
2026-08-10 17:42:17 +08:00
}
2026-08-14 19:59:32 +08:00
async function removeAll() {
2026-08-24 18:07:38 +08:00
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
2026-08-17 16:45:46 +08:00
if (!removableItems.length) return
const itemCount = removableItems.length
2026-08-24 18:07:38 +08:00
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
2026-08-17 16:45:46 +08:00
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
2026-08-14 19:59:32 +08:00
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
let current = plan.value!
2026-08-24 18:07:38 +08:00
for (const inboundItem of removableItems) {
if (inboundItem.coLoadGroup) {
current = await removeSmartLoadingGroup(current.id, inboundItem.coLoadGroup.groupId, current.version)
acceptPlan(current)
continue
}
2026-08-24 18:07:38 +08:00
for (const original of inboundItem.sourceItems) {
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)
}
2026-08-14 19:59:32 +08:00
}
await loadSelectedData()
2026-08-24 18:07:38 +08:00
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
2026-08-14 19:59:32 +08:00
}, '全部移除失败', { pickingConflict: true })
}
2026-08-10 17:42:17 +08:00
async function pushPreload() {
if (!plan.value) return
2026-08-27 21:48:15 +08:00
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', {
2026-08-10 17:42:17 +08:00
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
2026-08-27 21:48:15 +08:00
ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认')
2026-08-10 17:42:17 +08:00
}, '推送预装清单失败')
}
async function mutate(
action: () => Promise<void>,
fallback: string,
2026-08-11 11:52:30 +08:00
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
2026-08-10 17:42:17 +08:00
) {
mutating.value = true
mutationError.value = null
2026-08-10 17:42:17 +08:00
try {
await action()
} catch (error) {
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
await loadAll(recovery.preserveDrafts)
2026-08-11 11:52:30 +08:00
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
? '该订单在拣货中不许删除'
: null
2026-08-22 10:02:13 +08:00
const message = pickingMessage || details.message || '计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作'
2026-08-10 17:42:17 +08:00
if (recovery.preserveAssign) assignOpen.value = true
2026-08-11 11:52:30 +08:00
if (pickingMessage) ElMessage.error(pickingMessage)
2026-08-14 19:59:32 +08:00
else ElMessage.warning(message)
2026-08-10 17:42:17 +08:00
} else {
ElMessage.error(details.message)
}
mutationError.value = details
2026-08-10 17:42:17 +08:00
} finally {
mutating.value = false
}
}
2026-08-14 12:50:42 +08:00
onMounted(() => { void load(); void loadCustomers() })
2026-08-10 17:42:17 +08:00
</script>
<template>
2026-08-14 19:02:34 +08:00
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
2026-08-10 17:42:17 +08:00
<section class="global-loading-page">
2026-08-14 19:59:32 +08:00
<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="当前渠道装柜统计">
2026-08-18 16:09:43 +08:00
<div><dt>候选明细</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
2026-08-17 16:45:46 +08:00
<div><dt>柜次</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
2026-08-14 19:59:32 +08:00
<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>
2026-08-17 18:32:41 +08:00
<el-alert v-else-if="missingChannelContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-else-if="completedContainerCount" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
2026-08-10 17:42:17 +08:00
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
2026-08-14 19:59:32 +08:00
preference-key="global-smart-loading-query-v2"
2026-08-10 17:42:17 +08:00
: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>
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
<template #details><ApiErrorMeta :details="mutationError" /></template>
</LoadErrorState>
2026-08-10 17:42:17 +08:00
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
2026-08-18 16:09:43 +08:00
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
2026-08-10 17:42:17 +08:00
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
:loading="loading || selectionLoading || mutating"
2026-08-12 18:16:44 +08:00
stripe
2026-08-10 17:42:17 +08:00
preference-key="global-smart-loading-candidates"
2026-08-11 20:39:34 +08:00
column-settings-mode="hide"
2026-08-18 16:09:43 +08:00
:empty-text="selectedContainer ? '当前仓库暂无可装明细' : '暂无可用柜次'"
2026-08-10 17:42:17 +08:00
>
<template #actions="{ row }">
<el-button
2026-08-17 16:45:46 +08:00
v-if="canAssign && assignable"
2026-08-12 18:00:26 +08:00
size="small"
2026-08-10 17:42:17 +08:00
type="primary"
2026-08-12 18:00:26 +08:00
plain
:disabled="candidateBlocked(row as SmartLoadingCandidateRow)"
2026-08-10 17:42:17 +08:00
@click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
2026-08-10 17:42:17 +08:00
</template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
2026-08-21 16:28:59 +08:00
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
2026-08-21 17:07:37 +08:00
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
2026-08-10 17:42:17 +08:00
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
2026-08-18 16:09:43 +08:00
<header class="pane-header"><div><strong>当前渠道柜次</strong></div></header>
2026-08-10 17:42:17 +08:00
<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)"
>
2026-08-15 12:12:38 +08:00
<span class="container-option__top">
<strong>{{ hasRealContainerNo(item) ? displayActualContainerNo(item) : '模拟柜次' }}</strong>
2026-08-15 12:12:38 +08:00
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
{{ hasRealContainerNo(item) ? '已录入集装箱号' : '空柜' }}
2026-08-15 12:12:38 +08:00
</el-tag>
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
2026-08-15 12:12:38 +08:00
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
2026-08-18 16:09:43 +08:00
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
2026-08-10 17:42:17 +08:00
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
2026-08-18 16:09:43 +08:00
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong></div>
2026-08-10 17:42:17 +08:00
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
2026-08-17 16:45:46 +08:00
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canDelete && editable && plan?.items.some((item) => item.canRemove)" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />移除未拣</el-button>
2026-08-10 20:53:48 +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 17:42:17 +08:00
</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>
2026-08-10 17:42:17 +08:00
<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">
2026-08-18 16:09:43 +08:00
<!--
已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。
操作权限仍由 plan.canEdit、canAdjust 和 canRemove 控制。
-->
2026-08-17 16:45:46 +08:00
<el-alert
2026-08-18 16:09:43 +08:00
v-if="!plan.canEdit"
2026-08-17 16:45:46 +08:00
class="preload-readiness-alert"
:type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'"
:closable="false"
show-icon
:title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'"
:description="plan.blockedReason || '请刷新计划获取最新作业状态。'"
/>
2026-08-18 16:09:43 +08:00
<!-- 推送预装的流程说明已由操作确认框承载,不在方案区重复展示。 -->
2026-08-10 17:42:17 +08:00
<div class="capacity-grid">
2026-08-11 20:02:38 +08:00
<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>
2026-08-10 17:42:17 +08:00
</div>
2026-08-24 18:07:38 +08:00
<DataTable :data="displayedPlanItems" :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 && row.canAdjust && !row.coLoadGroup" 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 && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />{{ row.coLoadGroup ? '移除整组' : '移除整票' }}</el-button><small v-if="row.coLoadGroup">整组固定全箱数,不允许单票调整</small><small v-else-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
2026-08-10 17:42:17 +08:00
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
2026-08-18 16:09:43 +08:00
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
2026-08-10 17:42:17 +08:00
</DataTable>
</template>
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
2026-08-10 17:42:17 +08:00
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<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>
</template>
2026-08-10 17:42:17 +08:00
</div>
</AppDialog>
</template>
</section>
2026-08-14 19:02:34 +08:00
</component>
2026-08-10 17:42:17 +08:00
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
2026-08-14 19:02:34 +08:00
.embedded-smart-loading { min-width: 0; }
2026-08-14 19:59:32 +08:00
.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; }
2026-08-15 12:12:38 +08:00
.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; }
2026-08-14 19:02:34 +08:00
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
2026-08-14 19:59:32 +08:00
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
2026-08-10 17:42:17 +08:00
.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; }
2026-08-14 19:59:32 +08:00
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
2026-08-15 12:12:38 +08:00
.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; }
2026-08-10 17:42:17 +08:00
.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; }
2026-08-15 12:12:38 +08:00
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
2026-08-14 19:59:32 +08:00
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
2026-08-10 17:42:17 +08:00
.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; }
2026-08-10 17:42:17 +08:00
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
2026-08-27 21:48:15 +08:00
.package-count-editor { display: flex; min-width: 0; }
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
2026-08-10 17:42:17 +08:00
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; gap: 4px; }
.candidate-block { color: var(--el-color-danger) !important; }
2026-08-10 17:42:17 +08:00
@media (max-width: 1180px) {
2026-08-14 19:59:32 +08:00
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
2026-08-14 19:09:54 +08:00
.embedded-smart-loading .dispatch-grid { height: auto; }
2026-08-10 17:42:17 +08:00
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
2026-08-14 19:59:32 +08:00
.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; }
2026-08-10 17:42:17 +08:00
}
@media (max-width: 720px) {
2026-08-14 19:59:32 +08:00
.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; }
2026-08-10 17:42:17 +08:00
.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; }
2026-08-14 19:59:32 +08:00
.channel-tabs :deep(.el-tabs__item) { max-width: 180px; padding-inline: 12px; }
2026-08-10 17:42:17 +08:00
}
</style>