This commit is contained in:
@@ -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.48/install.sh | sudo env KAIDI_APP_PORT=18080 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
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令只安装程序运行所需的 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.48/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.49/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.43 直链与宝塔手动部署
|
### Preview.49 直链与宝塔手动部署
|
||||||
|
|
||||||
本版提供 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.48/kaidi-finance-1.0.0-preview.48.tar.gz`
|
`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.48/SHA256SUMS`
|
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.49/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.48
|
VERSION=1.0.0-preview.49
|
||||||
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.48/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.49/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
|
||||||
```
|
```
|
||||||
|
|
||||||
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
||||||
@@ -255,11 +255,11 @@ curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previ
|
|||||||
## 全状态演示数据
|
## 全状态演示数据
|
||||||
|
|
||||||
线上数据库为空时,可导入 [`deploy/demo-data-mysql8-v073.sql`](deploy/demo-data-mysql8-v073.sql)。该文件按
|
线上数据库为空时,可导入 [`deploy/demo-data-mysql8-v073.sql`](deploy/demo-data-mysql8-v073.sql)。该文件按
|
||||||
MySQL 8.4 和 Flyway **精确版本 V073** 编写,并已在 Preview.48 的完整迁移库中验证。它会生成 14 类 OA 表单、
|
MySQL 8.4 和 Flyway **兼容版本 V073/V074** 编写,并已在 Preview.49 的完整迁移库中验证。它会生成 14 类 OA 表单、
|
||||||
项目阶段、主数据、合同成本、收款发票、付款、记账、档案借阅、报表导出、幂等和审计等演示记录,覆盖主要正常、
|
项目阶段、主数据、合同成本、收款发票、付款、记账、档案借阅、报表导出、幂等和审计等演示记录,覆盖主要正常、
|
||||||
待办、退回、驳回、作废、失败、完成和冻结状态。
|
待办、退回、驳回、作废、失败、完成和冻结状态。
|
||||||
|
|
||||||
导入前先备份现有数据库,确认 `/setup` 已完成且 `flyway_schema_history` 当前版本为 `073`。推荐使用 MySQL
|
导入前先备份现有数据库,确认 `/setup` 已完成且 `flyway_schema_history` 当前版本为 `073` 或 `074`。推荐使用 MySQL
|
||||||
命令行客户端执行;命令只建立数据库连接,不安装、不启动也不修改 MySQL 服务:
|
命令行客户端执行;命令只建立数据库连接,不安装、不启动也不修改 MySQL 服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -295,8 +295,8 @@ Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-not
|
|||||||
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
|
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.0-preview.48
|
git tag v1.0.0-preview.49
|
||||||
git push origin v1.0.0-preview.48
|
git push origin v1.0.0-preview.49
|
||||||
```
|
```
|
||||||
|
|
||||||
在线更新仍使用独立的 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.48
|
./scripts/package-release.sh 1.0.0-preview.49
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
import com.kaidi.finance.dashboard.application.DashboardApplicationService;
|
||||||
|
import com.kaidi.finance.shared.api.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dashboard")
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
private final DashboardApplicationService dashboardService;
|
||||||
|
|
||||||
|
public DashboardController(DashboardApplicationService dashboardService) {
|
||||||
|
this.dashboardService = dashboardService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/overview")
|
||||||
|
@Operation(operationId = "getDashboardOverview", summary = "查询当前身份可见的系统仪表盘")
|
||||||
|
@PreAuthorize("@authorizationService.hasPermission('dashboard:overview:view')")
|
||||||
|
public ApiResponse<DashboardOverviewView> overview(
|
||||||
|
@RequestParam(defaultValue = "CNY") String currency
|
||||||
|
) {
|
||||||
|
return ApiResponse.ok(dashboardService.overview(currency));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
public record DashboardDistributionView(
|
||||||
|
String code,
|
||||||
|
String label,
|
||||||
|
long value,
|
||||||
|
boolean available,
|
||||||
|
String targetRoute
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DashboardGeographyView(
|
||||||
|
boolean available,
|
||||||
|
String message,
|
||||||
|
List<DashboardRegionView> regions
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
public record DashboardMetricView(
|
||||||
|
String code,
|
||||||
|
String label,
|
||||||
|
String kind,
|
||||||
|
String value,
|
||||||
|
String unit,
|
||||||
|
boolean available,
|
||||||
|
String targetRoute
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
import com.kaidi.finance.workbench.api.WorkbenchActivityView;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DashboardOverviewView(
|
||||||
|
String title,
|
||||||
|
Instant refreshedAt,
|
||||||
|
String currency,
|
||||||
|
DashboardSystemView system,
|
||||||
|
List<DashboardMetricView> metrics,
|
||||||
|
DashboardTrendView trend,
|
||||||
|
List<DashboardDistributionView> lifecycle,
|
||||||
|
List<DashboardRiskView> risks,
|
||||||
|
DashboardGeographyView geography,
|
||||||
|
List<WorkbenchActivityView> activities
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
public record DashboardRegionView(
|
||||||
|
String regionCode,
|
||||||
|
String regionName,
|
||||||
|
long projectCount,
|
||||||
|
String amount,
|
||||||
|
String currency
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
public record DashboardRiskView(
|
||||||
|
String code,
|
||||||
|
String label,
|
||||||
|
String severity,
|
||||||
|
long count,
|
||||||
|
String detail,
|
||||||
|
boolean available,
|
||||||
|
String targetRoute
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
public record DashboardServiceStatusView(
|
||||||
|
String code,
|
||||||
|
String label,
|
||||||
|
String status,
|
||||||
|
String detail
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DashboardSystemView(
|
||||||
|
String overallStatus,
|
||||||
|
String currentVersion,
|
||||||
|
long uptimeSeconds,
|
||||||
|
List<DashboardServiceStatusView> services
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
public record DashboardTrendPointView(
|
||||||
|
String period,
|
||||||
|
String receivedAmount,
|
||||||
|
String paidAmount,
|
||||||
|
String invoicedAmount
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.kaidi.finance.dashboard.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DashboardTrendView(
|
||||||
|
String currency,
|
||||||
|
boolean receiptsAvailable,
|
||||||
|
boolean paymentsAvailable,
|
||||||
|
boolean invoicesAvailable,
|
||||||
|
List<DashboardTrendPointView> points
|
||||||
|
) {
|
||||||
|
}
|
||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
package com.kaidi.finance.dashboard.application;
|
||||||
|
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardDistributionView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardGeographyView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardMetricView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardOverviewView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardRiskView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardServiceStatusView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardSystemView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardTrendPointView;
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardTrendView;
|
||||||
|
import com.kaidi.finance.dashboard.infrastructure.DashboardMapper;
|
||||||
|
import com.kaidi.finance.iam.domain.FinancePrincipal;
|
||||||
|
import com.kaidi.finance.shared.api.BusinessException;
|
||||||
|
import com.kaidi.finance.shared.api.ErrorCode;
|
||||||
|
import com.kaidi.finance.shared.file.FileStorageProperties;
|
||||||
|
import com.kaidi.finance.shared.security.AuthorizationService;
|
||||||
|
import com.kaidi.finance.shared.security.IdentityContext;
|
||||||
|
import com.kaidi.finance.update.application.SystemUpdateProperties;
|
||||||
|
import com.kaidi.finance.workbench.api.WorkbenchActivityView;
|
||||||
|
import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper;
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DashboardApplicationService {
|
||||||
|
|
||||||
|
private static final Set<String> SUPPORTED_CURRENCIES = Set.of("CNY", "USD", "EUR", "HKD", "JPY", "GBP");
|
||||||
|
private static final List<String> STAGES = List.of(
|
||||||
|
"INITIATION", "CONTRACTING", "EXECUTION", "ACCEPTANCE", "SETTLEMENT", "CLOSED"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final DashboardMapper mapper;
|
||||||
|
private final WorkbenchMapper workbenchMapper;
|
||||||
|
private final IdentityContext identityContext;
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
private final SystemUpdateProperties updateProperties;
|
||||||
|
private final FileStorageProperties storageProperties;
|
||||||
|
|
||||||
|
public DashboardApplicationService(
|
||||||
|
DashboardMapper mapper,
|
||||||
|
WorkbenchMapper workbenchMapper,
|
||||||
|
IdentityContext identityContext,
|
||||||
|
AuthorizationService authorizationService,
|
||||||
|
SystemUpdateProperties updateProperties,
|
||||||
|
FileStorageProperties storageProperties
|
||||||
|
) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.workbenchMapper = workbenchMapper;
|
||||||
|
this.identityContext = identityContext;
|
||||||
|
this.authorizationService = authorizationService;
|
||||||
|
this.updateProperties = updateProperties;
|
||||||
|
this.storageProperties = storageProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public DashboardOverviewView overview(String requestedCurrency) {
|
||||||
|
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");
|
||||||
|
|
||||||
|
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
|
||||||
|
BigDecimal contractAmount = amount(canContracts,
|
||||||
|
() -> mapper.sumContracts(actor.userId(), role, currency));
|
||||||
|
BigDecimal receiptAmount = amount(canReceipts,
|
||||||
|
() -> mapper.sumReceipts(actor.userId(), role, currency));
|
||||||
|
BigDecimal paymentAmount = amount(canPayments,
|
||||||
|
() -> mapper.sumPayments(actor.userId(), role, currency));
|
||||||
|
long pendingTasks = count(canWorkflow, () -> workbenchMapper.countPendingTasks(actor.userId(), role));
|
||||||
|
long activeRisks = count(canRisks, () -> mapper.countActiveRisks(actor.userId(), role));
|
||||||
|
|
||||||
|
List<DashboardMetricView> metrics = List.of(
|
||||||
|
countMetric("ACTIVE_PROJECTS", "进行中项目", projectCount, canProjects, "/projects"),
|
||||||
|
moneyMetric("CONTRACT_AMOUNT", "合同总额", contractAmount, currency, canContracts,
|
||||||
|
"/finance/contracts-costs"),
|
||||||
|
moneyMetric("RECEIPT_AMOUNT", "累计收款", receiptAmount, currency, canReceipts,
|
||||||
|
"/finance/receipts-invoices?resource=receipts"),
|
||||||
|
moneyMetric("PAYMENT_AMOUNT", "累计付款", paymentAmount, currency, canPayments,
|
||||||
|
"/finance/payments"),
|
||||||
|
countMetric("PENDING_TASKS", "待办审批", pendingTasks, canWorkflow, "/tasks?view=TODO"),
|
||||||
|
countMetric("ACTIVE_RISKS", "风险事项", activeRisks, canRisks, "/projects?riskStatus=ACTIVE")
|
||||||
|
);
|
||||||
|
|
||||||
|
List<DashboardRiskView> risks = List.of(
|
||||||
|
risk("PROJECT_RISK", "项目风险", "DANGER", activeRisks, "当前权限范围内的有效风险标记",
|
||||||
|
canRisks, "/projects?riskStatus=ACTIVE"),
|
||||||
|
risk("OVERDUE_TASK", "审批超时", "DANGER",
|
||||||
|
count(canWorkflow, () -> workbenchMapper.countOverdueTasks(actor.userId(), role)),
|
||||||
|
"超过处理时限的审批任务", canWorkflow, "/tasks?view=TODO"),
|
||||||
|
risk("PAYMENT_BLOCK", "付款阻断", "WARNING",
|
||||||
|
count(canPayments, () -> mapper.countPaymentBlocks(actor.userId(), role)),
|
||||||
|
"付款检查中仍未解除的阻断项", canPayments,
|
||||||
|
"/finance/payments?tab=finance-check&riskCode=BLOCK"),
|
||||||
|
risk("ARCHIVE_MISSING", "档案缺件", "WARNING",
|
||||||
|
count(canArchives, () -> workbenchMapper.countArchiveMissing(actor.userId(), role)),
|
||||||
|
"待补充的归档材料", canArchives, "/archives/projects?completeness=INCOMPLETE"),
|
||||||
|
risk("QUARANTINED_FILE", "隔离文件", "INFO",
|
||||||
|
count(canFiles, () -> workbenchMapper.countFiles("QUARANTINED", actor.userId(), role)),
|
||||||
|
"等待处理的隔离文件", canFiles, "/archives/files?scanStatus=QUARANTINED")
|
||||||
|
);
|
||||||
|
|
||||||
|
List<WorkbenchActivityView> activities = workbenchMapper.listRecentActivities(actor.publicId(), 8).stream()
|
||||||
|
.map(row -> new WorkbenchActivityView(row.publicId(), row.actionCode(), row.objectType(),
|
||||||
|
row.objectPublicId(), row.title(), row.resultCode(), instant(row.occurredAt()),
|
||||||
|
activityRoute(row.objectType(), row.objectPublicId())))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new DashboardOverviewView(
|
||||||
|
"系统经营仪表盘",
|
||||||
|
Instant.now(),
|
||||||
|
currency,
|
||||||
|
systemView(),
|
||||||
|
metrics,
|
||||||
|
trend(actor.userId(), role, currency, canReceipts, canPayments, canInvoices),
|
||||||
|
lifecycle(actor.userId(), role, canProjects),
|
||||||
|
risks,
|
||||||
|
new DashboardGeographyView(false, "项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。", List.of()),
|
||||||
|
activities
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashboardTrendView trend(long userId, String role, String currency,
|
||||||
|
boolean canReceipts, boolean canPayments, boolean canInvoices) {
|
||||||
|
YearMonth firstMonth = YearMonth.now(ZoneOffset.UTC).minusMonths(5);
|
||||||
|
LocalDate fromDate = firstMonth.atDay(1);
|
||||||
|
Map<String, BigDecimal> receipts = amountMap(canReceipts
|
||||||
|
? mapper.receiptTrend(userId, role, currency, fromDate) : List.of());
|
||||||
|
Map<String, BigDecimal> payments = amountMap(canPayments
|
||||||
|
? mapper.paymentTrend(userId, role, currency, fromDate) : List.of());
|
||||||
|
Map<String, BigDecimal> invoices = amountMap(canInvoices
|
||||||
|
? mapper.invoiceTrend(userId, role, currency, fromDate) : List.of());
|
||||||
|
List<DashboardTrendPointView> points = new ArrayList<>();
|
||||||
|
for (int index = 0; index < 6; index++) {
|
||||||
|
String period = firstMonth.plusMonths(index).toString();
|
||||||
|
points.add(new DashboardTrendPointView(period, plain(receipts.get(period)),
|
||||||
|
plain(payments.get(period)), plain(invoices.get(period))));
|
||||||
|
}
|
||||||
|
return new DashboardTrendView(currency, canReceipts, canPayments, canInvoices, points);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DashboardDistributionView> lifecycle(long userId, String role, boolean available) {
|
||||||
|
Map<String, Long> counts = new LinkedHashMap<>();
|
||||||
|
if (available) {
|
||||||
|
mapper.countProjectStages(userId, role).forEach(row -> counts.put(row.code(), row.itemCount()));
|
||||||
|
}
|
||||||
|
return STAGES.stream()
|
||||||
|
.map(stage -> new DashboardDistributionView(stage, stageLabel(stage), counts.getOrDefault(stage, 0L),
|
||||||
|
available, "/projects?stage=" + stage))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashboardSystemView systemView() {
|
||||||
|
List<DashboardServiceStatusView> services = new ArrayList<>();
|
||||||
|
services.add(new DashboardServiceStatusView("APPLICATION", "应用服务", "UP", "服务运行正常"));
|
||||||
|
services.add(new DashboardServiceStatusView("DATABASE", "数据库连接", "UP", "业务数据库连接正常"));
|
||||||
|
String storageStatus = storageStatus();
|
||||||
|
services.add(new DashboardServiceStatusView("FILE_STORAGE", "文件存储", storageStatus,
|
||||||
|
"UP".equals(storageStatus) ? "存储目录可读写" : "存储目录待初始化或当前不可写"));
|
||||||
|
services.add(new DashboardServiceStatusView("UPDATE", "更新服务", updateProperties.enabled() ? "UP" : "DISABLED",
|
||||||
|
updateProperties.enabled() ? "在线更新服务已启用" : "在线更新服务未启用"));
|
||||||
|
String overall = services.stream().anyMatch(item -> "DOWN".equals(item.status())) ? "DOWN"
|
||||||
|
: services.stream().anyMatch(item -> "WARNING".equals(item.status())) ? "WARNING" : "UP";
|
||||||
|
return new DashboardSystemView(overall, updateProperties.currentVersion(),
|
||||||
|
ManagementFactory.getRuntimeMXBean().getUptime() / 1000, List.copyOf(services));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String storageStatus() {
|
||||||
|
try {
|
||||||
|
Path path = storageProperties.rootPath();
|
||||||
|
return Files.isDirectory(path) && Files.isReadable(path) && Files.isWritable(path) ? "UP" : "WARNING";
|
||||||
|
} catch (InvalidPathException | NullPointerException exception) {
|
||||||
|
return "WARNING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, BigDecimal> amountMap(List<DashboardMapper.AmountRow> rows) {
|
||||||
|
Map<String, BigDecimal> values = new LinkedHashMap<>();
|
||||||
|
rows.forEach(row -> values.merge(row.period(), value(row.amount()), BigDecimal::add));
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashboardMetricView countMetric(String code, String label, long value, boolean available,
|
||||||
|
String route) {
|
||||||
|
return new DashboardMetricView(code, label, "COUNT", available ? Long.toString(value) : "0", "项",
|
||||||
|
available, available ? route : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashboardMetricView moneyMetric(String code, String label, BigDecimal value, String currency,
|
||||||
|
boolean available, String route) {
|
||||||
|
return new DashboardMetricView(code, label, "MONEY", available ? plain(value) : "0", currency,
|
||||||
|
available, available ? route : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashboardRiskView risk(String code, String label, String severity, long count, String detail,
|
||||||
|
boolean available, String route) {
|
||||||
|
return new DashboardRiskView(code, label, severity, count, detail, available, available ? route : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long count(boolean available, Supplier<Long> supplier) {
|
||||||
|
return available ? supplier.get() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal amount(boolean available, Supplier<BigDecimal> supplier) {
|
||||||
|
return available ? value(supplier.get()) : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal value(BigDecimal value) {
|
||||||
|
return value == null ? BigDecimal.ZERO : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String plain(BigDecimal value) {
|
||||||
|
return value(value).stripTrailingZeros().toPlainString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeCurrency(String requested) {
|
||||||
|
String currency = requested == null ? "CNY" : requested.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!SUPPORTED_CURRENCIES.contains(currency)) {
|
||||||
|
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
|
||||||
|
"币种仅支持 CNY、USD、EUR、HKD、JPY 或 GBP");
|
||||||
|
}
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stageLabel(String stage) {
|
||||||
|
return switch (stage) {
|
||||||
|
case "INITIATION" -> "立项";
|
||||||
|
case "CONTRACTING" -> "合同签订";
|
||||||
|
case "EXECUTION" -> "执行中";
|
||||||
|
case "ACCEPTANCE" -> "验收";
|
||||||
|
case "SETTLEMENT" -> "结算";
|
||||||
|
case "CLOSED" -> "已关闭";
|
||||||
|
default -> stage;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String activityRoute(String objectType, String objectPublicId) {
|
||||||
|
if (objectPublicId == null || objectPublicId.isBlank()) return "/reports";
|
||||||
|
return switch (objectType) {
|
||||||
|
case "PROJECT" -> "/projects/%s".formatted(objectPublicId);
|
||||||
|
case "PAYMENT", "PAYMENT_REQUEST" -> "/finance/payments?keyword=%s".formatted(objectPublicId);
|
||||||
|
case "VOUCHER", "ACCOUNTING" -> "/finance/accounting?keyword=%s".formatted(objectPublicId);
|
||||||
|
case "ARCHIVE_PACKAGE", "ARCHIVE" -> "/archives/projects";
|
||||||
|
default -> "/reports";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant instant(java.time.LocalDateTime value) {
|
||||||
|
return value == null ? null : value.toInstant(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
}
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
package com.kaidi.finance.dashboard.infrastructure;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@org.apache.ibatis.annotations.Mapper
|
||||||
|
public interface DashboardMapper {
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM md_project project
|
||||||
|
JOIN md_company company ON company.id = project.company_id
|
||||||
|
WHERE project.status IN ('ACTIVE', 'SUSPENDED')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
long countProjects(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COALESCE(SUM(contract.original_amount + contract.approved_change_amount), 0)
|
||||||
|
FROM md_contract contract
|
||||||
|
JOIN md_project project ON project.id = contract.project_id
|
||||||
|
JOIN md_company company ON company.id = contract.company_id
|
||||||
|
WHERE contract.status NOT IN ('VOID', 'DISABLED')
|
||||||
|
AND contract.currency = #{currency}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'masterdata:contract:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
BigDecimal sumContracts(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||||
|
@Param("currency") String currency);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COALESCE(SUM(receipt.amount), 0)
|
||||||
|
FROM fin_receipt receipt
|
||||||
|
JOIN md_company company ON company.id = receipt.company_id
|
||||||
|
LEFT JOIN md_project project ON project.id = receipt.project_id
|
||||||
|
WHERE receipt.status NOT IN ('DRAFT', 'VOID')
|
||||||
|
AND receipt.currency = #{currency}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'receivable:receipt:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
BigDecimal sumReceipts(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||||
|
@Param("currency") String currency);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COALESCE(SUM(COALESCE(payment.approved_amount, payment.requested_amount)), 0)
|
||||||
|
FROM fin_payment_request payment
|
||||||
|
JOIN md_company company ON company.id = payment.company_id
|
||||||
|
JOIN md_project project ON project.id = payment.project_id
|
||||||
|
WHERE payment.status = 'PAID'
|
||||||
|
AND payment.currency = #{currency}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
BigDecimal sumPayments(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||||
|
@Param("currency") String currency);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT project.stage AS code, COUNT(*) AS item_count
|
||||||
|
FROM md_project project
|
||||||
|
JOIN md_company company ON company.id = project.company_id
|
||||||
|
WHERE project.status IN ('ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
GROUP BY project.stage
|
||||||
|
ORDER BY FIELD(project.stage, 'INITIATION', 'CONTRACTING', 'EXECUTION',
|
||||||
|
'ACCEPTANCE', 'SETTLEMENT', 'CLOSED')
|
||||||
|
""")
|
||||||
|
List<CountRow> countProjectStages(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT DATE_FORMAT(receipt.receipt_date, '%Y-%m') AS period,
|
||||||
|
COALESCE(SUM(receipt.amount), 0) AS amount
|
||||||
|
FROM fin_receipt receipt
|
||||||
|
JOIN md_company company ON company.id = receipt.company_id
|
||||||
|
LEFT JOIN md_project project ON project.id = receipt.project_id
|
||||||
|
WHERE receipt.status NOT IN ('DRAFT', 'VOID')
|
||||||
|
AND receipt.currency = #{currency}
|
||||||
|
AND receipt.receipt_date >= #{fromDate}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'receivable:receipt:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
GROUP BY DATE_FORMAT(receipt.receipt_date, '%Y-%m')
|
||||||
|
ORDER BY period
|
||||||
|
""")
|
||||||
|
List<AmountRow> receiptTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||||
|
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT DATE_FORMAT(payment.requested_date, '%Y-%m') AS period,
|
||||||
|
COALESCE(SUM(COALESCE(payment.approved_amount, payment.requested_amount)), 0) AS amount
|
||||||
|
FROM fin_payment_request payment
|
||||||
|
JOIN md_company company ON company.id = payment.company_id
|
||||||
|
JOIN md_project project ON project.id = payment.project_id
|
||||||
|
WHERE payment.status = 'PAID'
|
||||||
|
AND payment.currency = #{currency}
|
||||||
|
AND payment.requested_date >= #{fromDate}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
GROUP BY DATE_FORMAT(payment.requested_date, '%Y-%m')
|
||||||
|
ORDER BY period
|
||||||
|
""")
|
||||||
|
List<AmountRow> paymentTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||||
|
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT DATE_FORMAT(COALESCE(invoice.issued_date, invoice.requested_date), '%Y-%m') AS period,
|
||||||
|
COALESCE(SUM(invoice.amount), 0) AS amount
|
||||||
|
FROM fin_invoice invoice
|
||||||
|
JOIN md_company company ON company.id = invoice.company_id
|
||||||
|
JOIN md_project project ON project.id = invoice.project_id
|
||||||
|
WHERE invoice.status = 'ISSUED'
|
||||||
|
AND project.currency = #{currency}
|
||||||
|
AND COALESCE(invoice.issued_date, invoice.requested_date) >= #{fromDate}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'receivable:invoice:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
GROUP BY DATE_FORMAT(COALESCE(invoice.issued_date, invoice.requested_date), '%Y-%m')
|
||||||
|
ORDER BY period
|
||||||
|
""")
|
||||||
|
List<AmountRow> invoiceTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||||
|
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM project_risk_flag risk
|
||||||
|
JOIN md_project project ON project.id = risk.project_id
|
||||||
|
JOIN md_company company ON company.id = project.company_id
|
||||||
|
WHERE risk.status = 'ACTIVE'
|
||||||
|
AND risk.effective_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (risk.effective_until IS NULL OR risk.effective_until >= UTC_TIMESTAMP(3))
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'project:risk-flag:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
long countActiveRisks(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(DISTINCT payment.id)
|
||||||
|
FROM fin_payment_check payment_check
|
||||||
|
JOIN fin_payment_request payment ON payment.id = payment_check.payment_id
|
||||||
|
JOIN md_company company ON company.id = payment.company_id
|
||||||
|
JOIN md_project project ON project.id = payment.project_id
|
||||||
|
WHERE payment_check.result = 'BLOCK'
|
||||||
|
AND payment.status NOT IN ('VOID', 'PAID', 'REFUNDED')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iam_scope scope
|
||||||
|
JOIN iam_role role ON role.id = scope.role_id
|
||||||
|
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||||
|
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||||
|
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
|
||||||
|
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||||
|
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||||
|
AND (scope.scope_type = 'GLOBAL'
|
||||||
|
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||||
|
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
long countPaymentBlocks(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||||
|
|
||||||
|
record CountRow(String code, long itemCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
record AmountRow(String period, BigDecimal amount) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -376,7 +376,7 @@ public class AuthApplicationService {
|
|||||||
case "PROJECT_MANAGER" -> "/workbench/project";
|
case "PROJECT_MANAGER" -> "/workbench/project";
|
||||||
case "FINANCE_MANAGER" -> "/workbench/finance";
|
case "FINANCE_MANAGER" -> "/workbench/finance";
|
||||||
case "ARCHIVE_MANAGER" -> "/workbench/archive";
|
case "ARCHIVE_MANAGER" -> "/workbench/archive";
|
||||||
case "SYSTEM_ADMIN" -> "/governance/settings";
|
case "SYSTEM_ADMIN" -> "/dashboard";
|
||||||
default -> "/role-select";
|
default -> "/role-select";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
INSERT INTO iam_permission (code, name)
|
||||||
|
VALUES ('dashboard:overview:view', '查看系统仪表盘')
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||||
|
|
||||||
|
-- The isolated system administrator is the only role that receives the new
|
||||||
|
-- entry automatically. Business roles can be granted the permission from
|
||||||
|
-- "权限与配置" according to the customer's actual responsibility matrix.
|
||||||
|
INSERT IGNORE INTO iam_role_permission (role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM iam_role role
|
||||||
|
JOIN iam_permission permission ON permission.code = 'dashboard:overview:view'
|
||||||
|
WHERE role.code = 'SYSTEM_ADMIN'
|
||||||
|
AND role.enabled = TRUE;
|
||||||
|
|
||||||
|
-- Keep the server-side permission and data-scope model aligned for existing
|
||||||
|
-- administrator accounts. Front-end super-admin bypasses are not considered
|
||||||
|
-- an authorization boundary.
|
||||||
|
INSERT IGNORE INTO iam_scope (
|
||||||
|
user_id,
|
||||||
|
role_id,
|
||||||
|
permission_id,
|
||||||
|
scope_type,
|
||||||
|
company_public_id,
|
||||||
|
project_public_id,
|
||||||
|
amount_limit,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
SELECT user_role.user_id,
|
||||||
|
user_role.role_id,
|
||||||
|
permission.id,
|
||||||
|
'GLOBAL',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'ACTIVE'
|
||||||
|
FROM iam_user_role user_role
|
||||||
|
JOIN iam_role role ON role.id = user_role.role_id
|
||||||
|
JOIN iam_permission permission ON permission.code = 'dashboard:overview:view'
|
||||||
|
WHERE role.code = 'SYSTEM_ADMIN'
|
||||||
|
AND role.enabled = TRUE;
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package com.kaidi.finance.dashboard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
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.when;
|
||||||
|
|
||||||
|
import com.kaidi.finance.dashboard.api.DashboardMetricView;
|
||||||
|
import com.kaidi.finance.dashboard.application.DashboardApplicationService;
|
||||||
|
import com.kaidi.finance.dashboard.infrastructure.DashboardMapper;
|
||||||
|
import com.kaidi.finance.iam.domain.FinancePrincipal;
|
||||||
|
import com.kaidi.finance.iam.domain.RoleAssignment;
|
||||||
|
import com.kaidi.finance.shared.api.BusinessException;
|
||||||
|
import com.kaidi.finance.shared.file.FileStorageProperties;
|
||||||
|
import com.kaidi.finance.shared.security.AuthorizationService;
|
||||||
|
import com.kaidi.finance.shared.security.IdentityContext;
|
||||||
|
import com.kaidi.finance.update.application.SystemUpdateProperties;
|
||||||
|
import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class DashboardApplicationServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private DashboardMapper mapper;
|
||||||
|
private WorkbenchMapper workbenchMapper;
|
||||||
|
private IdentityContext identity;
|
||||||
|
private AuthorizationService authorization;
|
||||||
|
private DashboardApplicationService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mapper = mock(DashboardMapper.class);
|
||||||
|
workbenchMapper = mock(WorkbenchMapper.class);
|
||||||
|
identity = mock(IdentityContext.class);
|
||||||
|
authorization = mock(AuthorizationService.class);
|
||||||
|
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(
|
||||||
|
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
|
||||||
|
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
|
||||||
|
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
|
||||||
|
when(authorization.hasPermission(anyString())).thenReturn(true);
|
||||||
|
when(workbenchMapper.countPendingTasks(anyLong(), anyString())).thenReturn(3L);
|
||||||
|
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
|
||||||
|
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
|
||||||
|
when(workbenchMapper.countFiles(anyString(), anyLong(), anyString())).thenReturn(1L);
|
||||||
|
when(workbenchMapper.listRecentActivities(anyString(), anyInt())).thenReturn(List.of());
|
||||||
|
when(mapper.countProjectStages(anyLong(), anyString())).thenReturn(List.of(
|
||||||
|
new DashboardMapper.CountRow("INITIATION", 2L),
|
||||||
|
new DashboardMapper.CountRow("EXECUTION", 1L)));
|
||||||
|
when(mapper.receiptTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
|
||||||
|
when(mapper.paymentTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
|
||||||
|
when(mapper.invoiceTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
|
||||||
|
when(mapper.countProjects(anyLong(), anyString())).thenReturn(3L);
|
||||||
|
when(mapper.sumContracts(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("12345.67"));
|
||||||
|
when(mapper.sumReceipts(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("2345.67"));
|
||||||
|
when(mapper.sumPayments(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("345.67"));
|
||||||
|
when(mapper.countActiveRisks(anyLong(), anyString())).thenReturn(4L);
|
||||||
|
when(mapper.countPaymentBlocks(anyLong(), anyString())).thenReturn(1L);
|
||||||
|
service = new DashboardApplicationService(
|
||||||
|
mapper,
|
||||||
|
workbenchMapper,
|
||||||
|
identity,
|
||||||
|
authorization,
|
||||||
|
new SystemUpdateProperties(true, "1.0.0-preview.48", tempDir.resolve("VERSION"),
|
||||||
|
"https://git.example.invalid/releases", "https://git.example.invalid/api/latest", "",
|
||||||
|
tempDir.resolve("request.json"), tempDir.resolve("status.json"), Duration.ofSeconds(1),
|
||||||
|
Duration.ofSeconds(1), false),
|
||||||
|
new FileStorageProperties(tempDir.toString(), tempDir.resolve("tmp").toString(), 100_000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsPermissionAwareOverviewWithMoneyAndLifecycleData() {
|
||||||
|
var view = service.overview("usd");
|
||||||
|
|
||||||
|
assertEquals("USD", view.currency());
|
||||||
|
assertEquals("1.0.0-preview.48", view.system().currentVersion());
|
||||||
|
assertEquals("UP", view.system().overallStatus());
|
||||||
|
assertEquals(6, view.trend().points().size());
|
||||||
|
assertEquals(2L, view.lifecycle().stream()
|
||||||
|
.filter(item -> item.code().equals("INITIATION"))
|
||||||
|
.findFirst().orElseThrow().value());
|
||||||
|
DashboardMetricView contract = view.metrics().stream()
|
||||||
|
.filter(item -> item.code().equals("CONTRACT_AMOUNT"))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals("12345.67", contract.value());
|
||||||
|
assertTrue(contract.available());
|
||||||
|
assertFalse(view.geography().available());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
|
||||||
|
var view = service.overview("CNY");
|
||||||
|
|
||||||
|
assertTrue(view.metrics().stream().noneMatch(DashboardMetricView::available));
|
||||||
|
assertTrue(view.lifecycle().stream().noneMatch(item -> item.available()));
|
||||||
|
assertTrue(view.risks().stream().noneMatch(item -> item.available()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedCurrencyBeforeQueryingBusinessData() {
|
||||||
|
assertThrows(BusinessException.class, () -> service.overview("RMB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiresTheDedicatedDashboardPermission() {
|
||||||
|
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
|
||||||
|
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
|
||||||
|
.when(authorization).requirePermission("dashboard:overview:view");
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () -> service.overview("CNY"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,7 +179,7 @@ class AuthIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.data.roles.length()").value(1))
|
.andExpect(jsonPath("$.data.roles.length()").value(1))
|
||||||
.andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN"))
|
.andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN"))
|
||||||
.andExpect(jsonPath("$.data.roles[0].name").value("超级管理员"))
|
.andExpect(jsonPath("$.data.roles[0].name").value("超级管理员"))
|
||||||
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/governance/settings"))
|
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/dashboard"))
|
||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
ClientSession session = clientSession(login);
|
ClientSession session = clientSession(login);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Kaidi Finance V073 全功能状态演示数据
|
-- Kaidi Finance V073 全功能状态演示数据
|
||||||
-- 兼容:MySQL 8.4.x + Flyway 精确版本 V073(已在 Kaidi Finance Preview 48 验证)
|
-- 兼容:MySQL 8.4.x + Flyway 版本 V073/V074(已在 Kaidi Finance Preview 49 验证)
|
||||||
-- 用途:在已完成 /setup、但尚无业务数据的数据库中导入展示数据。
|
-- 用途:在已完成 /setup、但尚无业务数据的数据库中导入展示数据。
|
||||||
--
|
--
|
||||||
-- 导入示例:
|
-- 导入示例:
|
||||||
@@ -21,7 +21,7 @@ SET @demo_now := UTC_TIMESTAMP(3);
|
|||||||
SET @demo_today := DATE(UTC_TIMESTAMP());
|
SET @demo_today := DATE(UTC_TIMESTAMP());
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 0. 强制前置检查:MySQL 8.4、V073、有效超级管理员、未重复导入
|
-- 0. 强制前置检查:MySQL 8.4、V073/V074、有效超级管理员、未重复导入
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
CREATE TEMPORARY TABLE kaidi_demo_v073_guard (
|
CREATE TEMPORARY TABLE kaidi_demo_v073_guard (
|
||||||
check_name VARCHAR(64) NOT NULL,
|
check_name VARCHAR(64) NOT NULL,
|
||||||
@@ -33,13 +33,13 @@ INSERT INTO kaidi_demo_v073_guard (check_name, ok)
|
|||||||
SELECT 'MYSQL_8_4', IF(VERSION() REGEXP '^8\\.4\\.', 1, 0);
|
SELECT 'MYSQL_8_4', IF(VERSION() REGEXP '^8\\.4\\.', 1, 0);
|
||||||
|
|
||||||
INSERT INTO kaidi_demo_v073_guard (check_name, ok)
|
INSERT INTO kaidi_demo_v073_guard (check_name, ok)
|
||||||
SELECT 'FLYWAY_EXACT_V073', IF(COALESCE((
|
SELECT 'FLYWAY_COMPATIBLE_V073_V074', IF(COALESCE((
|
||||||
SELECT version
|
SELECT version
|
||||||
FROM flyway_schema_history
|
FROM flyway_schema_history
|
||||||
WHERE success = 1 AND version IS NOT NULL
|
WHERE success = 1 AND version IS NOT NULL
|
||||||
ORDER BY installed_rank DESC
|
ORDER BY installed_rank DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
), '') IN ('73', '073'), 1, 0);
|
), '') IN ('73', '073', '74', '074'), 1, 0);
|
||||||
|
|
||||||
INSERT INTO kaidi_demo_v073_guard (check_name, ok)
|
INSERT INTO kaidi_demo_v073_guard (check_name, ok)
|
||||||
SELECT 'SYSTEM_ADMIN_EXISTS', IF(EXISTS (
|
SELECT 'SYSTEM_ADMIN_EXISTS', IF(EXISTS (
|
||||||
|
|||||||
@@ -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.48
|
APP_VERSION=1.0.0-preview.49
|
||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 系统经营仪表盘设计与验收说明
|
||||||
|
|
||||||
|
## 1. 交付目标
|
||||||
|
|
||||||
|
本轮新增 `PAGE-24 /dashboard`,作为登录并选择工作身份后的默认业务首页。页面采用“日常经营驾驶舱 + 系统运行监控 + 全屏故事巡航”的混合形态,同时遵循以下边界:
|
||||||
|
|
||||||
|
- 仪表盘是独立权限节点 `dashboard:overview:view`;未授权身份看不到菜单,也不能直接访问接口。
|
||||||
|
- 超级管理员默认拥有仪表盘权限;业务身份由“权限与配置”按岗位需要授予。
|
||||||
|
- 每个指标仍校验原业务权限和 `GLOBAL / COMPANY / PROJECT` 数据范围,不能因为拥有仪表盘权限而扩大数据可见范围。
|
||||||
|
- 金额按币种分别查询,不做跨币种相加。
|
||||||
|
- 当前项目没有可靠省市字段,因此不伪造地图点位;地图区域明确显示数据能力说明,后续补齐项目地域主数据后再接真实地图。
|
||||||
|
- 大屏使用现有 Vue、TDesign 和 ECharts 实现,不增加不透明的第三方大屏运行时依赖。
|
||||||
|
|
||||||
|
## 2. 页面形态
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 系统经营仪表盘 币种[CNY⌄] 刷新数据 进入大屏 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ● 应用正常 ● 数据库正常 ● 文件存储 ● 更新服务 版本/运行时长 │
|
||||||
|
├──────────┬──────────┬──────────┬──────────┬──────────┬───────────┤
|
||||||
|
│进行中项目│ 合同总额 │ 累计收款 │ 累计付款 │ 待办审批 │ 风险事项 │
|
||||||
|
├──────────────────────────────────┬───────────────────────────────────┤
|
||||||
|
│ 近六个月资金趋势(收款/付款/开票)│ 项目生命周期分布(环形图) │
|
||||||
|
├──────────────────────────────────┼───────────────────────────────────┤
|
||||||
|
│ 风险与异常(项目/审批/付款/档案) │ 地域能力区(无真实地域时明确降级)│
|
||||||
|
├──────────────────────────────────┴───────────────────────────────────┤
|
||||||
|
│ 最近业务活动 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
全屏巡航:
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 系统经营态势 章节 1/3 退出全屏 / Esc │
|
||||||
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 第一幕:资金态势 → 第二幕:项目进程 → 第三幕:风险与系统健康 │
|
||||||
|
│ 每 8 秒自动切换,可手动选择章节;保留真实图表和数据来源说明 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 功能清单与实现方式
|
||||||
|
|
||||||
|
| 功能 | 最终形态 | 实现方式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 默认首页 | 超级管理员及已授权身份进入 `/dashboard` | 后端角色工作台地址、前端回退地址和 Logo 返回地址统一使用仪表盘 |
|
||||||
|
| 菜单首项 | 左侧菜单第一行“仪表盘” | PAGE-24 路由合同 + `orderNo=1` 单菜单组 |
|
||||||
|
| 权限节点 | 可按角色授予 `dashboard:overview:view` | V074 写入权限;超级管理员自动获得角色权限和 GLOBAL scope |
|
||||||
|
| 系统状态条 | 应用、数据库、文件存储、更新服务、版本、运行时长 | 后端聚合只返回允许公开的健康摘要,不暴露 Actuator 明细 |
|
||||||
|
| 核心指标 | 进行中项目、合同额、收款、付款、待办、风险 | 每项独立检查底层业务权限;无权限时显示不可用状态而不是伪造零值 |
|
||||||
|
| 资金趋势 | 最近六个月收款、付款、开票折线/柱形组合图 | 服务端按月、币种、授权范围聚合;ECharts 模块化加载 |
|
||||||
|
| 生命周期 | 立项、合同、执行、验收、结算、关闭分布 | 服务端状态分桶;环形图和可跳转明细 |
|
||||||
|
| 风险异常 | 项目风险、审批超时、付款阻断、档案缺件、隔离文件 | 复用现有业务口径和权限范围,显示级别、数量和跳转入口 |
|
||||||
|
| 地图区域 | 高级视觉容器 + 数据能力说明 | 当前 `available=false`,明确不展示虚假地图;预留地域 DTO |
|
||||||
|
| 最近活动 | 最近 8 条业务审计活动 | 使用当前用户审计活动,只返回必要标题、结果和目标路由 |
|
||||||
|
| 自动刷新 | 60 秒自动刷新,也可手动刷新 | 页面定时器;离开页面时清理,避免后台重复请求 |
|
||||||
|
| 大屏巡航 | 三章节全屏故事模式 | 深色全屏层、8 秒巡航、手动切换、Esc 退出、减少动态效果兼容 |
|
||||||
|
| 响应式 | 1920、1440、1366、窄屏均不横向溢出 | 分段网格、弹性卡片、图表 ResizeObserver、窄屏重排 |
|
||||||
|
| 图表生命周期 | 主题/尺寸变化后正确重建 | ECharts `ResizeObserver`、销毁 `dispose()`、ARIA 描述 |
|
||||||
|
|
||||||
|
## 4. 后端接口合同
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/dashboard/overview?currency=CNY
|
||||||
|
Permission: dashboard:overview:view
|
||||||
|
```
|
||||||
|
|
||||||
|
响应包含:
|
||||||
|
|
||||||
|
- `system`:总体状态、版本、运行时长、服务状态列表;
|
||||||
|
- `metrics`:六项核心指标,包含 `available` 和目标路由;
|
||||||
|
- `trend`:六个月资金趋势及各序列可见性;
|
||||||
|
- `lifecycle`:项目阶段分布;
|
||||||
|
- `risks`:风险异常列表;
|
||||||
|
- `geography`:地域可用性、说明和区域数据;
|
||||||
|
- `activities`:最近业务活动。
|
||||||
|
|
||||||
|
币种首期允许 `CNY / USD / EUR / HKD / JPY / GBP`。非法币种返回 422。接口、OpenAPI、前端 operation 合同保持一一对应。
|
||||||
|
|
||||||
|
## 5. 状态与异常规则
|
||||||
|
|
||||||
|
- 首次加载显示骨架屏;刷新时保留已有内容并显示加载状态。
|
||||||
|
- 网络或后端失败显示中文错误提示和重试按钮。
|
||||||
|
- 指标底层权限不足时只隐藏该指标的真实值与跳转,不影响其他已授权模块。
|
||||||
|
- 无趋势数据时显示真实零序列和空态,不构造演示数值。
|
||||||
|
- 文件目录不可读写时系统状态显示警告;在线更新关闭时显示“未启用”,不误判为服务宕机。
|
||||||
|
- 地域数据未配置时展示明确说明,不加载虚假坐标、地图轮廓或项目点位。
|
||||||
|
|
||||||
|
## 6. 验收标准
|
||||||
|
|
||||||
|
### 6.1 权限与路由
|
||||||
|
|
||||||
|
- PAGE-24 存在于 routes、components、operations 三类合同中。
|
||||||
|
- 仪表盘是左侧第一项;无 `dashboard:overview:view` 时菜单不可见、直达返回 403。
|
||||||
|
- 业务角色获得该权限后可访问;超级管理员无须额外配置。
|
||||||
|
- 超级管理员选择身份、点击文字 Logo、结果页返回首页时均进入 `/dashboard`。
|
||||||
|
|
||||||
|
### 6.2 数据正确性
|
||||||
|
|
||||||
|
- 所有查询同时使用当前用户、当前角色、权限码、有效期和数据范围。
|
||||||
|
- 没有底层权限的组件返回 `available=false`,不能泄露金额、数量或跳转地址。
|
||||||
|
- 金额保留数据库精度并按所选币种返回;不得跨币种合计。
|
||||||
|
- 生命周期固定补齐六个阶段;没有数据的阶段返回 0。
|
||||||
|
- 非法币种必须返回业务校验错误。
|
||||||
|
|
||||||
|
### 6.3 视觉与交互
|
||||||
|
|
||||||
|
- 日常驾驶舱、系统状态条、六个 KPI、两类图表、风险区、地域降级区、活动区完整显示。
|
||||||
|
- 大屏支持自动巡航、手动章节切换、按钮退出和 Esc 退出。
|
||||||
|
- 1366×768、1440×900、1920×1080 以及 390px 窄屏无页面横向溢出。
|
||||||
|
- 图表存在可访问名称,容器变化后正确缩放,路由离开后释放实例。
|
||||||
|
- 严重和致命级可访问性问题为 0,浏览器控制台无页面错误。
|
||||||
|
|
||||||
|
### 6.4 工程门禁
|
||||||
|
|
||||||
|
- 后端单元测试、架构授权扫描、Maven `verify` 通过。
|
||||||
|
- 前端 ESLint、Stylelint、Vue 类型检查、Vitest、生产构建和产物卫生扫描通过。
|
||||||
|
- Playwright 全页面回归、PAGE-24 专项、多视口、可访问性和生产产物冒烟通过。
|
||||||
|
- OpenAPI 合同检查和 `git diff --check` 通过。
|
||||||
|
|
||||||
|
## 7. 本轮自验记录
|
||||||
|
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 后端 `mvn verify` | 通过;本机无 Docker,Testcontainers 的 MySQL 集成类按项目既有配置跳过 |
|
||||||
|
| Dashboard 后端单元测试与 Controller 授权扫描 | 通过 |
|
||||||
|
| 前端 ESLint / Stylelint | 通过 |
|
||||||
|
| Vitest | 13 个文件、64 项通过 |
|
||||||
|
| Vue 类型检查 + release build + dist hygiene | 通过 |
|
||||||
|
| PAGE-24 专项 Playwright | 三个桌面视口通过 |
|
||||||
|
| PAGE-24 axe / 页面溢出 | 通过 |
|
||||||
|
| 全页面 Playwright | 246 项均取得通过证据;三并发全量复跑出现 5 项环境性超时,改为单 worker 逐项复测后 5/5 通过 |
|
||||||
|
| release dist 冒烟 | 1 项通过 |
|
||||||
|
| OpenAPI / diff whitespace | 通过 |
|
||||||
|
|
||||||
|
## 8. 后续数据能力
|
||||||
|
|
||||||
|
地图进入真实交付前需为项目增加规范化地域字段(至少省、市、行政区代码),完成历史数据清洗、权限范围验证和坐标映射。地域字段未完成前继续保持当前降级形态,避免用项目名称、地址自由文本或随机坐标推断业务位置。
|
||||||
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
||||||
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
||||||
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、按配置选择是否调用已有 `mysqldump`、切换和回滚,不接受页面传入地址、Token 或命令 |
|
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、按配置选择是否调用已有 `mysqldump`、切换和回滚,不接受页面传入地址、Token 或命令 |
|
||||||
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 10 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
||||||
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 |
|
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 |
|
||||||
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
||||||
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
|
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
{ "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] },
|
{ "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] },
|
||||||
{ "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] },
|
{ "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] },
|
||||||
{ "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] },
|
{ "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] },
|
||||||
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] }
|
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] },
|
||||||
|
{ "pageId": "PAGE-24", "required": ["Card", "Statistic", "Tag", "Select", "Timeline", "Empty", "Alert"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1861,6 +1861,16 @@
|
|||||||
"module": "system-update",
|
"module": "system-update",
|
||||||
"export": "installSystemUpdate"
|
"export": "installSystemUpdate"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operationId": "getDashboardOverview",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/dashboard/overview",
|
||||||
|
"pages": ["PAGE-24"],
|
||||||
|
"api": {
|
||||||
|
"module": "dashboard",
|
||||||
|
"export": "getDashboardOverview"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"infrastructure": [
|
"infrastructure": [
|
||||||
|
|||||||
@@ -276,6 +276,18 @@
|
|||||||
"breadcrumb": ["系统治理", "系统更新"],
|
"breadcrumb": ["系统治理", "系统更新"],
|
||||||
"roles": ["SYSTEM_ADMIN"],
|
"roles": ["SYSTEM_ADMIN"],
|
||||||
"permission": "admin:update:view"
|
"permission": "admin:update:view"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pageId": "PAGE-24",
|
||||||
|
"path": "/dashboard",
|
||||||
|
"component": "src/pages/overview/DashboardPage.vue",
|
||||||
|
"pageType": "business",
|
||||||
|
"pageTemplate": "operations-dashboard",
|
||||||
|
"requiresAuth": true,
|
||||||
|
"description": "统一展示经营指标、资金趋势、项目生命周期、风险异常和系统运行状态,并提供大屏巡航模式。",
|
||||||
|
"breadcrumb": ["仪表盘"],
|
||||||
|
"roles": [],
|
||||||
|
"permission": "dashboard:overview:view"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const routes = (
|
|||||||
).routes;
|
).routes;
|
||||||
|
|
||||||
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
|
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
|
||||||
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']);
|
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23', 'PAGE-24']);
|
||||||
|
|
||||||
const roleNames: Record<string, string> = {
|
const roleNames: Record<string, string> = {
|
||||||
PROJECT_MANAGER: '项目管理人员',
|
PROJECT_MANAGER: '项目管理人员',
|
||||||
@@ -42,6 +42,7 @@ function expectedHeading(route: ContractRoute) {
|
|||||||
if (route.pageId === 'PAGE-03') return '个人中心';
|
if (route.pageId === 'PAGE-03') return '个人中心';
|
||||||
if (route.pageId === 'PAGE-08') return '自动化验收项目';
|
if (route.pageId === 'PAGE-08') return '自动化验收项目';
|
||||||
if (route.pageId === 'PAGE-17') return '项目档案包';
|
if (route.pageId === 'PAGE-17') return '项目档案包';
|
||||||
|
if (route.pageId === 'PAGE-24') return '系统经营仪表盘';
|
||||||
return route.breadcrumb.at(-1) || '';
|
return route.breadcrumb.at(-1) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Page, Route } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const adminSession = {
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
publicId: '01M00000000000000000000001',
|
||||||
|
username: 'dashboard-e2e',
|
||||||
|
displayName: '仪表盘验收管理员',
|
||||||
|
departmentName: '测试组',
|
||||||
|
mustChangePassword: false,
|
||||||
|
},
|
||||||
|
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
|
||||||
|
activeRole: 'SYSTEM_ADMIN',
|
||||||
|
permissions: [
|
||||||
|
'dashboard:overview:view',
|
||||||
|
'project:project:view',
|
||||||
|
'masterdata:contract:view',
|
||||||
|
'receivable:receipt:view',
|
||||||
|
'receivable:invoice:view',
|
||||||
|
'payment:request:view',
|
||||||
|
'workflow:task:view',
|
||||||
|
'project:risk-flag:view',
|
||||||
|
'archive:package:view',
|
||||||
|
'archive:file:view',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboard = {
|
||||||
|
title: '系统经营仪表盘',
|
||||||
|
refreshedAt: '2026-08-19T04:00:00Z',
|
||||||
|
currency: 'CNY',
|
||||||
|
system: {
|
||||||
|
overallStatus: 'UP',
|
||||||
|
currentVersion: '1.0.0-preview.48',
|
||||||
|
uptimeSeconds: 7260,
|
||||||
|
services: [
|
||||||
|
{ code: 'APPLICATION', label: '应用服务', status: 'UP', detail: '服务运行正常' },
|
||||||
|
{ code: 'DATABASE', label: '数据库连接', status: 'UP', detail: '业务数据库连接正常' },
|
||||||
|
{ code: 'FILE_STORAGE', label: '文件存储', status: 'UP', detail: '存储目录可读写' },
|
||||||
|
{ code: 'UPDATE', label: '更新服务', status: 'UP', detail: '在线更新服务已启用' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
code: 'ACTIVE_PROJECTS',
|
||||||
|
label: '进行中项目',
|
||||||
|
kind: 'COUNT',
|
||||||
|
value: '8',
|
||||||
|
unit: '项',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/projects',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'CONTRACT_AMOUNT',
|
||||||
|
label: '合同总额',
|
||||||
|
kind: 'MONEY',
|
||||||
|
value: '1234567.89',
|
||||||
|
unit: 'CNY',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/finance/contracts-costs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'RECEIPT_AMOUNT',
|
||||||
|
label: '累计收款',
|
||||||
|
kind: 'MONEY',
|
||||||
|
value: '456789.00',
|
||||||
|
unit: 'CNY',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/finance/receipts-invoices',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'PAYMENT_AMOUNT',
|
||||||
|
label: '累计付款',
|
||||||
|
kind: 'MONEY',
|
||||||
|
value: '234567.00',
|
||||||
|
unit: 'CNY',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/finance/payments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'PENDING_TASKS',
|
||||||
|
label: '待办审批',
|
||||||
|
kind: 'COUNT',
|
||||||
|
value: '4',
|
||||||
|
unit: '项',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/tasks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ACTIVE_RISKS',
|
||||||
|
label: '风险事项',
|
||||||
|
kind: 'COUNT',
|
||||||
|
value: '2',
|
||||||
|
unit: '项',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/projects?riskStatus=ACTIVE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trend: {
|
||||||
|
currency: 'CNY',
|
||||||
|
receiptsAvailable: true,
|
||||||
|
paymentsAvailable: true,
|
||||||
|
invoicesAvailable: true,
|
||||||
|
points: [
|
||||||
|
{ period: '2026-03', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' },
|
||||||
|
{ period: '2026-04', receivedAmount: '200', paidAmount: '120', invoicedAmount: '180' },
|
||||||
|
{ period: '2026-05', receivedAmount: '320', paidAmount: '180', invoicedAmount: '260' },
|
||||||
|
{ period: '2026-06', receivedAmount: '410', paidAmount: '220', invoicedAmount: '330' },
|
||||||
|
{ period: '2026-07', receivedAmount: '500', paidAmount: '280', invoicedAmount: '420' },
|
||||||
|
{ period: '2026-08', receivedAmount: '620', paidAmount: '360', invoicedAmount: '510' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lifecycle: [
|
||||||
|
{ code: 'INITIATION', label: '立项', value: 2, available: true, targetRoute: '/projects?stage=INITIATION' },
|
||||||
|
{ code: 'EXECUTION', label: '执行中', value: 4, available: true, targetRoute: '/projects?stage=EXECUTION' },
|
||||||
|
{ code: 'SETTLEMENT', label: '结算', value: 2, available: true, targetRoute: '/projects?stage=SETTLEMENT' },
|
||||||
|
],
|
||||||
|
risks: [
|
||||||
|
{
|
||||||
|
code: 'PROJECT_RISK',
|
||||||
|
label: '项目风险',
|
||||||
|
severity: 'DANGER',
|
||||||
|
count: 2,
|
||||||
|
detail: '当前权限范围内的有效风险标记',
|
||||||
|
available: true,
|
||||||
|
targetRoute: '/projects?riskStatus=ACTIVE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
geography: { available: false, message: '项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。', regions: [] },
|
||||||
|
activities: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function installFixture(page: Page) {
|
||||||
|
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') {
|
||||||
|
await route.fulfill({ json: { data: { required: false, locked: true } } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === '/api/v1/auth/session') {
|
||||||
|
await route.fulfill({ json: { data: adminSession } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === '/api/v1/auth/profile') {
|
||||||
|
await route.fulfill({ json: { data: adminSession.user } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === '/api/v1/dashboard/overview') {
|
||||||
|
await route.fulfill({ json: { data: dashboard } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hybrid dashboard renders the cockpit, safe fallback and big-screen mode', async ({ page }) => {
|
||||||
|
await installFixture(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('进行中项目')).toBeVisible();
|
||||||
|
await expect(page.getByText('项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '进入大屏' }).click();
|
||||||
|
await expect(page.getByText('当前章节:经营总览')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '退出大屏' })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -384,7 +384,7 @@ test('brand logo returns the active identity to its workbench', async ({ page })
|
|||||||
|
|
||||||
await page.locator('.brand-logo').click();
|
await page.locator('.brand-logo').click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/governance\/settings$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
import type { WorkbenchActivity } from './workbench';
|
||||||
|
|
||||||
|
export type DashboardStatus = 'UP' | 'WARNING' | 'DOWN' | 'DISABLED';
|
||||||
|
|
||||||
|
export interface DashboardServiceStatus {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
status: DashboardStatus;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSystem {
|
||||||
|
overallStatus: DashboardStatus;
|
||||||
|
currentVersion: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
services: DashboardServiceStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardMetric {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'COUNT' | 'MONEY';
|
||||||
|
value: string;
|
||||||
|
unit: string;
|
||||||
|
available: boolean;
|
||||||
|
targetRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTrendPoint {
|
||||||
|
period: string;
|
||||||
|
receivedAmount: string;
|
||||||
|
paidAmount: string;
|
||||||
|
invoicedAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTrend {
|
||||||
|
currency: string;
|
||||||
|
receiptsAvailable: boolean;
|
||||||
|
paymentsAvailable: boolean;
|
||||||
|
invoicesAvailable: boolean;
|
||||||
|
points: DashboardTrendPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardDistribution {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
available: boolean;
|
||||||
|
targetRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRisk {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
severity: 'DANGER' | 'WARNING' | 'INFO';
|
||||||
|
count: number;
|
||||||
|
detail: string;
|
||||||
|
available: boolean;
|
||||||
|
targetRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRegion {
|
||||||
|
regionCode: string;
|
||||||
|
regionName: string;
|
||||||
|
projectCount: number;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardGeography {
|
||||||
|
available: boolean;
|
||||||
|
message: string;
|
||||||
|
regions: DashboardRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOverview {
|
||||||
|
title: string;
|
||||||
|
refreshedAt: string;
|
||||||
|
currency: string;
|
||||||
|
system: DashboardSystem;
|
||||||
|
metrics: DashboardMetric[];
|
||||||
|
trend: DashboardTrend;
|
||||||
|
lifecycle: DashboardDistribution[];
|
||||||
|
risks: DashboardRisk[];
|
||||||
|
geography: DashboardGeography;
|
||||||
|
activities: WorkbenchActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
|
||||||
|
|
||||||
|
export function normalizeDashboard(view: Partial<DashboardOverview> | null | undefined): DashboardOverview {
|
||||||
|
return {
|
||||||
|
title: String(view?.title || '系统经营仪表盘'),
|
||||||
|
refreshedAt: String(view?.refreshedAt || ''),
|
||||||
|
currency: String(view?.currency || 'CNY'),
|
||||||
|
system: {
|
||||||
|
overallStatus: view?.system?.overallStatus || 'WARNING',
|
||||||
|
currentVersion: String(view?.system?.currentVersion || '-'),
|
||||||
|
uptimeSeconds: Number(view?.system?.uptimeSeconds || 0),
|
||||||
|
services: array(view?.system?.services),
|
||||||
|
},
|
||||||
|
metrics: array(view?.metrics),
|
||||||
|
trend: {
|
||||||
|
currency: String(view?.trend?.currency || view?.currency || 'CNY'),
|
||||||
|
receiptsAvailable: Boolean(view?.trend?.receiptsAvailable),
|
||||||
|
paymentsAvailable: Boolean(view?.trend?.paymentsAvailable),
|
||||||
|
invoicesAvailable: Boolean(view?.trend?.invoicesAvailable),
|
||||||
|
points: array(view?.trend?.points),
|
||||||
|
},
|
||||||
|
lifecycle: array(view?.lifecycle),
|
||||||
|
risks: array(view?.risks),
|
||||||
|
geography: {
|
||||||
|
available: Boolean(view?.geography?.available),
|
||||||
|
message: String(view?.geography?.message || '暂无地域数据'),
|
||||||
|
regions: array(view?.geography?.regions),
|
||||||
|
},
|
||||||
|
activities: array(view?.activities),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardOverview(currency = 'CNY') {
|
||||||
|
return request.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency } }).then(normalizeDashboard);
|
||||||
|
}
|
||||||
@@ -94,6 +94,6 @@ const getPath = (item: ListItemType) => {
|
|||||||
return active.value;
|
return active.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return item.meta?.single ? item.redirect : item.path;
|
return item.meta?.single ? item.redirect || item.path : item.path;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="dashboard-chart"
|
||||||
|
:style="{ height }"
|
||||||
|
:aria-label="label"
|
||||||
|
role="img"
|
||||||
|
data-testid="dashboard-chart"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||||
|
import { AriaComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components';
|
||||||
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
option: EChartsCoreOption;
|
||||||
|
height?: string;
|
||||||
|
label: string;
|
||||||
|
dark?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ height: '300px', dark: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
AriaComponent,
|
||||||
|
BarChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
LineChart,
|
||||||
|
PieChart,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const container = ref<HTMLDivElement>();
|
||||||
|
let chart: echarts.ECharts | null = null;
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
let chartDark: boolean | null = null;
|
||||||
|
|
||||||
|
const render = async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (!container.value) return;
|
||||||
|
if (!chart) {
|
||||||
|
chart = echarts.init(container.value, props.dark ? 'dark' : undefined, { renderer: 'canvas' });
|
||||||
|
chartDark = props.dark;
|
||||||
|
}
|
||||||
|
chart.setOption(props.option, { notMerge: true, lazyUpdate: false });
|
||||||
|
chart.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new ResizeObserver(() => chart?.resize());
|
||||||
|
if (container.value) observer.observe(container.value);
|
||||||
|
void render();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.option, props.dark] as const,
|
||||||
|
() => {
|
||||||
|
if (chart && chartDark !== props.dark) {
|
||||||
|
chart.dispose();
|
||||||
|
chart = null;
|
||||||
|
chartDark = null;
|
||||||
|
}
|
||||||
|
void render();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
chart?.dispose();
|
||||||
|
chart = null;
|
||||||
|
chartDark = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-chart {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,14 +21,7 @@ defineOptions({
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const workbenchRoute =
|
const workbenchRoute = userStore.workbenchRoute;
|
||||||
userStore.currentRole === 'PROJECT_MANAGER'
|
|
||||||
? '/workbench/project'
|
|
||||||
: userStore.currentRole === 'FINANCE_MANAGER'
|
|
||||||
? '/workbench/finance'
|
|
||||||
: userStore.currentRole === 'ARCHIVE_MANAGER'
|
|
||||||
? '/workbench/archive'
|
|
||||||
: '/governance/settings';
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.result-success {
|
.result-success {
|
||||||
|
|||||||
@@ -23,14 +23,7 @@ defineOptions({
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const workbenchRoute =
|
const workbenchRoute = userStore.workbenchRoute;
|
||||||
userStore.currentRole === 'PROJECT_MANAGER'
|
|
||||||
? '/workbench/project'
|
|
||||||
: userStore.currentRole === 'FINANCE_MANAGER'
|
|
||||||
? '/workbench/finance'
|
|
||||||
: userStore.currentRole === 'ARCHIVE_MANAGER'
|
|
||||||
? '/workbench/archive'
|
|
||||||
: '/governance/settings';
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.result-success {
|
.result-success {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const reportsPage = () => import('@/pages/reports/ReportsPage.vue');
|
|||||||
const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue');
|
const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue');
|
||||||
const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue');
|
const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue');
|
||||||
const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue');
|
const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue');
|
||||||
|
const dashboardPage = () => import('@/pages/overview/DashboardPage.vue');
|
||||||
const workbench = (name: 'project' | 'finance' | 'archive') => {
|
const workbench = (name: 'project' | 'finance' | 'archive') => {
|
||||||
if (name === 'project') return () => import('@/pages/workbench/project.vue');
|
if (name === 'project') return () => import('@/pages/workbench/project.vue');
|
||||||
if (name === 'finance') return () => import('@/pages/workbench/finance.vue');
|
if (name === 'finance') return () => import('@/pages/workbench/finance.vue');
|
||||||
@@ -77,6 +78,28 @@ const businessContract = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const financeRoutes: RouteRecordRaw[] = [
|
export const financeRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: LAYOUT,
|
||||||
|
meta: {
|
||||||
|
title: { zh_CN: '仪表盘', en_US: 'Dashboard' },
|
||||||
|
titleText: '仪表盘',
|
||||||
|
icon: shallowRef(ChartBubbleIcon),
|
||||||
|
orderNo: 1,
|
||||||
|
single: true,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
businessContract(
|
||||||
|
'PAGE-24',
|
||||||
|
'',
|
||||||
|
'DashboardOverview',
|
||||||
|
'仪表盘',
|
||||||
|
{ dataKind: 'dashboard', breadcrumb: ['仪表盘'] },
|
||||||
|
dashboardPage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [
|
group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [
|
||||||
{
|
{
|
||||||
path: 'project',
|
path: 'project',
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export const useUserStore = defineStore('user', {
|
|||||||
getters: {
|
getters: {
|
||||||
displayName: (state) => state.user.displayName || state.user.username,
|
displayName: (state) => state.user.displayName || state.user.username,
|
||||||
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
|
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
|
||||||
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
|
workbenchRoute: (state) =>
|
||||||
|
hasPermissionAccess(state.currentRole, state.permissions, 'dashboard:overview:view')
|
||||||
|
? '/dashboard'
|
||||||
|
: resolveWorkbenchRoute(state.currentRole, state.roles),
|
||||||
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
|
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
|
||||||
hasPermission: (state) => (permission: string) =>
|
hasPermission: (state) => (permission: string) =>
|
||||||
hasPermissionAccess(state.currentRole, state.permissions, permission),
|
hasPermissionAccess(state.currentRole, state.permissions, permission),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fallbackWorkbenchRoutes: Record<string, string> = {
|
|||||||
PROJECT_MANAGER: '/workbench/project',
|
PROJECT_MANAGER: '/workbench/project',
|
||||||
FINANCE_MANAGER: '/workbench/finance',
|
FINANCE_MANAGER: '/workbench/finance',
|
||||||
ARCHIVE_MANAGER: '/workbench/archive',
|
ARCHIVE_MANAGER: '/workbench/archive',
|
||||||
SYSTEM_ADMIN: '/governance/settings',
|
SYSTEM_ADMIN: '/dashboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
|
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
|
||||||
|
|||||||
@@ -171,12 +171,12 @@ describe('frontend machine contracts', () => {
|
|||||||
expect(paymentApi).toContain("responseType: 'blob'");
|
expect(paymentApi).toContain("responseType: 'blob'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('declares every PAGE-01 through PAGE-23 exactly once', () => {
|
it('declares every PAGE-01 through PAGE-24 exactly once', () => {
|
||||||
const expected = Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
const expected = Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
||||||
const routePageIds = routesContract.routes.map((route) => route.pageId);
|
const routePageIds = routesContract.routes.map((route) => route.pageId);
|
||||||
const componentPageIds = componentsContract.pages.map((page) => page.pageId);
|
const componentPageIds = componentsContract.pages.map((page) => page.pageId);
|
||||||
expect([...routePageIds].sort()).toEqual([...expected].sort());
|
expect([...routePageIds].sort()).toEqual([...expected].sort());
|
||||||
expect(new Set(routePageIds).size).toBe(23);
|
expect(new Set(routePageIds).size).toBe(24);
|
||||||
expect([...componentPageIds].sort()).toEqual([...expected].sort());
|
expect([...componentPageIds].sort()).toEqual([...expected].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { normalizeDashboard } from '../src/api/dashboard';
|
||||||
|
|
||||||
|
describe('pAGE-24 dashboard contracts', () => {
|
||||||
|
const pageSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/DashboardPage.vue'), 'utf8');
|
||||||
|
const chartSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/components/DashboardChart.vue'), 'utf8');
|
||||||
|
|
||||||
|
it('normalizes incomplete server data without creating fake business values', () => {
|
||||||
|
const view = normalizeDashboard({
|
||||||
|
system: { services: null } as any,
|
||||||
|
metrics: null,
|
||||||
|
trend: { points: null } as any,
|
||||||
|
lifecycle: undefined,
|
||||||
|
risks: null,
|
||||||
|
geography: { available: false, regions: null } as any,
|
||||||
|
activities: undefined,
|
||||||
|
} as unknown as Partial<ReturnType<typeof normalizeDashboard>>);
|
||||||
|
|
||||||
|
expect(view.metrics).toEqual([]);
|
||||||
|
expect(view.trend.points).toEqual([]);
|
||||||
|
expect(view.lifecycle).toEqual([]);
|
||||||
|
expect(view.risks).toEqual([]);
|
||||||
|
expect(view.activities).toEqual([]);
|
||||||
|
expect(view.geography.available).toBe(false);
|
||||||
|
expect(view.geography.regions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines the hybrid cockpit and explicit permission-safe geography fallback', () => {
|
||||||
|
expect(pageSource).toContain('data-scene');
|
||||||
|
expect(pageSource).toContain('进入大屏');
|
||||||
|
expect(pageSource).toContain('退出大屏');
|
||||||
|
expect(pageSource).toContain('dashboard.geography.available');
|
||||||
|
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
|
||||||
|
expect(pageSource).toContain('当前身份没有查看仪表盘或相关业务数据的权限');
|
||||||
|
expect(pageSource).toContain('setInterval(() => void loadDashboard(true), 60000)');
|
||||||
|
expect(pageSource).not.toMatch(/<button\b|<input\b|<select\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses modular ECharts with resize and disposal lifecycle', () => {
|
||||||
|
expect(chartSource).toContain("from 'echarts/core'");
|
||||||
|
expect(chartSource).toContain('ResizeObserver');
|
||||||
|
expect(chartSource).toContain('chart.dispose()');
|
||||||
|
expect(chartSource).toContain('aria-label="label"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import { canAccess, filterMenu, hasRoleAccess } from '../src/store/modules/menu-
|
|||||||
describe('router and TDesign menu contracts', () => {
|
describe('router and TDesign menu contracts', () => {
|
||||||
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
|
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
|
||||||
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
|
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
|
||||||
Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
||||||
);
|
);
|
||||||
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
|
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
|
||||||
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
|
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
|
||||||
@@ -32,12 +32,31 @@ describe('router and TDesign menu contracts', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
collectPaths(allRoutes);
|
collectPaths(allRoutes);
|
||||||
|
expect(flattenedPaths).toContain('/dashboard');
|
||||||
expect(flattenedPaths).toContain('/projects/');
|
expect(flattenedPaths).toContain('/projects/');
|
||||||
expect(flattenedPaths).not.toContain('/dashboard/base');
|
expect(flattenedPaths).not.toContain('/dashboard/base');
|
||||||
expect(flattenedPaths).not.toContain('/detail/base');
|
expect(flattenedPaths).not.toContain('/detail/base');
|
||||||
expect(flattenedPaths).not.toContain('/list/base');
|
expect(flattenedPaths).not.toContain('/list/base');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('places the dashboard first and hides it without its dedicated permission', () => {
|
||||||
|
const dashboard = allRoutes.find((route) => route.path === '/dashboard');
|
||||||
|
expect(dashboard?.meta?.orderNo).toBe(1);
|
||||||
|
expect(dashboard?.children?.[0]?.meta?.permission).toBe('dashboard:overview:view');
|
||||||
|
|
||||||
|
const withoutDashboard = filterMenu(allRoutes, {
|
||||||
|
currentRole: 'FINANCE_MANAGER',
|
||||||
|
permissions: ['workflow:task:view'],
|
||||||
|
});
|
||||||
|
expect(withoutDashboard.some((route) => route.path === '/dashboard')).toBe(false);
|
||||||
|
|
||||||
|
const withDashboard = filterMenu(allRoutes, {
|
||||||
|
currentRole: 'CUSTOM_ROLE',
|
||||||
|
permissions: ['dashboard:overview:view'],
|
||||||
|
});
|
||||||
|
expect(withDashboard[0]?.path).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
it('filters menu leaves with the same role and permission rules used by route access', () => {
|
it('filters menu leaves with the same role and permission rules used by route access', () => {
|
||||||
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
|
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
|
||||||
const financeIdentity = {
|
const financeIdentity = {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import type { RoleView } from '../src/api/auth';
|
|||||||
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
|
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
|
||||||
|
|
||||||
const roles: RoleView[] = [
|
const roles: RoleView[] = [
|
||||||
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
|
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/dashboard' },
|
||||||
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
|
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('active identity workbench route', () => {
|
describe('active identity workbench route', () => {
|
||||||
it('uses the route supplied for the active role', () => {
|
it('uses the route supplied for the active role', () => {
|
||||||
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
|
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/dashboard');
|
||||||
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
|
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+194
@@ -980,6 +980,16 @@ components:
|
|||||||
requestId:
|
requestId:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
ApiResponseDashboardOverviewView:
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
"$ref": "#/components/schemas/DashboardOverviewView"
|
||||||
|
meta:
|
||||||
|
additionalProperties: {}
|
||||||
|
type: object
|
||||||
|
requestId:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
ApiResponseWorkflowTaskDetailView:
|
ApiResponseWorkflowTaskDetailView:
|
||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
@@ -6482,6 +6492,163 @@ components:
|
|||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
DashboardOverviewView:
|
||||||
|
properties:
|
||||||
|
activities:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/WorkbenchActivityView"
|
||||||
|
type: array
|
||||||
|
currency:
|
||||||
|
type: string
|
||||||
|
geography:
|
||||||
|
"$ref": "#/components/schemas/DashboardGeographyView"
|
||||||
|
lifecycle:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/DashboardDistributionView"
|
||||||
|
type: array
|
||||||
|
metrics:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/DashboardMetricView"
|
||||||
|
type: array
|
||||||
|
refreshedAt:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
risks:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/DashboardRiskView"
|
||||||
|
type: array
|
||||||
|
system:
|
||||||
|
"$ref": "#/components/schemas/DashboardSystemView"
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
trend:
|
||||||
|
"$ref": "#/components/schemas/DashboardTrendView"
|
||||||
|
type: object
|
||||||
|
DashboardSystemView:
|
||||||
|
properties:
|
||||||
|
currentVersion:
|
||||||
|
type: string
|
||||||
|
overallStatus:
|
||||||
|
type: string
|
||||||
|
services:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/DashboardServiceStatusView"
|
||||||
|
type: array
|
||||||
|
uptimeSeconds:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DashboardServiceStatusView:
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
DashboardMetricView:
|
||||||
|
properties:
|
||||||
|
available:
|
||||||
|
type: boolean
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
targetRoute:
|
||||||
|
type: string
|
||||||
|
unit:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
DashboardTrendView:
|
||||||
|
properties:
|
||||||
|
currency:
|
||||||
|
type: string
|
||||||
|
invoicesAvailable:
|
||||||
|
type: boolean
|
||||||
|
paymentsAvailable:
|
||||||
|
type: boolean
|
||||||
|
points:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/DashboardTrendPointView"
|
||||||
|
type: array
|
||||||
|
receiptsAvailable:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
DashboardTrendPointView:
|
||||||
|
properties:
|
||||||
|
invoicedAmount:
|
||||||
|
type: string
|
||||||
|
paidAmount:
|
||||||
|
type: string
|
||||||
|
period:
|
||||||
|
type: string
|
||||||
|
receivedAmount:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
DashboardDistributionView:
|
||||||
|
properties:
|
||||||
|
available:
|
||||||
|
type: boolean
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
targetRoute:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DashboardRiskView:
|
||||||
|
properties:
|
||||||
|
available:
|
||||||
|
type: boolean
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
targetRoute:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
DashboardGeographyView:
|
||||||
|
properties:
|
||||||
|
available:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
regions:
|
||||||
|
items:
|
||||||
|
"$ref": "#/components/schemas/DashboardRegionView"
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
DashboardRegionView:
|
||||||
|
properties:
|
||||||
|
amount:
|
||||||
|
type: string
|
||||||
|
currency:
|
||||||
|
type: string
|
||||||
|
projectCount:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
regionCode:
|
||||||
|
type: string
|
||||||
|
regionName:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
WorkbenchArchiveItemView:
|
WorkbenchArchiveItemView:
|
||||||
properties:
|
properties:
|
||||||
completeness:
|
completeness:
|
||||||
@@ -14157,6 +14324,33 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
tags:
|
tags:
|
||||||
- workbench-controller
|
- workbench-controller
|
||||||
|
"/api/v1/dashboard/overview":
|
||||||
|
get:
|
||||||
|
operationId: getDashboardOverview
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: currency
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
default: CNY
|
||||||
|
enum:
|
||||||
|
- CNY
|
||||||
|
- USD
|
||||||
|
- EUR
|
||||||
|
- HKD
|
||||||
|
- JPY
|
||||||
|
- GBP
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
"$ref": "#/components/schemas/ApiResponseDashboardOverviewView"
|
||||||
|
description: OK
|
||||||
|
summary: 查询当前身份可见的系统仪表盘
|
||||||
|
tags:
|
||||||
|
- dashboard-controller
|
||||||
"/api/v1/workbenches/finance":
|
"/api/v1/workbenches/finance":
|
||||||
get:
|
get:
|
||||||
operationId: finance
|
operationId: finance
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
更新日志(Preview 49)
|
||||||
|
|
||||||
|
本版本新增系统经营仪表盘,并将其设为已授权用户的首要业务入口。
|
||||||
|
|
||||||
|
• 新增“仪表盘”菜单和独立权限节点 dashboard:overview:view;超级管理员默认拥有完整访问权限,其他岗位可按角色授权。
|
||||||
|
• 新增经营驾驶舱,集中展示进行中项目、合同金额、收款、付款、待办审批、风险事项和最近业务活动。
|
||||||
|
• 新增近六个月资金趋势、项目生命周期分布和风险异常图表;所有指标继续遵守原业务权限与 GLOBAL、COMPANY、PROJECT 数据范围。
|
||||||
|
• 新增应用、数据库、文件存储和在线更新服务状态摘要,并展示当前版本与运行时长。
|
||||||
|
• 新增三章节全屏大屏巡航模式,支持自动切换、手动切换和 Esc 退出,同时完成 1366、1440、1920 视口适配。
|
||||||
|
• 当前业务数据尚无统一省市字段,地图区域采用明确的数据能力降级提示,不生成虚假地域和随机坐标。
|
||||||
|
• 超级管理员选择身份、点击文字 Logo 或从结果页返回首页时,统一进入新仪表盘,不再默认打开“权限与配置”。
|
||||||
|
• 演示数据脚本兼容 Flyway V073 与本版本新增的 V074 仪表盘权限迁移。
|
||||||
|
|
||||||
|
升级说明:本版本新增 V074 权限迁移,只写入仪表盘权限及超级管理员的授权范围,不安装或管理 MySQL,也不改变现有财务业务记录、审批规则和外部服务配置。
|
||||||
@@ -241,6 +241,15 @@ ruby -ryaml -e '
|
|||||||
abort "system update response drifted" unless update_response ==
|
abort "system update response drifted" unless update_response ==
|
||||||
"#/components/schemas/ApiResponseSystemUpdateView"
|
"#/components/schemas/ApiResponseSystemUpdateView"
|
||||||
|
|
||||||
|
dashboard = paths.fetch("/api/v1/dashboard/overview").fetch("get")
|
||||||
|
abort "dashboard operationId drifted" unless dashboard["operationId"] == "getDashboardOverview"
|
||||||
|
dashboard_currency = dashboard.fetch("parameters").find { |parameter| parameter["name"] == "currency" }
|
||||||
|
abort "dashboard currency whitelist drifted" unless dashboard_currency&.dig("schema", "enum") ==
|
||||||
|
%w[CNY USD EUR HKD JPY GBP]
|
||||||
|
dashboard_response = dashboard.dig("responses", "200", "content", "application/json", "schema", "$ref")
|
||||||
|
abort "dashboard response drifted" unless dashboard_response ==
|
||||||
|
"#/components/schemas/ApiResponseDashboardOverviewView"
|
||||||
|
|
||||||
governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get")
|
governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get")
|
||||||
parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||||
expected_parameter_names = %w[keyword page resource size]
|
expected_parameter_names = %w[keyword page resource size]
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ mode_of() {
|
|||||||
source "$WORK/helpers.sh"
|
source "$WORK/helpers.sh"
|
||||||
# Avoid invoking the host's macOS `log` utility while exercising extracted
|
# Avoid invoking the host's macOS `log` utility while exercising extracted
|
||||||
# installer helpers.
|
# installer helpers.
|
||||||
|
# shellcheck disable=SC2329 # Referenced by the sourced installer helper functions.
|
||||||
log() { printf '%s\n' "$*" >/dev/null; }
|
log() { printf '%s\n' "$*" >/dev/null; }
|
||||||
|
|
||||||
export SETUP_WIZARD=true
|
export SETUP_WIZARD=true
|
||||||
|
|||||||
Reference in New Issue
Block a user