Compare commits

...
Author SHA1 Message Date
Qiufeng 6d4ae00ae0 feat: split online update into explicit stages
Release / release (push) Canceled after 0s
2026-08-18 09:01:34 +08:00
Qiufeng 3c7847c8f5 docs: point preview install example to latest release 2026-08-18 08:05:40 +08:00
Qiufeng d61bdf9da4 fix: redirect after setup and remove redundant breadcrumbs
Release / release (push) Canceled after 0s
2026-08-18 08:01:57 +08:00
Qiufeng 7237792424 fix: discover panel Java runtimes in one-click installer
Release / release (push) Canceled after 0s
2026-08-18 07:19:17 +08:00
14 changed files with 338 additions and 117 deletions
+29 -11
View File
@@ -41,11 +41,27 @@ npm run build
./scripts/export-openapi.sh http://127.0.0.1:18080 ./scripts/export-openapi.sh http://127.0.0.1:18080
``` ```
## R1 Preview 手动部署 ## R1 Preview 部署方式
代码仓库和 Release 已公开。本版交付物是签名的应用压缩包,部署方式固定为“下载、校验、解压、运行本地初始化脚本、 代码仓库和 Release 已公开,提供两种互斥方式:
在宝塔创建 Spring Boot 项目”;不再使用 `curl | bash` 一键安装。程序不会安装 MySQL、JDK、Nginx 或其他第三方服务,
也不会修改宝塔和反向代理配置。数据库只支持运维人员预先准备的外部 MySQL 8.4.x,PostgreSQL 18 兼容工作继续冻结。 1. **systemd 一键安装(推荐)**:程序独占 `/opt/kaidi` 和 `kaidi-finance.service`,不创建宝塔 Java 项目。
2. **宝塔手动部署**:下载压缩包后解压并运行本地初始化脚本,再创建 Spring Boot 项目。
两种方式不能同时运行在同一个端口。程序不会安装 MySQL,数据库只支持运维人员预先准备的外部 MySQL 8.4.x,
PostgreSQL 18 兼容工作继续冻结。
### systemd 一键安装(推荐)
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.28/install.sh | sudo env KAIDI_APP_PORT=18080 bash
```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
`/www/server/java/*/bin/java`);没有可用 Java 时才下载匹配架构的运行时。它不会安装 MySQL,也不会要求宝塔面板提供
`kaidi` 用户。安装完成后查看 `/root/kaidi-first-login.txt`,通过反向代理域名进入 `/setup`。
### 彻底清理旧安装 ### 彻底清理旧安装
@@ -173,7 +189,8 @@ systemctl is-enabled kaidi-update.path
#### 5. 后台在线更新 #### 5. 后台在线更新
初始化脚本已启用 `kaidi-update.path`,后台“系统治理 → 系统更新”流程保持不变:点击“获取更新”下载并验签,确认后点击“立即重启”。 初始化脚本已启用 `kaidi-update.path`。后台“系统治理 → 系统更新”按明确阶段执行:点击“获取版本”只读取版本信息,
发现新版本后点击“立即更新”才开始下载和验签;页面显示“下载完成”后,再点击“立即更新并重启”。
root 更新器原子切换 `/www/wwwroot/kaidi/current`,向当前 Java 进程发送停止信号;宝塔的意外重启守护随后按新 `current` 链接拉起 Java, root 更新器原子切换 `/www/wwwroot/kaidi/current`,向当前 Java 进程发送停止信号;宝塔的意外重启守护随后按新 `current` 链接拉起 Java,
健康检查通过后页面等待 10 秒并自动刷新。更新器不会安装或启动 `kaidi-finance.service`,也不会修改 Nginx。 健康检查通过后页面等待 10 秒并自动刷新。更新器不会安装或启动 `kaidi-finance.service`,也不会修改 Nginx。
@@ -229,12 +246,13 @@ git push origin v1.0.0-preview.25
更新流程固定为: 更新流程固定为:
1. 点击“获取更新”,后端排队 `DOWNLOAD`;更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、文件名和压缩包路径校验, 1. 点击“获取版本”,只实时请求 Gitea Latest Release 版本、发布时间和发布说明,不排队下载。
通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。 2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、
2. 页面显示 `READY/等待重启` 后才出现“立即重启”;未缓存或版本不一致的包不能进入安装。 文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
3. 管理员点击“立即重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。 3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
4. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。 4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。
5. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线由前端轮询恢复。任一检查失败则切回上一应用版本, 5. 更新器安装并保护新版本目录,原子切换 `/www/wwwroot/kaidi/current`,校验旧 Java 的受控 PID 后发送停止信号;宝塔守护自动启动新版本。
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
保留数据库备份和事务证据供人工处理。 保留数据库备份和事务证据供人工处理。
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。 忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
+30 -8
View File
@@ -233,14 +233,8 @@ java_arch_matches_host() {
esac esac
} }
system_java_home() { java_home_from_bin() {
local java_bin resolved_java java_major home local java_bin=$1 resolved_java java_major home
if [ -n "${KAIDI_JAVA_HOME:-}" ]; then
home=${KAIDI_JAVA_HOME%/}
java_bin="$home/bin/java"
else
java_bin=$(command -v java 2>/dev/null || true)
fi
[ -x "$java_bin" ] || return 1 [ -x "$java_bin" ] || return 1
java_major=$( java_major=$(
"$java_bin" -version 2>&1 \ "$java_bin" -version 2>&1 \
@@ -258,6 +252,34 @@ system_java_home() {
printf '%s\n' "$home" printf '%s\n' "$home"
} }
system_java_home() {
local candidate home
if [ -n "${KAIDI_JAVA_HOME:-}" ]; then
candidate="${KAIDI_JAVA_HOME%/}/bin/java"
java_home_from_bin "$candidate" && return 0
return 1
fi
candidate=$(command -v java 2>/dev/null || true)
if [ -n "$candidate" ] && home=$(java_home_from_bin "$candidate"); then
printf '%s\n' "$home"
return 0
fi
# Baota commonly keeps JDKs outside PATH. Prefer an existing Java 17 there
# before consulting the remote Azul metadata endpoint.
for candidate in \
/www/server/java/*/bin/java \
/www/server/java/*/jre/bin/java \
/usr/lib/jvm/*/bin/java \
/usr/java/*/bin/java; do
[ -x "$candidate" ] || continue
if home=$(java_home_from_bin "$candidate"); then
printf '%s\n' "$home"
return 0
fi
done
return 1
}
prepare_java() { prepare_java() {
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line
if system_home=$(system_java_home); then if system_home=$(system_java_home); then
+26 -15
View File
@@ -76,7 +76,7 @@
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 | | 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
| 来源表格校验 | 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 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 | | PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、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 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/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` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 | | Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 |
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | | 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
@@ -637,7 +637,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
### 7.3 Starter 菜单、路由与页面映射 ### 7.3 Starter 菜单、路由与页面映射
登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、面包屑和内容区。菜单名称、顺序和路由在第一轮固定如下;详情、编辑等隐藏路由不出现在左侧菜单,但必须保留面包屑和返回路径。 登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、页签和内容区。菜单层级已经表达业务归属,首期不在内容区显示面包屑,避免与菜单和页签重复;详情、编辑等隐藏路由必须保留安全返回路径。
| 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 | | 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@@ -664,7 +664,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
| 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 | | 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 |
| 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 | | 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 |
本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission`、`requiresAuth` 和面包屑信息;出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。 本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission` 和 `requiresAuth`;历史 `breadcrumb` 字段可保留作路由合同和返回路径元数据,但不得渲染为可见导航。出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。
同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。 同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。
@@ -695,7 +695,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
| PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 | | PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 |
| PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 | | PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 |
| PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 | | PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 |
| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、更新包获取、立即重启和历史更新记录 | | PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、显式下载、下载完成确认、安装重启和历史更新记录 |
### 8.2 PAGE-01 登录页 ### 8.2 PAGE-01 登录页
@@ -1002,9 +1002,9 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
### 8.22.1 PAGE-22 系统更新页 ### 8.22.1 PAGE-22 系统更新页
系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取更新”。 系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取版本”。
更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。 更新固定为“检查、下载、安装”三个服务端阶段和四个明确用户反馈。管理员点击“获取版本”时只请求最新版本、发布时间和发布说明,不提交下载;发现新版本后显示“立即更新”,再次确认后才提交 `DOWNLOAD`。root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY/下载完成`,期间当前业务版本继续运行。此时页面显示“立即更新并重启”,点击后只允许安装同版本缓存,并在切换前重新验证。页面以 3 秒轮询显示排队、下载、校验、备份、安装和重启状态;命令已经被服务端接受但 HTTP 响应丢失时也会主动重读状态。健康检查成功后显示 10 秒倒计时并自动刷新;失败时保留明确状态并恢复已验证的上一版本。
页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。 页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
@@ -1045,7 +1045,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
| PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 | | PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 |
| PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 | | PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 |
| PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 | | PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 |
| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取更新自动完成检查和下载;验签完成后显示立即重启;健康后 10 秒刷新;历史操作来自不可变审计日志 | | PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取版本只检查;立即更新才下载;下载完成后显示立即更新并重启;健康后 10 秒刷新;历史操作来自不可变审计日志 |
每个页面只有同时满足以下条件才算完成: 每个页面只有同时满足以下条件才算完成:
@@ -1083,7 +1083,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
| PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 | | PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 |
| PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 | | PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 |
| PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 | | PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 |
| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取更新 → 自动下载验签 → 立即重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 | | PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取版本 → 立即更新并下载验签 → 下载完成 → 立即更新并重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 |
所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。 所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。
@@ -1109,7 +1109,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
| PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 | | PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 |
| PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 | | PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 |
| PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 | | PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 |
| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取更新、立即重启 | | PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取版本、立即更新、立即更新并重启 |
列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。 列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。
@@ -1625,12 +1625,12 @@ Pull Request 只有同时满足以下条件才能合并:页面/操作合同完
| 区域 | 固定规则 | | 区域 | 固定规则 |
| --- | --- | | --- | --- |
| 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 | | 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 |
| 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、面包屑、页签栏和内容区 | | 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、页签栏和内容区;不渲染面包屑 |
| 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 | | 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 |
| 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 | | 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 |
| 面包屑 | 以 `routes.json` 页面合同为唯一层级来源,从一级菜单到当前页面完整显示;隐藏详情路由必须显示来源列表和当前页面,任一级可安全返回 | | 面包屑 | 首期删除可见面包屑;菜单和页签承担层级识别,隐藏详情路由通过显式返回按钮或安全返回路径返回来源列表 |
| 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 | | 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 |
| 内容头 | 静态列表、工作台、台账和配置页只显示顶部面包屑,不再在内容区重复模块名、页面名或功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面可以显示业务编号、对象名称和状态,但不得重复面包屑中的静态标题 | | 内容头 | 静态列表、工作台、台账和配置页直接进入紧凑操作栏、查询区或指标区,不显示面包屑,也不重复堆叠模块名、页面名和功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面显示业务编号、对象名称和状态,并提供明确返回路径 |
| 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 | | 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 |
主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。 主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。
@@ -1643,12 +1643,12 @@ PAGE-01~PAGE-22 必须从以下模板组合,不允许每个开发人员各
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` | | 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` |
| 工作台 | PAGE-04~06 | 紧凑指标行 + 待办/异常主列表 + 常用入口 + 最近动态;下一屏内容在首屏可见 | `Statistic`、`Table`、`Tabs`、`Tag`、`Timeline`、必要的 `Card` | | 工作台 | PAGE-04~06 | 紧凑指标行 + 待办/异常主列表 + 常用入口 + 最近动态;下一屏内容在首屏可见 | `Statistic`、`Table`、`Tabs`、`Tag`、`Timeline`、必要的 `Card` |
| 列表/台账 | PAGE-07、09、13~16、18~20 | 合同面包屑 + 紧凑操作栏 + 查询栏 + 数据表格 + 分页;内容区不重复静态页名,详情通过路由或抽屉进入 | `Form`、`Input`、`Select`、`DateRangePicker`、`Table`、`Pagination`、`Dropdown` | | 列表/台账 | PAGE-07、09、13~16、18~20 | 紧凑操作栏 + 查询栏 + 数据表格 + 分页;不额外渲染面包屑或重复静态页名,详情通过路由或抽屉进入 | `Form`、`Input`、`Select`、`DateRangePicker`、`Table`、`Pagination`、`Dropdown` |
| 业务详情 | PAGE-08 | 项目摘要 + 状态/主操作 + 页签 + 描述信息/明细表/时间线;跨对象都有返回路径 | `Descriptions`、`Tabs`、`Table`、`Timeline`、`Tag`、`Drawer` | | 业务详情 | PAGE-08 | 项目摘要 + 状态/主操作 + 页签 + 描述信息/明细表/时间线;跨对象都有返回路径 | `Descriptions`、`Tabs`、`Table`、`Timeline`、`Tag`、`Drawer` |
| 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` | | 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` |
| 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` | | 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` |
| 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` | | 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` |
| 系统更新 | PAGE-22 | 版本摘要 + 获取/下载进度 + 立即重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` | | 系统更新 | PAGE-22 | 版本摘要 + 独立检查 + 下载进度/完成确认 + 安装重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` |
| 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` | | 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` |
工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。 工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。
@@ -2932,7 +2932,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
| 检查项 | 本轮结果 | 证据边界 | | 检查项 | 本轮结果 | 证据边界 |
| --- | --- | --- | | --- | --- | --- |
| 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 | | 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 |
| 两阶段交互 | 管理员点击“获取更新”后自动完成检查和下载;下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即重启”确认不切换版本 | | 分阶段交互 | 管理员点击“获取版本”只检查发布信息;发现新版本后点击“立即更新”才下载。下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY/下载完成`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即更新并重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即更新并重启”确认不切换版本 |
| 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 | | 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 |
| 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` | | 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` |
| 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 | | 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 |
@@ -3005,6 +3005,17 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
**本轮结论**:Preview.8 具备可部署的首次安装和 32 位向导框架;生产录入安装码、数据库密码和管理员密码前必须先配置 HTTPS 反向代理或可信内网隧道,HTTP 仅用于隔离 Preview 验证。 **本轮结论**:Preview.8 具备可部署的首次安装和 32 位向导框架;生产录入安装码、数据库密码和管理员密码前必须先配置 HTTPS 反向代理或可信内网隧道,HTTP 仅用于隔离 Preview 验证。
### 13.1.17 Preview.27 安装跳转与内容区导航收敛记录(2026-08-18)
| 项目 | 结果 | 说明 |
| --- | --- | --- |
| 安装完成跳转 | 已修复 | 完成安装后以 `required=false` 且 `locked/ready` 状态确认服务已切换,再通过 Vue Router 进入 `/login`;不再等待永远为 `false` 的 setup `ready` 标志,也不再复用安装前缓存的 `required=true` 状态 |
| 面包屑 | 已删除可见渲染 | 菜单和路由页签已经表达层级,业务内容区不再显示“表单审批 → 表单中心”等重复导航;历史 `breadcrumb` 合同字段仅保留作元数据,不生成 DOM |
| 内容区留白 | 已收紧 | 移除面包屑占位和底部间距,页签下内容区顶部间距固定为 16px;列表、工作台和配置页直接进入操作栏/指标/查询区 |
| 自动化证据 | 通过 | TypeScript、ESLint、Stylelint、Vitest `12 files / 60 tests`、安装向导 Playwright `6/6`、全量页面可访问性与布局 Playwright `78/78`、production build/hygiene 均通过 |
**本轮结论**:业务页面不再把菜单层级重复显示为面包屑;安装完成后会稳定进入登录页,异常重启场景也通过状态确认后自动跳转。
### 13.2 第三方权限和自动化负向验收 ### 13.2 第三方权限和自动化负向验收
**当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。 **当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。
+1 -1
View File
@@ -272,7 +272,7 @@
"pageType": "business", "pageType": "business",
"pageTemplate": "system-update", "pageTemplate": "system-update",
"requiresAuth": true, "requiresAuth": true,
"description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。", "description": "查看当前版本、独立获取版本信息、显式下载签名更新包、下载完成后安装重启并查询历史更新记录。",
"breadcrumb": ["系统治理", "系统更新"], "breadcrumb": ["系统治理", "系统更新"],
"roles": ["SYSTEM_ADMIN"], "roles": ["SYSTEM_ADMIN"],
"permission": "admin:update:view" "permission": "admin:update:view"
+1 -7
View File
@@ -260,13 +260,7 @@ for (const route of routes) {
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1); await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0); await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0);
} }
if (!['PAGE-01', 'PAGE-02', 'PAGE-23'].includes(route.pageId)) { await expect(page.locator('.tdesign-breadcrumb')).toHaveCount(0);
const breadcrumb = page.locator('.tdesign-breadcrumb');
await expect(breadcrumb).toBeVisible();
for (const label of route.breadcrumb) {
await expect(breadcrumb.getByText(label, { exact: true })).toBeVisible();
}
}
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
if (route.pageId === 'PAGE-09') { if (route.pageId === 'PAGE-09') {
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0); await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
+6 -2
View File
@@ -11,7 +11,9 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
data: { data: {
required: !completed, required: !completed,
locked: completed, locked: completed,
ready: completed, // Production status is locked after setup; readiness is not the
// completion signal used by the redirect flow.
ready: false,
supportedDatabaseTypes: ['MYSQL'], supportedDatabaseTypes: ['MYSQL'],
message: completed ? '系统已完成安装' : '请完成安装', message: completed ? '系统已完成安装' : '请完成安装',
}, },
@@ -69,6 +71,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
await expect(page.getByText('安装完成', { exact: true })).toBeVisible(); await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible(); await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
const layout = await page.evaluate(() => ({ const layout = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth, clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth, scrollWidth: document.documentElement.scrollWidth,
@@ -128,8 +132,8 @@ test('confirms completion after the setup response is interrupted by a service r
await page.getByRole('button', { name: '完成安装' }).click(); await page.getByRole('button', { name: '完成安装' }).click();
await page.getByRole('button', { name: '确定', exact: true }).click(); await page.getByRole('button', { name: '确定', exact: true }).click();
await expect(page.getByText('连接暂时中断,正在确认安装结果。')).toBeVisible();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible(); await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible(); await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
await expect(page.getByText('网络请求失败')).toHaveCount(0); await expect(page.getByText('网络请求失败')).toHaveCount(0);
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
}); });
+102 -21
View File
@@ -7,7 +7,7 @@ function envelope(data: unknown) {
return { data, requestId: '01M00000000000000000000090' }; return { data, requestId: '01M00000000000000000000090' };
} }
function session() { function session(canExecute = true) {
return { return {
authenticated: true, authenticated: true,
user: { user: {
@@ -19,12 +19,13 @@ function session() {
}, },
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }], roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
activeRole: 'SYSTEM_ADMIN', activeRole: 'SYSTEM_ADMIN',
permissions, permissions: canExecute ? permissions : permissions.filter((permission) => permission !== 'admin:update:execute'),
}; };
} }
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) { function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
const busy = [ const busy = [
'QUEUED',
'DOWNLOAD_QUEUED', 'DOWNLOAD_QUEUED',
'INSTALL_QUEUED', 'INSTALL_QUEUED',
'VERIFYING', 'VERIFYING',
@@ -69,6 +70,12 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
}; };
} }
interface FixtureOptions {
canExecute?: boolean;
failDownloadResponse?: boolean;
failInstallResponse?: boolean;
}
async function installFixture( async function installFixture(
page: Page, page: Page,
downloadBodies: unknown[], downloadBodies: unknown[],
@@ -78,7 +85,9 @@ async function installFixture(
completeAfterInstall = false, completeAfterInstall = false,
initialState?: string, initialState?: string,
completeBusyAfterFirstRead = false, completeBusyAfterFirstRead = false,
options: FixtureOptions = {},
) { ) {
const { canExecute = true, failDownloadResponse = false, failInstallResponse = false } = options;
let checked = recoveryPending || Boolean(initialState); let checked = recoveryPending || Boolean(initialState);
let state = recoveryPending ? 'FAILED' : initialState || 'IDLE'; let state = recoveryPending ? 'FAILED' : initialState || 'IDLE';
const history: Array<Record<string, unknown>> = []; const history: Array<Record<string, unknown>> = [];
@@ -93,7 +102,7 @@ async function installFixture(
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
objectPublicId: 'SYSTEM_UPDATE', objectPublicId: 'SYSTEM_UPDATE',
resultCode: 'SUCCESS', resultCode: 'SUCCESS',
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即重启' : null, reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null,
beforeJson: null, beforeJson: null,
afterJson: JSON.stringify({ targetVersion }), afterJson: JSON.stringify({ targetVersion }),
occurredAt: '2026-08-16T00:02:00Z', occurredAt: '2026-08-16T00:02:00Z',
@@ -103,9 +112,9 @@ async function installFixture(
await page.route('**/api/v1/**', async (route: Route) => { await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request(); const request = route.request();
const pathname = new URL(request.url()).pathname; const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) }); if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session(canExecute)) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) }); if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session(canExecute).user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) }); if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session(canExecute).roles) });
if (pathname === '/api/v1/auth/csrf') { if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({ return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' }, headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
@@ -129,19 +138,23 @@ async function installFixture(
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
checked = true; checked = true;
recordHistory('SYSTEM_UPDATE_CHECK'); recordHistory('SYSTEM_UPDATE_CHECK');
return route.fulfill({ json: envelope(updateView(state, checked, enabled, recoveryPending)) }); const response = updateView(state, checked, enabled, recoveryPending);
if (!canExecute) response.allowedActions = ['CHECK'];
return route.fulfill({ json: envelope(response) });
} }
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
downloadBodies.push(request.postDataJSON()); downloadBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST'); recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
state = 'READY'; state = 'READY';
if (failDownloadResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) }); return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
} }
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
installBodies.push(request.postDataJSON()); installBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_REQUEST'); recordHistory('SYSTEM_UPDATE_REQUEST');
const queued = updateView('INSTALL_QUEUED', true, enabled); const queued = updateView('INSTALL_QUEUED', true, enabled);
state = completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED'; state = failInstallResponse || completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED';
if (failInstallResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(queued) }); return route.fulfill({ json: envelope(queued) });
} }
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') { if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
@@ -158,7 +171,7 @@ async function installFixture(
}); });
} }
test('system administrator checks and queues a signed online update', async ({ page }) => { test('system administrator checks, downloads, and explicitly installs a signed online update', async ({ page }) => {
const downloadBodies: unknown[] = []; const downloadBodies: unknown[] = [];
const installBodies: unknown[] = []; const installBodies: unknown[] = [];
await installFixture(page, downloadBodies, installBodies); await installFixture(page, downloadBodies, installBodies);
@@ -166,16 +179,77 @@ test('system administrator checks and queues a signed online update', async ({ p
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新'); await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1'); await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
await page.getByRole('button', { name: '获取更新', exact: true }).click(); await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2'); await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 }); await expect(page.getByRole('button', { name: '立即更新', exact: true })).toBeVisible({ timeout: 5_000 });
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]); await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
await expect(page.getByText('下载更新包', { exact: true })).toBeVisible(); expect(downloadBodies).toEqual([]);
await page.getByRole('button', { name: '立即重启', exact: true }).click(); await page.getByRole('button', { name: '立即更新', exact: true }).click();
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible();
await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible(); await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]); expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即更新并重启' }]);
});
test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY');
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.getByText('更新包已下载完成,请点击“立即更新并重启”安装新版本', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
});
test('view-only administrators see a download permission message', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, { canExecute: false });
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
});
test('download and install continue polling after an accepted command loses its response', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
failDownloadResponse: true,
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即更新', exact: true }).click();
await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
).toBeVisible({ timeout: 8_000 });
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
});
test('accepted install continues to the refresh countdown after its response is lost', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
failInstallResponse: true,
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即更新', exact: true }).click();
await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
).toBeVisible({ timeout: 8_000 });
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 8_000,
});
}); });
test('brand logo returns the active identity to its workbench', async ({ page }) => { test('brand logo returns the active identity to its workbench', async ({ page }) => {
@@ -193,8 +267,9 @@ test('disabled online updates expose status without an executable check action',
await page.goto('/governance/update'); await page.goto('/governance/update');
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible(); await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '获取更新', exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: '获取版本', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
}); });
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => { test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
@@ -202,15 +277,19 @@ test('rollback recovery keeps the failure visible without an install action', as
await page.goto('/governance/update'); await page.goto('/governance/update');
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible(); await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
}); });
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => { test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
await installFixture(page, [], [], true, false, true); await installFixture(page, [], [], true, false, true);
await page.goto('/governance/update'); await page.goto('/governance/update');
await page.getByRole('button', { name: '获取更新', exact: true }).click(); await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即重启', exact: true }).click({ timeout: 5_000 }); await page.getByRole('button', { name: '立即更新', exact: true }).click({ timeout: 5_000 });
await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
).toBeVisible({ timeout: 8_000 });
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 5_000, timeout: 5_000,
@@ -221,7 +300,9 @@ test('reloading during installation resumes polling and starts the refresh count
await installFixture(page, [], [], true, false, false, 'INSTALLING', true); await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
await page.goto('/governance/update'); await page.goto('/governance/update');
await expect(page.getByText('安装中', { exact: true })).toBeVisible(); await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('安装中', { exact: true }),
).toBeVisible();
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 6_000, timeout: 6_000,
}); });
-1
View File
@@ -1,7 +1,6 @@
export default { export default {
showFooter: false, showFooter: false,
isSidebarCompact: false, isSidebarCompact: false,
showBreadcrumb: true,
menuAutoCollapsed: false, menuAutoCollapsed: false,
mode: 'light', mode: 'light',
layout: 'side', layout: 'side',
@@ -61,7 +61,6 @@
</t-tab-panel> </t-tab-panel>
</t-tabs> </t-tabs>
<t-content :class="`${prefix}-content-layout`"> <t-content :class="`${prefix}-content-layout`">
<l-breadcrumb v-if="settingStore.showBreadcrumb" />
<l-content /> <l-content />
</t-content> </t-content>
<t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`"> <t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`">
@@ -81,7 +80,6 @@ import { useLocale } from '@/locales/useLocale';
import { useSettingStore, useTabsRouterStore } from '@/store'; import { useSettingStore, useTabsRouterStore } from '@/store';
import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface'; import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface';
import LBreadcrumb from './Breadcrumb.vue';
import LContent from './Content.vue'; import LContent from './Content.vue';
import LFooter from './Footer.vue'; import LFooter from './Footer.vue';
@@ -29,30 +29,40 @@
<div class="update-actions"> <div class="update-actions">
<t-button <t-button
variant="outline" variant="outline"
:loading="fetchingUpdate" :loading="checkingUpdate"
:disabled="!canFetchUpdate || updateBusy || restarting" :disabled="!canCheckUpdate || checkingUpdate || downloadingUpdate || installingUpdate"
@click="fetchUpdate" @click="checkLatest"
> >
<template #icon><t-icon name="cloud-download" /></template> <template #icon><t-icon name="refresh" /></template>
获取更新 获取版本
</t-button> </t-button>
<t-button <t-button
v-if="canRestart" v-if="canDownloadUpdate"
theme="primary" theme="primary"
:loading="restarting" :loading="downloadingUpdate"
:disabled="updateBusy || fetchingUpdate" :disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@click="restartNow" @click="downloadNow"
>
<template #icon><t-icon name="cloud-download" /></template>
立即更新
</t-button>
<t-button
v-if="canInstall"
theme="primary"
:loading="installingUpdate"
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@click="installNow"
> >
<template #icon><t-icon name="poweroff" /></template> <template #icon><t-icon name="poweroff" /></template>
立即重启 立即更新并重启
</t-button> </t-button>
</div> </div>
</div> </div>
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps"> <t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
<t-step-item title="获取版本" content="检查最新发布" /> <t-step-item title="获取版本" content="检查最新发布" />
<t-step-item title="下载校验" content="下载并验证签名" /> <t-step-item title="下载更新" content="下载并验证签名" />
<t-step-item title="立即重启" content="备份并切换版本" /> <t-step-item title="安装重启" content="备份并切换版本" />
<t-step-item title="完成更新" content="健康检查后刷新" /> <t-step-item title="完成更新" content="健康检查后刷新" />
</t-steps> </t-steps>
@@ -66,7 +76,11 @@
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。 新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert> </t-alert>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false"> <t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
{{ updateStatus?.message || '正在读取系统版本状态' }} <div class="update-status-message">
<strong>{{ updateStateLabel }}</strong>
<span>{{ updateStatus?.message || '正在读取系统版本状态' }}</span>
<small v-if="updateActionHint">{{ updateActionHint }}</small>
</div>
</t-alert> </t-alert>
<div v-if="updateStatus?.releaseNotes" class="release-notes"> <div v-if="updateStatus?.releaseNotes" class="release-notes">
@@ -133,8 +147,9 @@ type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const updateStatus = ref<SystemUpdateView | null>(null); const updateStatus = ref<SystemUpdateView | null>(null);
const isNarrow = ref(false); const isNarrow = ref(false);
const statusLoading = ref(false); const statusLoading = ref(false);
const fetchingUpdate = ref(false); const checkingUpdate = ref(false);
const restarting = ref(false); const downloadingUpdate = ref(false);
const installingUpdate = ref(false);
const statusError = ref(''); const statusError = ref('');
const historyRows = ref<AuditLog[]>([]); const historyRows = ref<AuditLog[]>([]);
const historyLoading = ref(false); const historyLoading = ref(false);
@@ -167,14 +182,27 @@ const currentIsLatest = computed(
!updateBusy.value && !updateBusy.value &&
!['FAILED', 'UNKNOWN'].includes(updateStatus.value.state), !['FAILED', 'UNKNOWN'].includes(updateStatus.value.state),
); );
const canFetchUpdate = computed( const canCheckUpdate = computed(
() => () =>
updateStatus.value?.enabled !== false && updateStatus.value?.enabled !== false &&
updateStatus.value?.state !== 'READY' && !updateBusy.value &&
(updateStatus.value?.allowedActions.includes('CHECK') === true || updateStatus.value?.allowedActions.includes('CHECK') === true,
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true), );
const canDownloadUpdate = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
Boolean(updateStatus.value?.latestVersion) &&
updateStatus.value?.updateAvailable === true &&
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true,
);
const canInstall = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
updateStatus.value?.state === 'READY' &&
updateStatus.value?.allowedActions.includes('INSTALL') === true,
); );
const canRestart = computed(() => updateStatus.value?.allowedActions.includes('INSTALL') === true);
const updateStep = computed(() => { const updateStep = computed(() => {
const state = updateStatus.value?.state || 'IDLE'; const state = updateStatus.value?.state || 'IDLE';
if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3; if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3;
@@ -190,11 +218,11 @@ const updateStateLabel = computed(() => {
DISABLED: '未启用', DISABLED: '未启用',
CURRENT: '已是最新', CURRENT: '已是最新',
QUEUED: '已排队', QUEUED: '已排队',
DOWNLOAD_QUEUED: '等待下载', DOWNLOAD_QUEUED: '下载已排队',
INSTALL_QUEUED: '等待重启', INSTALL_QUEUED: '等待重启',
VERIFYING: '校验中', VERIFYING: '校验中',
DOWNLOADING: '下载中', DOWNLOADING: '下载中',
READY: '等待重启', READY: '下载完成',
BACKING_UP: '备份中', BACKING_UP: '备份中',
INSTALLING: '安装中', INSTALLING: '安装中',
RUNNING: '重启中', RUNNING: '重启中',
@@ -206,6 +234,20 @@ const updateStateLabel = computed(() => {
'待获取' '待获取'
); );
}); });
const updateActionHint = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
if (state === 'READY') {
return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。';
}
if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。';
if (updateStatus.value?.updateAvailable) return '已发现新版本,但当前账号没有下载权限。';
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
if (['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
return '更新包已确认,系统正在备份、切换并重启。';
}
if (state === 'IDLE' && !updateStatus.value?.latestVersion) return '点击“获取版本”检查最新发布。';
return '';
});
const updateStateTheme = computed<TagTheme>(() => { const updateStateTheme = computed<TagTheme>(() => {
const state = updateStatus.value?.state || 'IDLE'; const state = updateStatus.value?.state || 'IDLE';
if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success'; if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success';
@@ -233,7 +275,7 @@ async function loadStatus(silent = false) {
updateStatus.value = status; updateStatus.value = status;
if (installStates.has(status.state)) awaitingRefresh.value = true; if (installStates.has(status.state)) awaitingRefresh.value = true;
handleCompletion(status); handleCompletion(status);
if (busyStates.has(status.state)) startPolling(); if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling();
else stopPolling(); else stopPolling();
} catch (error) { } catch (error) {
if (!silent) statusError.value = friendlyError(error, '系统版本状态读取失败'); if (!silent) statusError.value = friendlyError(error, '系统版本状态读取失败');
@@ -266,9 +308,9 @@ async function refreshPage() {
await Promise.all([loadStatus(), loadHistory()]); await Promise.all([loadStatus(), loadHistory()]);
} }
async function fetchUpdate() { async function checkLatest() {
if (fetchingUpdate.value || updateBusy.value || !canFetchUpdate.value) return; if (checkingUpdate.value || updateBusy.value || !canCheckUpdate.value) return;
fetchingUpdate.value = true; checkingUpdate.value = true;
statusError.value = ''; statusError.value = '';
try { try {
const checked = await checkSystemUpdate(); const checked = await checkSystemUpdate();
@@ -278,36 +320,61 @@ async function fetchUpdate() {
MessagePlugin.info('当前已是最新版本'); MessagePlugin.info('当前已是最新版本');
return; return;
} }
if (!checked.latestVersion || !checked.allowedActions.includes('DOWNLOAD')) { if (checked.state === 'READY') {
throw new Error('发现新版本,但当前账号没有下载更新包的权限'); MessagePlugin.success(
checked.allowedActions.includes('INSTALL')
? '更新包已下载完成,请点击“立即更新并重启”安装新版本'
: '更新包已下载完成,但当前账号没有安装权限',
);
} else if (!checked.allowedActions.includes('DOWNLOAD')) {
MessagePlugin.info('发现新版本,但当前账号没有下载权限');
} else {
MessagePlugin.success(`发现新版本 ${checked.latestVersion},请点击“立即更新”开始下载`);
} }
updateStatus.value = await downloadSystemUpdate(checked.latestVersion);
await loadHistory(true);
MessagePlugin.success('更新包已开始下载,下载和签名校验完成后可立即重启');
startPolling();
} catch (error) { } catch (error) {
statusError.value = friendlyError(error, '获取更新失败'); statusError.value = friendlyError(error, '获取版本失败');
} finally { } finally {
fetchingUpdate.value = false; checkingUpdate.value = false;
} }
} }
async function restartNow() { async function downloadNow() {
const version = updateStatus.value?.latestVersion; const version = updateStatus.value?.latestVersion;
if (!version || restarting.value || !canRestart.value) return; if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return;
restarting.value = true; downloadingUpdate.value = true;
statusError.value = '';
startPolling();
try {
updateStatus.value = await downloadSystemUpdate(version);
MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”');
void loadHistory(true);
} catch (error) {
statusError.value = friendlyError(error, '下载更新包失败');
void loadStatus(true);
} finally {
downloadingUpdate.value = false;
startPolling();
}
}
async function installNow() {
const version = updateStatus.value?.latestVersion;
if (!version || installingUpdate.value || !canInstall.value) return;
installingUpdate.value = true;
statusError.value = ''; statusError.value = '';
stopRefreshCountdown(); stopRefreshCountdown();
awaitingRefresh.value = true;
startPolling();
try { try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启'); updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
awaitingRefresh.value = true; MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新'); void loadHistory(true);
await loadHistory(true);
startPolling();
} catch (error) { } catch (error) {
statusError.value = friendlyError(error, '立即重启失败'); statusError.value = friendlyError(error, '立即更新并重启失败');
void loadStatus(true);
} finally { } finally {
restarting.value = false; installingUpdate.value = false;
startPolling();
} }
} }
@@ -324,6 +391,7 @@ function stopPolling() {
function handleCompletion(status: SystemUpdateView) { function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return; if (!awaitingRefresh.value) return;
if (status.state === 'SUCCEEDED') { if (status.state === 'SUCCEEDED') {
statusError.value = '';
awaitingRefresh.value = false; awaitingRefresh.value = false;
startRefreshCountdown(); startRefreshCountdown();
void loadHistory(true); void loadHistory(true);
@@ -374,7 +442,7 @@ function actionLabel(action: string) {
{ {
SYSTEM_UPDATE_CHECK: '获取版本', SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包', SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即重启', SYSTEM_UPDATE_REQUEST: '立即更新并重启',
}[action] || action }[action] || action
); );
} }
@@ -503,6 +571,17 @@ onBeforeUnmount(() => {
border-top: 1px solid var(--td-component-border); border-top: 1px solid var(--td-component-border);
} }
.update-status-message {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.update-status-message small {
color: var(--td-text-color-secondary);
}
.release-notes span, .release-notes span,
.section-heading p, .section-heading p,
.section-heading > span { .section-heading > span {
+2 -2
View File
@@ -355,7 +355,7 @@ async function waitForSetupCompletion() {
if (unmounted) return false; if (unmounted) return false;
try { try {
const status = await getSetupStatus(); const status = await getSetupStatus();
if (status.ready) return true; if (!status.required && (status.ready || status.locked)) return true;
} catch { } catch {
// A short connection failure is expected while systemd restarts the service. // A short connection failure is expected while systemd restarts the service.
} }
@@ -387,7 +387,7 @@ async function openLogin() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
completionMessage.value = '系统正在启动,请稍候。'; completionMessage.value = '系统正在启动,请稍候。';
if (await waitForSetupCompletion()) { if (await waitForSetupCompletion()) {
window.location.assign('/login'); await router.replace('/login');
return; return;
} }
completionMessage.value = '系统启动时间较长,请稍后刷新页面。'; completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
+14 -1
View File
@@ -17,7 +17,20 @@ const isPublic = (to: RouteLocationNormalized) =>
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null; let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
function loadSetupStatus() { function loadSetupStatus() {
setupStatusRequest ||= getSetupStatus(); if (setupStatusRequest) return setupStatusRequest;
// Share only an in-flight request. Keeping the resolved setup state would
// send the user back to /setup after the installation marker is written.
const request = getSetupStatus();
setupStatusRequest = request;
request.then(
() => {
if (setupStatusRequest === request) setupStatusRequest = null;
},
() => {
if (setupStatusRequest === request) setupStatusRequest = null;
},
);
return setupStatusRequest; return setupStatusRequest;
} }
+1 -1
View File
@@ -87,7 +87,7 @@
z-index: 100; z-index: 100;
} }
&-tabs-nav + .@{starter-prefix}-content-layout { &-tabs-nav + .@{starter-prefix}-content-layout {
padding-top: var(--td-comp-paddingTB-xxl); padding-top: var(--td-comp-paddingTB-l);
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
+2
View File
@@ -326,6 +326,8 @@ grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer verifies the Java runtime SHA-256' || fail 'installer no longer verifies the Java runtime SHA-256'
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \ grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|| fail 'installer default release source is not the public Gitea latest API' || fail 'installer default release source is not the public Gitea latest API'
grep -Fq '/www/server/java/*/bin/java' "$ROOT/deploy/install.sh" \
|| fail 'installer does not search the common Baota Java installation path'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \ grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \
|| fail 'installer does not pin the default release public-key fingerprint' || fail 'installer does not pin the default release public-key fingerprint'