From 1595fbcae277d00c54f1acea4eb55fce717d9080 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 10 Aug 2026 17:42:17 +0800
Subject: [PATCH] feat(phase3): complete loading workbenches
---
src/app/router/index.test.ts | 12 +
src/core/navigation/adminNavigation.test.ts | 4 +
src/core/navigation/adminNavigation.ts | 1 +
src/modules/auth/pages/LoginView.test.ts | 12 +
src/modules/auth/pages/LoginView.vue | 41 +-
.../pages/ContainersView.test.ts | 29 ++
.../pages/ContainersView.vue | 7 +-
.../pages/GlobalSmartLoadingView.test.ts | 98 ++++
.../pages/GlobalSmartLoadingView.vue | 476 ++++++++++++++++++
src/modules/container-loading/routes.ts | 6 +
.../customers/pages/CustomersView.test.ts | 64 ++-
src/modules/customers/pages/CustomersView.vue | 70 ++-
.../PlatformFirstLegOrdersPanel.test.ts | 29 +-
.../PlatformFirstLegOrdersPanel.vue | 74 ++-
src/modules/iam/pages/PermissionsView.test.ts | 131 ++++-
src/modules/iam/pages/PermissionsView.vue | 55 +-
src/modules/iam/routes.ts | 4 +-
.../inbounds/pages/InboundOrdersView.test.ts | 3 +-
.../api/platformLoadingLists.test.ts | 9 +-
.../loading-lists/api/platformLoadingLists.ts | 2 +-
.../components/LoadingListsView.test.ts | 9 +-
.../components/LoadingListsView.vue | 36 +-
src/modules/loading-lists/routes.ts | 2 +-
.../counterparties/pages/PartnersView.test.ts | 11 +
.../pages/OceanBookingsView.test.ts | 2 +-
.../pages/TruckAppointmentsView.test.ts | 27 +
.../trucking/pages/TruckAppointmentsView.vue | 8 +-
.../pages/UnifiedOrdersView.test.ts | 45 +-
.../pages/UnifiedOrdersView.vue | 85 +++-
.../locations/pages/LocationsView.test.ts | 61 ++-
.../locations/pages/LocationsView.vue | 36 +-
.../warehouses/pages/WarehousesView.test.ts | 1 +
src/shared/components/DataTable.test.ts | 26 +
src/shared/components/DataTable.vue | 35 +-
34 files changed, 1352 insertions(+), 159 deletions(-)
create mode 100644 src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
create mode 100644 src/modules/container-loading/pages/GlobalSmartLoadingView.vue
diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts
index ed21b420..41862693 100644
--- a/src/app/router/index.test.ts
+++ b/src/app/router/index.test.ts
@@ -51,6 +51,18 @@ describe('admin router access metadata', () => {
expect(route?.meta.permission).toBe('platform:express:list')
})
+ it('registers the global smart-loading route before the single-container deep link', () => {
+ const route = router.getRoutes().find((item) => item.name === 'global-smart-loading-workbench')
+ const moduleChildren = router.options.routes.find((item) => item.path === '/')?.children || []
+ const globalIndex = moduleChildren.findIndex((item) => item.name === 'global-smart-loading-workbench')
+ const detailIndex = moduleChildren.findIndex((item) => item.name === 'smart-loading-workbench')
+
+ expect(route?.path).toBe('/container-loading/smart-loading')
+ expect(route?.meta.permission).toBe('platform:firstleg:smart-loading:list')
+ expect(globalIndex).toBeGreaterThanOrEqual(0)
+ expect(detailIndex).toBeGreaterThan(globalIndex)
+ })
+
it('protects counterparty records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'counterparties')
expect(route?.path).toBe('/master-data/counterparties')
diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts
index 8abe0d7c..8fa1a96e 100644
--- a/src/core/navigation/adminNavigation.test.ts
+++ b/src/core/navigation/adminNavigation.test.ts
@@ -42,6 +42,7 @@ describe('adminNavigation', () => {
])
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'货柜列表',
+ '智能装柜',
'预装清单',
'装柜清单',
])
@@ -85,6 +86,7 @@ describe('adminNavigation', () => {
expect(paths).toContain('/downloads')
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
+ expect(paths).toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
expect(paths).not.toContain('/prototype/container-management/container-list')
@@ -150,6 +152,7 @@ describe('adminNavigation', () => {
it('shows each formal container workflow entry only with its matching permission', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
+ 'platform:firstleg:smart-loading:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
]))
@@ -157,6 +160,7 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
+ '/container-loading/smart-loading',
'/loading-plans',
'/loading-lists',
])
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index fbe81abf..29b828fd 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -97,6 +97,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: WarehouseIcon,
children: [
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
+ { key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
],
diff --git a/src/modules/auth/pages/LoginView.test.ts b/src/modules/auth/pages/LoginView.test.ts
index 371201bd..67ad5c19 100644
--- a/src/modules/auth/pages/LoginView.test.ts
+++ b/src/modules/auth/pages/LoginView.test.ts
@@ -42,6 +42,18 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip')
})
+ it('uses the admin design tokens and keeps the brand visible at 320px', () => {
+ expect(loginSource).toContain('
柏悦物流管理后台
')
+ expect(loginSource).toContain('class="login-mobile-brand"')
+ expect(loginSource).toContain('aria-label="平台员工登录"')
+ expect(loginSource).toContain('autocomplete="username" maxlength="100" autofocus')
+ expect(loginSource).toContain('aria-live="polite"')
+ expect(loginSource).toContain('background: var(--cc-surface-muted)')
+ expect(loginSource).toContain('border-radius: var(--cc-radius-lg)')
+ expect(loginSource).toContain('@media (max-width: 360px)')
+ expect(loginSource).not.toContain('让跨境物流管理')
+ })
+
it('initializes preview access after login and before redirecting', async () => {
const pinia = createPinia()
setActivePinia(pinia)
diff --git a/src/modules/auth/pages/LoginView.vue b/src/modules/auth/pages/LoginView.vue
index 96610942..7f62784e 100644
--- a/src/modules/auth/pages/LoginView.vue
+++ b/src/modules/auth/pages/LoginView.vue
@@ -54,8 +54,8 @@ async function submit() {
-
让跨境物流管理
清晰、可靠、可追踪
-
统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。
+
柏悦物流管理后台
+
平台内部运营与管理系统
@@ -65,12 +65,13 @@ async function submit() {
+
登录管理后台
使用平台内部员工账号登录
-
+
-
+
@@ -79,8 +80,38 @@ async function submit() {
登录
-
+
+
+
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts
index c7762c51..a5a37e5a 100644
--- a/src/modules/container-loading/pages/ContainersView.test.ts
+++ b/src/modules/container-loading/pages/ContainersView.test.ts
@@ -28,4 +28,33 @@ describe('ContainersView', () => {
expect(wrapper.text()).toContain('宁波 → 长滩')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
+
+ it('uses stable view labels when cabinetNo is missing', async () => {
+ const base = {
+ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 0,
+ billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
+ departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
+ destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
+ terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
+ }
+ vi.mocked(listContainers).mockResolvedValueOnce({
+ records: [
+ { ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' },
+ { ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' },
+ { ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' },
+ ],
+ page: 1, size: 20, total: 3, pages: 1,
+ } as never)
+ const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ const labels = wrapper.findAll('.el-table__body button')
+ .map((button) => button.attributes('aria-label'))
+ .filter((label): label is string => Boolean(label))
+ expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
+ expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
+ })
})
diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue
index a68ac90c..65459ee7 100644
--- a/src/modules/container-loading/pages/ContainersView.vue
+++ b/src/modules/container-loading/pages/ContainersView.vue
@@ -91,6 +91,11 @@ const columns: DataTableColumn[] = [
function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
+function containerViewLabel(row: ContainerBookingResource) {
+ const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
+ .find((value) => typeof value === 'string' && value.trim())
+ return reference ? `查看 ${reference}` : '查看货柜详情'
+}
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
}
@@ -189,7 +194,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
新增货柜
-
+
{{ row.cabinetNo }}{{ row.bookingNo }}
{{ row.containerNo || '待录入' }}
{{ statusText(row.planStatus) }}
diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
new file mode 100644
index 00000000..53a7c9ee
--- /dev/null
+++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
@@ -0,0 +1,98 @@
+import ElementPlus from 'element-plus'
+import { AxiosError } from 'axios'
+import { createPinia, setActivePinia } from 'pinia'
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
+import {
+ getSmartLoadingPlanByBooking,
+ listContainers,
+ listSmartLoadingCandidates,
+ recommendSmartLoading,
+} from '../api/platformContainerLoading'
+
+vi.mock('@/core/access/accessPreview', () => ({
+ useAccessPreviewStore: () => ({ can: () => true }),
+}))
+vi.mock('../api/platformContainerLoading', () => ({
+ listContainers: vi.fn(),
+ getSmartLoadingPlanByBooking: vi.fn(),
+ listSmartLoadingCandidates: vi.fn(),
+ createSmartLoadingPlan: vi.fn(),
+ assignSmartLoadingItem: vi.fn(),
+ recommendSmartLoading: vi.fn(),
+ adjustSmartLoadingItem: vi.fn(),
+ removeSmartLoadingItem: vi.fn(),
+ pushSmartLoadingPreload: vi.fn(),
+}))
+
+const container = {
+ id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
+ warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
+ billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
+ departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
+ destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
+ terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
+}
+const plan = {
+ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
+ items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1 }],
+}
+
+describe('GlobalSmartLoadingView', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
+ vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
+ vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
+ })
+
+ it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
+ const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
+ await flushPromises()
+
+ expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
+ expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
+ expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.warehouseId)
+ expect(wrapper.text()).toContain('候选订单与货物')
+ expect(wrapper.text()).toContain('ORD-2')
+ expect(wrapper.text()).toContain('CB-1 装柜方案')
+ expect(wrapper.text()).toContain('LP-99')
+ expect(wrapper.text()).toContain('ORD-1')
+ })
+
+ it('keeps unsupported prototype filters out of the real workbench', async () => {
+ const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('税率')
+ expect(wrapper.text()).not.toContain('是否退税')
+ expect(wrapper.text()).not.toContain('美森正班')
+ })
+
+ it('refreshes the latest plan after A0409 without losing the selected container', async () => {
+ const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
+ await flushPromises()
+ vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 })
+ vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError(
+ 'conflict',
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ { data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
+ ))
+
+ const button = wrapper.findAll('button').find((item) => item.text().includes('智能推荐'))
+ expect(button).toBeDefined()
+ await button!.trigger('click')
+ await flushPromises()
+
+ expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
+ expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
+ expect(wrapper.text()).toContain('版本 3')
+ expect(wrapper.text()).toContain('已刷新最新版本')
+ expect(wrapper.text()).toContain('request-1')
+ })
+})
diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
new file mode 100644
index 00000000..2311286d
--- /dev/null
+++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
@@ -0,0 +1,476 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 候选货物{{ filteredCandidates.length }}
+ 可见柜次{{ filteredContainers.length }}
+ 当前已装箱数{{ totalPackages }}
+ 当前总体积{{ formatNumber(totalVolume) }} m³
+ 当前总重量{{ formatNumber(totalWeight, 1) }} kg
+
+
+
+
+
+
+
+ 加入
+
+ {{ row.orderNo || '—' }}{{ row.inboundCode || '—' }}
+ {{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³
+ {{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg
+
+ {{ routeMatches(row) ? '匹配' : '不匹配' }}
+ {{ row.destinationWarehouseName || row.destinationWarehouseCode || '未设置' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 创建装柜方案
+
+
+
+
体积{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³
+
重量{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg
+
+
+ 保存移除
+ {{ row.orderNo || '—' }}{{ row.inboundCode || '—' }}
+ {{ row.productNameZh || '未命名货物' }}{{ row.skuCode || '无 SKU' }}
+
+ {{ formatNumber(Number(row.volumeCbm || 0)) }} m³
+ {{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg
+ {{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}
+
+
+
+
+
+
+
+
{{ selectedCandidate.goodsName || '未命名货物' }}
+
{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/container-loading/routes.ts b/src/modules/container-loading/routes.ts
index 5c06b759..43107c92 100644
--- a/src/modules/container-loading/routes.ts
+++ b/src/modules/container-loading/routes.ts
@@ -7,6 +7,12 @@ export const containerLoadingRoutes = [
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
},
+ {
+ path: 'container-loading/smart-loading',
+ name: 'global-smart-loading-workbench',
+ component: () => import('./pages/GlobalSmartLoadingView.vue'),
+ meta: { title: '智能装柜', description: '统一调度候选货物、货柜与装柜方案', permission: 'platform:firstleg:smart-loading:list' },
+ },
{
path: 'container-loading/:containerId/smart-loading',
name: 'smart-loading-workbench',
diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts
index b6960ef8..7f425c99 100644
--- a/src/modules/customers/pages/CustomersView.test.ts
+++ b/src/modules/customers/pages/CustomersView.test.ts
@@ -101,7 +101,7 @@ function tableButtonByText(text: string) {
}
async function clickMoreAction(text: string) {
- document.querySelector('.el-table__body button[aria-label="更多客户操作"]')?.click()
+ document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')?.click()
await flushPromises()
const item = [...document.querySelectorAll('.el-dropdown-menu__item')]
.find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled'))
@@ -455,7 +455,8 @@ describe('CustomersView', () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
- await clickMoreAction('重置密码')
+ tableButtonByText('重置密码')?.click()
+ await flushPromises()
const password = document.querySelector('input[autocomplete="new-password"]')
const confirmation = document.querySelector('input[placeholder="请再次输入新密码"]')
@@ -479,11 +480,10 @@ describe('CustomersView', () => {
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' })
- const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
+ mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
- const dropdowns = wrapper.findAllComponents({ name: 'ElDropdown' })
- expect(dropdowns.length).toBeGreaterThan(0)
- dropdowns.at(-1)!.vm.$emit('command', 'delete')
+ expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull()
+ tableButtonByText('删除')?.click()
await flushPromises()
const confirmation = document.querySelector('input[placeholder="TEN8"]')
@@ -519,9 +519,53 @@ describe('CustomersView', () => {
expect(tableButtonByText('详情')).toBeDefined()
expect(tableButtonByText('编辑')).toBeDefined()
- expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy()
- expect(tableButtonByText('重置密码')).toBeUndefined()
- await clickMoreAction('重置密码')
- expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy()
+ expect(document.querySelector('.el-table__body button[aria-label="更多客户操作:柏悦物流"]')).toBeTruthy()
+ expect(tableButtonByText('重置密码')).toBeDefined()
+ expect(tableButtonByText('跳转登录')).toBeUndefined()
+ await clickMoreAction('跳转登录')
+ expect(document.body.textContent).toContain('代登录原因')
+ })
+
+ it('uses stable unique customer identities for multiple more-action buttons', async () => {
+ const auth = useAuthStore()
+ auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
+ useAccessPreviewStore().initializeAfterLogin()
+ vi.mocked(listPlatformTenants).mockResolvedValue({
+ records: [
+ { ...fullTenant, id: '8', tenantName: '客户甲', tenantCode: 'TEN-A', status: 'ACTIVE' },
+ { ...fullTenant, id: '9', tenantName: '', tenantCode: 'TEN-B', status: 'ACTIVE' },
+ { ...fullTenant, id: '10', tenantName: '', tenantCode: '', status: 'ACTIVE' },
+ ],
+ page: 1, size: 10, total: 3, pages: 1,
+ })
+
+ mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
+ await flushPromises()
+
+ const buttons = [...document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]')]
+ expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
+ '更多客户操作:客户甲',
+ '更多客户操作:TEN-B',
+ '更多客户操作:10',
+ ])
+ expect(buttons.map((button) => button.getAttribute('title'))).toEqual(buttons.map((button) => button.getAttribute('aria-label')))
+ })
+
+ it('falls back to the current customer when all row identifiers are missing', async () => {
+ const auth = useAuthStore()
+ auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
+ useAccessPreviewStore().initializeAfterLogin()
+ vi.mocked(listPlatformTenants).mockResolvedValue({
+ records: [{ ...fullTenant, id: null, tenantName: '', tenantCode: '', status: 'ACTIVE' } as unknown as PlatformTenantResource],
+ page: 1, size: 10, total: 1, pages: 1,
+ })
+
+ mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
+ await flushPromises()
+
+ const button = document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')
+ expect(button?.getAttribute('aria-label')).toBe('更多客户操作:当前客户')
+ expect(button?.getAttribute('title')).toBe('更多客户操作:当前客户')
+ expect(button?.getAttribute('aria-label')).not.toMatch(/undefined|null/)
})
})
diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue
index 08f74588..d0c45fc8 100644
--- a/src/modules/customers/pages/CustomersView.vue
+++ b/src/modules/customers/pages/CustomersView.vue
@@ -1,5 +1,5 @@
@@ -246,8 +277,22 @@ onMounted(loadPermissions)
-
- 添加下级编辑{{ (row as Permission).status === '启用' ? '停用' : '启用' }}删除系统内置 仅查看
+
+ 新增权限
+
+
+
添加下级
+
编辑
+
{{ (row as Permission).status === '启用' ? '停用' : '启用' }}
+
删除
+
command === 'delete' && removePermission(row as Permission)">
+
+
+
+
系统内置
+
+ 仅查看
+
{{ (row as Permission).name }}{{ permissionSummary(row as PermissionTreeNode) }}
{{ (row as Permission).code }}
{{ (row as Permission).method }}{{ (row as Permission).path || '-' }}
@@ -277,16 +322,14 @@ onMounted(loadPermissions)