feat(web): move candidates/system nav to topbar, free mobile bottom nav
This commit is contained in:
1 parent
4253779501
commit
3cb9b92361
6 files changed
+59
-20
No files matched your search
+1
-1
@@ -509,7 +509,7 @@ P3 实现说明:
|
||||
- 数据管理(入库、忽略、拉黑、移出库、移除黑名单)继续通过 Telegram、CLI 或数据库完成;
|
||||
- `/api/health` 和 `/api/session` 可匿名;
|
||||
- 首页、榜单、视频/UP、粉丝页面和接口访客可见;
|
||||
- 配置 `web.auth_token` 后,`/api/candidates`、`/api/system` 和 `/candidates`、`/system` 页面要求 Cookie 或 `Authorization: Bearer <token>`,未登录访问页面会跳转 `/login?next=<原路径>`;
|
||||
- 配置 `web.auth_token` 后,`/api/candidates`、`/api/system` 和 `/candidates`、`/system` 页面要求 Cookie 或 `Authorization: Bearer <token>`,未登录访问页面会跳转 `/login?next=<原路径>`;「候选」「系统」入口放在顶栏右侧(未登录显示「登录」、登录后显示「候选 | 系统」),不占底部主导航,避免移动端底部拥挤;
|
||||
- 未配置 token 时视为无鉴权,全部页面可见;
|
||||
- 只读接口不需要 CSRF;登录 POST 只建立会话,不修改业务数据;
|
||||
- API 返回 JSON;前端不复制热度或状态机判断;
|
||||
|
||||
@@ -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 导入会立即让对应缓存失效。
|
||||
|
||||
+1
-1
@@ -720,7 +720,7 @@ UP 内容效率 = EB 收缩后的 Σview / Σ内容小时
|
||||
- 首页/榜单聚合结果缓存 1 小时,粉丝结果缓存 24 小时;新快照、收藏夹同步、发现、收藏操作和粉丝导入会通过 `kv` 版本号立即失效对应缓存;
|
||||
- 图表本地 vendored ECharts(Apache-2.0,离线);
|
||||
- `/api/*` JSON,前端只渲染;
|
||||
- 移动端优先,底部 5 项导航;桌面端左侧导航;
|
||||
- 移动端优先,底部主导航 5 项(首页/视频/UP主/粉丝/说明);桌面端左侧导航;「候选」「系统」入口放顶栏右侧(未登录为「登录」),避免移动端底部拥挤;
|
||||
- 首页增长榜在不足 2 天快照时显示空态,不伪造 0。
|
||||
|
||||
### 9.3 只读边界与鉴权
|
||||
|
||||
@@ -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) => `
|
||||
<a class="nav-item" href="${item.path}" data-nav="${item.path}">
|
||||
${icon(item.icon)}<span class="nav-label">${item.label}</span>
|
||||
</a>`).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) => `
|
||||
<a href="${item.path}" data-topbar-nav="${item.path}">${item.label}</a>`).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");
|
||||
}
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
<small class="brand-subtitle">最新动态</small>
|
||||
</span>
|
||||
</a>
|
||||
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
|
||||
<span class="topbar-hint topbar-preview" hidden>预览</span>
|
||||
<div class="topbar-actions">
|
||||
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
|
||||
<span class="topbar-links" data-topbar-links hidden></span>
|
||||
<span class="topbar-hint topbar-preview" hidden>预览</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
|
||||
<main class="main-shell">
|
||||
@@ -36,8 +39,11 @@
|
||||
<small class="brand-subtitle">最新动态</small>
|
||||
</span>
|
||||
</a>
|
||||
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
|
||||
<span class="topbar-hint topbar-preview" hidden>预览</span>
|
||||
<div class="topbar-actions">
|
||||
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
|
||||
<span class="topbar-links" data-topbar-links hidden></span>
|
||||
<span class="topbar-hint topbar-preview" hidden>预览</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-items" id="nav-items"></div>
|
||||
</nav>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user