# 登录 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 白名单 ```mermaid 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.login`](server/application/services/auth_service.py)(`LOGIN_ALLOWLIST_ENABLED` + 手动/订阅 IP) - 客户端 IP:[`get_client_ip`](server/utils/client_ip.py)(反代信任头) - **缺口**:登录页无预检;非白名单用户仍看到表单,体验差且暴露登录入口 ### 2.2 菜单切换「像刷新」的原因 | 因素 | 位置 | 影响 | |------|------|------| | `router-view` 强制 remount | [`App.vue`](frontend/src/App.vue) `:key="route.fullPath"` | 一切换路由(含 query)销毁组件、重建 DOM | | 每页 `onMounted` 拉数 | 各 `*Page.vue` | 每次进入白屏 loading | | 路由懒加载 | [`router/index.ts`](frontend/src/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`) 响应示例: ```json { "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/application/services/auth_service.py),抽到 `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`](frontend/src/pages/LoginPage.vue) 挂载流程: ```mermaid stateDiagram-v2 [*] --> Checking: onMounted Checking --> ShowForm: allowed_or_disabled Checking --> Blocked: not_allowed ShowForm --> [*]: 现有表单 Blocked --> [*]: 静态提示卡_无输入框 ``` **Blocked 态 UI**(保留壁纸背景,与现有一致): - 标题:「无法访问管理后台」 - 文案:`message` + 显示 `client_ip` - **无**用户名/密码/TOTP/登录按钮 - 可选:「返回」无意义,不提供跳转服务器列表(未登录) **ShowForm 态**:与现网一致;`allowlist_enabled=true` 且 `allowed=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 分阶段路线图 ```mermaid flowchart LR P0[P0_登录门禁] --> P1[P1_路由壳优化] P1 --> P2[P2_keep_alive] P2 --> P3[P3_数据缓存层] P3 --> P4[P4_预取与骨架屏] ``` #### P0 — 登录 IP 门禁(见方案 A) 工作量:约 0.5~1 人日。 #### P1 — 路由与登录过渡(小) | 项 | 做法 | |----|------| | 去掉过激 remount | `App.vue` 将 `:key="route.fullPath"` 改为 `:key="route.name"` 或移除 key;query 变化由页内 `watch(route.query)` 处理 | | 登录成功过渡 | `router.replace(redirect)`;`auth.login` 成功后预 `import('@/pages/DashboardPage.vue')` | | 路由守卫 | `beforeEach` 已 `tryRestoreSession`;登录页在 P0 预检后再渲染表单 | 风险:`ScriptRunDetail` 与 `/script-runs` 同组件 — 用 `route.name` 作 key 时详情与列表可能共享实例,需在 `ScriptRunsPage` 内 `watch(() => route.params.id)` 处理。 #### P2 — Keep-alive 页面缓存(中) ```vue ``` | 策略 | 页面 | |------|------| | **缓存** | Dashboard, Servers, Scripts, ScriptRuns, Push, Files, Credentials, Schedules, Retries, Commands, Alerts, Audit, Settings | | **不缓存** | Terminal(xterm 会话), Login | | **激活钩子** | 缓存页实现 `onActivated` → `loadXxx({ silent: true })` 替代仅 `onMounted` | 需为各页 `defineOptions({ name: 'ServersPage' })` 与 `include` 字符串对齐。 #### P3 — Pinia 数据缓存层(大) 新增 composable `useCachedQuery(key, fetcher, options)`: - 内存 TTL(如 30s)+ `staleWhileRevalidate` - 多页共享:`useServerStatsStore`、`useServerCategoriesStore`(部分已有,需统一) - 列表页离开再回来:先展示 cache,再静默刷新 优先接入:**仪表盘统计**、**服务器分类 Chip**、**侧栏角标**(在线数/告警数)。 #### P4 — 预取与骨架屏(中) - 侧栏 `v-list-item` `@mouseenter` → 预加载 route chunk + 预取关键 API(debounce 200ms) - 各页 `loading` 时显示 **骨架屏**(`v-skeleton-loader`)替代空白 `v-main` - 全局 **不** 使用全屏 blocking overlay ### 4.3 登录成功「无感」定义 | 阶段 | 体验 | |------|------| | 当前 | Login 全屏 → 跳转后 App 栏 + 侧栏 + Dashboard 依次出现 + 表格 loading | | P1 后 | 同一 `v-app` 下 `isLoggedIn` 切换;侧栏/顶栏 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.py`、`server/api/auth_jwt.py`、`server/application/services/auth_service.py` | | 前端 | `frontend/src/pages/LoginPage.vue` | | 测试 | `tests/test_login_access.py` | ### P1~P4 | 层 | 文件 | |----|------| | 壳 | `frontend/src/App.vue`、`frontend/src/router/index.ts` | | 缓存 | `frontend/src/composables/useCachedQuery.ts`(新)、各 `*Page.vue`、Pinia stores | | 文档 | 本文件 + 各阶段 changelog | --- ## 7. 验收标准(整体) ### P0 必须 - 非白名单 IP 看不到登录框 - 白名单 IP / 关闭白名单行为与现网一致 ### P1~P2 建议 - 菜单切换 5 个高频页(仪表盘/服务器/脚本/推送/设置)无白屏 remount - 返回上一页表格滚动位置可选保留(keep-alive 自带) ### P3~P4 可选增强 - 重复进入服务器列表 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` 拉数 |