diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go index 6f184e8..2d4fca2 100644 --- a/internal/httpapi/admin.go +++ b/internal/httpapi/admin.go @@ -396,6 +396,7 @@ type settingsDTO struct { SiteTitle *string `json:"site_title"` SiteDesc *string `json:"site_desc"` PageSize *int `json:"page_size"` + BeianNo *string `json:"beian_no"` } // handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。 @@ -414,7 +415,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) dec.DisallowUnknownFields() var dto settingsDTO if err := dec.Decode(&dto); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size)") + writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no)") return } if dto.SiteTitle != nil { @@ -448,6 +449,17 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) return } } + if dto.BeianNo != nil { + v := strings.TrimSpace(*dto.BeianNo) + if len([]rune(v)) > 200 { + writeError(w, http.StatusBadRequest, "bad_request", "备案号 ≤200 字符") + return + } + if err := s.st.SetSetting(store.KeyBeianNo, v); err != nil { + writeError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + } // admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。 s.log.Info("admin_action", "op", "settings.update") ss, err := s.st.GetSiteSettings() diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go index 2e14d1e..b584475 100644 --- a/internal/httpapi/api_test.go +++ b/internal/httpapi/api_test.go @@ -284,10 +284,31 @@ func TestSettingsWhitelist(t *testing.T) { } // PUT 合法键 resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", - []byte(`{"site_title":"新标题","site_desc":"描述","page_size":5}`), e.adminHeaders()) + []byte(`{"site_title":"新标题","site_desc":"描述","page_size":5,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders()) if resp.StatusCode != 200 { t.Errorf("合法 PUT 应 200: %d", resp.StatusCode) } + // beian_no 经 /api/site 可读且生效 + _, body = e.get(e.client(), "/api/site") + if !strings.Contains(string(body), `"beian_no":"京公网安备12345678901号"`) { + t.Errorf("beian_no 设置应生效: %s", body) + } + // beian_no 可清空(空串 = 不展示) + resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", + []byte(`{"beian_no":""}`), e.adminHeaders()) + if resp.StatusCode != 200 { + t.Errorf("beian_no 清空应 200: %d", resp.StatusCode) + } + _, body = e.get(e.client(), "/api/site") + if !strings.Contains(string(body), `"beian_no":""`) { + t.Errorf("beian_no 应可清空: %s", body) + } + // beian_no 过长拒绝 + resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", + []byte(`{"beian_no":"`+strings.Repeat("备", 201)+`"}`), e.adminHeaders()) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("beian_no 超长应 400: %d", resp.StatusCode) + } // page_size 生效为公开列表默认 _, body = e.get(e.client(), "/api/notes") if !strings.Contains(string(body), `"page_size":5`) { diff --git a/internal/store/settings.go b/internal/store/settings.go index 23b1b76..eee6d1a 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -13,6 +13,7 @@ const ( KeySiteTitle = "site_title" KeySiteDesc = "site_desc" KeyPageSize = "page_size" + KeyBeianNo = "beian_no" ) // GetSetting 读取单个设置。 @@ -37,6 +38,7 @@ type SiteSettings struct { SiteTitle string `json:"site_title"` SiteDesc string `json:"site_desc"` PageSize int `json:"page_size"` + BeianNo string `json:"beian_no"` } // Defaults,未初始化时兜底。 @@ -44,11 +46,12 @@ const ( DefaultSiteTitle = "Pure Note" DefaultSiteDesc = "" DefaultPageSize = 10 + DefaultBeianNo = "" ) -// GetSiteSettings 读取站点设置(白名单三键,带默认值)。 +// GetSiteSettings 读取站点设置(白名单键,带默认值)。 func (s *Store) GetSiteSettings() (*SiteSettings, error) { - ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize} + ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo} if v, ok, err := s.GetSetting(KeySiteTitle); err != nil { return nil, err } else if ok && v != "" { @@ -67,5 +70,10 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) { ss.PageSize = n } } + if v, ok, err := s.GetSetting(KeyBeianNo); err != nil { + return nil, err + } else if ok { + ss.BeianNo = strings.TrimSpace(v) + } return ss, nil } diff --git a/web/public/national_emblem.png b/web/public/national_emblem.png new file mode 100644 index 0000000..9f76394 Binary files /dev/null and b/web/public/national_emblem.png differ diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 993a408..14e7e79 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router' -import { Moon, Sun, Monitor, ExternalLink, House, PenLine, Trash2, Settings, LogOut } from 'lucide-react' +import { Moon, Sun, Monitor, ExternalLink, House, PenLine, Tags, Trash2, Settings, LogOut } from 'lucide-react' import { useSiteTitle } from '../features/site' import { useTheme } from '../lib/theme' import { useAuth } from '../lib/auth' @@ -32,8 +32,12 @@ export function BlogLayout() {
{title}
@@ -108,22 +127,33 @@ function AdminSidebar() {
- - 返回博客 - -
管 管理员 站点所有者 + + + + + +
diff --git a/web/src/index.css b/web/src/index.css index 2234231..dadfd1e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -517,6 +517,24 @@ button:focus-visible { .pn-site-footer-inner a:hover { color: var(--foreground); } +.pn-site-footer-line { + margin: 0; +} +.pn-site-beian { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; +} +.pn-site-beian img { + width: 14px; + height: 14px; + flex: none; + opacity: 0.85; +} +.pn-site-beian:hover img { + opacity: 1; +} @media (max-width: 640px) { .pn-site-header-inner { @@ -535,35 +553,9 @@ button:focus-visible { 首页(博客首页.html) ============================================================ */ .pn-home-content { - padding-top: 64px; + padding-top: 56px; padding-bottom: 24px; } -.pn-home-hero { - margin-bottom: 48px; -} -.pn-home-eyebrow { - margin: 0 0 18px; - font-family: var(--font-mono); - font-size: 12px; - letter-spacing: 0.14em; - color: var(--muted-foreground); -} -.pn-home-title { - margin: 0 0 18px; - font-family: var(--font-serif); - font-weight: 400; - font-size: clamp(34px, 6vw, 46px); - line-height: 1.18; - letter-spacing: var(--tracking-tight); - color: var(--foreground); -} -.pn-home-desc { - margin: 0; - max-width: 560px; - font-size: 15px; - line-height: 1.8; - color: var(--muted-foreground); -} .pn-home-filter { margin-bottom: 8px; } @@ -728,10 +720,7 @@ button:focus-visible { } @media (max-width: 640px) { .pn-home-content { - padding-top: 44px; - } - .pn-home-hero { - margin-bottom: 40px; + padding-top: 40px; } .pn-home-post { padding: 26px 0; @@ -1597,6 +1586,78 @@ button:focus-visible { gap: 10px; padding: 8px 10px 2px; } +.pn-admin-user-actions { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} +.pn-admin-iconbtn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted-foreground); + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} +.pn-admin-iconbtn:hover { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} +.pn-admin-iconbtn svg { + width: 15px; + height: 15px; +} +/* CSS 悬浮 tooltip(原生 title 延迟高且部分环境不显示) */ +.pn-admin-iconbtn::after { + content: attr(data-tip); + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + z-index: 30; + padding: 4px 8px; + border-radius: var(--radius-sm); + background: var(--primary); + color: var(--primary-foreground); + font-size: 11px; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 2px); + transition: opacity 0.15s ease, transform 0.15s ease; +} +.pn-admin-iconbtn::before { + content: ""; + position: absolute; + left: 50%; + bottom: 100%; + z-index: 30; + margin-left: -5px; + border: 5px solid transparent; + border-top-color: var(--primary); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} +.pn-admin-iconbtn:hover::after, +.pn-admin-iconbtn:focus-visible::after { + opacity: 1; + transform: translate(-50%, 0); +} +.pn-admin-iconbtn:hover::before, +.pn-admin-iconbtn:focus-visible::before { + opacity: 1; +} .pn-admin-avatar { width: 28px; height: 28px; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8f5a5db..5a31fa6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -87,6 +87,7 @@ export interface SiteSettings { site_title: string site_desc: string page_size: number + beian_no: string } export interface NoteItem { diff --git a/web/src/pages/AdminSettings.tsx b/web/src/pages/AdminSettings.tsx index eac5799..e4902df 100644 --- a/web/src/pages/AdminSettings.tsx +++ b/web/src/pages/AdminSettings.tsx @@ -18,6 +18,7 @@ export default function AdminSettings() { const [title, setTitle] = useState('') const [desc, setDesc] = useState('') + const [beian, setBeian] = useState('') const [pageSize, setPageSize] = useState(10) const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null) @@ -25,6 +26,7 @@ export default function AdminSettings() { if (data) { setTitle(data.site_title) setDesc(data.site_desc) + setBeian(data.beian_no) setPageSize(data.page_size) } }, [data]) @@ -33,7 +35,7 @@ export default function AdminSettings() { mutationFn: () => api('/api/admin/settings', { method: 'PUT', - body: JSON.stringify({ site_title: title, site_desc: desc, page_size: pageSize }), + body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize }), }), onSuccess: () => { setToast({ msg: '设置已保存', kind: 'success' }) @@ -74,6 +76,17 @@ export default function AdminSettings() { onChange={(e) => setDesc(e.target.value)} /> +
+ + setBeian(e.target.value)} + /> +

显示在博客页脚并链接至公安部备案官网;留空则不显示。

+
-
-

PURE NOTE · 极简笔记

-

记录,而不打扰。

-

Pure Note 是一个纯文本驱动的极简笔记工具,这里是公开的笔记。

-
- {topTags.length > 0 && (