This commit is contained in:
2026-08-15 12:12:38 +08:00
parent e000f8bed2
commit e214672779
4 changed files with 87 additions and 16 deletions
@@ -95,6 +95,34 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('LP-99')
})
it('explains simulated empty cabinets instead of presenting an internal id as a container number', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{
...container,
cabinetNo: '2088475410134773762',
containerNo: null,
channelName: '美森正班海卡包税',
warehouseName: '义乌国内仓',
containerType: '40HQ',
}],
page: 1,
size: 100,
total: 1,
pages: 1,
})
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const option = wrapper.find('.container-option')
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('模拟柜次:真实柜号待录入')
expect(option.text()).toContain('真实柜号:待录入')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HQ')
expect(option.text()).not.toContain('2088475410134773762')
})
it('groups containers by channel and reloads candidates when the channel changes', async () => {
const other = {
...container,
@@ -318,6 +346,8 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('overflow-wrap: anywhere;')
expect(source).toContain('.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }')
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
expect(source).toContain('grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr)')
expect(source).toContain('min-height: 112px')
})
it('uses overflow-aware channel tabs with a compact right-aligned summary', () => {
@@ -142,7 +142,17 @@ function displayContainerNo(container: ContainerBookingResource | null | undefin
if (!container) return '柜号待生成'
const actual = String(container.containerNo || '').trim()
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
return actual && !internalReference ? actual : String(container.cabinetNo || '').trim() || '柜号待生成'
if (actual && !internalReference) return actual
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '模拟柜次' : cabinetReference || '柜号待生成'
}
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function hasOpaqueCabinetReference(container: ContainerBookingResource | null | undefined) {
return Boolean(container && /^\d{15,}$/.test(String(container.cabinetNo || '').trim()))
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -445,7 +455,18 @@ onMounted(() => { void load(); void loadCustomers() })
:class="{ selected: item.id === selectedContainerId }"
@click="selectContainer(item.id)"
>
<span class="container-option__top"><strong>{{ displayContainerNo(item) }}</strong></span>
<span class="container-option__top">
<strong>{{ hasRealContainerNo(item) ? displayContainerNo(item) : '模拟柜次' }}</strong>
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
{{ hasRealContainerNo(item) ? '已录入柜号' : '空柜' }}
</el-tag>
</span>
<span class="container-option__identity">
{{ hasRealContainerNo(item) ? `柜号:${displayContainerNo(item)}` : (hasOpaqueCabinetReference(item) ? '模拟柜次:真实柜号待录入' : `柜次参考:${displayContainerNo(item)}`) }}
</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span v-if="!hasRealContainerNo(item)" class="container-option__hint">真实柜号:待录入</span>
</button>
</div>
</aside>
@@ -518,7 +539,7 @@ onMounted(() => { void load(); void loadCustomers() })
.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; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
@@ -528,10 +549,12 @@ onMounted(() => { void load(); void loadCustomers() })
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
.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: 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 { 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; }
@@ -559,7 +559,7 @@ describe('CustomersView', () => {
expect(document.querySelector('.el-table__fixed')).toBeNull()
})
it('keeps every permitted customer action visible without a more menu', async () => {
it('keeps three high-frequency actions visible and moves overflow actions into a menu', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
@@ -570,11 +570,10 @@ describe('CustomersView', () => {
expect(tableButtonByText('详情')).toBeDefined()
expect(tableButtonByText('编辑')).toBeDefined()
expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull()
expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).not.toBeNull()
expect(tableButtonByText('重置密码')).toBeDefined()
expect(tableButtonByText('跳转登录')).toBeDefined()
tableButtonByText('跳转登录')?.click()
await flushPromises()
expect(tableButtonByText('跳转登录')).toBeUndefined()
await clickMoreAction('跳转登录')
expect(document.body.textContent).toContain('代登录原因')
})
@@ -596,7 +595,7 @@ describe('CustomersView', () => {
expect(tableButtonByText('删除')).toBeUndefined()
})
it('renders all permitted actions directly for multiple customers', async () => {
it('renders one overflow menu per customer when permitted actions exceed three', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
@@ -612,8 +611,8 @@ describe('CustomersView', () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(0)
expect(document.querySelectorAll('.el-table__body button').length).toBeGreaterThan(9)
expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(3)
expect(document.querySelectorAll('.el-table__body .table-actions > .el-button').length).toBe(9)
})
it('keeps the action row usable when all customer identifiers are missing', async () => {
@@ -628,7 +627,7 @@ describe('CustomersView', () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull()
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作:当前客户"]')).not.toBeNull()
expect(tableButtonByText('详情')).toBeDefined()
})
})
+22 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
import { CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, MoreHorizontal, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -194,7 +194,7 @@ const creditCodeEstablished = ref(false)
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 500, slotName: 'actions' },
{ key: 'actions', label: '操作', width: 330, slotName: 'actions' },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', width: 220, className: 'customer-code-column' },
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
@@ -668,6 +668,21 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio
...(canArchive(row) ? ['delete' as const] : []),
]
}
function customerPrimaryActionCount(row: PlatformTenantResource) {
return Number(access.can('platform:customer:list'))
+ Number(access.can('platform:customer:edit') && row.status !== 'ARCHIVED')
+ Number(hasReviewAction(row))
}
function customerDirectSecondaryActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerOverflowActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerMoreActionsLabel(row: PlatformTenantResource) {
const identity = row.tenantName || row.tenantCode || String(row.id || '当前客户')
return `更多客户操作:${identity}`
}
function customerActionLabel(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'reset-password') return '重置密码'
if (action === 'impersonate') return '跳转登录'
@@ -1019,7 +1034,11 @@ onMounted(() => { void loadCustomers() })
<el-button v-if="access.can('platform:customer:list')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
<el-button v-if="access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="customer-action-button customer-action-review" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="14" />审核</el-button>
<el-button v-for="action in customerSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
<el-button v-for="action in customerDirectSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
<el-dropdown v-if="customerOverflowActions(row as PlatformTenantResource).length" trigger="click" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, $event as string)">
<el-button class="customer-action-button customer-action-more" size="small" plain :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in customerOverflowActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :class="{ 'customer-action-menu__danger': customerActionType(row as PlatformTenantResource, action) === 'danger' }"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>