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

508 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
ContainerBookingResource,
ContainerPlanStatus,
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
} from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const containers = ref<ContainerBookingResource[]>([])
const selectedContainerId = ref('')
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const selectionLoading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const selectionError = ref<ApiErrorDetails | null>(null)
const 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>({ orderKeyword: '', containerKeyword: '', status: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING')
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.1)
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.1)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const queryFields = computed<QueryField[]>(() => [
{ key: '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' },
{ label: '拣货中', value: 'LOADING' },
] },
])
const filteredContainers = computed(() => {
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
const status = String(appliedQuery.value.status || '')
return containers.value.filter((item) => {
if (item.planStatus === 'COMPLETED') return false
if (status && item.planStatus !== status) return false
if (!keyword) return true
return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName]
.some((value) => String(value || '').toLowerCase().includes(keyword))
})
})
const filteredCandidates = computed(() => {
const keyword = String(appliedQuery.value.orderKeyword || '').trim().toLowerCase()
if (!keyword) return candidates.value
return candidates.value.filter((item) => [item.orderNo, item.inboundCode, item.customerName, item.goodsName]
.some((value) => String(value || '').toLowerCase().includes(keyword)))
})
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: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 105, slotName: 'unitWeight' },
{ key: 'destination', label: '目的仓', minWidth: 130, slotName: 'destination' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
{ key: 'destination', label: '目的仓', minWidth: 130, slotName: 'destination' },
]
function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function statusLabel(status: ContainerPlanStatus) {
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as const)[status]
}
function statusType(status: ContainerPlanStatus) {
return status === 'WAIT_LOADING' ? 'info' : status === 'EDITING' ? 'primary' : status === 'LOADING' ? 'warning' : 'success'
}
function routeMatches(source: object) {
const candidate = candidateRow(source)
const target = selectedContainer.value?.destinationWarehouseCode
return !target || target === candidate.destinationWarehouseCode
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
for (const item of next.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
if (container) {
container.planId = next.id
if (syncContainerStatus) container.planStatus = next.status
}
}
function clearSelectionData() {
plan.value = null
candidates.value = []
noDraft.value = false
selectionError.value = null
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
}
async function loadSelectedData(preserved: Record<string, number> = {}) {
const container = selectedContainer.value
const request = ++selectionRequest
clearSelectionData()
if (!container) return
if (container.planStatus === 'COMPLETED') return
selectionLoading.value = true
try {
const [candidateResult, planResult] = await Promise.all([
listSmartLoadingCandidates(container.id, container.warehouseId),
getSmartLoadingPlanByBooking(container.id).then((value) => ({ value, missing: false })).catch((error) => {
const details = getApiErrorDetails(error)
if (details.code === 'A0404') return { value: null, missing: true }
throw error
}),
])
if (request !== selectionRequest) return
candidates.value = candidateResult
if (planResult.value) acceptPlan(planResult.value, preserved, false)
else noDraft.value = planResult.missing
} catch (error) {
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
} finally {
if (request === selectionRequest) selectionLoading.value = false
}
}
function openList(status: 'LOADING' | 'COMPLETED') {
const routeName = status === 'LOADING' ? 'loading-plans' : 'loading-lists'
const query = selectedContainer.value
? { planId: selectedContainer.value.planId || undefined, warehouseId: selectedContainer.value.warehouseId }
: undefined
void router.push({ name: routeName, query })
}
async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true
loadError.value = null
try {
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedData(preserved)
} catch (error) {
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
} finally {
loading.value = false
}
}
async function load() { await loadAll() }
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
mutationError.value = null
await loadSelectedData()
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
}
async function createPlan() {
const container = selectedContainer.value
if (!container) return
await mutate(async () => acceptPlan(await createSmartLoadingPlan(container.id, null)), '创建装柜计划失败')
}
function openAssign(source: object) {
const candidate = candidateRow(source)
selectedCandidate.value = candidate
assignForm.packageCount = Math.min(candidate.availablePackageCount, 1)
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
await mutate(async () => {
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
inboundId: selectedCandidate.value!.inboundId,
inboundDetailId: selectedCandidate.value!.inboundDetailId,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
ElMessage.success('货物已加入当前柜次')
}, '分配货物失败', { preserveAssign: true })
}
async function recommend() {
if (!plan.value) return
await mutate(async () => {
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value) return
const count = Number(quantityDrafts[item.id])
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
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}”吗?`, '移除装柜明细', {
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))
ElMessage.success('装柜明细已移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('已推送预装清单')
}, '推送预装清单失败')
}
async function mutate(
action: () => Promise<void>,
fallback: string,
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) {
mutating.value = true
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
mutationError.value = { ...details, message: pickingMessage || '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
if (recovery.preserveAssign) assignOpen.value = true
if (pickingMessage) ElMessage.error(pickingMessage)
else ElMessage.warning(mutationError.value.message)
} else {
mutationError.value = details
ElMessage.error(details.message)
}
} finally {
mutating.value = false
}
}
onMounted(load)
</script>
<template>
<AdminPage>
<section class="global-loading-page">
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
:fields="queryFields"
preference-key="global-smart-loading-query"
: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>
<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>{{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
:loading="loading || selectionLoading || mutating"
stripe
preference-key="global-smart-loading-candidates"
column-settings-mode="hide"
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
>
<template #actions="{ row }">
<el-button
v-if="canAssign && editable"
size="small"
type="primary"
plain
:disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1"
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
</template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
<template #destination="{ row }">
<el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '不匹配' }}</el-tag>
<small>{{ row.destinationWarehouseName || row.destinationWarehouseCode || '未设置' }}</small>
</template>
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>货柜</strong><small>待装柜、预装柜中与拣货中</small></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
v-for="item in filteredContainers"
:key="item.id"
type="button"
class="container-option"
:class="{ selected: item.id === selectedContainerId }"
@click="selectContainer(item.id)"
>
<span class="container-option__top"><strong>{{ item.cabinetNo }}</strong><el-tag size="small" :type="statusType(item.planStatus)">{{ statusLabel(item.planStatus) }}</el-tag></span>
<span>{{ item.containerNo || '集装箱号待录入' }} · {{ item.containerType }}</span>
<span>{{ item.warehouseName || '仓库未设置' }}</span>
<span>目的仓 {{ item.destinationWarehouseName || item.destinationWarehouseCode || '不限' }}</span>
<span v-if="item.id === selectedContainerId && plan">体积 {{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</span>
<span v-else>容量 {{ formatNumber(Number(item.capacityCbm || 0)) }} m³ · {{ formatNumber(Number(item.maxWeightKg || 0), 0) }} kg</span>
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
<div><strong>{{ selectedContainer?.cabinetNo || '当前柜次' }} 装柜方案</strong><small>{{ plan ? `${plan.planNo} · 版本 ${plan.version}` : '尚未创建方案' }}</small></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div>
</header>
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedData()">
<template #details><ApiErrorMeta :details="selectionError" /></template>
</LoadErrorState>
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
<section v-else-if="selectedPlanStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成柜次">
<el-tag type="success">装柜完成</el-tag>
<strong>该柜次已完成装柜</strong>
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
</section>
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan">
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
</DataTable>
</template>
</section>
</section>
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
<p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
</section>
</AdminPage>
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.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; }
.dispatch-pane { display: flex; min-width: 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-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; }
.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); }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.plan-pane :deep(.el-input-number) { width: 120px; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 1180px) {
.dispatch-grid { grid-template-columns: 1fr; overflow: visible; }
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: 360px; }
}
@media (max-width: 720px) {
.summary-band,.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; }
}
</style>