This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
{ "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] },
|
||||
{ "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] },
|
||||
{ "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] },
|
||||
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] }
|
||||
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] },
|
||||
{ "pageId": "PAGE-24", "required": ["Card", "Statistic", "Tag", "Select", "Timeline", "Empty", "Alert"] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1861,6 +1861,16 @@
|
||||
"module": "system-update",
|
||||
"export": "installSystemUpdate"
|
||||
}
|
||||
},
|
||||
{
|
||||
"operationId": "getDashboardOverview",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/dashboard/overview",
|
||||
"pages": ["PAGE-24"],
|
||||
"api": {
|
||||
"module": "dashboard",
|
||||
"export": "getDashboardOverview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"infrastructure": [
|
||||
|
||||
@@ -276,6 +276,18 @@
|
||||
"breadcrumb": ["系统治理", "系统更新"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "admin:update:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-24",
|
||||
"path": "/dashboard",
|
||||
"component": "src/pages/overview/DashboardPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "operations-dashboard",
|
||||
"requiresAuth": true,
|
||||
"description": "统一展示经营指标、资金趋势、项目生命周期、风险异常和系统运行状态,并提供大屏巡航模式。",
|
||||
"breadcrumb": ["仪表盘"],
|
||||
"roles": [],
|
||||
"permission": "dashboard:overview:view"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const routes = (
|
||||
).routes;
|
||||
|
||||
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
|
||||
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']);
|
||||
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23', 'PAGE-24']);
|
||||
|
||||
const roleNames: Record<string, string> = {
|
||||
PROJECT_MANAGER: '项目管理人员',
|
||||
@@ -42,6 +42,7 @@ function expectedHeading(route: ContractRoute) {
|
||||
if (route.pageId === 'PAGE-03') return '个人中心';
|
||||
if (route.pageId === 'PAGE-08') return '自动化验收项目';
|
||||
if (route.pageId === 'PAGE-17') return '项目档案包';
|
||||
if (route.pageId === 'PAGE-24') return '系统经营仪表盘';
|
||||
return route.breadcrumb.at(-1) || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const adminSession = {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000001',
|
||||
username: 'dashboard-e2e',
|
||||
displayName: '仪表盘验收管理员',
|
||||
departmentName: '测试组',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
permissions: [
|
||||
'dashboard:overview:view',
|
||||
'project:project:view',
|
||||
'masterdata:contract:view',
|
||||
'receivable:receipt:view',
|
||||
'receivable:invoice:view',
|
||||
'payment:request:view',
|
||||
'workflow:task:view',
|
||||
'project:risk-flag:view',
|
||||
'archive:package:view',
|
||||
'archive:file:view',
|
||||
],
|
||||
};
|
||||
|
||||
const dashboard = {
|
||||
title: '系统经营仪表盘',
|
||||
refreshedAt: '2026-08-19T04:00:00Z',
|
||||
currency: 'CNY',
|
||||
system: {
|
||||
overallStatus: 'UP',
|
||||
currentVersion: '1.0.0-preview.48',
|
||||
uptimeSeconds: 7260,
|
||||
services: [
|
||||
{ code: 'APPLICATION', label: '应用服务', status: 'UP', detail: '服务运行正常' },
|
||||
{ code: 'DATABASE', label: '数据库连接', status: 'UP', detail: '业务数据库连接正常' },
|
||||
{ code: 'FILE_STORAGE', label: '文件存储', status: 'UP', detail: '存储目录可读写' },
|
||||
{ code: 'UPDATE', label: '更新服务', status: 'UP', detail: '在线更新服务已启用' },
|
||||
],
|
||||
},
|
||||
metrics: [
|
||||
{
|
||||
code: 'ACTIVE_PROJECTS',
|
||||
label: '进行中项目',
|
||||
kind: 'COUNT',
|
||||
value: '8',
|
||||
unit: '项',
|
||||
available: true,
|
||||
targetRoute: '/projects',
|
||||
},
|
||||
{
|
||||
code: 'CONTRACT_AMOUNT',
|
||||
label: '合同总额',
|
||||
kind: 'MONEY',
|
||||
value: '1234567.89',
|
||||
unit: 'CNY',
|
||||
available: true,
|
||||
targetRoute: '/finance/contracts-costs',
|
||||
},
|
||||
{
|
||||
code: 'RECEIPT_AMOUNT',
|
||||
label: '累计收款',
|
||||
kind: 'MONEY',
|
||||
value: '456789.00',
|
||||
unit: 'CNY',
|
||||
available: true,
|
||||
targetRoute: '/finance/receipts-invoices',
|
||||
},
|
||||
{
|
||||
code: 'PAYMENT_AMOUNT',
|
||||
label: '累计付款',
|
||||
kind: 'MONEY',
|
||||
value: '234567.00',
|
||||
unit: 'CNY',
|
||||
available: true,
|
||||
targetRoute: '/finance/payments',
|
||||
},
|
||||
{
|
||||
code: 'PENDING_TASKS',
|
||||
label: '待办审批',
|
||||
kind: 'COUNT',
|
||||
value: '4',
|
||||
unit: '项',
|
||||
available: true,
|
||||
targetRoute: '/tasks',
|
||||
},
|
||||
{
|
||||
code: 'ACTIVE_RISKS',
|
||||
label: '风险事项',
|
||||
kind: 'COUNT',
|
||||
value: '2',
|
||||
unit: '项',
|
||||
available: true,
|
||||
targetRoute: '/projects?riskStatus=ACTIVE',
|
||||
},
|
||||
],
|
||||
trend: {
|
||||
currency: 'CNY',
|
||||
receiptsAvailable: true,
|
||||
paymentsAvailable: true,
|
||||
invoicesAvailable: true,
|
||||
points: [
|
||||
{ period: '2026-03', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' },
|
||||
{ period: '2026-04', receivedAmount: '200', paidAmount: '120', invoicedAmount: '180' },
|
||||
{ period: '2026-05', receivedAmount: '320', paidAmount: '180', invoicedAmount: '260' },
|
||||
{ period: '2026-06', receivedAmount: '410', paidAmount: '220', invoicedAmount: '330' },
|
||||
{ period: '2026-07', receivedAmount: '500', paidAmount: '280', invoicedAmount: '420' },
|
||||
{ period: '2026-08', receivedAmount: '620', paidAmount: '360', invoicedAmount: '510' },
|
||||
],
|
||||
},
|
||||
lifecycle: [
|
||||
{ code: 'INITIATION', label: '立项', value: 2, available: true, targetRoute: '/projects?stage=INITIATION' },
|
||||
{ code: 'EXECUTION', label: '执行中', value: 4, available: true, targetRoute: '/projects?stage=EXECUTION' },
|
||||
{ code: 'SETTLEMENT', label: '结算', value: 2, available: true, targetRoute: '/projects?stage=SETTLEMENT' },
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
code: 'PROJECT_RISK',
|
||||
label: '项目风险',
|
||||
severity: 'DANGER',
|
||||
count: 2,
|
||||
detail: '当前权限范围内的有效风险标记',
|
||||
available: true,
|
||||
targetRoute: '/projects?riskStatus=ACTIVE',
|
||||
},
|
||||
],
|
||||
geography: { available: false, message: '项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。', regions: [] },
|
||||
activities: [],
|
||||
};
|
||||
|
||||
async function installFixture(page: Page) {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
await route.fulfill({ json: { data: { required: false, locked: true } } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: adminSession } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: adminSession.user } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/dashboard/overview') {
|
||||
await route.fulfill({ json: { data: dashboard } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
|
||||
});
|
||||
}
|
||||
|
||||
test('hybrid dashboard renders the cockpit, safe fallback and big-screen mode', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('进行中项目')).toBeVisible();
|
||||
await expect(page.getByText('项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。')).toBeVisible();
|
||||
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '进入大屏' }).click();
|
||||
await expect(page.getByText('当前章节:经营总览')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '退出大屏' })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
||||
});
|
||||
@@ -384,7 +384,7 @@ test('brand logo returns the active identity to its workbench', async ({ page })
|
||||
|
||||
await page.locator('.brand-logo').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/governance\/settings$/);
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import type { WorkbenchActivity } from './workbench';
|
||||
|
||||
export type DashboardStatus = 'UP' | 'WARNING' | 'DOWN' | 'DISABLED';
|
||||
|
||||
export interface DashboardServiceStatus {
|
||||
code: string;
|
||||
label: string;
|
||||
status: DashboardStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DashboardSystem {
|
||||
overallStatus: DashboardStatus;
|
||||
currentVersion: string;
|
||||
uptimeSeconds: number;
|
||||
services: DashboardServiceStatus[];
|
||||
}
|
||||
|
||||
export interface DashboardMetric {
|
||||
code: string;
|
||||
label: string;
|
||||
kind: 'COUNT' | 'MONEY';
|
||||
value: string;
|
||||
unit: string;
|
||||
available: boolean;
|
||||
targetRoute?: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrendPoint {
|
||||
period: string;
|
||||
receivedAmount: string;
|
||||
paidAmount: string;
|
||||
invoicedAmount: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrend {
|
||||
currency: string;
|
||||
receiptsAvailable: boolean;
|
||||
paymentsAvailable: boolean;
|
||||
invoicesAvailable: boolean;
|
||||
points: DashboardTrendPoint[];
|
||||
}
|
||||
|
||||
export interface DashboardDistribution {
|
||||
code: string;
|
||||
label: string;
|
||||
value: number;
|
||||
available: boolean;
|
||||
targetRoute?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRisk {
|
||||
code: string;
|
||||
label: string;
|
||||
severity: 'DANGER' | 'WARNING' | 'INFO';
|
||||
count: number;
|
||||
detail: string;
|
||||
available: boolean;
|
||||
targetRoute?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRegion {
|
||||
regionCode: string;
|
||||
regionName: string;
|
||||
projectCount: number;
|
||||
amount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface DashboardGeography {
|
||||
available: boolean;
|
||||
message: string;
|
||||
regions: DashboardRegion[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
title: string;
|
||||
refreshedAt: string;
|
||||
currency: string;
|
||||
system: DashboardSystem;
|
||||
metrics: DashboardMetric[];
|
||||
trend: DashboardTrend;
|
||||
lifecycle: DashboardDistribution[];
|
||||
risks: DashboardRisk[];
|
||||
geography: DashboardGeography;
|
||||
activities: WorkbenchActivity[];
|
||||
}
|
||||
|
||||
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
|
||||
|
||||
export function normalizeDashboard(view: Partial<DashboardOverview> | null | undefined): DashboardOverview {
|
||||
return {
|
||||
title: String(view?.title || '系统经营仪表盘'),
|
||||
refreshedAt: String(view?.refreshedAt || ''),
|
||||
currency: String(view?.currency || 'CNY'),
|
||||
system: {
|
||||
overallStatus: view?.system?.overallStatus || 'WARNING',
|
||||
currentVersion: String(view?.system?.currentVersion || '-'),
|
||||
uptimeSeconds: Number(view?.system?.uptimeSeconds || 0),
|
||||
services: array(view?.system?.services),
|
||||
},
|
||||
metrics: array(view?.metrics),
|
||||
trend: {
|
||||
currency: String(view?.trend?.currency || view?.currency || 'CNY'),
|
||||
receiptsAvailable: Boolean(view?.trend?.receiptsAvailable),
|
||||
paymentsAvailable: Boolean(view?.trend?.paymentsAvailable),
|
||||
invoicesAvailable: Boolean(view?.trend?.invoicesAvailable),
|
||||
points: array(view?.trend?.points),
|
||||
},
|
||||
lifecycle: array(view?.lifecycle),
|
||||
risks: array(view?.risks),
|
||||
geography: {
|
||||
available: Boolean(view?.geography?.available),
|
||||
message: String(view?.geography?.message || '暂无地域数据'),
|
||||
regions: array(view?.geography?.regions),
|
||||
},
|
||||
activities: array(view?.activities),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDashboardOverview(currency = 'CNY') {
|
||||
return request.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency } }).then(normalizeDashboard);
|
||||
}
|
||||
@@ -94,6 +94,6 @@ const getPath = (item: ListItemType) => {
|
||||
return active.value;
|
||||
}
|
||||
|
||||
return item.meta?.single ? item.redirect : item.path;
|
||||
return item.meta?.single ? item.redirect || item.path : item.path;
|
||||
};
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="dashboard-chart"
|
||||
:style="{ height }"
|
||||
:aria-label="label"
|
||||
role="img"
|
||||
data-testid="dashboard-chart"
|
||||
></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import { AriaComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
option: EChartsCoreOption;
|
||||
height?: string;
|
||||
label: string;
|
||||
dark?: boolean;
|
||||
}>(),
|
||||
{ height: '300px', dark: false },
|
||||
);
|
||||
|
||||
echarts.use([
|
||||
AriaComponent,
|
||||
BarChart,
|
||||
CanvasRenderer,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
LineChart,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
]);
|
||||
|
||||
const container = ref<HTMLDivElement>();
|
||||
let chart: echarts.ECharts | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
let chartDark: boolean | null = null;
|
||||
|
||||
const render = async () => {
|
||||
await nextTick();
|
||||
if (!container.value) return;
|
||||
if (!chart) {
|
||||
chart = echarts.init(container.value, props.dark ? 'dark' : undefined, { renderer: 'canvas' });
|
||||
chartDark = props.dark;
|
||||
}
|
||||
chart.setOption(props.option, { notMerge: true, lazyUpdate: false });
|
||||
chart.resize();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver(() => chart?.resize());
|
||||
if (container.value) observer.observe(container.value);
|
||||
void render();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.option, props.dark] as const,
|
||||
() => {
|
||||
if (chart && chartDark !== props.dark) {
|
||||
chart.dispose();
|
||||
chart = null;
|
||||
chartDark = null;
|
||||
}
|
||||
void render();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
chartDark = null;
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.dashboard-chart {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -21,14 +21,7 @@ defineOptions({
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const workbenchRoute =
|
||||
userStore.currentRole === 'PROJECT_MANAGER'
|
||||
? '/workbench/project'
|
||||
: userStore.currentRole === 'FINANCE_MANAGER'
|
||||
? '/workbench/finance'
|
||||
: userStore.currentRole === 'ARCHIVE_MANAGER'
|
||||
? '/workbench/archive'
|
||||
: '/governance/settings';
|
||||
const workbenchRoute = userStore.workbenchRoute;
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.result-success {
|
||||
|
||||
@@ -23,14 +23,7 @@ defineOptions({
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const workbenchRoute =
|
||||
userStore.currentRole === 'PROJECT_MANAGER'
|
||||
? '/workbench/project'
|
||||
: userStore.currentRole === 'FINANCE_MANAGER'
|
||||
? '/workbench/finance'
|
||||
: userStore.currentRole === 'ARCHIVE_MANAGER'
|
||||
? '/workbench/archive'
|
||||
: '/governance/settings';
|
||||
const workbenchRoute = userStore.workbenchRoute;
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.result-success {
|
||||
|
||||
@@ -22,6 +22,7 @@ const reportsPage = () => import('@/pages/reports/ReportsPage.vue');
|
||||
const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue');
|
||||
const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue');
|
||||
const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue');
|
||||
const dashboardPage = () => import('@/pages/overview/DashboardPage.vue');
|
||||
const workbench = (name: 'project' | 'finance' | 'archive') => {
|
||||
if (name === 'project') return () => import('@/pages/workbench/project.vue');
|
||||
if (name === 'finance') return () => import('@/pages/workbench/finance.vue');
|
||||
@@ -77,6 +78,28 @@ const businessContract = (
|
||||
};
|
||||
|
||||
export const financeRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: LAYOUT,
|
||||
meta: {
|
||||
title: { zh_CN: '仪表盘', en_US: 'Dashboard' },
|
||||
titleText: '仪表盘',
|
||||
icon: shallowRef(ChartBubbleIcon),
|
||||
orderNo: 1,
|
||||
single: true,
|
||||
},
|
||||
children: [
|
||||
businessContract(
|
||||
'PAGE-24',
|
||||
'',
|
||||
'DashboardOverview',
|
||||
'仪表盘',
|
||||
{ dataKind: 'dashboard', breadcrumb: ['仪表盘'] },
|
||||
dashboardPage,
|
||||
),
|
||||
],
|
||||
},
|
||||
group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [
|
||||
{
|
||||
path: 'project',
|
||||
|
||||
@@ -29,7 +29,10 @@ export const useUserStore = defineStore('user', {
|
||||
getters: {
|
||||
displayName: (state) => state.user.displayName || state.user.username,
|
||||
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
|
||||
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
|
||||
workbenchRoute: (state) =>
|
||||
hasPermissionAccess(state.currentRole, state.permissions, 'dashboard:overview:view')
|
||||
? '/dashboard'
|
||||
: resolveWorkbenchRoute(state.currentRole, state.roles),
|
||||
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
|
||||
hasPermission: (state) => (permission: string) =>
|
||||
hasPermissionAccess(state.currentRole, state.permissions, permission),
|
||||
|
||||
@@ -4,7 +4,7 @@ const fallbackWorkbenchRoutes: Record<string, string> = {
|
||||
PROJECT_MANAGER: '/workbench/project',
|
||||
FINANCE_MANAGER: '/workbench/finance',
|
||||
ARCHIVE_MANAGER: '/workbench/archive',
|
||||
SYSTEM_ADMIN: '/governance/settings',
|
||||
SYSTEM_ADMIN: '/dashboard',
|
||||
};
|
||||
|
||||
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
|
||||
|
||||
@@ -171,12 +171,12 @@ describe('frontend machine contracts', () => {
|
||||
expect(paymentApi).toContain("responseType: 'blob'");
|
||||
});
|
||||
|
||||
it('declares every PAGE-01 through PAGE-23 exactly once', () => {
|
||||
const expected = Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
||||
it('declares every PAGE-01 through PAGE-24 exactly once', () => {
|
||||
const expected = Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
||||
const routePageIds = routesContract.routes.map((route) => route.pageId);
|
||||
const componentPageIds = componentsContract.pages.map((page) => page.pageId);
|
||||
expect([...routePageIds].sort()).toEqual([...expected].sort());
|
||||
expect(new Set(routePageIds).size).toBe(23);
|
||||
expect(new Set(routePageIds).size).toBe(24);
|
||||
expect([...componentPageIds].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeDashboard } from '../src/api/dashboard';
|
||||
|
||||
describe('pAGE-24 dashboard contracts', () => {
|
||||
const pageSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/DashboardPage.vue'), 'utf8');
|
||||
const chartSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/components/DashboardChart.vue'), 'utf8');
|
||||
|
||||
it('normalizes incomplete server data without creating fake business values', () => {
|
||||
const view = normalizeDashboard({
|
||||
system: { services: null } as any,
|
||||
metrics: null,
|
||||
trend: { points: null } as any,
|
||||
lifecycle: undefined,
|
||||
risks: null,
|
||||
geography: { available: false, regions: null } as any,
|
||||
activities: undefined,
|
||||
} as unknown as Partial<ReturnType<typeof normalizeDashboard>>);
|
||||
|
||||
expect(view.metrics).toEqual([]);
|
||||
expect(view.trend.points).toEqual([]);
|
||||
expect(view.lifecycle).toEqual([]);
|
||||
expect(view.risks).toEqual([]);
|
||||
expect(view.activities).toEqual([]);
|
||||
expect(view.geography.available).toBe(false);
|
||||
expect(view.geography.regions).toEqual([]);
|
||||
});
|
||||
|
||||
it('defines the hybrid cockpit and explicit permission-safe geography fallback', () => {
|
||||
expect(pageSource).toContain('data-scene');
|
||||
expect(pageSource).toContain('进入大屏');
|
||||
expect(pageSource).toContain('退出大屏');
|
||||
expect(pageSource).toContain('dashboard.geography.available');
|
||||
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
|
||||
expect(pageSource).toContain('当前身份没有查看仪表盘或相关业务数据的权限');
|
||||
expect(pageSource).toContain('setInterval(() => void loadDashboard(true), 60000)');
|
||||
expect(pageSource).not.toMatch(/<button\b|<input\b|<select\b/);
|
||||
});
|
||||
|
||||
it('uses modular ECharts with resize and disposal lifecycle', () => {
|
||||
expect(chartSource).toContain("from 'echarts/core'");
|
||||
expect(chartSource).toContain('ResizeObserver');
|
||||
expect(chartSource).toContain('chart.dispose()');
|
||||
expect(chartSource).toContain('aria-label="label"');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { canAccess, filterMenu, hasRoleAccess } from '../src/store/modules/menu-
|
||||
describe('router and TDesign menu contracts', () => {
|
||||
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
|
||||
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
|
||||
Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
||||
Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
||||
);
|
||||
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
|
||||
@@ -32,12 +32,31 @@ describe('router and TDesign menu contracts', () => {
|
||||
}
|
||||
};
|
||||
collectPaths(allRoutes);
|
||||
expect(flattenedPaths).toContain('/dashboard');
|
||||
expect(flattenedPaths).toContain('/projects/');
|
||||
expect(flattenedPaths).not.toContain('/dashboard/base');
|
||||
expect(flattenedPaths).not.toContain('/detail/base');
|
||||
expect(flattenedPaths).not.toContain('/list/base');
|
||||
});
|
||||
|
||||
it('places the dashboard first and hides it without its dedicated permission', () => {
|
||||
const dashboard = allRoutes.find((route) => route.path === '/dashboard');
|
||||
expect(dashboard?.meta?.orderNo).toBe(1);
|
||||
expect(dashboard?.children?.[0]?.meta?.permission).toBe('dashboard:overview:view');
|
||||
|
||||
const withoutDashboard = filterMenu(allRoutes, {
|
||||
currentRole: 'FINANCE_MANAGER',
|
||||
permissions: ['workflow:task:view'],
|
||||
});
|
||||
expect(withoutDashboard.some((route) => route.path === '/dashboard')).toBe(false);
|
||||
|
||||
const withDashboard = filterMenu(allRoutes, {
|
||||
currentRole: 'CUSTOM_ROLE',
|
||||
permissions: ['dashboard:overview:view'],
|
||||
});
|
||||
expect(withDashboard[0]?.path).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('filters menu leaves with the same role and permission rules used by route access', () => {
|
||||
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
|
||||
const financeIdentity = {
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { RoleView } from '../src/api/auth';
|
||||
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
|
||||
|
||||
const roles: RoleView[] = [
|
||||
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
|
||||
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/dashboard' },
|
||||
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
|
||||
];
|
||||
|
||||
describe('active identity workbench route', () => {
|
||||
it('uses the route supplied for the active role', () => {
|
||||
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
|
||||
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/dashboard');
|
||||
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user