style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
@@ -65,6 +66,12 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignmentTitle = computed(() => {
const candidate = selectedCandidate.value
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim() || candidate?.orderNo?.trim()
return code ? `${action} · ${code}` : action
})
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
@@ -590,6 +597,10 @@ async function adjust(source: object) {
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
function workbenchDialogTitle(action: string) {
const code = selectedContainer.value?.cabinetNo?.trim() || (hasRealContainerNo(selectedContainer.value) ? selectedContainer.value?.containerNo?.trim() : '')
return code ? `${action} · ${code}` : action
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
@@ -600,8 +611,8 @@ async function remove(source: object) {
: sealedPallet
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
workbenchDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -632,8 +643,8 @@ 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: '取消',
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, workbenchDialogTitle('移除未拣明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -676,7 +687,7 @@ onMounted(() => { void load(); void loadCustomers() })
</script>
<template>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<component class="admin-density" :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[])">
@@ -743,9 +754,10 @@ onMounted(() => { void load(); void loadCustomers() })
<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
class="density-action" data-tone="primary"
v-if="canAssign && assignable"
size="small"
type="text"
type="primary"
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
@@ -774,6 +786,7 @@ onMounted(() => { void load(); void loadCustomers() })
type="button"
class="container-option"
:class="{ selected: item.bookingId === selectedContainerId }"
:aria-pressed="item.bookingId === selectedContainerId"
@click="selectContainer(item.bookingId)"
>
<span class="container-option__top">
@@ -825,7 +838,7 @@ onMounted(() => { void load(); void loadCustomers() })
<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 #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><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 ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
@@ -837,28 +850,38 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : 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 || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<table class="density-table assignment-context-table" aria-label="全局装柜分配来源"><tbody>
<tr><th>目标柜次</th><td>{{ displayCabinetNo(selectedContainer) }}</td><th>集装箱号</th><td>{{ displayActualContainerNo(selectedContainer) }}</td></tr>
<tr><th>发货仓</th><td>{{ selectedContainer?.warehouseName || '—' }}</td><th>柜次渠道</th><td>{{ containerChannelGroup(selectedContainer)?.name || '—' }}</td></tr>
<tr><th>订单渠道</th><td>{{ selectedCandidate.channelName || '—' }}</td><th>{{ selectedCandidate.coLoadGroupId ? '同柜组' : '客户' }}</th><td>{{ selectedCandidate.coLoadGroupId ? selectedCandidate.groupNo : selectedCandidate.customerName || '客户待补充' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>托盘号</th><td>{{ selectedCandidate.palletNo || '—' }}</td><th>整托箱数</th><td>整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<template v-else>
<tr><th>入仓码</th><td>{{ selectedCandidate.inboundCode || '—' }}</td><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<tr><th>平均单箱毛重</th><td>{{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }}</td><th>平均单箱体积</th><td>{{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</td></tr>
<tr><td colspan="4"><el-form-item label="本次分配箱数" label-position="left" label-width="128px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
</template>
</tbody></table>
</el-form>
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="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>
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
</div>
</AppDialog>
</template>
@@ -867,24 +890,26 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.assignment-context-table { table-layout: fixed; }
.assignment-context-table th { width: 120px; }
.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__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: var(--admin-font-body); 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; }
.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: var(--admin-font-body); 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; }
.compact-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption); }
.compact-summary strong { color: var(--el-color-primary); font-size: var(--admin-font-section); }
: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; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.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; }
@@ -902,9 +927,9 @@ onMounted(() => { void load(); void loadCustomers() })
.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.selected { border-color: 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__identity { color: var(--cc-text); font-size: var(--admin-font-body); 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; }
@@ -914,17 +939,15 @@ onMounted(() => { void load(); void loadCustomers() })
.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 strong { font-size: var(--admin-font-title); }
.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; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: var(--admin-font-caption); }
.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; }
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.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%; }