From 472d8d1cd0c56f156cffc94e23616e9997ade0ab Mon Sep 17 00:00:00 2001 From: HomeDash Bot Date: Sat, 12 Sep 2026 09:12:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(pages):=20=E8=AE=A4=E8=AF=81=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E9=97=A8=E6=88=B7=E9=A1=B5=E6=96=87=E6=A1=88=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=86=99=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:登录页与门户页的内容应可自定义。 实现(配置存数据卷、后台可改、留空回落内置默认,不配置行为不变): - pages.go:pageStr 读取 pageInfo;renderPortalPage 优先数据卷 /portal/index.html 整页覆盖,否则用内置模板替换占位符; authPageInfo 提供登录页字段 - auth.go:登录页标题/副标题/徽标/页脚改为模板数据(含备案号) - gateway.go:kind=portal 的首页改由 admin-server /portal-page 渲染 (静态资源仍由 nginx 提供),使文案可配置 - portal/index.html:加入 / / / 占位符 - 后台「站点信息」新增六项文案输入(含回填与保存),并提示整页覆盖方式 - README 新增「认证页与门户页文案(可配置,不写死)」含字段表与自检命令 测试:默认回落、配置取值去空白、认证页字段读取、数据卷覆盖优先、空白覆盖忽略。 --- README.md | 25 +++++++++++ admin-server/auth.go | 9 ++-- admin-server/main.go | 25 +++++++++++ admin-server/pages.go | 89 ++++++++++++++++++++++++++++++++++++++ admin-server/pages_test.go | 67 ++++++++++++++++++++++++++++ admin/index.html | 32 ++++++++++++++ portal/index.html | 12 ++--- 7 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 admin-server/pages.go create mode 100644 admin-server/pages_test.go diff --git a/README.md b/README.md index 013c69d..924a9cd 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,31 @@ NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v50 - 配置:`portal/nginx.conf.bak-<时间戳>`;`conf.yml` 的改动另有 `conf.yml.bak-*` - 数据:`conf.yml`、`history.json`、`gateway.yml`、`portal-auth.json` 都在数据卷,容器重建不丢 +### 认证页与门户页文案(可配置,不写死) + +原先登录页与门户页的标题/副标题/徽标/页脚都硬编码在代码与镜像里。现在统一从 `conf.yml` +的 `pageInfo` 读取,**留空即回落内置默认值**(不配置时行为与之前一致): + +| 字段 | 作用 | 默认 | +| --- | --- | --- | +| `authTitle` / `authSubtitle` / `authLogo` | 登录页标题、副标题、徽标文字 | 统一门户 / 登录后访问受保护的内部服务 / JF | +| `portalTitle` | 门户页浏览器标题 | JianF Git - 个人开发环境 | +| `heroTitle` / `heroSubtitle` | 门户页主标题、副标题 | 代码、博客与灵感之站 / … | +| `icp` / `gongan` | 两页页脚备案号(与导航页共用) | — | + +入口:管理后台 →「站点信息」→「认证页与门户页文案」。 + +**整页完全自定义**:把 `index.html` 放进数据卷的 `portal/` 目录即生效(优先于上述字段, +用于改版式而非只改文案);`portal/` 下的 `portal.css`、favicon 等静态资源同样可覆盖。 +门户首页由 admin-server 渲染(`/portal-page`),静态资源仍由 nginx 提供。 + +自检(发布前确认占位符已被替换、页面无残留标记): + +```bash +curl -s https://<域名>/ | grep -c '", html.EscapeString(pageStr(info, "portalTitle", defaultPortalTitle)), + "", html.EscapeString(pageStr(info, "heroTitle", defaultPortalHero)), + "", html.EscapeString(pageStr(info, "heroSubtitle", defaultPortalHeroSub)), + "", beianFooter(mapString(info, "icp"), mapString(info, "gongan")), + ) + out := rep.Replace(string(builtin)) + // 未做占位替换的旧模板(例如自备的 index.html)原样返回 + return []byte(out) +} + +// handlePortalPage 供 nginx 的 kind=portal 路由反代使用。 +func handlePortalPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(renderPortalPage()) +} + +// authPageInfo 返回登录页所需字段(供 auth.go 渲染)。 +func authPageInfo() (title, subtitle, logo, icp, gongan string) { + cfg, err := loadConfig() + info := map[string]any{} + if err == nil && cfg != nil { + info = cfg.PageInfo + } + return pageStr(info, "authTitle", defaultAuthTitle), + pageStr(info, "authSubtitle", defaultAuthSubtitle), + pageStr(info, "authLogo", defaultAuthLogo), + mapString(info, "icp"), mapString(info, "gongan") +} diff --git a/admin-server/pages_test.go b/admin-server/pages_test.go new file mode 100644 index 0000000..2883e31 --- /dev/null +++ b/admin-server/pages_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 未配置任何文案时应回落到内置默认值(不配置也照旧)。 +func TestPageStrFallbacks(t *testing.T) { + empty := map[string]any{} + if got := pageStr(empty, "authTitle", defaultAuthTitle); got != defaultAuthTitle { + t.Fatalf("空配置应回落默认,得到 %q", got) + } + info := map[string]any{"authTitle": " 内网入口 ", "heroTitle": ""} + if got := pageStr(info, "authTitle", defaultAuthTitle); got != "内网入口" { + t.Fatalf("应取配置值并去空白,得到 %q", got) + } + // 空串视为未配置 + if got := pageStr(info, "heroTitle", defaultPortalHero); got != defaultPortalHero { + t.Fatalf("空串应回落默认,得到 %q", got) + } +} + +// 认证页字段从 pageInfo 读取,缺失时回落。 +func TestAuthPageInfoFallback(t *testing.T) { + dir := t.TempDir() + old := confFile + confFile = filepath.Join(dir, "conf.yml") + defer func() { confFile = old }() + if err := os.WriteFile(confFile, []byte("pageInfo:\n authTitle: 我的门户\n authLogo: AB\n"), 0o644); err != nil { + t.Fatal(err) + } + title, subtitle, logo, _, _ := authPageInfo() + if title != "我的门户" || logo != "AB" { + t.Fatalf("未读到配置值: %q %q", title, logo) + } + if subtitle != defaultAuthSubtitle { + t.Fatalf("未配置的字段应回落默认,得到 %q", subtitle) + } +} + +// 数据卷里的整页覆盖优先于内置模板。 +func TestPortalOverrideWins(t *testing.T) { + dir := t.TempDir() + old := confFile + confFile = filepath.Join(dir, "conf.yml") + defer func() { confFile = old }() + if err := os.MkdirAll(filepath.Join(dir, "portal"), 0o755); err != nil { + t.Fatal(err) + } + custom := "我的门户" + if err := os.WriteFile(filepath.Join(dir, "portal", "index.html"), []byte(custom), 0o644); err != nil { + t.Fatal(err) + } + if got := string(renderPortalPage()); !strings.Contains(got, "我的门户") { + t.Fatalf("数据卷覆盖未生效: %q", got) + } + // 空白覆盖文件应被忽略(回落到内置渲染) + if err := os.WriteFile(filepath.Join(dir, "portal", "index.html"), []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + if got := string(renderPortalPage()); strings.Contains(got, "我的门户") { + t.Fatalf("空白覆盖文件应被忽略") + } +} diff --git a/admin/index.html b/admin/index.html index 3eca4a4..29c75c5 100644 --- a/admin/index.html +++ b/admin/index.html @@ -168,6 +168,20 @@ html[data-theme="dark"] .toast{ box-shadow:0 10px 30px rgba(0,0,0,.5) }
+
+ 认证页与门户页文案(留空=用内置默认) +
+
+
+
+
+
+
+
+
+
+

整页完全自定义:把 index.html 放进数据卷的 portal/ 目录即生效(优先于以上字段)。

+
高级:自定义页脚 HTML
@@ -468,6 +482,12 @@ async function loadPageInfo() { document.getElementById('p-desc').value = PAGE.description || ''; const icp = PAGE.icp || parseIcp(PAGE.footer); const gongan = PAGE.gongan || parseGongan(PAGE.footer); + const setIf = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || ''; }; + + setIf('p-auth-title', PAGE.authTitle); setIf('p-auth-sub', PAGE.authSubtitle); setIf('p-auth-logo', PAGE.authLogo); + + setIf('p-portal-title', PAGE.portalTitle); setIf('p-hero-title', PAGE.heroTitle); setIf('p-hero-sub', PAGE.heroSubtitle); + document.getElementById('p-icp').value = icp; document.getElementById('p-gongan').value = gongan; // 能从备案号字段还原时,高级 HTML 留空,保存时按模板重新生成 @@ -490,6 +510,18 @@ document.getElementById('save-info').addEventListener('click', async () => { gongan: document.getElementById('p-gongan').value, // 留空 = 交给服务端按备案号生成模板;填写则整段覆盖 footer: document.getElementById('p-footer').value, + authTitle: document.getElementById('p-auth-title').value.trim(), + + authSubtitle: document.getElementById('p-auth-sub').value.trim(), + + authLogo: document.getElementById('p-auth-logo').value.trim(), + + portalTitle: document.getElementById('p-portal-title').value.trim(), + + heroTitle: document.getElementById('p-hero-title').value.trim(), + + heroSubtitle: document.getElementById('p-hero-sub').value.trim(), + navLinks: (PAGE.navLinks || []).filter(l => l && (l.name || l.url)), }; const r = await fetch(A + '/pageinfo', { diff --git a/portal/index.html b/portal/index.html index af093f8..ad9184d 100644 --- a/portal/index.html +++ b/portal/index.html @@ -3,7 +3,7 @@ -JianF Git - 个人开发环境 +<!--PAGE_TITLE--> @@ -38,8 +38,8 @@ 个人开发环境 -

代码、博客与灵感之站

-

JianF Git · 代码托管 · 技术博客 · 私有服务入口

+

+

- +