perf: optimize dashboard loading and production assets
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 15:34:11 +08:00
parent 12d78b4c86
commit 83f1bf82d6
17 changed files with 355 additions and 48 deletions
+15
View File
@@ -132,10 +132,12 @@ const dashboard = {
};
async function installFixture(page: Page) {
const requests = { setupStatus: 0 };
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') {
requests.setupStatus += 1;
await route.fulfill({ json: { data: { required: false, locked: true } } });
return;
}
@@ -153,6 +155,7 @@ async function installFixture(page: Page) {
}
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
});
return requests;
}
test('hybrid dashboard renders the cockpit, safe fallback and big-screen mode', async ({ page }) => {
@@ -173,3 +176,15 @@ test('hybrid dashboard renders the cockpit, safe fallback and big-screen mode',
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
});
test('installed setup status is reused across authenticated route navigation', async ({ page }) => {
const requests = await installFixture(page);
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
await page.getByText('系统治理', { exact: true }).click();
await page.getByText('权限与配置', { exact: true }).click();
await expect(page).toHaveURL(/\/governance\/settings$/);
expect(requests.setupStatus).toBe(1);
});
+87
View File
@@ -27,3 +27,90 @@ test('release build boots the setup wizard without runtime errors', async ({ pag
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
test('release build boots the dashboard and initializes charts without runtime errors', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.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: {
authenticated: true,
user: {
publicId: '01M00000000000000000000001',
username: 'dist-dashboard',
displayName: '构建验收管理员',
mustChangePassword: false,
},
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
activeRole: 'SYSTEM_ADMIN',
permissions: ['dashboard:overview:view'],
},
},
});
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({
json: {
data: {
publicId: '01M00000000000000000000001',
username: 'dist-dashboard',
displayName: '构建验收管理员',
mustChangePassword: false,
},
},
});
return;
}
if (pathname === '/api/v1/dashboard/overview') {
await route.fulfill({
json: {
data: {
title: '系统经营仪表盘',
refreshedAt: '2026-08-19T04:00:00Z',
currency: 'CNY',
system: {
overallStatus: 'UP',
currentVersion: '1.0.0-preview.49',
uptimeSeconds: 60,
services: [],
},
metrics: [],
trend: {
currency: 'CNY',
receiptsAvailable: true,
paymentsAvailable: true,
invoicesAvailable: true,
points: [{ period: '2026-08', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' }],
},
lifecycle: [],
risks: [],
geography: { available: false, message: '暂无地域数据', regions: [] },
activities: [],
},
},
});
return;
}
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
});
const response = await page.goto('/dashboard');
expect(response?.ok()).toBe(true);
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
+4 -2
View File
@@ -120,6 +120,8 @@ export function normalizeDashboard(view: Partial<DashboardOverview> | null | und
};
}
export function getDashboardOverview(currency = 'CNY') {
return request.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency } }).then(normalizeDashboard);
export function getDashboardOverview(currency = 'CNY', signal?: AbortSignal) {
return request
.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency }, signal })
.then(normalizeDashboard);
}
+37 -8
View File
@@ -285,7 +285,7 @@
<script setup lang="ts">
import type { EChartsCoreOption } from 'echarts/core';
import { RefreshIcon } from 'tdesign-icons-vue-next';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { DashboardMetric, DashboardOverview, DashboardRisk, DashboardStatus } from '@/api/dashboard';
@@ -310,6 +310,9 @@ let requestSequence = 0;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
let clockTimer: ReturnType<typeof setInterval> | undefined;
let tourTimer: ReturnType<typeof setInterval> | undefined;
let dashboardAbortController: AbortController | undefined;
let dashboardActive = false;
let lastLoadedAt = 0;
const currencyOptions = [
{ label: '人民币 CNY', value: 'CNY' },
@@ -420,17 +423,23 @@ const lifecycleOption = computed<EChartsCoreOption>(() => ({
async function loadDashboard(silent = false) {
const sequence = ++requestSequence;
dashboardAbortController?.abort();
const controller = new AbortController();
dashboardAbortController = controller;
if (silent && dashboard.value) refreshing.value = true;
else loading.value = true;
errorText.value = '';
try {
const response = await getDashboardOverview(currency.value);
const response = await getDashboardOverview(currency.value, controller.signal);
if (sequence !== requestSequence) return;
dashboard.value = response;
lastLoadedAt = Date.now();
} catch (error) {
if (controller.signal.aborted) return;
if (sequence !== requestSequence) return;
errorText.value = friendlyError(error);
} finally {
if (dashboardAbortController === controller) dashboardAbortController = undefined;
if (sequence === requestSequence) {
loading.value = false;
refreshing.value = false;
@@ -525,32 +534,52 @@ function riskTheme(severity: DashboardRisk['severity']) {
}
function friendlyError(error: unknown) {
const status = (error as { response?: { status?: number } }).response?.status;
const requestError = error as { code?: string; status?: number; response?: { status?: number } };
const status = requestError.status ?? requestError.response?.status;
if (status === 401) return '登录会话已失效,请重新登录。';
if (status === 403) return '当前身份没有查看仪表盘或相关业务数据的权限。';
if (status === 422) return '仪表盘筛选条件不正确,请重新选择。';
if (status && status >= 500) return '仪表盘服务暂时不可用,请稍后重试。';
if (requestError.code === 'ECONNABORTED') return '仪表盘加载超时,请稍后重试。';
return '网络请求失败,请检查网络后重试。';
}
onMounted(() => {
void loadDashboard(false);
function activateDashboard() {
if (dashboardActive) return;
dashboardActive = true;
if (!dashboard.value) void loadDashboard(false);
else if (Date.now() - lastLoadedAt >= 60000) void loadDashboard(true);
wallClock.value = new Date().toLocaleString('zh-CN', { hour12: false });
clockTimer = setInterval(() => {
wallClock.value = new Date().toLocaleString('zh-CN', { hour12: false });
}, 1000);
refreshTimer = setInterval(() => void loadDashboard(true), 60000);
window.addEventListener('keydown', handleEscape);
});
}
onBeforeUnmount(() => {
function deactivateDashboard() {
if (!dashboardActive) return;
dashboardActive = false;
requestSequence += 1;
dashboardAbortController?.abort();
dashboardAbortController = undefined;
loading.value = false;
refreshing.value = false;
if (refreshTimer) clearInterval(refreshTimer);
if (clockTimer) clearInterval(clockTimer);
if (tourTimer) clearInterval(tourTimer);
refreshTimer = undefined;
clockTimer = undefined;
tourTimer = undefined;
window.removeEventListener('keydown', handleEscape);
wallMode.value = false;
document.body.classList.remove('dashboard-wall-open');
});
}
onMounted(activateDashboard);
onActivated(activateDashboard);
onDeactivated(deactivateDashboard);
onBeforeUnmount(deactivateDashboard);
</script>
<style lang="less" scoped>
:global(body.dashboard-wall-open) {
+27 -5
View File
@@ -14,17 +14,28 @@ NProgress.configure({ showSpinner: false });
const isPublic = (to: RouteLocationNormalized) =>
to.path === '/login' || to.path === '/result/404' || to.path === '/setup';
const INSTALLED_SETUP_CACHE_MS = 5 * 60 * 1000;
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
let installedSetupStatus: Awaited<ReturnType<typeof getSetupStatus>> | null = null;
let installedSetupStatusExpiresAt = 0;
function loadSetupStatus() {
if (installedSetupStatus && Date.now() < installedSetupStatusExpiresAt) {
return Promise.resolve(installedSetupStatus);
}
if (setupStatusRequest) return setupStatusRequest;
// Share only an in-flight request. Keeping the resolved setup state would
// send the user back to /setup after the installation marker is written.
// A required setup is intentionally never cached because completion changes
// it to locked during the same browser session. The locked state is stable,
// so a short cache avoids blocking every authenticated route on this probe.
const request = getSetupStatus();
setupStatusRequest = request;
request.then(
() => {
(status) => {
if (!status.required) {
installedSetupStatus = status;
installedSetupStatusExpiresAt = Date.now() + INSTALLED_SETUP_CACHE_MS;
}
if (setupStatusRequest === request) setupStatusRequest = null;
},
() => {
@@ -38,6 +49,14 @@ router.beforeEach(async (to) => {
NProgress.start();
const userStore = useUserStore();
const permissionStore = getPermissionStore();
const publicRoute = isPublic(to);
const sessionRequest =
!publicRoute && !userStore.authenticated
? userStore.restoreSession().then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
)
: null;
let setupRequired = false;
let setupStatusAvailable = false;
@@ -55,12 +74,15 @@ router.beforeEach(async (to) => {
if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup';
if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login';
if (isPublic(to)) {
if (publicRoute) {
return true;
}
try {
if (!userStore.authenticated) await userStore.restoreSession();
if (sessionRequest) {
const sessionResult = await sessionRequest;
if (!sessionResult.ok) throw sessionResult.error;
}
permissionStore.initRoutes();
} catch {
userStore.clearSession();
+3
View File
@@ -36,6 +36,9 @@ describe('pAGE-24 dashboard contracts', () => {
expect(pageSource).toContain('dashboard.geography.available');
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
expect(pageSource).toContain('当前身份没有查看仪表盘或相关业务数据的权限');
expect(pageSource).toContain('onActivated(activateDashboard)');
expect(pageSource).toContain('onDeactivated(deactivateDashboard)');
expect(pageSource).toContain('dashboardAbortController?.abort()');
expect(pageSource).toContain('setInterval(() => void loadDashboard(true), 60000)');
expect(pageSource).not.toMatch(/<button\b|<input\b|<select\b/);
});
+10
View File
@@ -56,6 +56,16 @@ export default ({ mode }: ConfigEnv): UserConfig => {
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
priority: 30,
},
{
// ECharts has internal class inheritance across modules. Keeping
// ECharts and zrender in one chunk avoids Rolldown splitting a
// base class and its derived series into independently evaluated
// chunks, which can fail in a production browser with
// "Class extends value undefined".
name: 'echarts-vendor',
test: /node_modules[\\/](?:echarts|zrender)[\\/]/,
priority: 25,
},
{
name: 'vue-vendor',
test: /node_modules[\\/](?:vue|vue-router|pinia|vue-i18n|@vueuse)[\\/]/,