From 12d78b4c866ce564abcd47280b5aba0c45605915 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Wed, 19 Aug 2026 13:51:48 +0800 Subject: [PATCH] feat: add system operations dashboard --- README.md | 24 +- .../dashboard/api/DashboardController.java | 30 + .../api/DashboardDistributionView.java | 10 + .../dashboard/api/DashboardGeographyView.java | 10 + .../dashboard/api/DashboardMetricView.java | 12 + .../dashboard/api/DashboardOverviewView.java | 19 + .../dashboard/api/DashboardRegionView.java | 10 + .../dashboard/api/DashboardRiskView.java | 12 + .../api/DashboardServiceStatusView.java | 9 + .../dashboard/api/DashboardSystemView.java | 11 + .../api/DashboardTrendPointView.java | 9 + .../dashboard/api/DashboardTrendView.java | 12 + .../DashboardApplicationService.java | 278 ++++ .../infrastructure/DashboardMapper.java | 256 ++++ .../application/AuthApplicationService.java | 2 +- .../V074__dashboard_overview_permission.sql | 40 + .../DashboardApplicationServiceTest.java | 136 ++ .../finance/iam/AuthIntegrationTest.java | 2 +- deploy/demo-data-mysql8-v073.sql | 8 +- deploy/env.production.example | 2 +- docs/系统经营仪表盘设计与验收说明.md | 136 ++ docs/财务系统开发交付总方案.md | 2 +- frontend/contracts/components.json | 3 +- frontend/contracts/operations.json | 10 + frontend/contracts/routes.json | 12 + frontend/e2e/accessibility.e2e.ts | 3 +- frontend/e2e/dashboard.e2e.ts | 175 +++ frontend/e2e/system-update.e2e.ts | 2 +- frontend/src/api/dashboard.ts | 125 ++ .../src/layouts/components/MenuContent.vue | 2 +- frontend/src/pages/overview/DashboardPage.vue | 1172 +++++++++++++++++ .../overview/components/DashboardChart.vue | 89 ++ frontend/src/pages/result/fail/index.vue | 9 +- frontend/src/pages/result/success/index.vue | 9 +- frontend/src/router/modules/finance.ts | 23 + frontend/src/store/modules/user.ts | 5 +- frontend/src/store/modules/workbench-route.ts | 2 +- frontend/tests/contracts.test.ts | 6 +- frontend/tests/dashboard.test.ts | 49 + frontend/tests/router-menu-contract.test.ts | 21 +- frontend/tests/user-workbench-route.test.ts | 4 +- openapi.yaml | 194 +++ release-notes/1.0.0-preview.49.md | 14 + scripts/check-openapi.sh | 9 + scripts/test-install-fixture.sh | 1 + 45 files changed, 2921 insertions(+), 48 deletions(-) create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardController.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardDistributionView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardGeographyView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardMetricView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardOverviewView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRegionView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRiskView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardServiceStatusView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardSystemView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendPointView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendView.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/dashboard/infrastructure/DashboardMapper.java create mode 100644 backend/src/main/resources/db/migration/V074__dashboard_overview_permission.sql create mode 100644 backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java create mode 100644 docs/系统经营仪表盘设计与验收说明.md create mode 100644 frontend/e2e/dashboard.e2e.ts create mode 100644 frontend/src/api/dashboard.ts create mode 100644 frontend/src/pages/overview/DashboardPage.vue create mode 100644 frontend/src/pages/overview/components/DashboardChart.vue create mode 100644 frontend/tests/dashboard.test.ts create mode 100644 release-notes/1.0.0-preview.49.md diff --git a/README.md b/README.md index c67676c..1a46c36 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ PostgreSQL 18 兼容工作继续冻结。 先在宝塔面板停止并删除当前错误的 Java 项目,再执行: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.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(包括 @@ -97,7 +97,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 随后执行一键清理: ```bash -curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.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` 参数丢失。 @@ -111,16 +111,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删 `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 -### Preview.43 直链与宝塔手动部署 +### Preview.49 直链与宝塔手动部署 本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 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 一键安装**还需要 systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 @@ -137,7 +137,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 ```bash -VERSION=1.0.0-preview.48 +VERSION=1.0.0-preview.49 APP_ROOT=/www/wwwroot/kaidi RELEASE_ROOT="$APP_ROOT/releases/$VERSION" sudo install -d -m 0755 "$RELEASE_ROOT" @@ -246,7 +246,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线 `KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除: ```bash -curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.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 项目;宝塔面板元数据属于外部资源,必须由面板先停用, @@ -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)。该文件按 -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 服务: ```bash @@ -295,8 +295,8 @@ Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-not 清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.48 -git push origin v1.0.0-preview.48 +git tag v1.0.0-preview.49 +git push origin v1.0.0-preview.49 ``` 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 @@ -348,7 +348,7 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.48 + ./scripts/package-release.sh 1.0.0-preview.49 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardController.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardController.java new file mode 100644 index 0000000..6b2ab7d --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardController.java @@ -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 overview( + @RequestParam(defaultValue = "CNY") String currency + ) { + return ApiResponse.ok(dashboardService.overview(currency)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardDistributionView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardDistributionView.java new file mode 100644 index 0000000..089eb27 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardDistributionView.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.dashboard.api; + +public record DashboardDistributionView( + String code, + String label, + long value, + boolean available, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardGeographyView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardGeographyView.java new file mode 100644 index 0000000..f6eb747 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardGeographyView.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.dashboard.api; + +import java.util.List; + +public record DashboardGeographyView( + boolean available, + String message, + List regions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardMetricView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardMetricView.java new file mode 100644 index 0000000..b1dd081 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardMetricView.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardOverviewView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardOverviewView.java new file mode 100644 index 0000000..d54e132 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardOverviewView.java @@ -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 metrics, + DashboardTrendView trend, + List lifecycle, + List risks, + DashboardGeographyView geography, + List activities +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRegionView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRegionView.java new file mode 100644 index 0000000..b1abc80 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRegionView.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.dashboard.api; + +public record DashboardRegionView( + String regionCode, + String regionName, + long projectCount, + String amount, + String currency +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRiskView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRiskView.java new file mode 100644 index 0000000..54336c7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardRiskView.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardServiceStatusView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardServiceStatusView.java new file mode 100644 index 0000000..c47b1ad --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardServiceStatusView.java @@ -0,0 +1,9 @@ +package com.kaidi.finance.dashboard.api; + +public record DashboardServiceStatusView( + String code, + String label, + String status, + String detail +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardSystemView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardSystemView.java new file mode 100644 index 0000000..b3cb226 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardSystemView.java @@ -0,0 +1,11 @@ +package com.kaidi.finance.dashboard.api; + +import java.util.List; + +public record DashboardSystemView( + String overallStatus, + String currentVersion, + long uptimeSeconds, + List services +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendPointView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendPointView.java new file mode 100644 index 0000000..fdcb9cc --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendPointView.java @@ -0,0 +1,9 @@ +package com.kaidi.finance.dashboard.api; + +public record DashboardTrendPointView( + String period, + String receivedAmount, + String paidAmount, + String invoicedAmount +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendView.java b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendView.java new file mode 100644 index 0000000..f09b934 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/api/DashboardTrendView.java @@ -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 points +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java b/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java new file mode 100644 index 0000000..c5d9bcb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/application/DashboardApplicationService.java @@ -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 SUPPORTED_CURRENCIES = Set.of("CNY", "USD", "EUR", "HKD", "JPY", "GBP"); + private static final List 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 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 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 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 receipts = amountMap(canReceipts + ? mapper.receiptTrend(userId, role, currency, fromDate) : List.of()); + Map payments = amountMap(canPayments + ? mapper.paymentTrend(userId, role, currency, fromDate) : List.of()); + Map invoices = amountMap(canInvoices + ? mapper.invoiceTrend(userId, role, currency, fromDate) : List.of()); + List 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 lifecycle(long userId, String role, boolean available) { + Map 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 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 amountMap(List rows) { + Map 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 supplier) { + return available ? supplier.get() : 0; + } + + private BigDecimal amount(boolean available, Supplier 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); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/dashboard/infrastructure/DashboardMapper.java b/backend/src/main/java/com/kaidi/finance/dashboard/infrastructure/DashboardMapper.java new file mode 100644 index 0000000..f4b4178 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/dashboard/infrastructure/DashboardMapper.java @@ -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 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 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 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 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) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java b/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java index cc87dc9..5311e6e 100644 --- a/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java +++ b/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java @@ -376,7 +376,7 @@ public class AuthApplicationService { case "PROJECT_MANAGER" -> "/workbench/project"; case "FINANCE_MANAGER" -> "/workbench/finance"; case "ARCHIVE_MANAGER" -> "/workbench/archive"; - case "SYSTEM_ADMIN" -> "/governance/settings"; + case "SYSTEM_ADMIN" -> "/dashboard"; default -> "/role-select"; }; } diff --git a/backend/src/main/resources/db/migration/V074__dashboard_overview_permission.sql b/backend/src/main/resources/db/migration/V074__dashboard_overview_permission.sql new file mode 100644 index 0000000..5d8c10f --- /dev/null +++ b/backend/src/main/resources/db/migration/V074__dashboard_overview_permission.sql @@ -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; diff --git a/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java b/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java new file mode 100644 index 0000000..a353e0d --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/dashboard/DashboardApplicationServiceTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java index 258b1c8..fb42e8e 100644 --- a/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java +++ b/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java @@ -179,7 +179,7 @@ class AuthIntegrationTest { .andExpect(jsonPath("$.data.roles.length()").value(1)) .andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN")) .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(); ClientSession session = clientSession(login); diff --git a/deploy/demo-data-mysql8-v073.sql b/deploy/demo-data-mysql8-v073.sql index f895bb6..fd93c19 100644 --- a/deploy/demo-data-mysql8-v073.sql +++ b/deploy/demo-data-mysql8-v073.sql @@ -1,5 +1,5 @@ -- 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、但尚无业务数据的数据库中导入展示数据。 -- -- 导入示例: @@ -21,7 +21,7 @@ SET @demo_now := UTC_TIMESTAMP(3); SET @demo_today := DATE(UTC_TIMESTAMP()); -- --------------------------------------------------------------------------- --- 0. 强制前置检查:MySQL 8.4、V073、有效超级管理员、未重复导入 +-- 0. 强制前置检查:MySQL 8.4、V073/V074、有效超级管理员、未重复导入 -- --------------------------------------------------------------------------- CREATE TEMPORARY TABLE kaidi_demo_v073_guard ( 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); INSERT INTO kaidi_demo_v073_guard (check_name, ok) -SELECT 'FLYWAY_EXACT_V073', IF(COALESCE(( +SELECT 'FLYWAY_COMPATIBLE_V073_V074', IF(COALESCE(( SELECT version FROM flyway_schema_history WHERE success = 1 AND version IS NOT NULL ORDER BY installed_rank DESC LIMIT 1 -), '') IN ('73', '073'), 1, 0); +), '') IN ('73', '073', '74', '074'), 1, 0); INSERT INTO kaidi_demo_v073_guard (check_name, ok) SELECT 'SYSTEM_ADMIN_EXISTS', IF(EXISTS ( diff --git a/deploy/env.production.example b/deploy/env.production.example index 1b1663f..8054820 100644 --- a/deploy/env.production.example +++ b/deploy/env.production.example @@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_PASSWORD= -APP_VERSION=1.0.0-preview.48 +APP_VERSION=1.0.0-preview.49 UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION FINANCE_UPDATE_ENABLED=true # Use either a stable direct asset base URL or the public Gitea latest-release API. diff --git a/docs/系统经营仪表盘设计与验收说明.md b/docs/系统经营仪表盘设计与验收说明.md new file mode 100644 index 0000000..ba530e1 --- /dev/null +++ b/docs/系统经营仪表盘设计与验收说明.md @@ -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. 后续数据能力 + +地图进入真实交付前需为项目增加规范化地域字段(至少省、市、行政区代码),完成历史数据清洗、权限范围验证和坐标映射。地域字段未完成前继续保持当前降级形态,避免用项目名称、地址自由文本或随机坐标推断业务位置。 diff --git a/docs/财务系统开发交付总方案.md b/docs/财务系统开发交付总方案.md index 68c3bc2..813570a 100644 --- a/docs/财务系统开发交付总方案.md +++ b/docs/财务系统开发交付总方案.md @@ -77,7 +77,7 @@ | 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 | | PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 | | 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 中执行迁移 | | 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | | 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 | diff --git a/frontend/contracts/components.json b/frontend/contracts/components.json index 1e5de25..b0896dd 100644 --- a/frontend/contracts/components.json +++ b/frontend/contracts/components.json @@ -29,6 +29,7 @@ { "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] }, { "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] }, { "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"] } ] } diff --git a/frontend/contracts/operations.json b/frontend/contracts/operations.json index 1cd1517..97630f9 100644 --- a/frontend/contracts/operations.json +++ b/frontend/contracts/operations.json @@ -1861,6 +1861,16 @@ "module": "system-update", "export": "installSystemUpdate" } + }, + { + "operationId": "getDashboardOverview", + "method": "GET", + "path": "/api/v1/dashboard/overview", + "pages": ["PAGE-24"], + "api": { + "module": "dashboard", + "export": "getDashboardOverview" + } } ], "infrastructure": [ diff --git a/frontend/contracts/routes.json b/frontend/contracts/routes.json index 3edc713..75c4aa3 100644 --- a/frontend/contracts/routes.json +++ b/frontend/contracts/routes.json @@ -276,6 +276,18 @@ "breadcrumb": ["系统治理", "系统更新"], "roles": ["SYSTEM_ADMIN"], "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" } ] } diff --git a/frontend/e2e/accessibility.e2e.ts b/frontend/e2e/accessibility.e2e.ts index 173ad45..f7919cc 100644 --- a/frontend/e2e/accessibility.e2e.ts +++ b/frontend/e2e/accessibility.e2e.ts @@ -21,7 +21,7 @@ const routes = ( ).routes; 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 = { PROJECT_MANAGER: '项目管理人员', @@ -42,6 +42,7 @@ function expectedHeading(route: ContractRoute) { if (route.pageId === 'PAGE-03') return '个人中心'; if (route.pageId === 'PAGE-08') return '自动化验收项目'; if (route.pageId === 'PAGE-17') return '项目档案包'; + if (route.pageId === 'PAGE-24') return '系统经营仪表盘'; return route.breadcrumb.at(-1) || ''; } diff --git a/frontend/e2e/dashboard.e2e.ts b/frontend/e2e/dashboard.e2e.ts new file mode 100644 index 0000000..d276bb1 --- /dev/null +++ b/frontend/e2e/dashboard.e2e.ts @@ -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(); +}); diff --git a/frontend/e2e/system-update.e2e.ts b/frontend/e2e/system-update.e2e.ts index c610750..9b7b18c 100644 --- a/frontend/e2e/system-update.e2e.ts +++ b/frontend/e2e/system-update.e2e.ts @@ -384,7 +384,7 @@ test('brand logo returns the active identity to its workbench', async ({ page }) 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); }); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..b235c1c --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -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 = (value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []); + +export function normalizeDashboard(view: Partial | 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({ url: '/dashboard/overview', params: { currency } }).then(normalizeDashboard); +} diff --git a/frontend/src/layouts/components/MenuContent.vue b/frontend/src/layouts/components/MenuContent.vue index db4bf5e..e421e44 100644 --- a/frontend/src/layouts/components/MenuContent.vue +++ b/frontend/src/layouts/components/MenuContent.vue @@ -94,6 +94,6 @@ const getPath = (item: ListItemType) => { return active.value; } - return item.meta?.single ? item.redirect : item.path; + return item.meta?.single ? item.redirect || item.path : item.path; }; diff --git a/frontend/src/pages/overview/DashboardPage.vue b/frontend/src/pages/overview/DashboardPage.vue new file mode 100644 index 0000000..d1b52ca --- /dev/null +++ b/frontend/src/pages/overview/DashboardPage.vue @@ -0,0 +1,1172 @@ + + + diff --git a/frontend/src/pages/overview/components/DashboardChart.vue b/frontend/src/pages/overview/components/DashboardChart.vue new file mode 100644 index 0000000..01e5e69 --- /dev/null +++ b/frontend/src/pages/overview/components/DashboardChart.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/pages/result/fail/index.vue b/frontend/src/pages/result/fail/index.vue index fd1cab6..1a697b2 100644 --- a/frontend/src/pages/result/fail/index.vue +++ b/frontend/src/pages/result/fail/index.vue @@ -21,14 +21,7 @@ defineOptions({ const router = useRouter(); const userStore = useUserStore(); -const workbenchRoute = - userStore.currentRole === 'PROJECT_MANAGER' - ? '/workbench/project' - : userStore.currentRole === 'FINANCE_MANAGER' - ? '/workbench/finance' - : userStore.currentRole === 'ARCHIVE_MANAGER' - ? '/workbench/archive' - : '/governance/settings'; +const workbenchRoute = userStore.workbenchRoute;