Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f1bf82d6 |
@@ -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
|
||||
```
|
||||
|
||||
+12
-9
@@ -72,20 +72,23 @@ public class DashboardApplicationService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DashboardOverviewView overview(String requestedCurrency) {
|
||||
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) {
|
||||
|
||||
+17
-10
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)[\\/]/,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
更新日志(Preview 50)
|
||||
|
||||
本版本集中优化线上仪表盘的首次打开速度、重复进入速度和生产构建稳定性。
|
||||
|
||||
• 修复生产构建中 ECharts 与 zrender 被错误拆分后可能出现的空白页;图表依赖现在作为完整模块加载,并新增真实发布包启动回归测试。
|
||||
• 为带内容指纹的 `/assets/` 静态资源启用一年浏览器缓存,重复访问不再重新下载大型 TDesign、ECharts 与页面资源;HTML、接口和安装页面状态继续禁止使用该长期缓存策略。
|
||||
• 登录会话恢复与安装状态检查改为并行执行,减少首次进入仪表盘前的串行等待。
|
||||
• 已完成安装的状态在前端短时复用,页面间切换不再每次等待一次安装状态接口;仍需安装的状态不缓存,不影响首次安装向导切换。
|
||||
• 仪表盘离开页面后会取消未完成请求并清理自动刷新、时钟和大屏巡航定时器,重新进入时按数据新鲜度刷新,避免重复请求和后台资源占用。
|
||||
• 仪表盘后端改为一次读取当前身份的权限快照,再按权限决定指标可见性,减少重复权限查询,同时保持原角色、数据范围和超级管理员规则不变。
|
||||
• 补充三档桌面视口的路由缓存验收、静态资源缓存集成测试、仪表盘生产包图表启动测试及后端权限快照测试。
|
||||
|
||||
升级说明:本版本不新增数据库迁移,不安装或修改 MySQL,不改变财务业务数据、审批规则、端口和反向代理配置。更新完成后浏览器首次获取新指纹资源,后续访问将直接复用缓存。
|
||||
Reference in New Issue
Block a user