This commit is contained in:
2026-08-14 19:59:32 +08:00
parent f7594ebd39
commit 1540b80c89
29 changed files with 780 additions and 587 deletions
@@ -46,13 +46,12 @@ 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)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const query = ref<QueryModel>({ customerId: '', orderKeyword: '', containerKeyword: '', status: '' })
const query = ref<QueryModel>({ customerId: '', inboundCode: '', destinationWarehouse: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
@@ -62,15 +61,12 @@ const canDelete = computed(() => access.can('platform:firstleg:smart-loading:del
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string; containerCount: number }>()
const channels = new Map<string, { code: string; name: string }>()
for (const item of containers.value) {
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue
const current = channels.get(item.channelCode)
if (current) current.containerCount += 1
else channels.set(item.channelCode, {
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
containerCount: 1,
})
}
return [...channels.values()]
@@ -88,49 +84,45 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const destinationWarehouseOptions = computed(() => {
const options = new Map<string, string>()
for (const item of candidates.value) {
const value = destinationWarehouseKey(item)
if (value) options.set(value, item.destinationWarehouseName || item.destinationWarehouseCode || value)
}
return [...options].map(([value, label]) => ({ label, value }))
})
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、货物', width: 250 },
{ key: 'containerKeyword', type: 'input', label: '柜号', placeholder: '请输入柜号', width: 230 },
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
{ label: '待装柜', value: 'WAIT_LOADING' },
{ label: '预装柜中', value: 'EDITING' },
] },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
{ key: 'destinationWarehouse', type: 'select', label: '目的仓', placeholder: '全部目的仓', width: 200, options: destinationWarehouseOptions.value, props: { filterable: true } },
])
const filteredContainers = computed(() => {
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
const status = String(appliedQuery.value.status || '')
return containers.value.filter((item) => {
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus)) return false
if (!activeChannelCode.value || item.channelCode !== activeChannelCode.value) return false
if (status && item.planStatus !== status) return false
if (!keyword) return true
return displayContainerNo(item).toLowerCase().includes(keyword)
})
})
const filteredContainers = computed(() => containers.value.filter((item) => (
['WAIT_LOADING', 'EDITING'].includes(item.planStatus)
&& Boolean(activeChannelCode.value)
&& item.channelCode === activeChannelCode.value
)))
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
const keyword = String(appliedQuery.value.orderKeyword || '').trim().toLowerCase()
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
const destinationWarehouse = String(appliedQuery.value.destinationWarehouse || '')
return candidates.value.filter((item) => {
if (customerId && item.accountId !== customerId) return false
if (!keyword) return true
return [item.orderNo, item.inboundCode, item.goodsName]
.some((value) => String(value || '').toLowerCase().includes(keyword))
if (destinationWarehouse && destinationWarehouseKey(item) !== destinationWarehouse) return false
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
})
})
function preferredContainerId() {
const visible = filteredContainers.value
if (String(appliedQuery.value.status || '').trim()) return visible[0]?.id || ''
return visible.find((item) => item.planStatus === 'EDITING')?.id
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
}
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
@@ -139,7 +131,7 @@ const candidateColumns: DataTableColumn[] = [
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
@@ -152,6 +144,14 @@ function formatNumber(value: number, digits = 3) {
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function adjustmentMax(source: object) {
const item = itemRow(source)
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
return Math.max(1, item.packageCount + remaining)
}
function destinationWarehouseKey(candidate: SmartLoadingCandidate) {
return candidate.destinationWarehouseId || candidate.destinationWarehouseCode || candidate.destinationWarehouseName || ''
}
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '柜号待生成'
const actual = String(container.containerNo || '').trim()
@@ -250,14 +250,12 @@ async function loadCustomers() {
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
mutationError.value = null
await loadSelectedData()
}
async function selectChannel(channelCode: string) {
if (activeChannelCode.value === channelCode) return
activeChannelCode.value = channelCode
selectedContainerId.value = preferredContainerId()
mutationError.value = null
await loadSelectedData()
}
async function applyFilters(model: QueryModel) {
@@ -281,6 +279,10 @@ function openAssign(source: object) {
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
return
}
await mutate(async () => {
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
inboundId: selectedCandidate.value!.inboundId,
@@ -289,6 +291,7 @@ async function assign() {
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
await loadSelectedData()
ElMessage.success('货物已加入当前柜次')
}, '分配货物失败', { preserveAssign: true })
}
@@ -296,6 +299,7 @@ async function recommend() {
if (!plan.value) return
await mutate(async () => {
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
await loadSelectedData()
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
@@ -304,23 +308,46 @@ async function adjust(source: object) {
if (!plan.value) return
const count = Number(quantityDrafts[item.id])
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
const max = adjustmentMax(item)
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
await loadSelectedData()
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', {
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
await loadSelectedData()
ElMessage.success('装柜明细已移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
async function removeAll() {
if (!plan.value?.items.length) return
const itemCount = plan.value.items.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的全部 ${itemCount} 条装柜明细吗?`, '全部移除', {
type: 'warning', confirmButtonText: '全部移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
let current = plan.value!
for (const original of [...current.items]) {
const item = current.items.find((value) => value.id === original.id)
if (!item) continue
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
acceptPlan(current)
}
await loadSelectedData()
ElMessage.success(`已移除全部 ${itemCount} 条装柜明细`)
}, '全部移除失败', { pickingConflict: true })
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
@@ -339,7 +366,6 @@ async function mutate(
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) {
mutating.value = true
mutationError.value = null
try {
await action()
} catch (error) {
@@ -349,12 +375,11 @@ async function mutate(
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
? '该订单在拣货中不许删除'
: null
mutationError.value = { ...details, message: pickingMessage || '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
const message = pickingMessage || '方案已被其他人修改,已刷新最新版本,请重新确认后提交'
if (recovery.preserveAssign) assignOpen.value = true
if (pickingMessage) ElMessage.error(pickingMessage)
else ElMessage.warning(mutationError.value.message)
else ElMessage.warning(message)
} else {
mutationError.value = details
ElMessage.error(details.message)
}
} finally {
@@ -368,26 +393,24 @@ onMounted(() => { void load(); void loadCustomers() })
<template>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page">
<nav v-if="channelTabs.length" class="channel-tabs" aria-label="物流渠道" role="tablist">
<button
v-for="channel in channelTabs"
:key="channel.code"
type="button"
role="tab"
:aria-selected="channel.code === activeChannelCode"
:class="{ active: channel.code === activeChannelCode }"
@click="selectChannel(channel.code)"
>
<span>{{ channel.name }}</span>
<small>{{ channel.containerCount }} 个柜次</small>
</button>
</nav>
<div v-if="channelTabs.length" class="channel-summary-row">
<el-tabs :model-value="activeChannelCode" class="channel-tabs" aria-label="物流渠道" @tab-change="selectChannel(String($event))">
<el-tab-pane v-for="channel in channelTabs" :key="channel.code" :label="channel.name" :name="channel.code" />
</el-tabs>
<dl class="compact-summary" aria-label="当前渠道装柜统计">
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
<div><dt>空柜</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query"
preference-key="global-smart-loading-query-v2"
:search-on-reset="false"
@search="applyFilters"
/>
@@ -396,18 +419,6 @@ onMounted(() => { void load(); void loadCustomers() })
<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>
<section class="summary-band" aria-label="当前装柜汇总">
<div><span>候选货物</span><strong>{{ filteredCandidates.length }}</strong></div>
<div><span>可见空柜</span><strong>{{ filteredContainers.length }}</strong></div>
<div><span>当前已装箱数</span><strong>{{ totalPackages }}</strong></div>
<div><span>当前总体积</span><strong>{{ formatNumber(totalVolume) }} m³</strong></div>
<div><span>当前总重量</span><strong>{{ formatNumber(totalWeight, 1) }} kg</strong></div>
</section>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
@@ -430,7 +441,7 @@ onMounted(() => { void load(); void loadCustomers() })
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
</template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
</DataTable>
@@ -464,6 +475,7 @@ onMounted(() => { void load(); void loadCustomers() })
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canDelete && editable && plan?.items.length" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />全部移除</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div>
</header>
@@ -488,9 +500,9 @@ onMounted(() => { void load(); void loadCustomers() })
</div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
</DataTable>
@@ -501,7 +513,7 @@ onMounted(() => { void load(); void loadCustomers() })
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
<p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</div>
</AppDialog>
@@ -513,30 +525,33 @@ onMounted(() => { void load(); void loadCustomers() })
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.embedded-smart-loading { min-width: 0; }
.channel-tabs { display: flex; min-height: 48px; overflow-x: auto; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tabs button { display: flex; min-width: 150px; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 7px; padding: 8px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--cc-text); cursor: pointer; }
.channel-tabs button > span { overflow: hidden; max-width: 220px; text-overflow: ellipsis; white-space: nowrap; }
.channel-tabs button.active { margin-bottom: -2px; border-bottom-color: var(--el-color-primary); color: var(--el-color-primary); font-weight: 600; }
.channel-tabs button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -3px; }
.channel-tabs small { color: var(--cc-muted); font-weight: 400; white-space: nowrap; }
.summary-band { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
.summary-band > div { display: grid; gap: 4px; padding: 12px 14px; background: #fff; }
.summary-band span,.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.summary-band strong { font-size: 16px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); min-height: 620px; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.channel-summary-row { display: grid; grid-template-columns: minmax(240px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tabs { min-width: 0; }
.channel-tabs :deep(.el-tabs__header) { margin: 0 0 -2px; }
.channel-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.channel-tabs :deep(.el-tabs__item) { overflow: hidden; max-width: 240px; height: 48px; padding-inline: 18px; text-overflow: ellipsis; white-space: nowrap; }
.channel-tabs :deep(.el-tabs__content) { display: none; }
.compact-summary { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 16px; margin: 0; padding: 6px 12px; white-space: nowrap; }
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
.dispatch-pane { display: flex; min-width: 0; flex-direction: column; }
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
.container-pane { background: #fafafa; }
.container-list { display: grid; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
.container-option { display: grid; width: 100%; min-height: 118px; gap: 5px; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
.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: 0; height: auto; align-content: start; gap: 5px; padding: 10px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.35; 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 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; }
@@ -550,18 +565,20 @@ onMounted(() => { void load(); void loadCustomers() })
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 1180px) {
.dispatch-grid { grid-template-columns: 1fr; 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(70vh, 600px); }
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: 360px; }
.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }
.container-pane { max-height: min(58vh, 460px); }
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: none; }
}
@media (max-width: 720px) {
.summary-band,.capacity-grid { grid-template-columns: 1fr; }
.channel-summary-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
.compact-summary { min-height: 0; justify-content: flex-start; flex-wrap: wrap; gap: 6px 14px; padding: 8px 12px; white-space: normal; }
.capacity-grid { grid-template-columns: 1fr; }
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
.container-list { grid-template-columns: 1fr; }
.channel-tabs button { min-width: 156px; align-items: flex-start; flex-direction: column; gap: 2px; padding-inline: 12px; text-align: left; }
.channel-tabs button > span { max-width: 132px; }
.channel-tabs :deep(.el-tabs__item) { max-width: 180px; padding-inline: 12px; }
}
</style>