This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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)[\\/]/,
|
||||
|
||||
Reference in New Issue
Block a user