Files
admin-web/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
T
ouyangxq 871d0a56b9 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
2026-09-04 16:21:32 +08:00

899 lines
54 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 { Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
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 LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers,
recommendSmartLoading,
removeAllUnpickedSmartLoadingItems,
removeSmartLoadingGroup,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
SmartLoadingCandidate,
SmartLoadingPlan,
SmartLoadingWorkbenchContainer,
} from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
import {
SMART_LOADING_CHANNEL_GROUPS,
resolveSmartLoadingChannelGroup,
} from '../smartLoadingChannelGroups'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const editingQuantityId = ref('')
const access = useAccessPreviewStore()
const containers = ref<SmartLoadingWorkbenchContainer[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const selectedContainerId = ref('')
const selectedChannelCodes = ref<string[]>([])
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const selectionLoading = ref(false)
const candidateLoading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const selectionError = ref<ApiErrorDetails | null>(null)
const candidateError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
const appliedQuery = ref<QueryModel>({ ...query.value, orderStatuses: [] })
const candidatePage = ref(1)
const candidatePageSize = ref(20)
const candidateTotal = ref(0)
let selectionRequest = 0
let candidateRequest = 0
let containerRequest = 0
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null)
const crossChannelAssignment = computed(() => Boolean(
selectedCandidate.value?.channelCode
&& selectedContainer.value?.channelCode
&& selectedCandidate.value.channelCode !== selectedContainer.value.channelCode,
))
const orchestratableContainers = computed(() => containers.value)
const completedContainerCount = computed(() => 0)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
)).length)
const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
)).length)
const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const warehouseOptions = computed(() => {
return warehouses.value.map((warehouse) => ({ value: String(warehouse.id), label: warehouse.warehouseName }))
})
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
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 || '订单编码待补充', 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 groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
}
function candidateTotalVolume(candidate: SmartLoadingCandidate) {
return candidate.availableVolumeCbm == null
? candidate.averageUnitVolumeCbm == null ? null : Number(candidate.averageUnitVolumeCbm) * Number(candidate.availablePackageCount || 0)
: Number(candidate.availableVolumeCbm)
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
return candidates.value.map((candidate) => {
const members: Array<{ accountId: string; inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
? candidate.groupMembers
: [candidate]
return {
...candidate,
memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)),
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(candidate.memberCount || members.length),
totalGrossWeightKg: candidateTotalGrossWeight(candidate),
totalVolumeCbm: candidateTotalVolume(candidate),
}
})
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | 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
})
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.5)
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.5)
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 candidateOrderStatusOptions = [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
]
function candidateStatusLabel(status: string | null | undefined) {
return candidateOrderStatusOptions.find((item) => item.value === status)?.label || '—'
}
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '发货仓', placeholder: '全部发货仓', width: 160, options: warehouseOptions.value, props: { filterable: true } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, 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: 210 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 },
{ key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } },
])
const filteredContainers = computed(() => containers.value)
const candidateScopeLabel = computed(() => {
const warehouseId = String(appliedQuery.value.warehouseId || '')
const warehouse = warehouseOptions.value.find((item) => item.value === warehouseId)?.label || '全部发货仓'
const channels = channelOptions.value.filter((item) => selectedChannelCodes.value.includes(item.code)).map((item) => item.name)
return `${warehouse} · ${channels.join('、') || '未选择渠道'}`
})
function preferredContainerId() {
const visible = filteredContainers.value
return visible.find((item) => item.planStatus === 'EDITING')?.bookingId
|| visible.find((item) => item.planStatus === 'LOADING')?.bookingId
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.bookingId || visible[0]?.bookingId || ''
}
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 },
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
{ key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' },
{ key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' },
{ key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' },
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
]
function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
}
function formatMeasurement(value: number | null | undefined, unit: string, digits = 3) {
return value == null ? '待测量' : `${formatNumber(Number(value), digits)} ${unit}`
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
function adjustmentMax(source: object) {
const item = itemRow(source)
return Math.max(item.minPackageCount, item.totalPackageCount || item.packageCount)
}
function startQuantityEdit(source: object) {
const item = itemRow(source)
quantityDrafts[item.id] = item.packageCount
editingQuantityId.value = item.id
}
function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
}
function hasRealContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function displayActualContainerNo(container: SmartLoadingWorkbenchContainer | 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 groupSmartLoadingItems(next.items || [])) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next
planGroupsByInboundId.value = loadSmartLoadingPlanCoLoadGroups(next)
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
if (container) {
container.planId = next.id
if (syncContainerStatus && next.status !== 'COMPLETED') container.planStatus = next.status
}
}
function clearSelectionData() {
plan.value = null
noDraft.value = false
selectionError.value = null
mutationError.value = null
planGroupsByInboundId.value = new Map()
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
}
function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0] {
const createdRange = Array.isArray(appliedQuery.value.createdRange)
? appliedQuery.value.createdRange.map((value) => String(value || '').slice(0, 10))
: []
const params: Parameters<typeof listSmartLoadingCandidates>[0] = {
channelGroupCodes: [...selectedChannelCodes.value],
page: candidatePage.value,
size: candidatePageSize.value,
}
const warehouseId = String(appliedQuery.value.warehouseId || '')
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim()
const orderStatuses = Array.isArray(appliedQuery.value.orderStatuses)
? appliedQuery.value.orderStatuses.map((value) => String(value)).filter(Boolean) as NonNullable<typeof params.orderStatuses>
: []
if (warehouseId) params.warehouseId = warehouseId
if (customerId) params.customerId = customerId
if (inboundCode) params.inboundCode = inboundCode
if (createdRange.length === 2) {
params.createdStart = createdRange[0]
params.createdEnd = createdRange[1]
}
if (orderStatuses.length) params.orderStatuses = orderStatuses
return params
}
function containerQueryParams(page = 1): Parameters<typeof listSmartLoadingWorkbenchContainers>[0] {
return {
channelGroupCodes: [...selectedChannelCodes.value],
page,
size: 200,
}
}
async function loadCandidateData() {
if (!selectedChannelCodes.value.length) return
const request = ++candidateRequest
candidateLoading.value = true
candidateError.value = null
try {
const result = await listSmartLoadingCandidates(candidateQueryParams())
if (request !== candidateRequest) return
candidates.value = result.records
candidateTotal.value = result.total
} catch (error) {
if (request === candidateRequest) {
candidates.value = []
candidateTotal.value = 0
candidateError.value = getApiErrorDetails(error, '候选装柜明细加载失败')
}
} finally {
if (request === candidateRequest) candidateLoading.value = false
}
}
async function loadSelectedPlan(preserved: Record<string, number> = {}) {
const container = selectedContainer.value
const request = ++selectionRequest
clearSelectionData()
if (!container) return
selectionLoading.value = true
try {
const planResult = await getSmartLoadingPlanByBooking(container.bookingId).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
if (planResult.value) {
acceptPlan(planResult.value, preserved, false)
syncDrafts(planResult.value, preserved)
}
else noDraft.value = planResult.missing
} catch (error) {
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
} finally {
if (request === selectionRequest) selectionLoading.value = false
}
}
async function loadContainerData(preserved: Record<string, number> = {}) {
if (!selectedChannelCodes.value.length) return
const request = ++containerRequest
try {
const firstPage = await listSmartLoadingWorkbenchContainers(containerQueryParams())
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listSmartLoadingWorkbenchContainers(containerQueryParams(index + 2))))
: []
if (request !== containerRequest) return
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!containers.value.some((item) => item.bookingId === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedPlan(preserved)
} catch (error) {
if (request === containerRequest) {
containers.value = []
selectedContainerId.value = ''
clearSelectionData()
selectionError.value = getApiErrorDetails(error, '已选渠道柜次加载失败')
}
}
}
async function loadWorkbenchData(preserved: Record<string, number> = {}) {
await Promise.all([loadCandidateData(), loadContainerData(preserved)])
}
async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true
loadError.value = null
try {
warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code))
selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code))
if (!selectedChannelCodes.value.length) {
selectedChannelCodes.value = [channelOptions.value[0]?.code || '']
}
await loadWorkbenchData(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 loadSelectedPlan()
}
async function selectChannels(codes: string[]) {
if (!codes.length) {
ElMessage.warning('至少选择一个渠道')
return
}
selectedChannelCodes.value = codes
candidatePage.value = 1
await loadWorkbenchData()
}
async function selectOnlyChannel(code: string) {
if (selectedChannelCodes.value.length === 1 && selectedChannelCodes.value[0] === code) return
selectedChannelCodes.value = [code]
candidatePage.value = 1
await loadWorkbenchData()
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = {
...model,
orderStatuses: Array.isArray(model.orderStatuses) ? model.orderStatuses.map((value) => String(value)) : [],
}
candidatePage.value = 1
await loadCandidateData()
}
async function changeCandidatePage(page: number) {
candidatePage.value = page
await loadCandidateData()
}
async function changeCandidatePageSize(size: number) {
candidatePageSize.value = size
candidatePage.value = 1
await loadCandidateData()
}
function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
selectedCandidate.value = candidate
assignForm.packageCount = candidate.coLoadGroupId
? Number(candidate.groupTotalBoxes || candidate.availablePackageCount)
: candidate.availablePackageCount
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) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
return
}
await mutate(async () => {
const selected = selectedCandidate.value!
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
customerId: selected.accountId,
orderId: selected.orderId,
inboundCode: selected.inboundCode || '',
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
})
: selected.coLoadGroupId && selected.inboundId
? 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,
}))
assignOpen.value = false
await loadCandidateData()
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true })
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
}
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| !candidate.inboundCode
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|| candidate.availablePackageCount < 1
}
async function recommend() {
if (!plan.value) return
await mutate(async () => {
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 loadSelectedPlan()
if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
}
await loadCandidateData()
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
if (!Number.isInteger(count) || count < item.minPackageCount) {
ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`)
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 loadCandidateData()
editingQuantityId.value = ''
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
async function remove(source: object) {
const item = itemRow(source)
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: '取消',
}).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 loadCandidateData()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
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)
}
await loadCandidateData()
ElMessage.success('该入仓码已从当前柜次移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
async function removeAll() {
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
if (!removableItems.length) return
const itemCount = removableItems.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await removeAllUnpickedSmartLoadingItems(plan.value!.id, plan.value!.version))
await loadCandidateData()
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true })
}
async function mutate(
action: () => Promise<void>,
fallback: string,
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) {
if (mutating.value) return
mutating.value = true
mutationError.value = null
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 || details.message || '计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作'
if (recovery.preserveAssign) assignOpen.value = true
if (pickingMessage) ElMessage.error(pickingMessage)
else ElMessage.warning(message)
} else {
ElMessage.error(details.message)
}
mutationError.value = details
} 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="channelOptions.length" class="channel-summary-row">
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
<span class="channel-tags__label">渠道</span>
<el-checkbox-button
v-for="channel in channelOptions"
:key="channel.code"
:label="channel.code"
:value="channel.code"
title="双击仅选择此渠道"
@dblclick.stop.prevent="selectOnlyChannel(channel.code)"
>{{ channel.name }}</el-checkbox-button>
</el-checkbox-group>
<dl class="compact-summary" aria-label="已选渠道装柜统计">
<div><dt>候选明细</dt><dd><strong>{{ candidateTotal }}</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-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的渠道未纳入固定 9 类,已隔离`" />
<el-alert v-if="completedContainerCount && !orchestratableContainers.length" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<QueryToolbar
v-model="query"
class="smart-loading-query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query-v2"
:search-on-reset="false"
@search="applyFilters"
@reset="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="loadWorkbenchData()">
<template #details><ApiErrorMeta :details="mutationError" /></template>
</LoadErrorState>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ candidateScopeLabel }}</small></div></header>
<LoadErrorState v-if="candidateError" title="候选明细加载失败" :message="candidateError.message" compact @retry="loadCandidateData()">
<template #details><ApiErrorMeta :details="candidateError" /></template>
</LoadErrorState>
<DataTable
:page="candidatePage"
:page-size="candidatePageSize"
:data="displayedCandidates"
:columns="candidateColumns"
:loading="loading || candidateLoading || mutating"
:total="candidateTotal"
:page-sizes="[20, 50, 100]"
stripe
preference-key="global-smart-loading-candidates"
column-settings-mode="hide"
empty-text="当前筛选条件下暂无候选装柜明细"
@update:page="changeCandidatePage"
@update:page-size="changeCandidatePageSize"
>
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
<template #actions="{ row }">
<el-button
v-if="canAssign && assignable"
size="small"
type="text"
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
<template #averageUnitVolume="{ row }">{{ formatMeasurement(row.averageUnitVolumeCbm, 'm³') }}</template>
<template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>已选渠道柜次</strong></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.bookingId"
type="button"
class="container-option"
:class="{ selected: item.bookingId === selectedContainerId }"
@click="selectContainer(item.bookingId)"
>
<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>渠道:{{ resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.name || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</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></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedPlan()"><RefreshCw :size="16" /></el-button></el-tooltip>
<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>
</div>
</header>
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedPlan()">
<template #details><ApiErrorMeta :details="selectionError" /></template>
</LoadErrorState>
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
<el-empty v-else-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<template v-else-if="plan">
<!--
已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。
操作权限仍由 plan.canEdit、canAdjust 和 canRemove 控制。
-->
<el-alert
v-if="!plan.canEdit"
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 || '请刷新计划获取最新作业状态。'"
/>
<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="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="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</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">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<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 }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, '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" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
</template>
</div>
</AppDialog>
</template>
</section>
</component>
</template>
<style scoped>
.global-loading-page { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
.embedded-smart-loading { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.embedded-smart-loading .global-loading-page { width: 100%; min-height: 0; flex: 1; overflow: hidden; }
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tags { display: flex; min-width: 0; align-items: center; flex-wrap: nowrap; gap: 6px; padding: 6px 0; overflow-x: auto; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: 13px; font-weight: 600; }
.channel-tags :deep(.el-checkbox-button) { flex: 0 0 auto; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: 13px; line-height: 32px; }
.compact-summary { display: flex; min-height: 46px; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; padding: 5px 8px; 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; }
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: nowrap; gap: 8px; }
:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }
.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(0, 5fr) minmax(0, 2fr) minmax(0, 3fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { height: auto; min-height: 0; flex: 1; }
.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%; }
.candidate-pane :deep(.data-table__pagination) { min-height: 46px; flex-wrap: nowrap; gap: 8px; padding: 7px 8px; }
.candidate-pane :deep(.data-table__pagination-left) { flex: 0 0 auto; white-space: nowrap; }
.candidate-pane :deep(.data-table__pagination-controls),.candidate-pane :deep(.data-table__page-size),.candidate-pane :deep(.data-table__page-buttons) { flex-wrap: nowrap; gap: 4px; white-space: nowrap; }
.candidate-pane :deep(.data-table__page-button[aria-label^='第']),.candidate-pane :deep(.data-table__page-ellipsis) { display: none; }
.candidate-pane :deep(.data-table__page-size .el-select) { width: 68px; }
.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; }
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.package-count-summary strong { font-size: 13px; font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; }
.candidate-block { color: var(--el-color-danger) !important; }
@media (max-width: 1180px) {
:deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; }
.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; }
.candidate-pane :deep(.data-table__pagination) { flex-wrap: wrap; }
.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 { grid-column: 1 / -1; }
.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-tags { padding-inline: 8px; }
}
</style>