Files
Nexus/docs/design/specs/2026-06-08-login-gate-seamless-nav-design.md
T
Nexus Agent 08a0157c95 feat: 调度表单重构、登录门控、批量任务与页面缓存对齐
调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航;
服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:17:21 +08:00

10 KiB
Raw Blame History

登录 IP 门禁 + SPA 无感切换 — 设计文档

日期: 2026-06-08
状态: 待评审
关联: 功能指南 §5.6 登录 IP 白名单 · 前端 Hash 路由 14 页


1. 背景与目标

1.1 用户诉求

诉求 说明
IP 白名单门禁 白名单开启且当前 IP 不在名单时,不展示登录表单(用户名/密码/TOTP),避免误导用户尝试登录后才看到拒绝
无感切换 登录成功进入后台、以及左侧菜单在各功能页之间切换时,减少「整页白屏 / 重新加载 / 闪烁」感

1.2 非目标(本设计不做)

  • 不改 Agent API Key、安装向导流程
  • 不做 SSR / 微前端拆分
  • 不要求所有 14 页零请求(实时运维数据仍需刷新,但应 静默、局部

2. 现状分析

2.1 IP 白名单

sequenceDiagram
  participant Browser
  participant LoginPage
  participant API as POST_auth_login

  Browser->>LoginPage: 打开 #/login
  LoginPage->>LoginPage: 展示完整表单
  Browser->>API: 提交账号密码
  API->>API: auth_service 校验 IP
  API-->>Browser: 403 ip_blocked + 当前 IP
  • 校验位置:auth_service.loginLOGIN_ALLOWLIST_ENABLED + 手动/订阅 IP
  • 客户端 IPget_client_ip(反代信任头)
  • 缺口:登录页无预检;非白名单用户仍看到表单,体验差且暴露登录入口

2.2 菜单切换「像刷新」的原因

因素 位置 影响
router-view 强制 remount App.vue :key="route.fullPath" 一切换路由(含 query)销毁组件、重建 DOM
每页 onMounted 拉数 *Page.vue 每次进入白屏 loading
路由懒加载 router/index.ts import() 首次进入某页需下载 chunk
无共享缓存 无 Pinia 页面缓存层 回到上一页重复请求
登录后跳转 LoginPage router.push(redirect) 整页从 login 布局切到 app 布局,侧栏/顶栏一次性挂载

终端页、脚本看板等已有 WebSocket / 自动刷新,不适合与列表页同一套 keep-alive 策略,需单独约定。


3. 方案 A — 登录 IP 门禁(小工程,优先)

3.1 API:公开预检端点

GET /api/auth/login-access(无需 JWT,加入 PUBLIC_EXACT_PATHS

响应示例:

{
  "allowlist_enabled": true,
  "allowed": false,
  "client_ip": "203.0.113.10",
  "message": "当前 IP 不在登录白名单中,请联系管理员"
}
字段 说明
allowlist_enabled LOGIN_ALLOWLIST_ENABLED 一致
allowed 未开启白名单 → 恒 true;开启后按 _ip_in_allowlist
client_ip get_client_ip(request),与登录审计一致
message allowed=false 时返回,不泄露白名单列表

实现要点:

  • 复用 _ip_in_allowlist,抽到 server/utils/login_allowlist.py 供 auth_service 与 auth 路由共用
  • 不写入登录失败计数(仅浏览门禁页,未提交凭证)
  • 可选审计:login_access_denied(低频,仅预检 allowed=false)— 默认 关闭,避免扫描噪音;若开启需限流

安全:

  • 不返回白名单条目、订阅 URL
  • POST /login 相同 IP 解析逻辑,避免「预检通过、登录拒绝」不一致
  • 限流:同 IP 60s 内最多 30 次(复用现有 middleware 或轻量 Redis 计数)

3.2 前端:LoginPage 门禁 UI

LoginPage.vue 挂载流程:

stateDiagram-v2
  [*] --> Checking: onMounted
  Checking --> ShowForm: allowed_or_disabled
  Checking --> Blocked: not_allowed
  ShowForm --> [*]: 现有表单
  Blocked --> [*]: 静态提示卡_无输入框

Blocked 态 UI(保留壁纸背景,与现有一致):

  • 标题:「无法访问管理后台」
  • 文案:message + 显示 client_ip
  • 用户名/密码/TOTP/登录按钮
  • 可选:「返回」无意义,不提供跳转服务器列表(未登录)

ShowForm 态:与现网一致;allowlist_enabled=trueallowed=true 时表单正常。

加载态:预检完成前显示 v-progress-circular,避免表单闪现(FOUC)。

3.3 验收(A

  • 白名单关闭:预检 allowed=true,表单正常
  • 白名单开启 + IP 不在名单:仅提示卡,登录输入框
  • 白名单开启 + IP 在名单:表单正常,登录成功
  • 反代后 client_ip 与登录失败提示 IP 一致
  • 单测:test_login_access.py 覆盖开/关/命中/未命中

4. 方案 B — SPA 无感切换(大工程,分阶段)

4.1 设计原则

  1. 壳不动、内容换v-app-bar + v-navigation-drawer 常驻,只换 router-view 区域
  2. 先显后刷:有缓存则立即展示,后台静默 silent 刷新(与现有 usePageAutoRefresh 一致)
  3. 重页隔离:终端(WebSocket/xterm)、安装向导不参与 keep-alive
  4. 可度量:首屏切换 < 100ms 感知(无全屏 loading);数据刷新不阻断操作

4.2 分阶段路线图

flowchart LR
  P0[P0_登录门禁] --> P1[P1_路由壳优化]
  P1 --> P2[P2_keep_alive]
  P2 --> P3[P3_数据缓存层]
  P3 --> P4[P4_预取与骨架屏]

P0 — 登录 IP 门禁(见方案 A)

工作量:约 0.51 人日。

P1 — 路由与登录过渡(小)

做法
去掉过激 remount App.vue:key="route.fullPath" 改为 :key="route.name" 或移除 key;query 变化由页内 watch(route.query) 处理
登录成功过渡 router.replace(redirect)auth.login 成功后预 import('@/pages/DashboardPage.vue')
路由守卫 beforeEachtryRestoreSession;登录页在 P0 预检后再渲染表单

风险:ScriptRunDetail/script-runs 同组件 — 用 route.name 作 key 时详情与列表可能共享实例,需在 ScriptRunsPagewatch(() => route.params.id) 处理。

P2 — Keep-alive 页面缓存(中)

<router-view v-slot="{ Component, route }">
  <keep-alive :include="cachedPageNames" :max="10">
    <component :is="Component" :key="route.name" />
  </keep-alive>
</router-view>
策略 页面
缓存 Dashboard, Servers, Scripts, ScriptRuns, Push, Files, Credentials, Schedules, Retries, Commands, Alerts, Audit, Settings
不缓存 Terminalxterm 会话), Login
激活钩子 缓存页实现 onActivatedloadXxx({ silent: true }) 替代仅 onMounted

需为各页 defineOptions({ name: 'ServersPage' })include 字符串对齐。

P3 — Pinia 数据缓存层(大)

新增 composable useCachedQuery(key, fetcher, options)

  • 内存 TTL(如 30s+ staleWhileRevalidate
  • 多页共享:useServerStatsStoreuseServerCategoriesStore(部分已有,需统一)
  • 列表页离开再回来:先展示 cache,再静默刷新

优先接入:仪表盘统计服务器分类 Chip侧栏角标(在线数/告警数)。

P4 — 预取与骨架屏(中)

  • 侧栏 v-list-item @mouseenter → 预加载 route chunk + 预取关键 APIdebounce 200ms
  • 各页 loading 时显示 骨架屏v-skeleton-loader)替代空白 v-main
  • 全局 使用全屏 blocking overlay

4.3 登录成功「无感」定义

阶段 体验
当前 Login 全屏 → 跳转后 App 栏 + 侧栏 + Dashboard 依次出现 + 表格 loading
P1 后 同一 v-appisLoggedIn 切换;侧栏/顶栏 CSS 过渡 150ms
P2+P3 后 Dashboard 若曾访问过则瞬间展示缓存数字,后台刷新

4.4 不宜无感化的场景

  • 终端:必须保留独立布局与会话生命周期
  • 强制登出 / token 失效:仍跳转 #/login 并清缓存
  • 部署后 chunk 失效:保留现有 router.onError 一次 reload 策略

5. 安全与性能约束

约束
预检 API 不泄露白名单内容;限流
keep-alive 登出时 clearCache() 或重置 Pinia,防下一用户见上一用户数据
缓存 TTL 运维数据不宜过长(建议 30~60s);敏感页(凭据)可缩短或不缓存列表明文
终端 禁止 keep-alive;避免 WebSocket 与 DOM 泄漏

6. 涉及文件(预估)

P0

文件
后端 server/utils/login_allowlist.py(新)、server/api/auth.pyserver/api/auth_jwt.pyserver/application/services/auth_service.py
前端 frontend/src/pages/LoginPage.vue
测试 tests/test_login_access.py

P1P4

文件
frontend/src/App.vuefrontend/src/router/index.ts
缓存 frontend/src/composables/useCachedQuery.ts(新)、各 *Page.vue、Pinia stores
文档 本文件 + 各阶段 changelog

7. 验收标准(整体)

P0 必须

  • 非白名单 IP 看不到登录框
  • 白名单 IP / 关闭白名单行为与现网一致

P1P2 建议

  • 菜单切换 5 个高频页(仪表盘/服务器/脚本/推送/设置)无白屏 remount
  • 返回上一页表格滚动位置可选保留(keep-alive 自带)

P3P4 可选增强

  • 重复进入服务器列表 30s 内无全屏 loading
  • 侧栏悬停预取后首次进入该页 chunk 等待 < 200ms(弱网除外)

8. 推荐实施顺序

  1. 先做 P0(独立、低风险、用户明确诉求)→ 可单独部署
  2. P1 与终端/脚本看板小修复并行时注意 route.name key 回归
  3. P2 逐页启用 keep-alive + onActivated,每页验证
  4. P3/P4 按仪表盘 → 服务器列表 → 其余页推广

9. 回滚

阶段 回滚
P0 删除 login-access 端点;LoginPage 恢复常显表单
P1 恢复 route.fullPath key
P2 移除 keep-alive 包装
P3 停用 cached stores,页内仍 onMounted 拉数