Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f1bf82d6 |
@@ -84,7 +84,7 @@ PostgreSQL 18 兼容工作继续冻结。
|
|||||||
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
|
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
|
||||||
|
|
||||||
```bash
|
```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(包括
|
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
|
||||||
@@ -97,7 +97,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
|
|||||||
随后执行一键清理:
|
随后执行一键清理:
|
||||||
|
|
||||||
```bash
|
```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` 参数丢失。
|
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
|
||||||
@@ -111,16 +111,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删
|
|||||||
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
|
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
|
||||||
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
|
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
|
||||||
|
|
||||||
### Preview.49 直链与宝塔手动部署
|
### Preview.50 直链与宝塔手动部署
|
||||||
|
|
||||||
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||||
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
|
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 一键安装**还需要
|
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
|
||||||
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
|
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
|
||||||
@@ -137,7 +137,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
|
|||||||
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
|
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VERSION=1.0.0-preview.49
|
VERSION=1.0.0-preview.50
|
||||||
APP_ROOT=/www/wwwroot/kaidi
|
APP_ROOT=/www/wwwroot/kaidi
|
||||||
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
|
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
|
||||||
sudo install -d -m 0755 "$RELEASE_ROOT"
|
sudo install -d -m 0755 "$RELEASE_ROOT"
|
||||||
@@ -246,7 +246,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线
|
|||||||
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
|
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
|
||||||
|
|
||||||
```bash
|
```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 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
||||||
@@ -295,8 +295,8 @@ Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-not
|
|||||||
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
|
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.0-preview.49
|
git tag v1.0.0-preview.50
|
||||||
git push origin v1.0.0-preview.49
|
git push origin v1.0.0-preview.50
|
||||||
```
|
```
|
||||||
|
|
||||||
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
|
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
|
||||||
@@ -348,7 +348,7 @@ cat /var/lib/kaidi-update/status.json
|
|||||||
```bash
|
```bash
|
||||||
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
||||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
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 \
|
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||||
./scripts/verify-release.sh dist/release
|
./scripts/verify-release.sh dist/release
|
||||||
```
|
```
|
||||||
|
|||||||
+13
-10
@@ -72,20 +72,23 @@ public class DashboardApplicationService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public DashboardOverviewView overview(String requestedCurrency) {
|
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();
|
FinancePrincipal actor = identityContext.requirePrincipal();
|
||||||
String role = identityContext.requireActiveRole();
|
String role = identityContext.requireActiveRole();
|
||||||
String currency = normalizeCurrency(requestedCurrency);
|
String currency = normalizeCurrency(requestedCurrency);
|
||||||
|
|
||||||
boolean canProjects = authorizationService.hasPermission("project:project:view");
|
boolean canProjects = permissions.contains("project:project:view");
|
||||||
boolean canContracts = authorizationService.hasPermission("masterdata:contract:view");
|
boolean canContracts = permissions.contains("masterdata:contract:view");
|
||||||
boolean canReceipts = authorizationService.hasPermission("receivable:receipt:view");
|
boolean canReceipts = permissions.contains("receivable:receipt:view");
|
||||||
boolean canPayments = authorizationService.hasPermission("payment:request:view");
|
boolean canPayments = permissions.contains("payment:request:view");
|
||||||
boolean canInvoices = authorizationService.hasPermission("receivable:invoice:view");
|
boolean canInvoices = permissions.contains("receivable:invoice:view");
|
||||||
boolean canWorkflow = authorizationService.hasPermission("workflow:task:view");
|
boolean canWorkflow = permissions.contains("workflow:task:view");
|
||||||
boolean canRisks = authorizationService.hasPermission("project:risk-flag:view");
|
boolean canRisks = permissions.contains("project:risk-flag:view");
|
||||||
boolean canArchives = authorizationService.hasPermission("archive:package:view");
|
boolean canArchives = permissions.contains("archive:package:view");
|
||||||
boolean canFiles = authorizationService.hasPermission("archive:file:view");
|
boolean canFiles = permissions.contains("archive:file:view");
|
||||||
|
|
||||||
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
|
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
|
||||||
BigDecimal contractAmount = amount(canContracts,
|
BigDecimal contractAmount = amount(canContracts,
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
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) {
|
public void requireGlobalScope(String permissionCode) {
|
||||||
requireScope(permissionCode, null, null, null);
|
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.setup.SetupProperties;
|
||||||
import com.kaidi.finance.shared.web.SpaForwardFilter;
|
import com.kaidi.finance.shared.web.SpaForwardFilter;
|
||||||
|
import com.kaidi.finance.shared.web.StaticAssetCacheFilter;
|
||||||
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
|
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -30,7 +31,7 @@ import org.springframework.context.annotation.Import;
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@EnableConfigurationProperties(SetupProperties.class)
|
@EnableConfigurationProperties(SetupProperties.class)
|
||||||
@Import(SpaForwardFilter.class)
|
@Import({SpaForwardFilter.class, StaticAssetCacheFilter.class})
|
||||||
public class SetupApplication {
|
public class SetupApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
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.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.kaidi.finance.dashboard.api.DashboardMetricView;
|
import com.kaidi.finance.dashboard.api.DashboardMetricView;
|
||||||
@@ -27,6 +28,7 @@ import java.math.BigDecimal;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -52,7 +54,18 @@ class DashboardApplicationServiceTest {
|
|||||||
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
|
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
|
||||||
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
|
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
|
||||||
when(identity.requireActiveRole()).thenReturn("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.countPendingTasks(anyLong(), anyString())).thenReturn(3L);
|
||||||
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
|
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
|
||||||
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
|
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
|
||||||
@@ -99,19 +112,12 @@ class DashboardApplicationServiceTest {
|
|||||||
assertEquals("12345.67", contract.value());
|
assertEquals("12345.67", contract.value());
|
||||||
assertTrue(contract.available());
|
assertTrue(contract.available());
|
||||||
assertFalse(view.geography().available());
|
assertFalse(view.geography().available());
|
||||||
|
verify(authorization).activePermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
|
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
|
||||||
when(authorization.hasPermission("project:project:view")).thenReturn(false);
|
when(authorization.activePermissions()).thenReturn(Set.of("dashboard:overview:view"));
|
||||||
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);
|
|
||||||
|
|
||||||
var view = service.overview("CNY");
|
var view = service.overview("CNY");
|
||||||
|
|
||||||
@@ -127,6 +133,7 @@ class DashboardApplicationServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void requiresTheDedicatedDashboardPermission() {
|
void requiresTheDedicatedDashboardPermission() {
|
||||||
|
when(authorization.activePermissions()).thenReturn(Set.of());
|
||||||
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
|
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
|
||||||
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
|
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
|
||||||
.when(authorization).requirePermission("dashboard:overview:view");
|
.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.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
@@ -57,6 +58,10 @@ class SetupContextSmokeTest {
|
|||||||
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
|
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
|
||||||
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
|
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
|
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);
|
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
|
||||||
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
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_ENABLED=false
|
||||||
FINANCE_BOOTSTRAP_PASSWORD=
|
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
|
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
|
||||||
FINANCE_UPDATE_ENABLED=true
|
FINANCE_UPDATE_ENABLED=true
|
||||||
# Use either a stable direct asset base URL or the public Gitea latest-release API.
|
# 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) {
|
async function installFixture(page: Page) {
|
||||||
|
const requests = { setupStatus: 0 };
|
||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const pathname = new URL(request.url()).pathname;
|
const pathname = new URL(request.url()).pathname;
|
||||||
if (pathname === '/api/v1/setup/status') {
|
if (pathname === '/api/v1/setup/status') {
|
||||||
|
requests.setupStatus += 1;
|
||||||
await route.fulfill({ json: { data: { required: false, locked: true } } });
|
await route.fulfill({ json: { data: { required: false, locked: true } } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,7 @@ async function installFixture(page: Page) {
|
|||||||
}
|
}
|
||||||
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
|
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 }) => {
|
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 page.keyboard.press('Escape');
|
||||||
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
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(pageErrors).toEqual([]);
|
||||||
expect(consoleErrors).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') {
|
export function getDashboardOverview(currency = 'CNY', signal?: AbortSignal) {
|
||||||
return request.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency } }).then(normalizeDashboard);
|
return request
|
||||||
|
.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency }, signal })
|
||||||
|
.then(normalizeDashboard);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EChartsCoreOption } from 'echarts/core';
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
import { RefreshIcon } from 'tdesign-icons-vue-next';
|
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 { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import type { DashboardMetric, DashboardOverview, DashboardRisk, DashboardStatus } from '@/api/dashboard';
|
import type { DashboardMetric, DashboardOverview, DashboardRisk, DashboardStatus } from '@/api/dashboard';
|
||||||
@@ -310,6 +310,9 @@ let requestSequence = 0;
|
|||||||
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let clockTimer: ReturnType<typeof setInterval> | undefined;
|
let clockTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let tourTimer: ReturnType<typeof setInterval> | undefined;
|
let tourTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let dashboardAbortController: AbortController | undefined;
|
||||||
|
let dashboardActive = false;
|
||||||
|
let lastLoadedAt = 0;
|
||||||
|
|
||||||
const currencyOptions = [
|
const currencyOptions = [
|
||||||
{ label: '人民币 CNY', value: 'CNY' },
|
{ label: '人民币 CNY', value: 'CNY' },
|
||||||
@@ -420,17 +423,23 @@ const lifecycleOption = computed<EChartsCoreOption>(() => ({
|
|||||||
|
|
||||||
async function loadDashboard(silent = false) {
|
async function loadDashboard(silent = false) {
|
||||||
const sequence = ++requestSequence;
|
const sequence = ++requestSequence;
|
||||||
|
dashboardAbortController?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
dashboardAbortController = controller;
|
||||||
if (silent && dashboard.value) refreshing.value = true;
|
if (silent && dashboard.value) refreshing.value = true;
|
||||||
else loading.value = true;
|
else loading.value = true;
|
||||||
errorText.value = '';
|
errorText.value = '';
|
||||||
try {
|
try {
|
||||||
const response = await getDashboardOverview(currency.value);
|
const response = await getDashboardOverview(currency.value, controller.signal);
|
||||||
if (sequence !== requestSequence) return;
|
if (sequence !== requestSequence) return;
|
||||||
dashboard.value = response;
|
dashboard.value = response;
|
||||||
|
lastLoadedAt = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
if (sequence !== requestSequence) return;
|
if (sequence !== requestSequence) return;
|
||||||
errorText.value = friendlyError(error);
|
errorText.value = friendlyError(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (dashboardAbortController === controller) dashboardAbortController = undefined;
|
||||||
if (sequence === requestSequence) {
|
if (sequence === requestSequence) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
refreshing.value = false;
|
refreshing.value = false;
|
||||||
@@ -525,32 +534,52 @@ function riskTheme(severity: DashboardRisk['severity']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function friendlyError(error: unknown) {
|
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 === 401) return '登录会话已失效,请重新登录。';
|
||||||
if (status === 403) return '当前身份没有查看仪表盘或相关业务数据的权限。';
|
if (status === 403) return '当前身份没有查看仪表盘或相关业务数据的权限。';
|
||||||
if (status === 422) return '仪表盘筛选条件不正确,请重新选择。';
|
if (status === 422) return '仪表盘筛选条件不正确,请重新选择。';
|
||||||
if (status && status >= 500) return '仪表盘服务暂时不可用,请稍后重试。';
|
if (status && status >= 500) return '仪表盘服务暂时不可用,请稍后重试。';
|
||||||
|
if (requestError.code === 'ECONNABORTED') return '仪表盘加载超时,请稍后重试。';
|
||||||
return '网络请求失败,请检查网络后重试。';
|
return '网络请求失败,请检查网络后重试。';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function activateDashboard() {
|
||||||
void loadDashboard(false);
|
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 });
|
wallClock.value = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||||
clockTimer = setInterval(() => {
|
clockTimer = setInterval(() => {
|
||||||
wallClock.value = new Date().toLocaleString('zh-CN', { hour12: false });
|
wallClock.value = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
refreshTimer = setInterval(() => void loadDashboard(true), 60000);
|
refreshTimer = setInterval(() => void loadDashboard(true), 60000);
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
});
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
function deactivateDashboard() {
|
||||||
|
if (!dashboardActive) return;
|
||||||
|
dashboardActive = false;
|
||||||
requestSequence += 1;
|
requestSequence += 1;
|
||||||
|
dashboardAbortController?.abort();
|
||||||
|
dashboardAbortController = undefined;
|
||||||
|
loading.value = false;
|
||||||
|
refreshing.value = false;
|
||||||
if (refreshTimer) clearInterval(refreshTimer);
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
if (clockTimer) clearInterval(clockTimer);
|
if (clockTimer) clearInterval(clockTimer);
|
||||||
if (tourTimer) clearInterval(tourTimer);
|
if (tourTimer) clearInterval(tourTimer);
|
||||||
|
refreshTimer = undefined;
|
||||||
|
clockTimer = undefined;
|
||||||
|
tourTimer = undefined;
|
||||||
window.removeEventListener('keydown', handleEscape);
|
window.removeEventListener('keydown', handleEscape);
|
||||||
|
wallMode.value = false;
|
||||||
document.body.classList.remove('dashboard-wall-open');
|
document.body.classList.remove('dashboard-wall-open');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
onMounted(activateDashboard);
|
||||||
|
onActivated(activateDashboard);
|
||||||
|
onDeactivated(deactivateDashboard);
|
||||||
|
onBeforeUnmount(deactivateDashboard);
|
||||||
</script>
|
</script>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
:global(body.dashboard-wall-open) {
|
:global(body.dashboard-wall-open) {
|
||||||
|
|||||||
@@ -14,17 +14,28 @@ NProgress.configure({ showSpinner: false });
|
|||||||
const isPublic = (to: RouteLocationNormalized) =>
|
const isPublic = (to: RouteLocationNormalized) =>
|
||||||
to.path === '/login' || to.path === '/result/404' || to.path === '/setup';
|
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 setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
|
||||||
|
let installedSetupStatus: Awaited<ReturnType<typeof getSetupStatus>> | null = null;
|
||||||
|
let installedSetupStatusExpiresAt = 0;
|
||||||
|
|
||||||
function loadSetupStatus() {
|
function loadSetupStatus() {
|
||||||
|
if (installedSetupStatus && Date.now() < installedSetupStatusExpiresAt) {
|
||||||
|
return Promise.resolve(installedSetupStatus);
|
||||||
|
}
|
||||||
if (setupStatusRequest) return setupStatusRequest;
|
if (setupStatusRequest) return setupStatusRequest;
|
||||||
|
|
||||||
// Share only an in-flight request. Keeping the resolved setup state would
|
// A required setup is intentionally never cached because completion changes
|
||||||
// send the user back to /setup after the installation marker is written.
|
// 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();
|
const request = getSetupStatus();
|
||||||
setupStatusRequest = request;
|
setupStatusRequest = request;
|
||||||
request.then(
|
request.then(
|
||||||
() => {
|
(status) => {
|
||||||
|
if (!status.required) {
|
||||||
|
installedSetupStatus = status;
|
||||||
|
installedSetupStatusExpiresAt = Date.now() + INSTALLED_SETUP_CACHE_MS;
|
||||||
|
}
|
||||||
if (setupStatusRequest === request) setupStatusRequest = null;
|
if (setupStatusRequest === request) setupStatusRequest = null;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
@@ -38,6 +49,14 @@ router.beforeEach(async (to) => {
|
|||||||
NProgress.start();
|
NProgress.start();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const permissionStore = getPermissionStore();
|
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 setupRequired = false;
|
||||||
let setupStatusAvailable = false;
|
let setupStatusAvailable = false;
|
||||||
@@ -55,12 +74,15 @@ router.beforeEach(async (to) => {
|
|||||||
if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup';
|
if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup';
|
||||||
if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login';
|
if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login';
|
||||||
|
|
||||||
if (isPublic(to)) {
|
if (publicRoute) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!userStore.authenticated) await userStore.restoreSession();
|
if (sessionRequest) {
|
||||||
|
const sessionResult = await sessionRequest;
|
||||||
|
if (!sessionResult.ok) throw sessionResult.error;
|
||||||
|
}
|
||||||
permissionStore.initRoutes();
|
permissionStore.initRoutes();
|
||||||
} catch {
|
} catch {
|
||||||
userStore.clearSession();
|
userStore.clearSession();
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ describe('pAGE-24 dashboard contracts', () => {
|
|||||||
expect(pageSource).toContain('dashboard.geography.available');
|
expect(pageSource).toContain('dashboard.geography.available');
|
||||||
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
|
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
|
||||||
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).toContain('setInterval(() => void loadDashboard(true), 60000)');
|
||||||
expect(pageSource).not.toMatch(/<button\b|<input\b|<select\b/);
|
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)[\\/]/,
|
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
|
||||||
priority: 30,
|
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',
|
name: 'vue-vendor',
|
||||||
test: /node_modules[\\/](?:vue|vue-router|pinia|vue-i18n|@vueuse)[\\/]/,
|
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