diff --git a/README.md b/README.md index 1a46c36..c5e2b2e 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java b/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java index c5d9bcb..d374259 100644 --- a/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java +++ b/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java @@ -72,20 +72,23 @@ public class DashboardApplicationService { @Transactional(readOnly = true) public DashboardOverviewView overview(String requestedCurrency) { - authorizationService.requirePermission("dashboard:overview:view"); + Set 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, diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java b/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java index a34757a..68b2cab 100644 --- a/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java +++ b/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java @@ -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 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); } diff --git a/backend/src/main/java/com/kaidi/finance/shared/web/StaticAssetCacheFilter.java b/backend/src/main/java/com/kaidi/finance/shared/web/StaticAssetCacheFilter.java new file mode 100644 index 0000000..56bbc37 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/web/StaticAssetCacheFilter.java @@ -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/"); + } +} diff --git a/backend/src/main/java/com/kaidi/setup/SetupApplication.java b/backend/src/main/java/com/kaidi/setup/SetupApplication.java index 57de276..0748546 100644 --- a/backend/src/main/java/com/kaidi/setup/SetupApplication.java +++ b/backend/src/main/java/com/kaidi/setup/SetupApplication.java @@ -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) { diff --git a/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java b/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java index a353e0d..bdac1b5 100644 --- a/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java +++ b/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java @@ -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"); diff --git a/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java b/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java index 0f33d25..819a8cd 100644 --- a/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java @@ -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 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 business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class); assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); diff --git a/backend/src/test/java/com/kaidi/finance/shared/web/StaticAssetCacheFilterTest.java b/backend/src/test/java/com/kaidi/finance/shared/web/StaticAssetCacheFilterTest.java new file mode 100644 index 0000000..6f1e308 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/web/StaticAssetCacheFilterTest.java @@ -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(); + } +} diff --git a/deploy/env.production.example b/deploy/env.production.example index 8054820..9c6fef6 100644 --- a/deploy/env.production.example +++ b/deploy/env.production.example @@ -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. diff --git a/frontend/e2e/dashboard.e2e.ts b/frontend/e2e/dashboard.e2e.ts index d276bb1..d375bef 100644 --- a/frontend/e2e/dashboard.e2e.ts +++ b/frontend/e2e/dashboard.e2e.ts @@ -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); +}); diff --git a/frontend/e2e/dist-smoke.e2e.ts b/frontend/e2e/dist-smoke.e2e.ts index 60b325f..e7d7714 100644 --- a/frontend/e2e/dist-smoke.e2e.ts +++ b/frontend/e2e/dist-smoke.e2e.ts @@ -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([]); +}); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index b235c1c..c35d0cf 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -120,6 +120,8 @@ export function normalizeDashboard(view: Partial | null | und }; } -export function getDashboardOverview(currency = 'CNY') { - return request.get({ url: '/dashboard/overview', params: { currency } }).then(normalizeDashboard); +export function getDashboardOverview(currency = 'CNY', signal?: AbortSignal) { + return request + .get({ url: '/dashboard/overview', params: { currency }, signal }) + .then(normalizeDashboard); } diff --git a/frontend/src/pages/overview/DashboardPage.vue b/frontend/src/pages/overview/DashboardPage.vue index d1b52ca..c1dbd8f 100644 --- a/frontend/src/pages/overview/DashboardPage.vue +++ b/frontend/src/pages/overview/DashboardPage.vue @@ -285,7 +285,7 @@