This commit is contained in:
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.4.6] - 2026-09-28
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **HTTPS 直连证书指纹不匹配可定位**:nginx 配置校验通过、但公网 `7443` 返回的证书与刚写入的不一致时,原来只提示“证书指纹不匹配”,无法判断是 nginx 未生效还是端口被其他服务接管。现在 HTTPS server 使用 `default_server` 绑定,Agent 会在 reload 后从本机 `127.0.0.1:<端口>` 握手自检并重试 5 秒;指纹不一致时回滚,并回报实际指纹与 `ss -tlnp` 端口占用进程
|
||||||
|
- **公网链路错误提示**:主程序在 Agent 本机自检通过、但公网地址校验失败时,会明确提示检查 `7443` 的端口转发、防火墙或其他服务接管
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 版本号提升到 1.4.6;`web/package.json` 与 `web/package-lock.json` 同步对齐
|
||||||
|
- `AgentVersion` 0.13.2 → **0.13.3**,内嵌 amd64 / arm64 二进制已重编;VPS 上需重新点一次「升级 Agent」才会生效
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `internal/agent`:新增证书 PEM 指纹计算测试
|
||||||
|
|
||||||
## [v1.4.5] - 2026-09-28
|
## [v1.4.5] - 2026-09-28
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Fonu 文档索引
|
# Fonu 文档索引
|
||||||
|
|
||||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||||
> 最近更新:2026-09-28(v1.4.5)
|
> 最近更新:2026-09-28(v1.4.6)
|
||||||
|
|
||||||
## 方案与设计(本目录)
|
## 方案与设计(本目录)
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
> 方案编号:P0-5
|
> 方案编号:P0-5
|
||||||
> 制定日期:2026-09-28
|
> 制定日期:2026-09-28
|
||||||
> 当前 AgentVersion:0.13.2
|
> 当前 AgentVersion:0.13.3
|
||||||
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
|
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
|
||||||
|
|
||||||
> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;0.13.1 / 0.13.2 修复 nginx 版本目录被 `include *` 误加载及探测阶段无法自愈的问题;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。
|
> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;0.13.1 / 0.13.2 修复 nginx 版本目录被 `include *` 误加载及探测阶段无法自愈的问题,0.13.3 增加 HTTPS reload 后本机证书自检;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
|
|||||||
|
|
||||||
## 10. 在线升级验证
|
## 10. 在线升级验证
|
||||||
|
|
||||||
示例页面用于验证升级结果。v1.4.5 的验证流程:
|
示例页面用于验证升级结果。v1.4.6 的验证流程:
|
||||||
|
|
||||||
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
||||||
2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.5`。
|
2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.6`。
|
||||||
3. 在侧栏看到「一键升级」后点击并确认。
|
3. 在侧栏看到「一键升级」后点击并确认。
|
||||||
4. 等待升级弹窗显示成功,页面自动刷新。
|
4. 等待升级弹窗显示成功,页面自动刷新。
|
||||||
5. 打开侧栏「示例页面」,确认:
|
5. 打开侧栏「示例页面」,确认:
|
||||||
- 页面路由为 `/example`;
|
- 页面路由为 `/example`;
|
||||||
- 页面显示的版本为 `1.4.5`;
|
- 页面显示的版本为 `1.4.6`;
|
||||||
- 页面显示验证标识 `v1.4.5-example-page`;
|
- 页面显示验证标识 `v1.4.6-example-page`;
|
||||||
- 页面显示独立的「本次升级验证」区块;
|
- 页面显示独立的「本次升级验证」区块;
|
||||||
- 「升级验证」显示为「通过」。
|
- 「升级验证」显示为「通过」。
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ import (
|
|||||||
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
|
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
|
||||||
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
|
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
|
||||||
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
|
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
|
||||||
const AgentVersion = "0.13.2"
|
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
|
||||||
|
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
|
||||||
|
const AgentVersion = "0.13.3"
|
||||||
|
|
||||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/fsutil"
|
"github.com/fonu/fonu/internal/fsutil"
|
||||||
)
|
)
|
||||||
@@ -49,7 +56,7 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port, "self_check": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -96,6 +103,14 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
|
|||||||
s.restoreHTTPSConfig(path, previous, readErr)
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
pin, err := certificatePin(req.CertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.verifyHTTPSProxy(req.Port, pin); err != nil {
|
||||||
|
s.restoreHTTPSConfig(path, previous, readErr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
|
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +165,7 @@ func (s *Server) ensureNginxDir() (string, error) {
|
|||||||
func renderHTTPSProxy(port int, certPath, keyPath string) string {
|
func renderHTTPSProxy(port int, certPath, keyPath string) string {
|
||||||
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
|
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
|
||||||
server {
|
server {
|
||||||
listen %d ssl;
|
listen %d ssl default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
ssl_certificate %s;
|
ssl_certificate %s;
|
||||||
ssl_certificate_key %s;
|
ssl_certificate_key %s;
|
||||||
@@ -168,6 +183,71 @@ server {
|
|||||||
`, port, certPath, keyPath)
|
`, port, certPath, keyPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func certificatePin(certPEM string) (string, error) {
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
return "", errors.New("证书 PEM 解码失败")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(block.Bytes)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyHTTPSProxy 在 reload 后从本机回环地址握手,确认 7443 上真正返回的就是刚写入的证书。
|
||||||
|
// nginx reload 是异步的,这里留出短暂重试窗口,避免旧 worker 造成的瞬时误判。
|
||||||
|
func (s *Server) verifyHTTPSProxy(port int, want string) error {
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
lastErr = probeHTTPSPin(port, want)
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeHTTPSPin(port int, want string) error {
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||||
|
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("本机 https://%s 自检失败:%w;%s", addr, err, portListenerHint(port))
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
certs := conn.ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return fmt.Errorf("本机 https://%s 未返回证书;%s", addr, portListenerHint(port))
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(certs[0].Raw)
|
||||||
|
got := hex.EncodeToString(sum[:])
|
||||||
|
if !strings.EqualFold(got, want) {
|
||||||
|
return fmt.Errorf("本机 https://%s 证书指纹不匹配:期望 %s,实际 %s;%s", addr, want, got, portListenerHint(port))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// portListenerHint 把监听该端口的进程信息带进错误里,便于区分 nginx 未生效与端口被其他服务占用。
|
||||||
|
func portListenerHint(port int) string {
|
||||||
|
out, err := exec.Command("ss", "-tlnp").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "无法读取端口监听信息"
|
||||||
|
}
|
||||||
|
suffix := ":" + strconv.Itoa(port)
|
||||||
|
lines := make([]string, 0, 2)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if strings.Contains(line, suffix+" ") || strings.HasSuffix(strings.TrimSpace(line), suffix) {
|
||||||
|
lines = append(lines, strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return "未发现监听 " + suffix + " 的进程"
|
||||||
|
}
|
||||||
|
return "监听 " + suffix + ":" + strings.Join(lines, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
func httpsProxyStatePath(dataDir string) string {
|
func httpsProxyStatePath(dataDir string) string {
|
||||||
return filepath.Join(dataDir, "https_transport.json")
|
return filepath.Join(dataDir, "https_transport.json")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMigrateLegacyVersionDirs(t *testing.T) {
|
func TestMigrateLegacyVersionDirs(t *testing.T) {
|
||||||
@@ -43,3 +52,28 @@ func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
|
|||||||
t.Fatalf("旧版本目录应被移出 include 范围:%v", err)
|
t.Fatalf("旧版本目录应被移出 include 范围:%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCertificatePin(t *testing.T) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
NotBefore: time.Now().Add(-time.Minute),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
got, err := certificatePin(string(block))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(der)
|
||||||
|
if want := hex.EncodeToString(sum[:]); got != want {
|
||||||
|
t.Fatalf("certificatePin() = %s, 期望 %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,13 +119,14 @@ func (c *AgentClient) Ping(ctx context.Context) error {
|
|||||||
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
|
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) error {
|
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) (map[string]any, error) {
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
return c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
|
err := c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
|
||||||
"cert_pem": certPEM,
|
"cert_pem": certPEM,
|
||||||
"key_pem": keyPEM,
|
"key_pem": keyPEM,
|
||||||
"port": port,
|
"port": port,
|
||||||
}, &result)
|
}, &result)
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) DisableHTTPS(ctx context.Context) error {
|
func (c *AgentClient) DisableHTTPS(ctx context.Context) error {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -979,13 +979,17 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Server{}, err
|
return Server{}, err
|
||||||
}
|
}
|
||||||
if err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port); err != nil {
|
result, err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
|
||||||
|
if err != nil {
|
||||||
return Server{}, err
|
return Server{}, err
|
||||||
}
|
}
|
||||||
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, port)
|
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, port)
|
||||||
pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
|
pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
|
||||||
if err := pinned.Ping(ctx); err != nil {
|
if err := pinned.Ping(ctx); err != nil {
|
||||||
_ = currentClient.DisableHTTPS(ctx)
|
_ = currentClient.DisableHTTPS(ctx)
|
||||||
|
if result["self_check"] == "ok" {
|
||||||
|
return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 7443 自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", baseURL, err)
|
||||||
|
}
|
||||||
return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err)
|
return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", port), "connected", ""); err != nil {
|
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", port), "connected", ""); err != nil {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.11.1",
|
"@codemirror/commands": "^6.11.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.5 示例页面验证标识已生效">
|
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.6 示例页面验证标识已生效">
|
||||||
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
||||||
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="example-proof__hint">
|
<div class="example-proof__hint">
|
||||||
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.5。
|
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.6。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@
|
|||||||
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
||||||
<template #icon><n-icon :component="FlashOutline" /></template>
|
<template #icon><n-icon :component="FlashOutline" /></template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
<StatCard label="页面状态" value="已更新" sub="v1.4.5 前端资源已加载" tone="teal">
|
<StatCard label="页面状态" value="已更新" sub="v1.4.6 前端资源已加载" tone="teal">
|
||||||
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
||||||
<ul class="example-changes">
|
<ul class="example-changes">
|
||||||
<li>页面版本提示更新为 v1.4.5。</li>
|
<li>页面版本提示更新为 v1.4.6。</li>
|
||||||
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.5。</li>
|
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.6。</li>
|
||||||
<li>验证标识更新为 <span class="mono">v1.4.5-example-page</span>。</li>
|
<li>验证标识更新为 <span class="mono">v1.4.6-example-page</span>。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ const message = useMessage()
|
|||||||
const version = ref(__APP_VERSION__)
|
const version = ref(__APP_VERSION__)
|
||||||
const checkedAt = ref('')
|
const checkedAt = ref('')
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const verificationRound = 'v1.4.5'
|
const verificationRound = 'v1.4.6'
|
||||||
const verificationMarker = 'v1.4.5-example-page'
|
const verificationMarker = 'v1.4.6-example-page'
|
||||||
|
|
||||||
async function refreshVersion() {
|
async function refreshVersion() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user