diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 0b8a161b..edb2b452 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -18,7 +18,7 @@ describe('adminNavigation', () => { { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] }, { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, - { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '出车记录'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, @@ -26,7 +26,7 @@ describe('adminNavigation', () => { { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) expect(visible).toHaveLength(11) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(46) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(47) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 72cdbb8c..486394b5 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -125,6 +125,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' }, + { key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], }, diff --git a/src/modules/dashboard/pages/DashboardView.test.ts b/src/modules/dashboard/pages/DashboardView.test.ts index ad3e702b..a1c14dbf 100644 --- a/src/modules/dashboard/pages/DashboardView.test.ts +++ b/src/modules/dashboard/pages/DashboardView.test.ts @@ -1,7 +1,10 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DashboardView from './DashboardView.vue' -import { getTodayNewOrderCount } from '../api/dashboard' +import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard' + +const access = vi.hoisted(() => ({ permissions: new Set() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) })) vi.mock('../api/dashboard', () => ({ getTodayNewOrderCount: vi.fn().mockResolvedValue(12), @@ -18,7 +21,28 @@ vi.mock('../api/dashboard', () => ({ enableAutoUnmount(afterEach) describe('DashboardView', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + access.permissions = new Set(['platform:order:list', 'platform:customer:list', 'platform:firstleg:container:list', 'platform:firstleg:customs:list']) + }) + + it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => { + access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel']) + const wrapper = mount(DashboardView); await flushPromises() + expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled() + expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0) + expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务') + expect(wrapper.text()).not.toContain('指标加载失败'); expect(wrapper.text()).not.toContain('刷新数据') + }) + + it('requests only granted metric sources and requires both container and customs permissions', async () => { + access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list']) + const wrapper = mount(DashboardView); await flushPromises() + expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled() + expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户']) + await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises() + expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled() + }) it('renders only the four live metrics', async () => { const wrapper = mount(DashboardView) diff --git a/src/modules/dashboard/pages/DashboardView.vue b/src/modules/dashboard/pages/DashboardView.vue index a78d58a6..874f8225 100644 --- a/src/modules/dashboard/pages/DashboardView.vue +++ b/src/modules/dashboard/pages/DashboardView.vue @@ -7,6 +7,7 @@ import { RefreshCw, } from '@lucide/vue' import AdminPage from '@/shared/components/AdminPage.vue' +import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getActiveCustomerCount, getDashboardOperationalMetrics, @@ -22,6 +23,11 @@ const todayNewOrderCount = ref(null) const activeCustomerCount = ref(null) const operationalMetrics = ref(null) const metricsUpdatedAt = ref(null) +const access = useAccessPreviewStore() +const canReadOrders = computed(() => access.can('platform:order:list')) +const canReadCustomers = computed(() => access.can('platform:customer:list')) +const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list')) +const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value) function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) { if (metricsLoading.value) return '…' @@ -30,17 +36,18 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits = } const metrics = computed(() => [ - { label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' }, + { label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value }, { label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU', note: operationalMetrics.value ? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}` : '已开船且尚未到仓', tone: 'green', + visible: canReadOperations.value, }, - { label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' }, - { label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' }, -]) + { label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value }, + { label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', visible: canReadOperations.value }, +].filter(metric => metric.visible)) async function toggleFullscreen() { if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen() @@ -55,17 +62,17 @@ async function loadMetrics() { metricsLoading.value = true metricsError.value = false const [orders, customers, operations] = await Promise.allSettled([ - getTodayNewOrderCount(), - getActiveCustomerCount(), - getDashboardOperationalMetrics(), + canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null), + canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null), + canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null), ]) todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected') - metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf + metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf ? operations.value.asOf - : dayjs().format('YYYY-MM-DDTHH:mm:ss') + : hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null metricsLoading.value = false } @@ -80,10 +87,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
-

{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}

+

{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}

- 数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }} - @@ -95,7 +102,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
-
+