Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4ae00ae0 | ||
|
|
3c7847c8f5 | ||
|
|
d61bdf9da4 | ||
|
|
7237792424 |
@@ -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
@@ -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
@@ -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`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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();
|
||||||
try {
|
|
||||||
updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启');
|
|
||||||
awaitingRefresh.value = true;
|
awaitingRefresh.value = true;
|
||||||
MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新');
|
|
||||||
await loadHistory(true);
|
|
||||||
startPolling();
|
startPolling();
|
||||||
|
try {
|
||||||
|
updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
|
||||||
|
MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
|
||||||
|
void loadHistory(true);
|
||||||
} 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 {
|
||||||
|
|||||||
@@ -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 = '系统启动时间较长,请稍后刷新页面。';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user