feat: simplify online update center

This commit is contained in:
Qiufeng
2026-08-04 21:25:43 +08:00
parent d3892320dd
commit a608284709
8 changed files with 382 additions and 302 deletions
+11 -11
View File
@@ -111,7 +111,7 @@ PostgreSQL-only 正式构建:
```bash ```bash
cd oa-backend cd oa-backend
./gradlew clean bootJar -PreleaseVersion=0.3.5 -PproductionBuild=true ./gradlew clean bootJar -PreleaseVersion=0.3.6 -PproductionBuild=true
``` ```
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc``hibernate-community-dialects` 正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc``hibernate-community-dialects`
@@ -214,18 +214,18 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
set -e set -e
tmp="$(mktemp)" tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/install.sh -o "$tmp" curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.6/install.sh -o "$tmp"
printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c - printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" \ sudo -E bash "$tmp" \
--gitea-url http://38.76.196.225:10099 \ --gitea-url http://38.76.196.225:10099 \
--repository awaioi/ERP \ --repository awaioi/ERP \
--version 0.3.5 \ --version 0.3.6 \
--public-url http://38.76.196.225:8091 \ --public-url http://38.76.196.225:8091 \
--allow-insecure --allow-insecure
) )
``` ```
只有 `v0.3.5` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。 只有 `v0.3.6` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。
### 完整卸载后重装 ### 完整卸载后重装
@@ -236,7 +236,7 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
set -e set -e
tmp="$(mktemp)" tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/uninstall.sh -o "$tmp" curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.6/uninstall.sh -o "$tmp"
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c - printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --purge-database --yes sudo -E bash "$tmp" --purge-database --yes
) )
@@ -289,9 +289,9 @@ location / {
应用定制平台 -> 系统更新 应用定制平台 -> 系统更新
``` ```
入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面显示当前版本、最新版本、检查时间、Release 更新日志、发布日期、发布文件、Ed25519/SHA-256 校验方式,以及下载、验签、安装、重启和自动回滚阶段。顶部和手机入口发现新版本时会显示版本提示。 入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面显示当前版本、在线最新版本、检查时间、最新版本更新日志、历史正式版本,以及下载、验签、安装、重启和自动回滚进度。顶部和手机入口发现新版本时会显示版本提示。
更新源设置”可在线修改启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、私有仓库 Token 和 HTTP 测试开关。设置原子写回 `/etc/kaidi-erp/erp.env`,只允许修改 `OA_UPDATE_*` 白名单,不会覆盖 PostgreSQL 密码等其他配置;Token 永不通过 API 回显,页面只显示“Token 已配置”。公开仓库无需填写 Token,正式环境应使用 HTTPS。 更新源由安装器写入服务器的 `/etc/kaidi-erp/erp.env`,后台页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。需要变更基础设施配置时由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务;公开仓库无需 Token,正式环境应使用 HTTPS。
更新过程如下: 更新过程如下:
@@ -307,7 +307,7 @@ location / {
同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发: 同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发:
```bash ```bash
/opt/kaidi-erp/current/bin/erp-update install 0.3.5 /opt/kaidi-erp/current/bin/erp-update install 0.3.6
``` ```
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用: 应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
@@ -348,8 +348,8 @@ base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
```bash ```bash
git switch main git switch main
git pull --ff-only origin main git pull --ff-only origin main
git tag -a v0.3.5 -m 'Kaidi ERP v0.3.5' git tag -a v0.3.6 -m 'Kaidi ERP v0.3.6'
git push origin v0.3.5 git push origin v0.3.6
``` ```
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。 发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
@@ -358,7 +358,7 @@ git push origin v0.3.5
```bash ```bash
ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \ ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
bash scripts/package-release.sh 0.3.5 bash scripts/package-release.sh 0.3.6
``` ```
## 配置参考 ## 配置参考
+9 -9
View File
@@ -27,8 +27,8 @@ base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
发布稳定版本: 发布稳定版本:
```bash ```bash
git tag v0.3.5 git tag v0.3.6
git push origin v0.3.5 git push origin v0.3.6
``` ```
## 首次安装 ## 首次安装
@@ -52,17 +52,17 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。 当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。
当前 `v0.3.5` 安装命令: 当前 `v0.3.6` 安装命令:
```bash ```bash
( (
set -e set -e
tmp="$(mktemp)" tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/install.sh -o "$tmp" curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.6/install.sh -o "$tmp"
printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c - printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \ sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \
--repository awaioi/ERP --version 0.3.5 \ --repository awaioi/ERP --version 0.3.6 \
--public-url http://38.76.196.225:8091 --allow-insecure --public-url http://38.76.196.225:8091 --allow-insecure
) )
``` ```
@@ -74,7 +74,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
set -e set -e
tmp="$(mktemp)" tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/uninstall.sh -o "$tmp" curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.6/uninstall.sh -o "$tmp"
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c - printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --purge-database --yes sudo -E bash "$tmp" --purge-database --yes
) )
@@ -86,9 +86,9 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。 管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
页面的“更新源设置”可保存启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、可选 Token 和 HTTP 测试开关。配置原子写回 `ERP_CONFIG_FILE` 指向的 `erp.env`,只修改 `OA_UPDATE_*` 白名单;数据库密码等字段保持不变。Token 不会通过 API 回显,公开仓库可以留空 更新源由首次安装器写入 `ERP_CONFIG_FILE` 指向的 `erp.env`。更新页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关;需要变更这些基础设施参数时,由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务
点击“保存并检查”或“检查更新”后,页面会展示当前/最新版本、发布日期、Release 更新日志、发布资产和签名校验方式。安装时持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会: 点击“检查更新”后,页面会展示当前版本、在线最新版本、发布日期、最新版本更新日志和历史正式版本记录。点击“安装并重启”后持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。 1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
2. 拒绝路径穿越、符号链接和结构不完整的安装包。 2. 拒绝路径穿越、符号链接和结构不完整的安装包。
@@ -99,7 +99,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行: 更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
```bash ```bash
/opt/kaidi-erp/current/bin/erp-update install 0.3.5 /opt/kaidi-erp/current/bin/erp-update install 0.3.6
``` ```
在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS``ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。 在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS``ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。
@@ -47,6 +47,7 @@ public class SystemUpdateService {
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"); + "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
private static final long MAX_STATE_BYTES = 64 * 1024; private static final long MAX_STATE_BYTES = 64 * 1024;
private static final int MAX_RELEASE_NOTES_CHARS = 32_000; private static final int MAX_RELEASE_NOTES_CHARS = 32_000;
private static final int MAX_RELEASE_HISTORY = 20;
private static final int MAX_RELEASE_ASSETS = 32; private static final int MAX_RELEASE_ASSETS = 32;
private static final int MAX_ASSET_NAME_CHARS = 255; private static final int MAX_ASSET_NAME_CHARS = 255;
private static final int MAX_ASSET_URL_CHARS = 4_096; private static final int MAX_ASSET_URL_CHARS = 4_096;
@@ -138,6 +139,24 @@ public class SystemUpdateService {
} }
} }
public List<ReleaseHistoryItem> releases() {
requireConfigured();
List<ReleaseInfo> releases = new ArrayList<>(fetchReleaseHistory());
releases.sort((left, right) -> compareVersions(right.version(), left.version()));
String latestVersion = releases.isEmpty() ? null : releases.get(0).version();
List<ReleaseHistoryItem> history = new ArrayList<>(releases.size());
for (ReleaseInfo release : releases) {
history.add(new ReleaseHistoryItem(
release.version(),
release.publishedAt(),
release.notes(),
sameVersion(release.version(), currentVersion),
latestVersion != null && sameVersion(release.version(), latestVersion)
));
}
return List.copyOf(history);
}
public UpdateStatus install(String requestedVersion) { public UpdateStatus install(String requestedVersion) {
requireConfigured(); requireConfigured();
if (!isVersion(requestedVersion)) { if (!isVersion(requestedVersion)) {
@@ -197,13 +216,57 @@ public class SystemUpdateService {
} }
private ReleaseInfo fetchLatestRelease() { private ReleaseInfo fetchLatestRelease() {
JsonNode latest = fetchReleaseJson(releaseApiUri("/latest"));
if (!isStableChannel() || !isPrerelease(latest)) {
return parseRelease(latest);
}
ReleaseInfo selected = null;
JsonNode selectedNode = null;
for (JsonNode node : fetchReleaseList()) {
ReleaseInfo candidate = parseReleaseHistoryItem(node);
if (candidate != null && (selected == null
|| compareVersions(candidate.version(), selected.version()) > 0)) {
selected = candidate;
selectedNode = node;
}
}
if (selectedNode == null) {
throw new ApiException(404, "Gitea 尚未发布正式版本");
}
return parseRelease(selectedNode);
}
private List<ReleaseInfo> fetchReleaseHistory() {
List<ReleaseInfo> releases = new ArrayList<>();
for (JsonNode node : fetchReleaseList()) {
ReleaseInfo release = parseReleaseHistoryItem(node);
if (release != null) {
releases.add(release);
}
}
return List.copyOf(releases);
}
private JsonNode fetchReleaseList() {
JsonNode root = fetchReleaseJson(releaseApiUri("?limit=" + MAX_RELEASE_HISTORY + "&page=1"));
if (!root.isArray() || root.size() > MAX_RELEASE_HISTORY) {
throw new ApiException(502, "Gitea Release 历史数据无效");
}
return root;
}
private URI releaseApiUri(String suffix) {
String[] repository = properties.getRepository().split("/", 2); String[] repository = properties.getRepository().split("/", 2);
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) { if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
throw new ApiException(500, "更新仓库配置无效"); throw new ApiException(500, "更新仓库配置无效");
} }
String base = properties.getGiteaBaseUrl().replaceAll("/+$", ""); String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
URI uri = URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/" return URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases/latest"); + encode(repository[1]) + "/releases" + suffix);
}
private JsonNode fetchReleaseJson(URI uri) {
HttpRequest.Builder builder = HttpRequest.newBuilder(uri) HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds()))) .timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.header("Accept", "application/json") .header("Accept", "application/json")
@@ -221,7 +284,7 @@ public class SystemUpdateService {
if (response.statusCode() < 200 || response.statusCode() >= 300) { if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode()); throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
} }
return parseRelease(objectMapper.readTree(response.body())); return objectMapper.readTree(response.body());
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new ApiException(503, "检查更新被中断"); throw new ApiException(503, "检查更新被中断");
@@ -231,6 +294,22 @@ public class SystemUpdateService {
} }
} }
private ReleaseInfo parseReleaseHistoryItem(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag) || root.path("draft").asBoolean(false)) {
return null;
}
if (isPrerelease(root) && isStableChannel()) {
return null;
}
return new ReleaseInfo(
normalizeVersion(tag),
releaseNotes(root),
releasePublishedAt(root),
List.of()
);
}
private ReleaseInfo parseRelease(JsonNode root) { private ReleaseInfo parseRelease(JsonNode root) {
String tag = text(root, "tag_name"); String tag = text(root, "tag_name");
if (!isVersion(tag)) { if (!isVersion(tag)) {
@@ -239,11 +318,7 @@ public class SystemUpdateService {
if (root.path("draft").asBoolean(false)) { if (root.path("draft").asBoolean(false)) {
throw new ApiException(502, "最新 Release 仍是草稿"); throw new ApiException(502, "最新 Release 仍是草稿");
} }
Matcher versionMatcher = VERSION_PATTERN.matcher(tag); if (isPrerelease(root) && isStableChannel()) {
versionMatcher.matches();
boolean taggedPrerelease = versionMatcher.group(4) != null;
if ((root.path("prerelease").asBoolean(false) || taggedPrerelease)
&& "stable".equalsIgnoreCase(properties.getChannel())) {
throw new ApiException(502, "稳定频道拒绝预发布版本"); throw new ApiException(502, "稳定频道拒绝预发布版本");
} }
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) { if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
@@ -268,20 +343,36 @@ public class SystemUpdateService {
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz"); requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
requireAsset(assets, "SHA256SUMS"); requireAsset(assets, "SHA256SUMS");
requireAsset(assets, "SHA256SUMS.sig"); requireAsset(assets, "SHA256SUMS.sig");
Instant publishedAt = null; return new ReleaseInfo(version, releaseNotes(root), releasePublishedAt(root), List.copyOf(assets));
}
private boolean isStableChannel() {
return "stable".equalsIgnoreCase(properties.getChannel());
}
private static boolean isPrerelease(JsonNode root) {
Matcher matcher = VERSION_PATTERN.matcher(text(root, "tag_name"));
return root.path("prerelease").asBoolean(false) || (matcher.matches() && matcher.group(4) != null);
}
private static Instant releasePublishedAt(JsonNode root) {
String published = text(root, "published_at"); String published = text(root, "published_at");
if (!published.isBlank()) { if (published.isBlank()) {
return null;
}
try { try {
publishedAt = Instant.parse(published); return Instant.parse(published);
} catch (RuntimeException ignore) { } catch (RuntimeException ignore) {
// An invalid optional timestamp must not hide an otherwise valid release. return null;
} }
} }
private static String releaseNotes(JsonNode root) {
String notes = text(root, "body"); String notes = text(root, "body");
if (notes.length() > MAX_RELEASE_NOTES_CHARS) { if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]"; return notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
} }
return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets)); return notes;
} }
private void requireConfigured() { private void requireConfigured() {
@@ -604,6 +695,15 @@ public class SystemUpdateService {
public record ReleaseAsset(String name, String downloadUrl, long size) { public record ReleaseAsset(String name, String downloadUrl, long size) {
} }
public record ReleaseHistoryItem(
String version,
Instant publishedAt,
String releaseNotes,
boolean current,
boolean latest
) {
}
public enum UpdatePhase { public enum UpdatePhase {
IDLE, IDLE,
CHECKING, CHECKING,
@@ -5,6 +5,7 @@ import com.kaidi.oa.service.SystemUpdateConfigService;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig; import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest; import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest;
import com.kaidi.oa.service.SystemUpdateService; import com.kaidi.oa.service.SystemUpdateService;
import com.kaidi.oa.service.SystemUpdateService.ReleaseHistoryItem;
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus; import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@@ -15,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Administrator-only API for checking and installing signed Gitea releases. */ /** Administrator-only API for checking and installing signed Gitea releases. */
@RestController @RestController
@RequestMapping("/api/oa/system-update") @RequestMapping("/api/oa/system-update")
@@ -46,6 +49,11 @@ public class SystemUpdateController {
return ApiResp.ok(updateService.status()); return ApiResp.ok(updateService.status());
} }
@GetMapping("/releases")
public ApiResp<List<ReleaseHistoryItem>> releases() {
return ApiResp.ok(updateService.releases());
}
@PostMapping("/check") @PostMapping("/check")
public ApiResp<UpdateStatus> check() { public ApiResp<UpdateStatus> check() {
return ApiResp.ok(updateService.check()); return ApiResp.ok(updateService.check());
@@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Properties; import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -79,6 +80,86 @@ class SystemUpdateServiceTest {
} }
} }
@Test
void stableChannelFallsBackToNewestStableReleaseWhenLatestIsPrerelease() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
[
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]},
{"tag_name":"v0.3.5","draft":false,"prerelease":false,"assets":[]},
{"tag_name":"v0.3.6","body":"Latest stable","draft":false,"prerelease":false,
"published_at":"2026-08-04T10:00:00Z","assets":[
{"name":"kaidi-erp-0.3.6.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}
]}
]
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.5");
SystemUpdateService.UpdateStatus status = service.check();
assertThat(status.latestVersion()).isEqualTo("0.3.6");
assertThat(status.updateAvailable()).isTrue();
assertThat(status.releaseNotes()).isEqualTo("Latest stable");
} finally {
server.stop(0);
}
}
@Test
void listsStableReleaseHistoryNewestFirstAndMarksCurrentVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
byte[] body = """
[
{"tag_name":"v0.3.4","body":"Older fixes","draft":false,"prerelease":false,
"published_at":"2026-08-03T10:00:00Z"},
{"tag_name":"v0.3.6","body":"Simplified updater","draft":false,"prerelease":false,
"published_at":"2026-08-04T10:00:00Z"},
{"tag_name":"v0.3.7-rc.1","body":"Preview","draft":false,"prerelease":true},
{"tag_name":"v0.3.8","body":"Draft","draft":true,"prerelease":false},
{"tag_name":"not-a-version","body":"Invalid","draft":false,"prerelease":false}
]
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.4");
List<SystemUpdateService.ReleaseHistoryItem> releases = service.releases();
assertThat(releases).extracting(SystemUpdateService.ReleaseHistoryItem::version)
.containsExactly("0.3.6", "0.3.4");
assertThat(releases.get(0).latest()).isTrue();
assertThat(releases.get(0).current()).isFalse();
assertThat(releases.get(1).current()).isTrue();
assertThat(releases.get(1).releaseNotes()).isEqualTo("Older fixes");
} finally {
server.stop(0);
}
}
@Test @Test
void persistsReleaseMetadataAcrossServiceRestart() throws Exception { void persistsReleaseMetadataAcrossServiceRestart() throws Exception {
Path stateFile = Files.createTempFile("system-update-metadata-", ".json"); Path stateFile = Files.createTempFile("system-update-metadata-", ".json");
@@ -189,9 +189,8 @@ export const oaApi = {
// 统一预警(全平台期限/异常聚合) // 统一预警(全平台期限/异常聚合)
listAlerts: alertsApi.listAlerts, listAlerts: alertsApi.listAlerts,
// 系统更新 // 系统更新
getSystemUpdateConfig: updateApi.getSystemUpdateConfig,
saveSystemUpdateConfig: updateApi.saveSystemUpdateConfig,
getSystemUpdateStatus: updateApi.getSystemUpdateStatus, getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
getSystemUpdateReleases: updateApi.getSystemUpdateReleases,
checkSystemUpdate: updateApi.checkSystemUpdate, checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate installSystemUpdate: updateApi.installSystemUpdate
} }
@@ -219,7 +218,7 @@ export type {
} from './reportdefs' } from './reportdefs'
export type { Alert } from './alerts' export type { Alert } from './alerts'
export type { export type {
SystemUpdateStatus, SystemUpdateConfig, SystemUpdateConfigInput, UpdatePhase, ReleaseAsset SystemUpdateStatus, ReleaseHistoryItem, UpdatePhase, ReleaseAsset
} from './update' } from './update'
export type { export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
@@ -36,37 +36,22 @@ export interface SystemUpdateStatus {
error: string | null error: string | null
} }
export interface SystemUpdateConfig { export interface ReleaseHistoryItem {
enabled: boolean version: string
giteaBaseUrl: string publishedAt: string | null
repository: string releaseNotes: string
channel: 'stable' | 'preview' current: boolean
tokenConfigured: boolean latest: boolean
allowInsecureHttp: boolean
}
export interface SystemUpdateConfigInput {
enabled: boolean
giteaBaseUrl: string
repository: string
channel: 'stable' | 'preview'
token: string
clearToken: boolean
allowInsecureHttp: boolean
}
export function getSystemUpdateConfig() {
return http.get<SystemUpdateConfig>('/system-update/config')
}
export function saveSystemUpdateConfig(input: SystemUpdateConfigInput) {
return http.put<SystemUpdateConfig>('/system-update/config', input)
} }
export function getSystemUpdateStatus() { export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status') return http.get<SystemUpdateStatus>('/system-update/status')
} }
export function getSystemUpdateReleases() {
return http.get<ReleaseHistoryItem[]>('/system-update/releases', undefined, { timeoutMs: 30000 })
}
export function checkSystemUpdate() { export function checkSystemUpdate() {
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 }) return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
} }
@@ -1,21 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import { Check, Download, Refresh, Warning } from '@element-plus/icons-vue'
Check,
Connection,
Download,
Refresh,
Setting,
Warning
} from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue' import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import { import {
oaApi, oaApi,
OaApiError, OaApiError,
type SystemUpdateConfig, type ReleaseHistoryItem,
type SystemUpdateConfigInput,
type SystemUpdateStatus, type SystemUpdateStatus,
type UpdatePhase type UpdatePhase
} from '../../api' } from '../../api'
@@ -26,19 +18,10 @@ const session = useSession()
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true) const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const status = ref<SystemUpdateStatus | null>(null) const status = ref<SystemUpdateStatus | null>(null)
const config = ref<SystemUpdateConfig | null>(null) const releases = ref<ReleaseHistoryItem[]>([])
const sourceForm = reactive<SystemUpdateConfigInput>({
enabled: true,
giteaBaseUrl: '',
repository: 'awaioi/ERP',
channel: 'stable',
token: '',
clearToken: false,
allowInsecureHttp: false
})
const loading = ref(false) const loading = ref(false)
const historyLoading = ref(false)
const checking = ref(false) const checking = ref(false)
const saving = ref(false)
const installing = ref(false) const installing = ref(false)
const reconnecting = ref(false) const reconnecting = ref(false)
const pollFailures = ref(0) const pollFailures = ref(0)
@@ -66,13 +49,33 @@ const phaseLabels: Record<UpdatePhase, string> = {
} }
const busy = computed(() => !!status.value && activePhases.has(status.value.phase)) const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
const configLocked = computed(() => busy.value || reconnecting.value)
const canCheck = computed(() => Boolean( const canCheck = computed(() => Boolean(
sourceForm.enabled && sourceForm.giteaBaseUrl && !configLocked.value status.value?.configured
&& !busy.value
&& !reconnecting.value
&& !checking.value
&& !installing.value
)) ))
const canInstall = computed(() => Boolean( const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value status.value?.configured
&& status.value.updateAvailable
&& status.value.latestVersion
&& !busy.value
&& !reconnecting.value
&& !checking.value
&& !installing.value
)) ))
const remoteLatest = computed(() => releases.value.find(release => release.latest) || releases.value[0] || null)
const latestVersion = computed(() => status.value?.latestVersion || remoteLatest.value?.version || null)
const latestRelease = computed(() => {
const checkedVersion = status.value?.latestVersion
if (checkedVersion) {
return releases.value.find(release => release.version === checkedVersion) || remoteLatest.value
}
return remoteLatest.value
})
const latestPublishedAt = computed(() => status.value?.publishedAt || latestRelease.value?.publishedAt || null)
const latestReleaseNotes = computed(() => status.value?.releaseNotes || latestRelease.value?.releaseNotes || '')
const phaseTone = computed<'success' | 'warning' | 'danger' | 'info'>(() => { const phaseTone = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
const phase = status.value?.phase const phase = status.value?.phase
if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger' if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger'
@@ -96,36 +99,9 @@ function formatDate(value: string | null | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
} }
function formatBytes(size: number) { function formatVersion(value: string | null | undefined) {
if (size <= 0) return '-' if (!value) return '-'
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB` return /^v/i.test(value) ? value : `v${value}`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
function verificationLabel(name: string) {
if (name === 'SHA256SUMS.sig') return 'Ed25519 签名'
if (name === 'SHA256SUMS') return 'SHA-256 清单'
if (name.endsWith('.tar.gz') || name.endsWith('.jar')) return '签名清单校验'
return '-'
}
function applyConfig(value: SystemUpdateConfig) {
config.value = value
sourceForm.enabled = value.enabled
sourceForm.giteaBaseUrl = value.giteaBaseUrl
sourceForm.repository = value.repository
sourceForm.channel = value.channel
sourceForm.token = ''
sourceForm.clearToken = false
sourceForm.allowInsecureHttp = value.allowInsecureHttp
}
async function loadConfig(silent = false) {
try {
applyConfig(await oaApi.getSystemUpdateConfig())
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '更新源加载失败'))
}
} }
async function loadStatus(silent = false) { async function loadStatus(silent = false) {
@@ -146,66 +122,33 @@ async function loadStatus(silent = false) {
} }
} }
async function loadReleases(silent = false) {
historyLoading.value = true
try {
releases.value = await oaApi.getSystemUpdateReleases()
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '历史版本加载失败'))
} finally {
historyLoading.value = false
}
}
async function loadPage() { async function loadPage() {
loading.value = true loading.value = true
try { try {
await Promise.all([loadConfig(), loadStatus()]) await loadStatus()
if (status.value?.configured) await loadReleases()
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function validateSource() {
const baseUrl = sourceForm.giteaBaseUrl.trim()
const repository = sourceForm.repository.trim()
if (sourceForm.enabled && !baseUrl) {
ElMessage.warning('请填写 Gitea 地址')
return false
}
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
ElMessage.warning('Gitea 地址必须以 https:// 或 http:// 开头')
return false
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository)) {
ElMessage.warning('仓库格式必须为 owner/repository')
return false
}
if (baseUrl.startsWith('http://') && !sourceForm.allowInsecureHttp) {
ElMessage.warning('HTTP 更新源必须显式开启允许 HTTP')
return false
}
return true
}
async function saveSource(checkAfterSave: boolean) {
if (configLocked.value) {
ElMessage.warning('更新任务执行期间不能修改更新源')
return
}
if (!validateSource()) return
saving.value = true
try {
const saved = await oaApi.saveSystemUpdateConfig({
...sourceForm,
giteaBaseUrl: sourceForm.giteaBaseUrl.trim(),
repository: sourceForm.repository.trim(),
token: sourceForm.token.trim()
})
applyConfig(saved)
ElMessage.success('更新源已保存')
await loadStatus(true)
if (checkAfterSave && saved.enabled) await checkUpdate()
} catch (error) {
ElMessage.error(apiMessage(error, '更新源保存失败'))
} finally {
saving.value = false
}
}
async function checkUpdate() { async function checkUpdate() {
if (!canCheck.value) return
checking.value = true checking.value = true
try { try {
status.value = await oaApi.checkSystemUpdate() status.value = await oaApi.checkSystemUpdate()
await loadReleases(true)
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本') ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
} catch (error) { } catch (error) {
ElMessage.error(apiMessage(error, '检查更新失败')) ElMessage.error(apiMessage(error, '检查更新失败'))
@@ -216,18 +159,20 @@ async function checkUpdate() {
} }
async function installUpdate() { async function installUpdate() {
if (!canInstall.value) return
const version = status.value?.latestVersion const version = status.value?.latestVersion
if (!version) return if (!version) return
installing.value = true
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确认安装 ${version}?服务将自动重启,健康检查失败会切回上一版本。`, `确认安装 ${formatVersion(version)}?服务将自动重启,健康检查失败会切回上一版本。`,
'安装正式更新', '安装正式更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' } { type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
) )
} catch { } catch {
installing.value = false
return return
} }
installing.value = true
try { try {
status.value = await oaApi.installSystemUpdate(version) status.value = await oaApi.installSystemUpdate(version)
reconnecting.value = false reconnecting.value = false
@@ -278,13 +223,13 @@ onBeforeUnmount(stopPolling)
<ErpPageHeader <ErpPageHeader
title="系统更新" title="系统更新"
:crumbs="['应用定制平台', '系统更新']" :crumbs="['应用定制平台', '系统更新']"
:description="`当前版本 ${status?.currentVersion || '-'}`" :description="`当前版本 ${formatVersion(status?.currentVersion)}`"
> >
<template #actions> <template #actions>
<el-button <el-button
:icon="Refresh" :icon="Refresh"
:loading="checking" :loading="checking"
:disabled="!canCheck || saving" :disabled="!canCheck"
@click="checkUpdate" @click="checkUpdate"
> >
检查更新 检查更新
@@ -302,7 +247,16 @@ onBeforeUnmount(stopPolling)
</ErpPageHeader> </ErpPageHeader>
<el-alert <el-alert
v-if="reconnecting" v-if="status && !status.configured"
type="warning"
:closable="false"
show-icon
title="服务器尚未启用在线更新"
description="请通过安装器完成正式服务配置。"
class="update-alert"
/>
<el-alert
v-else-if="reconnecting"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
@@ -331,11 +285,11 @@ onBeforeUnmount(stopPolling)
<section class="update-summary" aria-label="版本状态"> <section class="update-summary" aria-label="版本状态">
<div class="version-block"> <div class="version-block">
<span class="field-label">当前版本</span> <span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong> <strong>{{ formatVersion(status?.currentVersion) }}</strong>
</div> </div>
<div class="version-block"> <div class="version-block">
<span class="field-label">最新版本</span> <span class="field-label">在线最新版本</span>
<strong>{{ status?.latestVersion || '-' }}</strong> <strong>{{ formatVersion(latestVersion) }}</strong>
</div> </div>
<div class="version-block"> <div class="version-block">
<span class="field-label">更新状态</span> <span class="field-label">更新状态</span>
@@ -344,77 +298,20 @@ onBeforeUnmount(stopPolling)
</el-tag> </el-tag>
</div> </div>
<div class="version-block"> <div class="version-block">
<span class="field-label">检查时间</span> <span class="field-label">最后检查时间</span>
<span>{{ formatDate(status?.checkedAt) }}</span> <span>{{ formatDate(status?.checkedAt) }}</span>
</div> </div>
</section> </section>
<section class="source-section"> <section
<div class="section-heading"> v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')"
<div class="heading-title"><el-icon><Setting /></el-icon><h2>更新源设置</h2></div> class="update-section"
<el-tag v-if="config?.tokenConfigured" type="success" effect="plain" size="small">Token 已配置</el-tag>
<el-tag v-else type="info" effect="plain" size="small">公开仓库</el-tag>
</div>
<el-form :disabled="configLocked" label-position="top" class="source-form" @submit.prevent>
<el-form-item label="在线更新">
<el-switch v-model="sourceForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item label="Gitea 地址" class="source-form__wide">
<el-input v-model="sourceForm.giteaBaseUrl" placeholder="https://git.example.com" clearable />
</el-form-item>
<el-form-item label="仓库">
<el-input v-model="sourceForm.repository" placeholder="owner/repository" />
</el-form-item>
<el-form-item label="更新通道">
<el-select v-model="sourceForm.channel" style="width: 100%">
<el-option label="正式版" value="stable" />
<el-option label="预览版" value="preview" />
</el-select>
</el-form-item>
<el-form-item label="Gitea Token" class="source-form__wide">
<el-input
v-model="sourceForm.token"
type="password"
show-password
autocomplete="new-password"
:disabled="configLocked || sourceForm.clearToken"
:placeholder="config?.tokenConfigured ? '已配置,留空保持不变' : '公开仓库可留空'"
/>
</el-form-item>
<el-form-item label="HTTP 更新源">
<el-switch v-model="sourceForm.allowInsecureHttp" active-text="允许" inactive-text="禁止" />
</el-form-item>
<el-form-item v-if="config?.tokenConfigured" label="凭据操作">
<el-checkbox
v-model="sourceForm.clearToken"
:disabled="configLocked || Boolean(sourceForm.token)"
> >
清除现有 Token
</el-checkbox>
</el-form-item>
</el-form>
<div class="source-actions">
<el-button :loading="saving" :disabled="configLocked || checking" @click="saveSource(false)">保存设置</el-button>
<el-button
type="primary"
:icon="Connection"
:loading="saving || checking"
:disabled="configLocked"
@click="saveSource(true)"
>
保存并检查
</el-button>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')" class="update-progress">
<div class="section-heading"> <div class="section-heading">
<div class="heading-title"> <div class="heading-title">
<el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon> <el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon>
<el-icon v-else><Check /></el-icon> <el-icon v-else><Check /></el-icon>
<h2>执行结果</h2> <h2>更新进度</h2>
</div> </div>
<span>{{ status.progress }}%</span> <span>{{ status.progress }}%</span>
</div> </div>
@@ -427,35 +324,36 @@ onBeforeUnmount(stopPolling)
<p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p> <p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p>
</section> </section>
<section class="release-section"> <section class="update-section">
<div class="section-heading"> <div class="section-heading">
<div class="heading-title"><h2>版本更新日志</h2></div> <div class="heading-title"><h2>最新版本更新日志</h2></div>
<span>发布日期 {{ formatDate(status?.publishedAt) }}</span> <span>发布日期 {{ formatDate(latestPublishedAt) }}</span>
</div> </div>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div> <div class="release-notes">{{ latestReleaseNotes || '暂无发布说明' }}</div>
</section> </section>
<section class="release-section"> <section class="update-section history-section">
<div class="section-heading"><div class="heading-title"><h2>发布文件与校验</h2></div></div> <div class="section-heading">
<el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件"> <div class="heading-title"><h2>历史版本</h2></div>
<el-table-column prop="name" label="文件" min-width="280" /> <span>{{ releases.length ? `${releases.length} 个正式版本` : '暂无记录' }}</span>
<el-table-column label="大小" width="120"> </div>
<template #default="{ row }">{{ formatBytes(row.size) }}</template> <el-skeleton v-if="historyLoading" :rows="4" animated />
</el-table-column> <div v-else-if="releases.length" class="release-history">
<el-table-column label="校验方式" min-width="160"> <article v-for="release in releases" :key="release.version" class="release-entry">
<template #default="{ row }"> <div class="release-version">
<el-tag <strong>{{ formatVersion(release.version) }}</strong>
v-if="verificationLabel(row.name) !== '-'" <div class="release-tags">
:type="row.name === 'SHA256SUMS.sig' ? 'success' : 'info'" <el-tag v-if="release.current" type="primary" effect="plain" size="small">当前版本</el-tag>
effect="plain" <el-tag v-if="release.latest" type="success" effect="plain" size="small">最新版本</el-tag>
size="small" </div>
> <time>{{ formatDate(release.publishedAt) }}</time>
{{ verificationLabel(row.name) }} </div>
</el-tag> <div class="release-notes release-notes--history">
<span v-else>-</span> {{ release.releaseNotes || '暂无发布说明' }}
</template> </div>
</el-table-column> </article>
</el-table> </div>
<el-empty v-else description="暂无历史版本" :image-size="72" />
</section> </section>
</div> </div>
</template> </template>
@@ -504,16 +402,14 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-xs); font-size: var(--erp-font-size-xs);
} }
.source-section, .update-section {
.update-progress,
.release-section {
padding: var(--erp-space-5) 0; padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft); border-bottom: 1px solid var(--erp-color-border-soft);
} }
.section-heading, .section-heading,
.heading-title, .heading-title,
.source-actions { .release-tags {
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -540,24 +436,8 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-xs); font-size: var(--erp-font-size-xs);
} }
.source-form {
display: grid;
grid-template-columns: 140px minmax(260px, 2fr) minmax(200px, 1fr) 160px;
gap: 0 var(--erp-space-4);
}
.source-form__wide {
min-width: 0;
grid-column: span 2;
}
.source-actions {
justify-content: flex-end;
gap: var(--erp-space-2);
}
.release-notes { .release-notes {
min-height: 96px; min-height: 72px;
color: var(--erp-color-text-muted); color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm); font-size: var(--erp-font-size-sm);
line-height: 1.75; line-height: 1.75;
@@ -580,14 +460,47 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-sm); font-size: var(--erp-font-size-sm);
} }
@media (max-width: 1080px) { .release-history {
.source-form { border-top: 1px solid var(--erp-color-border-soft);
grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.source-form__wide { .release-entry {
grid-column: span 1; display: grid;
} grid-template-columns: 190px minmax(0, 1fr);
gap: var(--erp-space-5);
padding: var(--erp-space-4) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.release-entry:last-child {
border-bottom: 0;
}
.release-version {
min-width: 0;
}
.release-version > strong {
display: block;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.release-tags {
flex-wrap: wrap;
gap: var(--erp-space-1);
margin-top: var(--erp-space-2);
}
.release-version time {
display: block;
margin-top: var(--erp-space-2);
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.release-notes--history {
min-height: 0;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -605,8 +518,7 @@ onBeforeUnmount(stopPolling)
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.update-summary, .update-summary {
.source-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -625,14 +537,9 @@ onBeforeUnmount(stopPolling)
flex-wrap: wrap; flex-wrap: wrap;
} }
.source-actions { .release-entry {
align-items: stretch; grid-template-columns: 1fr;
flex-direction: column; gap: var(--erp-space-3);
}
.source-actions .el-button {
width: 100%;
margin-left: 0;
} }
} }
</style> </style>