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
+10 -10
View File
@@ -84,7 +84,7 @@ PostgreSQL 18 兼容工作继续冻结。
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.49/install.sh | sudo env KAIDI_APP_PORT=18080 bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/install.sh | sudo env KAIDI_APP_PORT=18080 bash
```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -97,7 +97,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理:
```bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.49/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
```
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
@@ -111,16 +111,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.49 直链与宝塔手动部署
### Preview.50 直链与宝塔手动部署
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
下载地址:
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.49/kaidi-finance-1.0.0-preview.49.tar.gz`
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/kaidi-finance-1.0.0-preview.50.tar.gz`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.49/SHA256SUMS`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/SHA256SUMS`
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
@@ -137,7 +137,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash
VERSION=1.0.0-preview.49
VERSION=1.0.0-preview.50
APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -246,7 +246,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
```bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.49/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
```
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
@@ -295,8 +295,8 @@ Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-not
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
```bash
git tag v1.0.0-preview.49
git push origin v1.0.0-preview.49
git tag v1.0.0-preview.50
git push origin v1.0.0-preview.50
```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
@@ -348,7 +348,7 @@ cat /var/lib/kaidi-update/status.json
```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.49
./scripts/package-release.sh 1.0.0-preview.50
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release
```
@@ -72,20 +72,23 @@ public class DashboardApplicationService {
@Transactional(readOnly = true)
public DashboardOverviewView overview(String requestedCurrency) {
authorizationService.requirePermission("dashboard:overview:view");
Set<String> permissions = authorizationService.activePermissions();
if (!permissions.contains("dashboard:overview:view")) {
authorizationService.requirePermission("dashboard:overview:view");
}
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String currency = normalizeCurrency(requestedCurrency);
boolean canProjects = authorizationService.hasPermission("project:project:view");
boolean canContracts = authorizationService.hasPermission("masterdata:contract:view");
boolean canReceipts = authorizationService.hasPermission("receivable:receipt:view");
boolean canPayments = authorizationService.hasPermission("payment:request:view");
boolean canInvoices = authorizationService.hasPermission("receivable:invoice:view");
boolean canWorkflow = authorizationService.hasPermission("workflow:task:view");
boolean canRisks = authorizationService.hasPermission("project:risk-flag:view");
boolean canArchives = authorizationService.hasPermission("archive:package:view");
boolean canFiles = authorizationService.hasPermission("archive:file:view");
boolean canProjects = permissions.contains("project:project:view");
boolean canContracts = permissions.contains("masterdata:contract:view");
boolean canReceipts = permissions.contains("receivable:receipt:view");
boolean canPayments = permissions.contains("payment:request:view");
boolean canInvoices = permissions.contains("receivable:invoice:view");
boolean canWorkflow = permissions.contains("workflow:task:view");
boolean canRisks = permissions.contains("project:risk-flag:view");
boolean canArchives = permissions.contains("archive:package:view");
boolean canFiles = permissions.contains("archive:file:view");
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
BigDecimal contractAmount = amount(canContracts,
@@ -10,8 +10,9 @@ import java.security.NoSuchAlgorithmException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -47,6 +48,17 @@ public class AuthorizationService {
}
}
/** Loads the active role's permissions once for request-level capability decisions. */
public Set<String> activePermissions() {
FinancePrincipal principal = identityContext.requirePrincipal();
if (principal.mustChangePassword()) {
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_PASSWORD_CHANGE_REQUIRED,
"首次登录必须先修改密码");
}
String role = identityContext.activeRole();
return role == null ? Set.of() : Set.copyOf(userAccountMapper.findPermissions(principal.userId(), role));
}
public void requireGlobalScope(String permissionCode) {
requireScope(permissionCode, null, null, null);
}
@@ -0,0 +1,43 @@
package com.kaidi.finance.shared.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Adds long-lived browser caching only to fingerprinted frontend assets. */
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class StaticAssetCacheFilter extends OncePerRequestFilter {
static final String IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (isStaticAssetRequest(request)) {
// Set this before the response is committed. Spring Security keeps an existing cache
// policy instead of replacing it with its default no-store response headers.
response.setHeader(HttpHeaders.CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL);
}
filterChain.doFilter(request, response);
}
private boolean isStaticAssetRequest(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
return path.startsWith("/assets/");
}
}
@@ -2,6 +2,7 @@ package com.kaidi.setup;
import com.kaidi.finance.setup.SetupProperties;
import com.kaidi.finance.shared.web.SpaForwardFilter;
import com.kaidi.finance.shared.web.StaticAssetCacheFilter;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -30,7 +31,7 @@ import org.springframework.context.annotation.Import;
}
)
@EnableConfigurationProperties(SetupProperties.class)
@Import(SpaForwardFilter.class)
@Import({SpaForwardFilter.class, StaticAssetCacheFilter.class})
public class SetupApplication {
public static void main(String[] args) {
@@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.kaidi.finance.dashboard.api.DashboardMetricView;
@@ -27,6 +28,7 @@ import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -52,7 +54,18 @@ class DashboardApplicationServiceTest {
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
when(authorization.hasPermission(anyString())).thenReturn(true);
when(authorization.activePermissions()).thenReturn(Set.of(
"dashboard:overview:view",
"project:project:view",
"masterdata:contract:view",
"receivable:receipt:view",
"payment:request:view",
"receivable:invoice:view",
"workflow:task:view",
"project:risk-flag:view",
"archive:package:view",
"archive:file:view"
));
when(workbenchMapper.countPendingTasks(anyLong(), anyString())).thenReturn(3L);
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
@@ -99,19 +112,12 @@ class DashboardApplicationServiceTest {
assertEquals("12345.67", contract.value());
assertTrue(contract.available());
assertFalse(view.geography().available());
verify(authorization).activePermissions();
}
@Test
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
when(authorization.hasPermission("project:project:view")).thenReturn(false);
when(authorization.hasPermission("masterdata:contract:view")).thenReturn(false);
when(authorization.hasPermission("receivable:receipt:view")).thenReturn(false);
when(authorization.hasPermission("payment:request:view")).thenReturn(false);
when(authorization.hasPermission("receivable:invoice:view")).thenReturn(false);
when(authorization.hasPermission("workflow:task:view")).thenReturn(false);
when(authorization.hasPermission("project:risk-flag:view")).thenReturn(false);
when(authorization.hasPermission("archive:package:view")).thenReturn(false);
when(authorization.hasPermission("archive:file:view")).thenReturn(false);
when(authorization.activePermissions()).thenReturn(Set.of("dashboard:overview:view"));
var view = service.overview("CNY");
@@ -127,6 +133,7 @@ class DashboardApplicationServiceTest {
@Test
void requiresTheDedicatedDashboardPermission() {
when(authorization.activePermissions()).thenReturn(Set.of());
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
.when(authorization).requirePermission("dashboard:overview:view");
@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
@@ -57,6 +58,10 @@ class SetupContextSmokeTest {
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
assertThat(asset.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isEqualTo("public, max-age=31536000, immutable");
assertThat(setupPage.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isNotEqualTo("public, max-age=31536000, immutable");
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
@@ -0,0 +1,55 @@
package com.kaidi.finance.shared.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class StaticAssetCacheFilterTest {
private final StaticAssetCacheFilter filter = new StaticAssetCacheFilter();
@Test
void cachesFingerprintAssetsForOneYear() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/assets/index-a1b2c3.js");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(continued).isTrue();
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
}
@Test
void supportsContextPathAndHeadRequests() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("HEAD", "/finance/assets/index-a1b2c3.css");
request.setContextPath("/finance");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
}
@Test
void doesNotCacheHtmlApiOrWriteRequests() throws Exception {
assertNotCached("GET", "/index.html");
assertNotCached("GET", "/api/v1/dashboard/overview");
assertNotCached("POST", "/assets/index-a1b2c3.js");
}
private void assertNotCached(String method, String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(method, uri);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull();
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.49
APP_VERSION=1.0.0-preview.50
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
FINANCE_UPDATE_ENABLED=true
# Use either a stable direct asset base URL or the public Gitea latest-release API.
+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)[\\/]/,
+13
View File
@@ -0,0 +1,13 @@
更新日志(Preview 50)
本版本集中优化线上仪表盘的首次打开速度、重复进入速度和生产构建稳定性。
• 修复生产构建中 ECharts 与 zrender 被错误拆分后可能出现的空白页;图表依赖现在作为完整模块加载,并新增真实发布包启动回归测试。
• 为带内容指纹的 `/assets/` 静态资源启用一年浏览器缓存,重复访问不再重新下载大型 TDesign、ECharts 与页面资源;HTML、接口和安装页面状态继续禁止使用该长期缓存策略。
• 登录会话恢复与安装状态检查改为并行执行,减少首次进入仪表盘前的串行等待。
• 已完成安装的状态在前端短时复用,页面间切换不再每次等待一次安装状态接口;仍需安装的状态不缓存,不影响首次安装向导切换。
• 仪表盘离开页面后会取消未完成请求并清理自动刷新、时钟和大屏巡航定时器,重新进入时按数据新鲜度刷新,避免重复请求和后台资源占用。
• 仪表盘后端改为一次读取当前身份的权限快照,再按权限决定指标可见性,减少重复权限查询,同时保持原角色、数据范围和超级管理员规则不变。
• 补充三档桌面视口的路由缓存验收、静态资源缓存集成测试、仪表盘生产包图表启动测试及后端权限快照测试。
升级说明:本版本不新增数据库迁移,不安装或修改 MySQL,不改变财务业务数据、审批规则、端口和反向代理配置。更新完成后浏览器首次获取新指纹资源,后续访问将直接复用缓存。