feat(admin): 备案号可编辑,页脚模板由服务端生成

备案号原来只能写死在 pageInfo.footer 的 HTML 里。现在 /admin/ 站点信息
拆出「ICP 备案号」「公安备案号」两个输入框,页脚 HTML 由服务端
beianFooter() 统一按模板生成(含 html.EscapeString 转义),
自定义页脚 HTML 降级为「高级」折叠项;旧 footer 会被解析回填到字段。

conf.example.yml 里重复的 .navi-search-bar 规则已由 web/navi.css 接管,删除。
This commit is contained in:
JianFeeeee
2026-09-11 16:25:26 +08:00
parent 9eb8214fab
commit e4967b99ee
4 changed files with 134 additions and 5 deletions

View File

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net/http"
@ -475,6 +476,33 @@ func handleSyncInfo(w http.ResponseWriter, r *http.Request) {
})
}
// beianFooter 按固定模板生成页脚备案号 HTML。
//
// 备案号在 /admin/ 里就是两个普通输入框,模板统一由服务端生成,
// 用户不必写 HTML;类名与 web/navi.css 里的 .navi-footer 对应。
// footer 字段本身仍然保留:传了自定义 HTML 就以它为准(高级用法)。
func beianFooter(icp, gongan string) string {
icp = strings.TrimSpace(icp)
gongan = strings.TrimSpace(gongan)
parts := make([]string, 0, 2)
if icp != "" {
parts = append(parts, `<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(icp)+`</a>`)
}
if gongan != "" {
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(gongan)+`</a>`)
}
if len(parts) == 0 {
return ""
}
return `<div class="navi-footer">` + strings.Join(parts, `<span class="navi-footer-sep">·</span>`) + `</div>`
}
// mapString 读取 map 里的字符串字段,缺失或类型不符时返回空串。
func mapString(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
// 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。
func handlePageInfo(w http.ResponseWriter, r *http.Request) {
@ -501,6 +529,8 @@ func handlePageInfo(w http.ResponseWriter, r *http.Request) {
Title *string `json:"title"`
Description *string `json:"description"`
Footer *string `json:"footer"`
ICP *string `json:"icp"`
Gongan *string `json:"gongan"`
NavLinks *[]map[string]any `json:"navLinks"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@ -516,6 +546,17 @@ func handlePageInfo(w http.ResponseWriter, r *http.Request) {
if body.Footer != nil {
cfg.PageInfo["footer"] = *body.Footer
}
// 备案号单独存字段,便于在 /admin/ 里直接编辑,而不是让用户写 HTML。
if body.ICP != nil {
cfg.PageInfo["icp"] = *body.ICP
}
if body.Gongan != nil {
cfg.PageInfo["gongan"] = *body.Gongan
}
// 页脚为空(留空 = 交给模板)时,按备案号重新生成。
if strings.TrimSpace(mapString(cfg.PageInfo, "footer")) == "" {
cfg.PageInfo["footer"] = beianFooter(mapString(cfg.PageInfo, "icp"), mapString(cfg.PageInfo, "gongan"))
}
if body.NavLinks != nil {
cfg.PageInfo["navLinks"] = *body.NavLinks
}

63
admin-server/main_test.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"strings"
"testing"
)
func TestBeianFooter(t *testing.T) {
cases := []struct {
name, icp, gongan string
wantParts []string
wantEmpty bool
}{
{name: "both", icp: "京ICP备00000000号-1", gongan: "京公网安备00000000000000号",
wantParts: []string{"class=\"navi-footer\"", "beian.miit.gov.cn", "京ICP备00000000号-1", "navi-footer-sep", "beian.mps.gov.cn", "京公网安备00000000000000号"}},
{name: "only icp", icp: " 京ICP备12345678号 ", wantParts: []string{"class=\"navi-footer\"", "京ICP备12345678号"}},
{name: "only gongan", gongan: "沪公网安备31010102002555号", wantParts: []string{"class=\"navi-footer\"", "沪公网安备31010102002555号"}},
{name: "blank", icp: " ", gongan: "", wantEmpty: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := beianFooter(tc.icp, tc.gongan)
if tc.wantEmpty {
if got != "" {
t.Fatalf("want empty footer, got %q", got)
}
return
}
for _, p := range tc.wantParts {
if !strings.Contains(got, p) {
t.Fatalf("footer %q missing %q", got, p)
}
}
if strings.Contains(got, " ") {
t.Fatalf("footer keeps stray whitespace: %q", got)
}
})
}
}
// 备案号里若混入引号/尖括号,必须被转义,不能原样拼进 HTML。
func TestBeianFooterEscapesInput(t *testing.T) {
got := beianFooter(`"><script>alert(1)</script>`, "")
if strings.Contains(got, "<script>") {
t.Fatalf("unescaped input leaked into footer: %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") && !strings.Contains(got, "&#34;") {
t.Fatalf("expected escaped output, got %q", got)
}
}
func TestMapString(t *testing.T) {
m := map[string]any{"a": "x", "b": 1, "c": nil}
if got := mapString(m, "a"); got != "x" {
t.Fatalf("mapString(a) = %q; want x", got)
}
if got := mapString(m, "b"); got != "" {
t.Fatalf("mapString(b) = %q; want empty for non-string", got)
}
if got := mapString(m, "missing"); got != "" {
t.Fatalf("mapString(missing) = %q; want empty", got)
}
}

View File

@ -88,10 +88,16 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<div class="fg"><label>标题</label><input id="p-title" placeholder="JianF 统一门户"></div>
<div class="fg"><label>副标题</label><input id="p-desc" placeholder="内网服务导航"></div>
</div>
<div class="fg" style="margin-bottom:12px">
<label>页脚(可含 HTML)</label>
<textarea id="p-footer" placeholder="留空则不显示"></textarea>
<div class="row">
<div class="fg"><label>ICP 备案号</label><input id="p-icp" placeholder="京ICP备00000000号-1"></div>
<div class="fg"><label>公安备案号(可选)</label><input id="p-gongan" placeholder="京公网安备00000000000000号"></div>
</div>
<details style="margin:12px 0">
<summary style="cursor:pointer;font-size:13px;color:#475569">高级:自定义页脚 HTML</summary>
<div class="fg" style="margin-top:8px">
<textarea id="p-footer" placeholder="留空则按上面的备案号自动生成;填写后以这里为准"></textarea>
</div>
</details>
<label>顶部导航链接</label>
<div id="navlinks" style="margin:8px 0 10px"></div>
<button class="ghost icon-btn" id="add-link">+ 添加链接</button>
@ -237,6 +243,17 @@ function renderNavLinks() {
});
}
/* ── 备案号 ──
备案号直接编辑,页脚 HTML 由服务端模板生成;高级模式下可整段覆盖。 */
function parseIcp(html) {
const m = /[\u4e00-\u9fa5]{2}ICP备\d+号(-\d+)?/.exec(html || '');
return m ? m[0] : '';
}
function parseGongan(html) {
const m = /[\u4e00-\u9fa5]{2}公网安备\d+号/.exec(html || '');
return m ? m[0] : '';
}
async function loadPageInfo() {
try {
const r = await fetch(A + '/pageinfo', { headers: authHeaders() });
@ -248,7 +265,12 @@ async function loadPageInfo() {
}
document.getElementById('p-title').value = PAGE.title || '';
document.getElementById('p-desc').value = PAGE.description || '';
document.getElementById('p-footer').value = PAGE.footer || '';
const icp = PAGE.icp || parseIcp(PAGE.footer);
const gongan = PAGE.gongan || parseGongan(PAGE.footer);
document.getElementById('p-icp').value = icp;
document.getElementById('p-gongan').value = gongan;
// 能从备案号字段还原时,高级 HTML 留空,保存时按模板重新生成
document.getElementById('p-footer').value = (icp || gongan) ? '' : (PAGE.footer || '');
if (!PAGE.navLinks && PAGE.navLinks !== null) PAGE.navLinks = [];
renderNavLinks();
}
@ -263,6 +285,9 @@ document.getElementById('save-info').addEventListener('click', async () => {
const body = {
title: document.getElementById('p-title').value,
description: document.getElementById('p-desc').value,
icp: document.getElementById('p-icp').value,
gongan: document.getElementById('p-gongan').value,
// 留空 = 交给服务端按备案号生成模板;填写则整段覆盖
footer: document.getElementById('p-footer').value,
navLinks: (PAGE.navLinks || []).filter(l => l && (l.name || l.url)),
};

View File

@ -12,7 +12,7 @@ appConfig:
\ .home {\n background: transparent !important;\n}\n\n/* ── 顶栏 ── */\nhtml[data-theme] header {\n background: rgba(255,255,255,.44) !important;\n backdrop-filter: blur(18px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(18px) saturate(180%) !important;\n border-bottom: 1px solid rgba(255,255,255,.6) !important;\n box-shadow: 0 6px 24px rgba(15,23,42,.06) !important;\n}\n\n/* ── 卡片:真正的表面是 a.item ── */\nhtml[data-theme] a.item {\n background: rgba(255,255,255,.52) !important;\n backdrop-filter: blur(18px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(18px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.66) !important;\n border-radius: 18px !important;\n box-shadow:\n 0 6px 18px rgba(15,23,42,.07),\n inset 0 1px 0 rgba(255,255,255,.85) !important;\n transition: transform .28s cubic-bezier(.34,1.2,.64,1),\n box-shadow .28s ease, background .28s ease, border-color .28s ease !important;\n}\nhtml[data-theme] a.item:hover\
\ {\n background: rgba(255,255,255,.88) !important;\n border-color: rgba(147,197,253,.85) !important;\n transform: translateY(-4px) !important;\n box-shadow:\n 0 18px 36px rgba(30,64,175,.18),\n 0 0 0 1px rgba(147,197,253,.35),\n inset 0 1px 0 rgba(255,255,255,.9) !important;\n}\n/* 外层网格单元保持透明,避免叠两层底 */\nhtml[data-theme] div.item-wrapper {\n background: transparent !important;\n backdrop-filter: none !important;\n -webkit-backdrop-filter: none !important;\n border: none !important;\n box-shadow: none !important;\n}\nhtml[data-theme] a.item .tile-icon,\nhtml[data-theme] a.item .item-icon {\n border-radius: 13px !important;\n filter: drop-shadow(0 3px 6px rgba(15,23,42,.10)) !important;\n transition: transform .28s cubic-bezier(.34,1.2,.64,1) !important;\n}\nhtml[data-theme] a.item:hover .tile-icon { transform: scale(1.08) !important; }\nhtml[data-theme] a.item .tile-title { font-weight: 650 !important; letter-spacing: .1px !important; }\nhtml[data-theme] a.item .description,\n\
html[data-theme] a.item .item-url { color: #64748b !important; }\n\n/* ── 分组容器 ── */\nhtml[data-theme] div.collapsable {\n background: rgba(255,255,255,.40) !important;\n backdrop-filter: blur(20px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(20px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.6) !important;\n border-radius: 24px !important;\n box-shadow:\n 0 12px 32px rgba(15,23,42,.09),\n inset 0 1px 0 rgba(255,255,255,.85) !important;\n overflow: hidden !important;\n}\nhtml[data-theme] div.collapsible-content {\n background: transparent !important;\n border-radius: 0 0 22px 22px !important;\n}\nhtml[data-theme] div.collapsable .content-inner { padding: 10px 12px !important; }\nhtml[data-theme] .item-group-container { padding: 0 4px !important; }\n\n/* 分组标题 */\nhtml[data-theme] .collapsable .title,\nhtml[data-theme] .collapsable h1,\nhtml[data-theme] .collapsable h2 {\n font-weight: 700 !important;\n letter-spacing: .2px !important;\n\
}\n\n/* ── 搜索 ── */\nhtml[data-theme] .search-container,\nhtml[data-theme] header input[type=\"text\"] {\n background: rgba(255,255,255,.6) !important;\n border: 1px solid rgba(255,255,255,.7) !important;\n border-radius: 16px !important;\n backdrop-filter: blur(14px) saturate(170%) !important;\n -webkit-backdrop-filter: blur(14px) saturate(170%) !important;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 4px 14px rgba(15,23,42,.05) !important;\n}\nhtml[data-theme] .navi-search-bar:focus-within {\n box-shadow: 0 14px 34px rgba(37,99,235,.22), 0 0 0 3px rgba(37,99,235,.16) !important;\n}\n\n/* ── 滚动条 ── */\nhtml[data-theme] ::-webkit-scrollbar { width: 10px; height: 10px; }\nhtml[data-theme] ::-webkit-scrollbar-track { background: transparent; }\nhtml[data-theme] ::-webkit-scrollbar-thumb {\n background: rgba(100,116,139,.32);\n border-radius: 999px;\n border: 2px solid transparent;\n background-clip: content-box;\n}\nhtml[data-theme] ::-webkit-scrollbar-thumb:hover {\n\
}\n\n/* ── 搜索 ── */\nhtml[data-theme] .search-container,\nhtml[data-theme] header input[type=\"text\"] {\n background: rgba(255,255,255,.6) !important;\n border: 1px solid rgba(255,255,255,.7) !important;\n border-radius: 16px !important;\n backdrop-filter: blur(14px) saturate(170%) !important;\n -webkit-backdrop-filter: blur(14px) saturate(170%) !important;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 4px 14px rgba(15,23,42,.05) !important;\n}\n\n/* ── 滚动条 ── */\nhtml[data-theme] ::-webkit-scrollbar { width: 10px; height: 10px; }\nhtml[data-theme] ::-webkit-scrollbar-track { background: transparent; }\nhtml[data-theme] ::-webkit-scrollbar-thumb {\n background: rgba(100,116,139,.32);\n border-radius: 999px;\n border: 2px solid transparent;\n background-clip: content-box;\n}\nhtml[data-theme] ::-webkit-scrollbar-thumb:hover {\n\
\ background: rgba(100,116,139,.5);\n background-clip: content-box;\n}\n\n/* ── 工作空间侧边栏 ──\n 底部「切换视图」按钮组内容 70px 塞进 56px 的栏里,把 side-bar 撑出横向\n 滚动条,必须左右滑动才能看全。这里让它换行并收住溢出。 */\nhtml[data-theme] nav.side-bar { overflow-x: hidden !important; }\nhtml[data-theme] nav.side-bar .switch-view-buttons {\n width: 100% !important;\n display: flex !important;\n flex-wrap: wrap !important;\n justify-content: center !important;\n align-items: center !important;\n gap: 2px !important;\n margin: 8px 0 0 !important;\n}\nhtml[data-theme] nav.side-bar .switch-view-buttons > * {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n max-width: 100% !important;\n}\n\n/* ── 设置抽屉 / 菜单 / 提示 ── */\nhtml[data-theme] .settings-outer,\nhtml[data-theme] .config-container,\nhtml[data-theme] .context-menu,\nhtml[data-theme] .toast,\nhtml[data-theme] .welcome-popup {\n background: rgba(255,255,255,.72) !important;\n backdrop-filter: blur(20px) saturate(180%) !important;\n -webkit-backdrop-filter:\
\ blur(20px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.66) !important;\n border-radius: 18px !important;\n box-shadow: 0 14px 34px rgba(15,23,42,.14) !important;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-theme] a.item,\n html[data-theme] a.item .tile-icon { transition: none !important; }\n html[data-theme] a.item:hover { transform: none !important; }\n}\n"
layout: masonry