diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 04b1289..3e699ce 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -509,7 +509,7 @@ P3 实现说明: - 数据管理(入库、忽略、拉黑、移出库、移除黑名单)继续通过 Telegram、CLI 或数据库完成; - `/api/health` 和 `/api/session` 可匿名; - 首页、榜单、视频/UP、粉丝页面和接口访客可见; -- 配置 `web.auth_token` 后,`/api/candidates`、`/api/system` 和 `/candidates`、`/system` 页面要求 Cookie 或 `Authorization: Bearer `,未登录访问页面会跳转 `/login?next=<原路径>`; +- 配置 `web.auth_token` 后,`/api/candidates`、`/api/system` 和 `/candidates`、`/system` 页面要求 Cookie 或 `Authorization: Bearer `,未登录访问页面会跳转 `/login?next=<原路径>`;「候选」「系统」入口放在顶栏右侧(未登录显示「登录」、登录后显示「候选 | 系统」),不占底部主导航,避免移动端底部拥挤; - 未配置 token 时视为无鉴权,全部页面可见; - 只读接口不需要 CSRF;登录 POST 只建立会话,不修改业务数据; - API 返回 JSON;前端不复制热度或状态机判断; diff --git a/README.md b/README.md index 5bba522..4e2a8e7 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ uv run python app.py serve --preview --config data/preview/config.json - 候选池和系统状态只读展示,操作入口指向 Telegram/CLI; - 系统状态页展示访问统计(近 30 天每日 PV/UV 与 Top 页面):前端在整页加载与站内路由切换时通过 `navigator.sendBeacon("/api/visit")` 上报,服务端按客户端 IP 与归一化路由聚合,先写内存缓冲、每分钟批量落库(详见 [访问统计与真实来源 IP](#访问统计与真实来源-ip)); - 首页、视频榜、UP 主榜、视频/UP 详情、粉丝页和数据说明页对访客可见; -- 配置 `web.auth_token` 后,“候选”和“系统”页面及其接口需要登录,启用登录页和 HttpOnly Cookie;访客的底部导航只显示公开页面; +- 配置 `web.auth_token` 后,“候选”和“系统”页面及其接口需要登录,启用登录页和 HttpOnly Cookie;这两个入口放在顶栏右侧:未登录显示「登录」,登录后显示「候选 | 系统」(从底部主导航移出,避免移动端底部拥挤); - 本地回环地址且未配置 token 时视为无鉴权,全部页面可见;非回环地址且未配置 token 时拒绝启动; - 所有业务接口只接受 GET,写方法返回 `405`;仅显式放行 `POST /api/visit`(访问统计埋点)与 `POST /login`(会话),其余 POST/PUT/PATCH/DELETE 仍统一 `405`; - 首页/榜单聚合结果缓存 1 小时,粉丝结果缓存 24 小时;快照、发现、入库/忽略/拉黑、候选清理和粉丝 CSV 导入会立即让对应缓存失效。 diff --git a/project.md b/project.md index 745d6e8..7cdde9b 100644 --- a/project.md +++ b/project.md @@ -720,7 +720,7 @@ UP 内容效率 = EB 收缩后的 Σview / Σ内容小时 - 首页/榜单聚合结果缓存 1 小时,粉丝结果缓存 24 小时;新快照、收藏夹同步、发现、收藏操作和粉丝导入会通过 `kv` 版本号立即失效对应缓存; - 图表本地 vendored ECharts(Apache-2.0,离线); - `/api/*` JSON,前端只渲染; -- 移动端优先,底部 5 项导航;桌面端左侧导航; +- 移动端优先,底部主导航 5 项(首页/视频/UP主/粉丝/说明);桌面端左侧导航;「候选」「系统」入口放顶栏右侧(未登录为「登录」),避免移动端底部拥挤; - 首页增长榜在不足 2 天快照时显示空态,不伪造 0。 ### 9.3 只读边界与鉴权 diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 806ec58..caf881f 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -15,11 +15,16 @@ const NAV_ITEMS = [ { path: "/videos", label: "视频", icon: "video" }, { path: "/ups", label: "UP主", icon: "up" }, { path: "/fans", label: "粉丝", icon: "fans" }, - { path: "/candidates", label: "候选", icon: "candidate", protected: true }, - { path: "/system", label: "系统", icon: "system", protected: true }, { path: "/about", label: "说明", icon: "info" }, ]; +// 登录后的「候选 / 系统」入口放在顶栏右侧(未登录显示「登录」), +// 以减轻移动端底部主导航的拥挤。 +const TOPBAR_ITEMS = [ + { path: "/candidates", label: "候选" }, + { path: "/system", label: "系统" }, +]; + const ROUTES = [ { pattern: /^\/home$/, view: renderHome, title: "最新动态" }, { pattern: /^\/videos$/, view: renderVideos, title: "视频" }, @@ -79,13 +84,23 @@ function icon(name) { function renderNav() { const nav = document.querySelector("#nav-items"); - const items = NAV_ITEMS.filter( - (item) => !item.protected || canAccessProtected(), - ); - nav.innerHTML = items.map((item) => ` + nav.innerHTML = NAV_ITEMS.map((item) => ` ${icon(item.icon)}${item.label} `).join(""); + + // 未登录显示「登录」;可访问受保护页面时显示「候选 | 系统」。 + const loggedIn = canAccessProtected(); + document.querySelectorAll(".topbar-auth").forEach((element) => { + element.hidden = loggedIn; + }); + document.querySelectorAll("[data-topbar-links]").forEach((element) => { + element.hidden = !loggedIn; + element.innerHTML = loggedIn + ? TOPBAR_ITEMS.map((item) => ` + ${item.label}`).join("") + : ""; + }); } function activeNav(path) { @@ -99,6 +114,9 @@ function activeNav(path) { document.querySelectorAll("[data-nav]").forEach((item) => { item.classList.toggle("active", item.dataset.nav === active); }); + document.querySelectorAll("[data-topbar-nav]").forEach((item) => { + item.classList.toggle("active", item.dataset.topbarNav === active); + }); } function setTitle(route, path) { @@ -144,11 +162,6 @@ async function boot() { element.hidden = false; }); } - if (authState.authEnabled && !authState.authenticated) { - document.querySelectorAll(".topbar-auth").forEach((element) => { - element.hidden = false; - }); - } } catch { toast("无法连接看板服务", "error"); } diff --git a/reaction_board/web/static/index.html b/reaction_board/web/static/index.html index 278a998..23e9ff0 100644 --- a/reaction_board/web/static/index.html +++ b/reaction_board/web/static/index.html @@ -20,8 +20,11 @@ 最新动态 - - +
+ + + +
@@ -36,8 +39,11 @@ 最新动态 - - +
+ + + +
diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index 7be3936..4ef17bd 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -123,10 +123,30 @@ h1, h2, h3, p { margin: 0; } color: var(--muted); font-size: 11px; } +.topbar-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } +.topbar-links { display: flex; align-items: center; gap: 6px; } +.topbar-links a { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} +.topbar-links a:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); +} +.topbar-links a.active { + color: var(--accent); + background: var(--accent-soft); + border-color: transparent; +} .sidebar-topbar { display: none; - flex-direction: column; - align-items: flex-start; + flex-direction: row; + align-items: center; + justify-content: space-between; gap: 8px; width: 100%; padding: 0 4px 12px;