Explorar el Código

feat: 底层框架落地——EP 布局壳 + 菜单权限码下发 + 账号密码/微信扫码登录 + router.push 收口

底层 4 项决策落地(2026-11-06 经确认拍板),并修复 2 个已暴露 bug:

布局与导航(EP 标准布局 + 菜单权限码下发)
- 新增 src/views/layout/index.vue:EP 标准布局壳(el-aside 侧边菜单 + el-header + el-main),
  作为父路由 component(嵌套路由套壳;登录页/作用域选择页/404 顶层不套壳)
- 新增 src/components/side-menu.vue:递归菜单组件(父节点 el-sub-menu 自递归,叶子 el-menu-item
  index=path + el-menu router 模式点击跳转);MenuNode.icon 保留不渲染(图标包待独立决策)
- 菜单权限码下发:后端按角色/权限过滤后下发 menuTree(MenuNode[] 树,types/api.d.ts),
  前端原样渲染、不在前端二次过滤;menuTree 不落 localStorage(刷新降级空,恢复靠 /api/auth/me 规划项)

登录模型(账号密码 + 微信扫码)
- 账号密码主通道已就绪;微信扫码为占位(后端端点未就绪,api/auth/wechat.ts 取码 + 轮询,
  TODO 标注联调前需与后端核对路径/字段);success 时 token 复用 LoginResult → applyLogin
- 登录页 el-tabs 两 tab;微信 tab 取码 + setInterval 1.5s 轮询,onUnmounted 必清定时器防泄漏;
  单次轮询错误静默、终态停止;后端未就绪显"暂未开放"占位

路由跳转收口(解循环依赖)
- http.ts 拦截器无法直接 import router(router 守卫用 useLogin→login store,http 被守卫依赖,
  会成 router→http→router 循环),改模块级注入 setRouterForRedirect(main.ts 装配后注入);
  未注入降级 location.href。401 跳登录 / 登出跳登录 / 登录成功跳机构统一 router.push(SPA 免刷新)

修复 2 个已暴露 bug
- A1:variables.scss 定义了 --app-* token 但全 src 无引入 → main.ts 全局 import 注入 :root 变量
- A2:package.json 没装 sass(.scss 编译不了)→ pnpm add -D sass@1.93.2

契约 / store / 路由
- types/api.d.ts 新增 MenuNode(code 权限码 + label + path + icon + 递归 children)
- api/auth/login.ts LoginResult 增 menuTree 字段
- store/login.ts setup store 增 menuTree(仅内存态;applyLogin 写 / doLogout 清 / hydrate 无 token 清)
- router/index.ts 重组为嵌套结构(layout 父路由 path=/ redirect=/org/institutions + 业务域子路由)
- main.ts 装配顺序改 8 步(+ setRouterForRedirect 注入 + variables.scss 引入)

文档同步
- README.md:技术栈加 SCSS 行;新增 §1.6 布局与导航模型、§1.7 登录模型;目录树 + 目录用途约定补
  layout/side-menu/wechat
- EXAMPLES.md:19→22 文件;索引补 layout/side-menu/wechat 及 main/http/login/types 更新;
  新增范式摘录 3.5(setRouterForRedirect 模块级注入解循环依赖)

质量门禁全绿:vue-tsc 0 / vite build ✓(40 模块,主 chunk 10.55KB)/ eslint 0 /
dist grep(vnode/Teleport/Suspense/createBaseVNode)clean
Q hace 3 días
padre
commit
ece17bb25e

+ 32 - 9
EXAMPLES.md

@@ -1,6 +1,6 @@
 # PC 前端样例索引与注释规范
 
-> 本仓库 `src/` 下 19 个文件全部按「三段式注释标准」编写(与后端 `EXAMPLES.md` 同源),既是**可运行脚手架**,也是**编码范式参照**。
+> 本仓库 `src/` 下 22 个文件全部按「三段式注释标准」编写(与后端 `EXAMPLES.md` 同源),既是**可运行脚手架**,也是**编码范式参照**。
 > 新建任何 `.ts` / `.vue` 文件时,对照本文件的「注释模板」与「范式摘录」落笔。
 > 接口契约与角色权限见工作区文档库 `unified/`、`academic/05-architecture/`、`teaching/05-architecture/`。
 
@@ -55,7 +55,7 @@
  */
 ```
 
-## 2. 样例文件索引(19 文件,按目录)
+## 2. 样例文件索引(22 文件,按目录)
 
 > 每行标注「范式亮点」——读对应文件时重点看什么。
 
@@ -72,21 +72,24 @@
 
 | 文件 | 职责 | 范式亮点 |
 |---|---|---|
-| `main.ts` | 业务装配入口 | 7 步装配顺序逐行注释 + `failMissingGlobal` 失败提示函数(异常场景示范) |
+| `main.ts` | 业务装配入口 | 8 步装配顺序逐行注释(含 `setRouterForRedirect` 注入 + variables.scss 全局 token 引入)+ `failMissingGlobal` 失败提示函数(异常场景示范) |
 | `App.vue` | 根组件(locale 声明式注入) | `const locale = ElementPlusLocaleZhCn` 顶层解构(模板可引用) |
-| `types/api.d.ts` | 契约类型 `R<T>`/`PageData<T>`/`PageQuery` | 泛型 `T` 的默认值与边界注释(`R<undefined>` 省略 data) |
+| `types/api.d.ts` | 契约类型 `R<T>`/`PageData<T>`/`PageQuery`/`MenuNode` | 泛型 `T` 的默认值与边界注释(`R<undefined>` 省略 data);`MenuNode` 导航菜单树(`code` 权限码 + `path` + 递归 `children`,README §1.6) |
 | `identity/token-store.ts` | token 物理存取收口 | **为何独立于 store**(http 拦截器在 pinia 激活前可能触发)——职责边界注释范本 |
-| `api/http.ts` | axios 实例 + 信封处理 + 401 refresh 单例 | 请求/响应拦截器分段注释;`refreshAccessToken` 单例 Promise 的并发安全注释 |
-| `api/auth/login.ts` | 登录/登出接口 | 一资源一文件:导出 `login`/`logout` + `LoginResult` DTO 类型 |
+| `api/http.ts` | axios 实例 + 信封处理 + 401 refresh 单例 | 请求/响应拦截器分段注释;`refreshAccessToken` 单例 Promise 的并发安全注释;`setRouterForRedirect` 模块级注入(401 跳登录走 SPA push,**解 router→http→router 循环依赖**,README §1.7) |
+| `api/auth/login.ts` | 登录/登出接口 | 一资源一文件:导出 `login`/`logout` + `LoginResult` DTO 类型(含 `menuTree` 字段,登录响应下发菜单树) |
+| `api/auth/wechat.ts` | 微信扫码登录接口(**占位**,后端端点未就绪) | 取码 + 轮询两接口;`WechatQrcodeStatus` 状态机 + `WechatQrcodeResult`/`WechatStatusResult` 占位 DTO(`token` 复用 `LoginResult`,README §1.7);TODO 注释标明联调前需与后端核对路径/字段 |
 | `api/org/institution.ts` | 机构列表接口(示范) | fetcher 签名对齐 `useListPage` 的 `PageR<T>` 返回 |
-| `store/login.ts` | 登录态 store(setup 风格) | **setup store 返回面类型**(`Ref`/`ComputedRef` 字段)+ 消费时自动解包的注释 |
+| `store/login.ts` | 登录态 store(setup 风格) | **setup store 返回面类型**(`Ref`/`ComputedRef` 字段)+ 消费时自动解包的注释;`menuTree` 仅内存态(不落 localStorage,刷新后随 roles/permissions 降级为空,README §1.6) |
 | `store/scope.ts` | 作用域 store | `toQuery()` 的"campus=0 省略键"边界注释 |
 | `composables/use-list-page.ts` | 列表页唯一规范入口 | **泛型 composable**:入参 `UseListPageOptions<T>` + 出参 `ListPageState<T>` 双接口定义范式 |
 | `components/ListPage.vue` | 泛型表格+分页组件 | **Vue 3.5 泛型 SFC**(`generic="T extends object"`)+ `withDefaults` 注释 |
 | `components/list-column.ts` | 列定义类型 `ListColumn<T>` | 纯类型文件:字段逐项注释(`formatter` 签名) |
+| `components/side-menu.vue` | 侧边菜单递归组件 | **自递归**:父节点 `el-sub-menu` 内嵌 `<side-menu>` 递归子节点;叶子 `el-menu-item index=path` + `el-menu router` 模式点击跳转;`MenuNode.icon` 保留不渲染(图标包待独立决策,README §1.6) |
 | `directives/perm.ts` | `v-perm` 权限指令 | `mounted`/`updated` 双钩子 + fail-closed 行为注释 |
-| `router/index.ts` | 路由表 + 守卫链 | 守卫链 4 步顺序注释 + `declare module 'vue-router'` 扩展 `RouteMeta` |
-| `views/login/index.vue` | 登录页(公开路由) | SFC 三段式:`<script setup>` 内 `defineProps` + 响应式 + 生命周期 |
+| `router/index.ts` | 路由表(嵌套)+ 守卫链 | 守卫链 4 步顺序注释 + `declare module 'vue-router'` 扩展 `RouteMeta`;**嵌套路由模型**(layout 父路由 + 业务域子路由,README §1.6) |
+| `views/layout/index.vue` | EP 标准布局壳(父路由 component) | **EP 布局骨架** `el-container>el-aside+el-header+el-main`;`el-menu :default-active="route.path"` 自动高亮;退出 `ElMessageBox.confirm` 二次确认 + `doLogout` + `router.push`(README §1.6) |
+| `views/login/index.vue` | 登录页(公开路由) | SFC 三段式 + `el-tabs` 两 tab(账号密码 / 微信扫码);微信 tab 取码 + `setInterval` 轮询,**`onUnmounted` 必清定时器防泄漏**;单次轮询错误静默、终态停止(README §1.7) |
 | `views/org/scope-select.vue` | 作用域选择器 | 联动组件:从 store 读(例外于"零业务耦合"的注释说明) |
 | `views/org/institution/list.vue` | 机构列表(示范页) | **setup 顶层解构** `const { rows, total, page... } = useListPage(...)` 范式 |
 | `utils/format.ts` | 纯函数工具集 | 零 Vue 依赖:每个函数独立 JSDoc(入参/出参/异常) |
@@ -188,6 +191,26 @@ function failMissingGlobal(name: string): never {
 }
 ```
 
+### 3.5 模块级注入解循环依赖 —— 摘自 `api/http.ts` + `main.ts`
+
+```ts
+// http.ts:拦截器需要 router.push 跳登录,但 http 被 router 守卫间接依赖(守卫调 refreshAccessToken)。
+// 若此处 `import { router } from '@/router'` 会形成 router → http → router 循环。
+// 解法:模块级变量 + 由 main.ts 装配后注入,未注入时降级 location.href。
+let routerRef: Router | undefined;
+export function setRouterForRedirect(router: Router): void { routerRef = router; }
+function spaPush(path: string): void {
+  if (routerRef) routerRef.push(path).catch(() => { /* 重复导航等异常静默 */ });
+  else window.location.href = path;
+}
+
+// main.ts:装配 router 后立即注入(须在首次路由解析前,但本注入不依赖 router 实例的解析能力)
+const router = buildRouter();
+setupGuards(router);
+app.use(router);
+setRouterForRedirect(router); // ← 注入路由实例给 http 拦截器
+```
+
 ## 4. 与后端的对照
 
 | 维度 | 后端(`backend/EXAMPLES.md`) | 前端(本文件) |

+ 31 - 6
README.md

@@ -48,6 +48,7 @@
 | 鉴权 | SSO token(access 30min + refresh 30 天,ADR-014) | 统一 identity 签发;菜单/按钮按**权限码**显隐,禁 `role == N` 硬编码 |
 | 包管理 | **pnpm 10.12.1**(lockfile 入库) | 本机已装;与移动端仓同栈 |
 | 代码风格 | ESLint 9 + Prettier 3 | 提交前 lint;`no-explicit-any` 强制 |
+| 样式预处理 | **SCSS**(`sass` devDep) | 全局 token 集中在 `src/styles/variables.scss`(`:root` 变量);main.ts 全局 import 注入;组件 `<style scoped>` 引 `var(--app-xxx)` token,禁硬编码色值/尺寸 |
 | 类型检查 | vue-tsc `--noEmit` | 质量门禁 0 错误 |
 
 ### 1.4 默认简体中文
@@ -61,6 +62,24 @@
 1. 下载新版本 UMD 文件 → 2. 覆盖仓库 `vendor/` 对应目录并 commit → 3. 上传 OSS 对应路径 → 4. 改 `index.html` 版本表 → 5. 全量回归(vue-tsc + build + 端点联调)。
 > 版本升级不再受 lockfile 保护,靠仓库 diff + 回归兜底;发布前 `vendor/` 与 OSS 内容 md5 对账。
 
+### 1.6 布局与导航模型(2026-11-06 定稿:EP 标准布局 + 菜单权限码下发)
+
+- **布局壳**(`src/views/layout/index.vue`):EP 标准骨架 `el-container > el-aside(侧边菜单) + el-container(el-header + el-main)`。作为**父路由 component**(`router/index.ts` 嵌套路由),App.vue 保持裸 `<router-view/>` 不改——套壳与否由路由树决定(登录页/作用域选择页/404 是顶层路由不套壳;业务域是 layout 的子路由套壳)。
+- **侧边菜单**(`src/components/side-menu.vue`,递归组件):父节点(`children` 非空)渲染 `el-sub-menu` 内嵌 `<side-menu>` 自递归;叶子节点 `el-menu-item` 的 `index = path`,配合 `el-menu` 的 `router` 模式点击即跳转。菜单高亮靠 `:default-active="route.path"`(el-menu 内部 watch route 自动同步)。
+- **菜单权限码下发**:后端按当前用户角色/权限**过滤后**下发 `menuTree`(`MenuNode[]` 树,契约在 `types/api.d.ts`),前端原样渲染、**不在前端二次过滤**(避免维护"权限码 → 菜单"映射的双份漂移)。`MenuNode.code` 保留权限码(`域.资源.动作`),供按钮级 `v-perm` 与菜单项联动复用。
+- **menuTree 不落 localStorage**:`menuTree` 是响应式视图非凭证(与 roles/permissions 一致),刷新后降级空数组、菜单全隐藏,完整恢复依赖后续 `/api/auth/me` 端点(规划项)。
+- **图标**:暂不引 `@element-plus/icons-vue` UMD(需新增第 9 项 vendor + 改 index.html,属独立决策);`MenuNode.icon` 字段保留但不渲染,菜单先纯文本呈现。
+
+### 1.7 登录模型(2026-11-06 定稿:账号密码 + 微信扫码)
+
+- **账号密码**(主通道,已就绪):`POST /api/auth/login` → 凭证落盘(`applyLogin` 写 token-store + 响应式视图)→ `router.push('/org/scope')` 跳机构选择页。
+- **微信扫码**(副通道,**占位**):后端端点未就绪,`src/api/auth/wechat.ts` 为占位接口(路径/字段按通用扫码流程假设,联调前与后端核对):
+  - `POST /api/auth/wechat/qrcode` 取二维码(返回 `url` + 本地 `state` + `expireSeconds`);
+  - `GET /api/auth/wechat/status?state=xxx` 轮询状态(`pending/scanning/success/cancel/expire`);
+  - `success` 时返回的 `token` 结构同 `LoginResult`,直接复用 `applyLogin`(不新增冗余登录方法)。
+- **登录页 UI**(`views/login/index.vue`):`el-tabs` 两 tab(账号密码 / 微信扫码);切到微信 tab 时取码 + `setInterval` 1.5s 轮询,**组件 `onUnmounted` 必清定时器防泄漏**;单次轮询业务错误/网络故障静默跳过(下轮重试),终态(success/cancel/expire)停止;后端未就绪时扫码区显"暂未开放"占位框。
+- **路由跳转收口**(避免循环依赖):`http.ts` 拦截器无法直接 `import router`(router 守卫用 useLogin→login store,而 http 被 router 守卫依赖,会成 router→http→router 循环),改用**模块级注入** `setRouterForRedirect(router)`(main.ts 装配后注入);未注入时降级 `location.href`。所有登录态跳转(401 跳登录、登出跳登录、登录成功跳机构)统一走 `router.push`(SPA,免全页刷新)。
+
 ## 2. 目录结构与用途
 
 ```
@@ -101,19 +120,25 @@
     │   └── perm.ts            ←   v-perm 按钮级权限指令:读 login store 权限码集,缺失时 el.remove()(fail-closed)
     ├── types/
     │   └── api.d.ts           ←   契约类型:R<T> 信封 / PageData<T>(list,total,page,pageSize,pages)/ PageQuery
+    │                                  / MenuNode(导航菜单树,登录响应下发,layout 壳 + 侧边菜单消费)
     │                              (UMD 全局声明在仓库根 global.d.ts,不在此目录)
     ├── views/                 ← 页面层(只放视图与页面级状态;禁散写 axios)
     │   ├── academic/          ←   教务域:排班/费用/打卡/充值/报课 ——【新写】(旧 jQuery 视图全部重写)
     │   ├── teaching/          ←   教学 admin:备课/题库/组卷/练习分析 ——【按现有 Vue3 逐步迁入】
     │   ├── org/               ←   机构/校区/班级/成员管理(示范页 institution/list.vue + scope-select.vue)
     │   ├── system/            ←   系统:用户/角色/字典/任务监控/日志
-    │   └── login/             ←   登录页(公开路由,守卫白名单)
-    ├── components/            ← 跨页面复用组件:ListPage.vue(泛型表格+分页+工具条)、list-column.ts(列定义类型)
+    │   ├── layout/            ←   【EP 标准布局壳】index.vue:el-aside 侧边菜单 + el-header + el-main(父路由
+    │   │                          component,嵌套路由套壳;侧边菜单递归渲染 login.menuTree,README §1.6)
+    │   └── login/             ←   登录页(公开路由,守卫白名单):el-tabs 两 tab 账号密码 + 微信扫码(占位)
+    ├── components/            ← 跨页面复用组件:ListPage.vue(泛型表格+分页+工具条)、list-column.ts(列定义类型)、
+    │                             side-menu.vue(侧边菜单递归组件,el-sub-menu 自递归 + el-menu router 模式)
     ├── api/
     │   ├── http.ts            ←   axios 实例 + 统一信封处理 + 401 refresh 单例重放 + 业务错误 toast 去重
-    │   ├── auth/login.ts      ←   登录/登出接口(一资源一文件,返回类型化 DTO)
+    │   │                          + setRouterForRedirect 注入(401 跳登录走 SPA push,免全页刷新,README §1.7)
+    │   ├── auth/login.ts      ←   登录/登出接口(LoginResult 含 menuTree 字段,一资源一文件,返回类型化 DTO)
+    │   ├── auth/wechat.ts     ←   微信扫码登录接口(占位,后端端点未就绪:取码 + 轮询状态,README §1.7)
     │   └── org/institution.ts ←   机构列表接口(示范:fetcher 供 useListPage 用)
-    ├── router/                ← 路由表按端组织 + 守卫链:登录态 → 静默刷新 → 白名单 → 作用域就绪
+    ├── router/                ← 路由表嵌套组织(layout 父路由 + 业务域子路由)+ 守卫链:登录态 → 静默刷新 → 白名单 → 作用域就绪
     ├── store/                 ← Pinia:login(token/角色/权限码)、scope(institution/campus)——唯一 token 视图存取点
     ├── composables/
     │   └── use-list-page.ts   ←   useListPage<T>:列表页唯一规范入口(分页五元组 + 统一解构 res.data.{list,total})
@@ -132,8 +157,8 @@
 | `shims/` | 框架模块名 → UMD 全局转发胶水层 | 全仓**唯一**允许出现 `vue`/`vue-router`/`pinia` 模块名的目录;由 `scripts/gen-shims.cjs` 生成,**禁手改**;运行时值取自 `globalThis`,编译后框架本体不进 dist |
 | `scripts/` | 构建辅助脚本 | `gen-shims.cjs`(框架升级五步流程后重跑);Node CJS,不 lint、不随业务进 dist |
 | `global.d.ts` | UMD 全局 + define 裸标识符 TS 声明 | 放**仓库根**(覆盖 shims/ 与 src/);新增框架资源先补声明后写业务 |
-| `src/views/{域}/` | 页面层(四域分包:academic/teaching/org/system) | 只经 `src/api/` 发请求;views 之间互引禁止(复用 → 上提 components/) |
-| `src/components/` | 跨页面复用组件 | 零业务耦合(作用域感知组件除外,从 store 读) |
+| `src/views/{域}/` | 页面层(四域分包:academic/teaching/org/system + layout 壳 + login) | 只经 `src/api/` 发请求;views 之间互引禁止(复用 → 上提 components/);`layout/index.vue` 是**布局壳**(父路由 component,非业务域,README §1.6) |
+| `src/components/` | 跨页面复用组件 | 零业务耦合(作用域感知组件除外,从 store 读);`side-menu.vue` 递归渲染 `login.menuTree`(README §1.6) |
 | `src/api/{域}/` | 接口封装(一域一目录,一资源一文件) | 信封处理与分页解构**只在此**;返回类型化 DTO |
 | `src/router/` | 路由与守卫 | 权限码驱动;不写 `role == N` 硬编码 |
 | `src/store/` | 全局状态(登录态/作用域) | 唯一 token 存取点(禁 localStorage 散写);scope 未就绪时守卫拦截 |

+ 1 - 0
package.json

@@ -22,6 +22,7 @@
     "eslint-plugin-vue": "^10.12.0",
     "pinia": "2.3.1",
     "prettier": "^3.9.10",
+    "sass": "1.93.2",
     "typescript": "~5.8.3",
     "typescript-eslint": "^8.72.1",
     "vite": "^6.4.5",

+ 193 - 5
pnpm-lock.yaml

@@ -13,7 +13,7 @@ importers:
         version: 9.39.4
       '@vitejs/plugin-vue':
         specifier: ^6.0.10
-        version: 6.0.10(vite@6.4.5)(vue@3.5.45(typescript@5.8.3))
+        version: 6.0.10(vite@6.4.5(sass@1.93.2))(vue@3.5.45(typescript@5.8.3))
       axios:
         specifier: 1.21.0
         version: 1.21.0
@@ -38,6 +38,9 @@ importers:
       prettier:
         specifier: ^3.9.10
         version: 3.9.10
+      sass:
+        specifier: 1.93.2
+        version: 1.93.2
       typescript:
         specifier: ~5.8.3
         version: 5.8.3
@@ -46,7 +49,7 @@ importers:
         version: 8.72.1(eslint@9.39.4)(typescript@5.8.3)
       vite:
         specifier: ^6.4.5
-        version: 6.4.5
+        version: 6.4.5(sass@1.93.2)
       vue:
         specifier: 3.5.45
         version: 3.5.45(typescript@5.8.3)
@@ -311,6 +314,88 @@ packages:
   '@jridgewell/sourcemap-codec@1.6.0':
     resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
 
+  '@parcel/watcher-android-arm64@2.6.0':
+    resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [android]
+
+  '@parcel/watcher-darwin-arm64@2.6.0':
+    resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@parcel/watcher-darwin-x64@2.6.0':
+    resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@parcel/watcher-freebsd-x64@2.6.0':
+    resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@parcel/watcher-linux-arm-glibc@2.6.0':
+    resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@parcel/watcher-linux-arm-musl@2.6.0':
+    resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@parcel/watcher-linux-arm64-glibc@2.6.0':
+    resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@parcel/watcher-linux-arm64-musl@2.6.0':
+    resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@parcel/watcher-linux-x64-glibc@2.6.0':
+    resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@parcel/watcher-linux-x64-musl@2.6.0':
+    resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@parcel/watcher-win32-arm64@2.6.0':
+    resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@parcel/watcher-win32-x64@2.6.0':
+    resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
+    engines: {node: '>= 10.0.0'}
+    cpu: [x64]
+    os: [win32]
+
+  '@parcel/watcher@2.6.0':
+    resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
+    engines: {node: '>= 10.0.0'}
+
   '@rolldown/pluginutils@1.0.1':
     resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
 
@@ -680,6 +765,10 @@ packages:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
 
+  chokidar@4.0.3:
+    resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+    engines: {node: '>= 14.16.0'}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -725,6 +814,10 @@ packages:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
 
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
   dunder-proto@1.0.1:
     resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
     engines: {node: '>= 0.4'}
@@ -942,6 +1035,9 @@ packages:
     resolution: {integrity: sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==}
     engines: {node: '>= 4'}
 
+  immutable@5.1.9:
+    resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
+
   import-fresh@3.3.1:
     resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
     engines: {node: '>=6'}
@@ -1040,6 +1136,9 @@ packages:
   natural-compare@1.4.0:
     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
 
+  node-addon-api@7.1.1:
+    resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
   normalize-wheel-es@1.2.0:
     resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
 
@@ -1114,6 +1213,10 @@ packages:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
 
+  readdirp@4.1.2:
+    resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+    engines: {node: '>= 14.18.0'}
+
   resolve-from@4.0.0:
     resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
     engines: {node: '>=4'}
@@ -1123,6 +1226,11 @@ packages:
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
     hasBin: true
 
+  sass@1.93.2:
+    resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
   semver@7.8.6:
     resolution: {integrity: sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==}
     engines: {node: '>=10'}
@@ -1466,6 +1574,63 @@ snapshots:
 
   '@jridgewell/sourcemap-codec@1.6.0': {}
 
+  '@parcel/watcher-android-arm64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-darwin-arm64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-darwin-x64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-freebsd-x64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm-glibc@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm-musl@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm64-glibc@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-arm64-musl@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-x64-glibc@2.6.0':
+    optional: true
+
+  '@parcel/watcher-linux-x64-musl@2.6.0':
+    optional: true
+
+  '@parcel/watcher-win32-arm64@2.6.0':
+    optional: true
+
+  '@parcel/watcher-win32-x64@2.6.0':
+    optional: true
+
+  '@parcel/watcher@2.6.0':
+    dependencies:
+      detect-libc: 2.1.2
+      is-glob: 4.0.3
+      node-addon-api: 7.1.1
+      picomatch: 4.0.7
+    optionalDependencies:
+      '@parcel/watcher-android-arm64': 2.6.0
+      '@parcel/watcher-darwin-arm64': 2.6.0
+      '@parcel/watcher-darwin-x64': 2.6.0
+      '@parcel/watcher-freebsd-x64': 2.6.0
+      '@parcel/watcher-linux-arm-glibc': 2.6.0
+      '@parcel/watcher-linux-arm-musl': 2.6.0
+      '@parcel/watcher-linux-arm64-glibc': 2.6.0
+      '@parcel/watcher-linux-arm64-musl': 2.6.0
+      '@parcel/watcher-linux-x64-glibc': 2.6.0
+      '@parcel/watcher-linux-x64-musl': 2.6.0
+      '@parcel/watcher-win32-arm64': 2.6.0
+      '@parcel/watcher-win32-x64': 2.6.0
+    optional: true
+
   '@rolldown/pluginutils@1.0.1': {}
 
   '@rollup/rollup-android-arm-eabi@4.67.5':
@@ -1653,10 +1818,10 @@ snapshots:
       '@typescript-eslint/types': 8.72.1
       eslint-visitor-keys: 5.0.1
 
-  '@vitejs/plugin-vue@6.0.10(vite@6.4.5)(vue@3.5.45(typescript@5.8.3))':
+  '@vitejs/plugin-vue@6.0.10(vite@6.4.5(sass@1.93.2))(vue@3.5.45(typescript@5.8.3))':
     dependencies:
       '@rolldown/pluginutils': 1.0.1
-      vite: 6.4.5
+      vite: 6.4.5(sass@1.93.2)
       vue: 3.5.45(typescript@5.8.3)
 
   '@volar/language-core@2.4.30':
@@ -1830,6 +1995,10 @@ snapshots:
       ansi-styles: 4.3.0
       supports-color: 7.2.0
 
+  chokidar@4.0.3:
+    dependencies:
+      readdirp: 4.1.2
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -1862,6 +2031,9 @@ snapshots:
 
   delayed-stream@1.0.0: {}
 
+  detect-libc@2.1.2:
+    optional: true
+
   dunder-proto@1.0.1:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -2125,6 +2297,8 @@ snapshots:
 
   ignore@7.0.14: {}
 
+  immutable@5.1.9: {}
+
   import-fresh@3.3.1:
     dependencies:
       parent-module: 1.0.1
@@ -2205,6 +2379,9 @@ snapshots:
 
   natural-compare@1.4.0: {}
 
+  node-addon-api@7.1.1:
+    optional: true
+
   normalize-wheel-es@1.2.0: {}
 
   nth-check@2.1.1:
@@ -2271,6 +2448,8 @@ snapshots:
 
   punycode@2.3.1: {}
 
+  readdirp@4.1.2: {}
+
   resolve-from@4.0.0: {}
 
   rollup@4.67.5:
@@ -2305,6 +2484,14 @@ snapshots:
       '@rollup/rollup-win32-x64-msvc': 4.67.5
       fsevents: 2.3.3
 
+  sass@1.93.2:
+    dependencies:
+      chokidar: 4.0.3
+      immutable: 5.1.9
+      source-map-js: 1.2.2
+    optionalDependencies:
+      '@parcel/watcher': 2.6.0
+
   semver@7.8.6: {}
 
   shebang-command@2.0.0:
@@ -2353,7 +2540,7 @@ snapshots:
 
   util-deprecate@1.0.2: {}
 
-  vite@6.4.5:
+  vite@6.4.5(sass@1.93.2):
     dependencies:
       esbuild: 0.25.12
       fdir: 6.5.0(picomatch@4.0.7)
@@ -2363,6 +2550,7 @@ snapshots:
       tinyglobby: 0.2.17
     optionalDependencies:
       fsevents: 2.3.3
+      sass: 1.93.2
 
   vscode-jsonrpc@10.0.0: {}
 

+ 3 - 1
src/api/auth/login.ts

@@ -11,7 +11,7 @@
  *   this.accessToken = res.data.accessToken;
  */
 import { http } from '@/api/http';
-import type { R } from '@/types/api';
+import type { MenuNode, R } from '@/types/api';
 
 /** 登录请求体(后端 username/password 单一口径,兼容期旧端点别名映射在后端完成) */
 export interface LoginReq {
@@ -43,6 +43,8 @@ export interface LoginResult {
   expiresAt: number;
   /** RBAC 权限码数组(`域.资源.动作` 三段式;菜单/按钮显隐唯一依据) */
   permissions: string[];
+  /** 可见导航菜单树(后端按角色/权限过滤后下发;layout 壳渲染侧边菜单,README §2 layout) */
+  menuTree: MenuNode[];
 }
 
 /**

+ 74 - 0
src/api/auth/wechat.ts

@@ -0,0 +1,74 @@
+/**
+ * wechat.ts —— 微信扫码登录接口封装(auth 域;一域一目录、一资源一文件纪律,README §3.2)。
+ *
+ * ⚠️ 占位说明(2026-11-06):后端微信登录端点**尚未就绪**。
+ *   本文件的接口路径 / 字段为**占位**(按通用微信扫码流程假设),联调前必须与后端核对:
+ *     - POST /api/auth/wechat/qrcode  获取扫码二维码(返回 wx 二维码 URL + 本地 state 标识)
+ *     - GET  /api/auth/wechat/status   轮询扫码状态(pending/scanning/success/cancel/expire)
+ *   后端就绪后按真实契约修订本文件字段名与路径(保持 camelCase,与后端 DTO 逐字段对齐)。
+ *
+ * 典型使用(views/login/index.vue 扫码 tab):
+ *
+ *   const qr = ref<Awaited<ReturnType<typeof fetchWechatQrcode>>>;
+ *   // 进入扫码 tab:
+ *   const q = await fetchWechatQrcode();
+ *   qr.url = q.url; qr.state = q.state;
+ *   // 轮询:
+ *   const timer = setInterval(async () => {
+ *     const s = await pollWechatQrcodeStatus(q.state);
+ *     if (s.status === 'success' && s.token) { loginStore.applyLogin(s.token); clearInterval(timer); }
+ *     if (s.status === 'expire' || s.status === 'cancel') { clearInterval(timer); }
+ *   }, 1500);
+ */
+import { http } from '@/api/http';
+import type { R } from '@/types/api';
+
+/** 扫码状态机(前端轮询得到的状态;与后端枚举对齐,后端未定前为占位) */
+export type WechatQrcodeStatus = 'pending' | 'scanning' | 'success' | 'cancel' | 'expire';
+
+/** 获取二维码返回体(占位 DTO,后端就绪后对齐) */
+export interface WechatQrcodeResult {
+  /** 二维码图片 URL(微信 wx 生成,前端 <img> 直显;过期后需重新取) */
+  url: string;
+  /** 本地 state 标识(轮询时回传,后端据此关联扫码会话) */
+  state: string;
+  /** 二维码有效期(秒;前端据此展示"已过期请刷新") */
+  expireSeconds: number;
+}
+
+/** 轮询扫码状态返回体(占位 DTO;success 时携带登录凭证,结构同账号密码的 LoginResult) */
+export interface WechatStatusResult {
+  /** 当前扫码状态 */
+  status: WechatQrcodeStatus;
+  /** 登录凭证(仅 status === 'success' 时非空;其余状态省略) */
+  token?: import('./login').LoginResult;
+}
+
+/**
+ * 获取微信扫码二维码(POST /api/auth/wechat/qrcode)。
+ *
+ * 入参:无(后端自行生成 wx 二维码 + state)。
+ * 出参:
+ *   - Promise<R<WechatQrcodeResult>>:code=200 时 data 为二维码 URL + state;失败展示 message。
+ * 异常场景:
+ *   - 后端未就绪(404/501):按占位语义,调用方 catch 提示"微信扫码暂未开放"。
+ */
+export function fetchWechatQrcode(): Promise<R<WechatQrcodeResult>> {
+  // TODO(后端就绪后):核对路径与返回结构
+  return http.post<R<WechatQrcodeResult>>('/api/auth/wechat/qrcode', {}).then((r) => r.data);
+}
+
+/**
+ * 轮询微信扫码状态(GET /api/auth/wechat/status?state=xxx)。
+ *
+ * 入参:
+ *   - state string:fetchWechatQrcode 返回的本地 state 标识。
+ * 出参:
+ *   - Promise<R<WechatStatusResult>>:code=200 时 data.status 为状态机值;success 时 data.token 非空。
+ * 异常场景:
+ *   - 网络故障:Promise reject,调用方 catch(轮询里静默跳过本次,下轮重试)。
+ */
+export function pollWechatQrcodeStatus(state: string): Promise<R<WechatStatusResult>> {
+  // TODO(后端就绪后):核对路径、query 参数名与状态枚举
+  return http.get<R<WechatStatusResult>>('/api/auth/wechat/status', { params: { state } }).then((r) => r.data);
+}

+ 33 - 1
src/api/http.ts

@@ -15,13 +15,45 @@
  *     const res = await http.get<R<PageData<InstitutionDTO>>>('/api/org/institutions', { params });
  *     return res.data; // 调用方自行判断 res.code(或页面统一 try/catch)
  *   }
+ *
+ * 装配(main.ts,须在首帧路由解析前):
+ *
+ *   import { setRouterForRedirect } from '@/api/http';
+ *   const router = buildRouter();
+ *   setRouterForRedirect(router); // 注入路由实例,401 跳登录走 SPA push(免全页刷新)
  */
 import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/identity/token-store';
 import type { R } from '@/types/api';
+import type { Router } from 'vue-router';
 
 /** 登录态失效相关错误码(与后端 errors 包 1xxx 段对齐) */
 const UNAUTHORIZED_CODES: readonly number[] = [1001, 1002];
 
+/**
+ * 路由跳转收口(SPA router.push,避免 location.href 全页刷新)。
+ *
+ * 为何用注入而非 import:本文件被 router/index.ts 的守卫链间接依赖(守卫调 refreshAccessToken),
+ * 若此处 `import router` 会形成 router → http → router 循环。故用模块级变量,由 main.ts 装配
+ * 后调 setRouterForRedirect 注入;未注入时(极端早启动)降级 location.href。
+ */
+let routerRef: Router | undefined;
+
+/** 注入路由实例(main.ts 装配 router 后调用;幂等)。入参:路由实例。出参:无。 */
+export function setRouterForRedirect(router: Router): void {
+  routerRef = router;
+}
+
+/** SPA 跳登录页(router.push;未注入 router 时降级 location.href)。入参:无。出参:无。 */
+function spaPush(path: string): void {
+  if (routerRef) {
+    routerRef.push(path).catch(() => {
+      /* push 重复导航等异常:静默(目标页已到位或将被守卫重定向) */
+    });
+  } else {
+    window.location.href = path;
+  }
+}
+
 /** 错误 toast 去重表:同一 message 1s 内不重复弹(README §3.4) */
 const toastDedup = new Map<string, number>();
 const TOAST_DEDUP_MS = 1000;
@@ -61,7 +93,7 @@ let refreshing: Promise<boolean> | undefined;
  */
 function redirectToLogin(): void {
   clearTokens();
-  window.location.href = '/login';
+  spaPush('/login');
 }
 
 /**

+ 51 - 0
src/components/side-menu.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+/**
+ * side-menu.vue —— 侧边导航菜单(递归渲染 login store 下发的 menuTree)。
+ *
+ * 职责边界:
+ *   - 只负责**渲染**菜单树(el-sub-menu / el-menu-item),不持有菜单数据(数据源 = useLogin().menuTree,
+ *     由父级 layout 壳传入);
+ *   - 叶子节点 el-menu-item 的 index = 路由 path,配合 el-menu 的 router 模式点击即跳转;
+ *   - 自递归:父节点(children 非空)渲染 el-sub-menu,其内再挂一层 <side-menu> 递归子节点。
+ *
+ * 图标说明(2026-11-06 定稿:暂不引 @element-plus/icons-vue UMD):
+ *   - MenuNode.icon 字段保留(后端可下发图标组件名),但当前不渲染——引图标包需新增第 9 项 vendor
+ *     UMD + 改 index.html,属独立决策;菜单先以纯文本呈现,后续要图标时再装 icons-vue 并在此消费 icon。
+ *
+ * 典型使用(layout/index.vue 的 el-aside 内):
+ *
+ *   <el-menu :default-active="route.path" router>
+ *     <side-menu :menu-tree="login.menuTree" />
+ *   </el-menu>
+ */
+import type { MenuNode } from '@/types/api';
+
+defineProps<{
+  /** 可见菜单树(后端按角色/权限过滤后下发;递归入参 = 当前层节点数组) */
+  menuTree: MenuNode[];
+}>();
+</script>
+
+<template>
+  <div class="side-menu">
+    <template v-for="item in menuTree" :key="item.code">
+      <!-- 叶子节点:直接菜单项(index = path,el-menu router 模式点击跳转) -->
+      <el-menu-item v-if="!item.children || item.children.length === 0" :index="item.path ?? ''">
+        {{ item.label }}
+      </el-menu-item>
+      <!-- 父节点:子菜单(el-sub-menu 包裹递归的 <side-menu>) -->
+      <el-sub-menu v-else :index="item.code">
+        <template #title>
+          <span class="side-menu__title">{{ item.label }}</span>
+        </template>
+        <side-menu :menu-tree="item.children" />
+      </el-sub-menu>
+    </template>
+  </div>
+</template>
+
+<style scoped>
+.side-menu__title {
+  font-size: var(--app-font-md);
+}
+</style>

+ 12 - 2
src/main.ts

@@ -9,7 +9,9 @@
  *      读此 locale(README §1.4 双注入之一;声明式注入在 App.vue 的 el-config-provider);
  *   5. 权限指令:v-perm(README §3.1 权限码三段式);
  *   6. 登录态 hydrate(页面刷新后从 token-store 恢复 UI 状态,守卫链第①步即可用);
- *   7. mount('#app')。
+ *   7. 全局样式:import '@/styles/variables.scss'(Vite 全局注入 :root 设计 token,
+ *      所有组件 <style scoped> 内 var(--app-xxx) 即可引用;须早于 App.vue 挂载);
+ *   8. mount('#app')。
  *
  * 异常场景:
  *   - UMD 全局缺失(index.html 漏引/vendor 404):typeof Vue === 'undefined' 时 fail-fast 白屏提示,
@@ -18,7 +20,10 @@
 import { buildRouter, setupGuards } from '@/router';
 import { useLogin } from '@/store/login';
 import { perm } from '@/directives/perm';
+import { setRouterForRedirect } from '@/api/http';
 import App from '@/App.vue';
+// 全局设计 token(Vite 全局注入 :root 变量;须在 createApp 前引入,组件 var() 引用才生效)
+import '@/styles/variables.scss';
 
 /** UMD 全局缺失提示(开发期定位"vendor 没加载"用;生产 nginx 侧应监控 404) */
 function failMissingGlobal(name: string): never {
@@ -47,6 +52,9 @@ const router = buildRouter();
 setupGuards(router);
 app.use(router);
 
+// 3b. 注入路由实例给 http 拦截器(401 跳登录走 SPA push,免全页刷新)
+setRouterForRedirect(router);
+
 // 4. Element Plus 命令式 locale 注入(ElMessageBox/ElNotification 等命令式 API 读此处)
 app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
 
@@ -56,5 +64,7 @@ app.directive('perm', perm);
 // 6. 登录态 hydrate(必须在首次路由解析前:守卫链第①步要读 isLoggedIn)
 useLogin().hydrate();
 
-// 7. 挂载
+// 7. 全局样式已在 import 阶段注入(variables.scss → :root 设计 token)
+
+// 8. 挂载
 app.mount('#app');

+ 20 - 7
src/router/index.ts

@@ -30,14 +30,19 @@ declare module 'vue-router' {
 /**
  * 路由表(createRouter 的 routes 入参;vite define 注入为 VueRouter.createRouter)。
  *
+ * 套壳模型(嵌套路由,README §2 layout 壳):
+ *   - 顶层路由**不套壳**:/login(登录,公开)/ /org/scope(作用域选择,登录后全屏过渡页);
+ *   - **layout 父路由**(path '/',component = views/layout):内容区 <router-view/> 渲染 children;
+ *     业务域页面全部作为其 children(相对 path),统一带侧边菜单 + 顶栏骨架;
+ *   - 访问 '/' 自动 redirect 到首个业务页 /org/institutions(scope 未就绪时守卫会拦到 /org/scope)。
+ *
  * 入参:无。
  *
  * 出参:
- *   - RouteRecordRaw[]:四条记录——登录页(公开)/ 机构列表(org 域示范页)/
- *     作用域选择页(公开)/ 兜底 404。
+ *   - RouteRecordRaw[]:/login / /org/scope / layout(children: org/institutions) / 兜底 404。
  *
  * 异常场景:
- *   - 路径未匹配:通配路由落到 404 视图(本样例中 redirect 到登录页,避免白屏)。
+ *   - 路径未匹配:通配路由落到 404(本样例 redirect 到登录页,避免白屏)。
  */
 export const routes: RouteRecordRaw[] = [
   {
@@ -53,10 +58,18 @@ export const routes: RouteRecordRaw[] = [
     meta: { scopeRequired: false, title: '选择机构' },
   },
   {
-    path: '/org/institutions',
-    name: 'InstitutionList',
-    component: () => import('@/views/org/institution/list.vue'),
-    meta: { title: '机构管理' },
+    // layout 父路由:套壳(侧边菜单 + 顶栏 + 内容区 router-view)。业务域路由嵌套其下。
+    path: '/',
+    component: () => import('@/views/layout/index.vue'),
+    redirect: '/org/institutions',
+    children: [
+      {
+        path: 'org/institutions',
+        name: 'InstitutionList',
+        component: () => import('@/views/org/institution/list.vue'),
+        meta: { title: '机构管理' },
+      },
+    ],
   },
   {
     path: '/:pathMatch(.*)*',

+ 10 - 0
src/store/login.ts

@@ -15,6 +15,7 @@
 import { logout as apiLogout, type LoginResult } from '@/api/auth/login';
 import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
 import type { ComputedRef, Ref } from 'vue';
+import type { MenuNode } from '@/types/api';
 
 /**
  * setup store 的返回面类型(defineStore 的 setup 函数返回值;字段为 ref 本体)。
@@ -32,6 +33,8 @@ export interface LoginStore {
   roles: Ref<number[]>;
   /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */
   permissions: Ref<Set<string>>;
+  /** 可见导航菜单树(登录响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
+  menuTree: Ref<MenuNode[]>;
   /** 是否已登录(有 access token 且未过期;守卫链第一步) */
   isLoggedIn: ComputedRef<boolean>;
   /** 判断是否持有指定权限码(v-perm 指令用) */
@@ -50,6 +53,9 @@ export const useLogin = defineStore('login', (): LoginStore => {
   const realName = ref('');
   const roles = ref<number[]>([]);
   const permissions = ref(new Set<string>());
+  // 可见导航菜单树(仅内存态,不落 localStorage——menuTree 是响应式视图非凭证,
+  // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
+  const menuTree = ref<MenuNode[]>([]);
 
   /** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
   const isLoggedIn = computed(() => {
@@ -89,6 +95,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
     realName.value = data.realName;
     roles.value = data.roles ?? [];
     permissions.value = new Set(data.permissions ?? []);
+    menuTree.value = data.menuTree ?? [];
   }
 
   /**
@@ -112,6 +119,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
     realName.value = '';
     roles.value = [];
     permissions.value = new Set();
+    menuTree.value = [];
   }
 
   /**
@@ -132,6 +140,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
       realName.value = '';
       roles.value = [];
       permissions.value = new Set();
+      menuTree.value = [];
     }
   }
 
@@ -141,6 +150,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
     realName,
     roles,
     permissions,
+    menuTree,
     isLoggedIn,
     hasPerm,
     applyLogin,

+ 22 - 0
src/types/api.d.ts

@@ -57,3 +57,25 @@ export interface PageQuery {
   /** 每页条数(缺省 10,>200 由后端钳制) */
   pageSize?: number;
 }
+
+/**
+ * 导航菜单节点(登录响应下发;layout 壳 + 侧边菜单组件消费的跨域契约)。
+ *
+ * 设计:
+ *   - 后端按当前用户角色/权限**过滤后**下发可见菜单树,前端原样渲染(不在前端二次过滤,
+ *     避免维护"权限码 → 菜单"映射的双份漂移);
+ *   - `code` 保留该菜单项对应的权限码(`域.资源.动作`),供按钮级 v-perm 与菜单项联动复用;
+ *   - 叶子节点必有 `path`(路由跳转目标);父节点 `path` 可省略(仅展开,不跳转)。
+ */
+export interface MenuNode {
+  /** 权限码(`域.资源.动作` 三段式;菜单项显隐 + 按钮权限联动依据) */
+  code: string;
+  /** 菜单中文名(直接渲染,不引入 i18n) */
+  label: string;
+  /** 路由 path(叶子节点必填;父节点可省略 = 仅展开) */
+  path?: string;
+  /** Element Plus 图标组件名(可选,如 'Setting'/'Folder';缺省不显示图标) */
+  icon?: string;
+  /** 子菜单(递归;空数组/缺省 = 叶子节点) */
+  children?: MenuNode[];
+}

+ 119 - 0
src/views/layout/index.vue

@@ -0,0 +1,119 @@
+<script setup lang="ts">
+/**
+ * layout/index.vue —— 管理端页面骨架(EP 标准布局:侧边菜单 + 顶栏 + 内容区,README §2 layout 壳)。
+ *
+ * 职责边界:
+ *   - 提供**统一骨架**:el-aside(侧边导航,递归渲染 login.menuTree)+ el-header(机构/用户/退出)
+ *     + el-main(内容区,<router-view/> 渲染嵌套路由);
+ *   - **不持有业务状态**:菜单树读 useLogin().menuTree、用户读 useLogin().realName、
+ *     机构读 useScope().current——全部从 store 读,本组件零数据逻辑;
+ *   - 作为**父路由 component** 使用(router/index.ts 嵌套路由):App.vue 只放一个 <router-view/>,
+ *     套壳与否由路由树决定(登录页/404 是顶层路由,不套壳;业务域路由是 layout 的子路由,套壳)。
+ *
+ * 菜单高亮:el-menu 的 :default-active 绑当前路由 path(useRoute().path,裸标识符 define 注入),
+ *   路由切换时 el-menu 自动高亮对应项(el-menu 内部 watch route 实现,无需手动同步)。
+ *
+ * 退出:doLogout(调后端吊销 + 清本地态)→ router.push('/login')(SPA,免全页刷新)。
+ *   ElMessageBox.confirm 二次确认(README §3.3-9 敏感操作)。
+ */
+import { useLogin } from '@/store/login';
+import { useScope } from '@/store/scope';
+import SideMenu from '@/components/side-menu.vue';
+
+const login = useLogin();
+const scope = useScope();
+const route = useRoute();
+const router = useRouter();
+
+/** 退出二次确认(敏感操作,README §3.3-9)→ 登出 → 跳登录页 */
+async function handleLogout(): Promise<void> {
+  const ElMessageBox = (globalThis.ElementPlus ?? {}).ElMessageBox;
+  try {
+    await ElMessageBox.confirm('确认退出当前账号?', '退出登录', {
+      confirmButtonText: '退出',
+      cancelButtonText: '取消',
+      type: 'warning',
+    });
+  } catch {
+    return; // 用户点取消:不退出
+  }
+  await login.doLogout();
+  await router.push('/login');
+}
+</script>
+
+<template>
+  <el-container class="app-layout">
+    <!-- 侧边导航:递归渲染权限菜单(el-menu router 模式:点击叶子节点 index=path 即跳转) -->
+    <el-aside width="220px" class="app-layout__aside">
+      <div class="app-layout__logo">统一管理平台</div>
+      <el-menu :default-active="route.path" router class="app-layout__menu">
+        <side-menu :menu-tree="login.menuTree" />
+      </el-menu>
+    </el-aside>
+
+    <el-container class="app-layout__body">
+      <!-- 顶栏:机构作用域 + 当前用户 + 退出 -->
+      <el-header class="app-layout__header">
+        <span class="app-layout__scope" v-if="scope.ready">
+          机构:{{ scope.current.institutionId }}
+          <el-button link type="primary" @click="router.push('/org/scope')" class="app-layout__switch">
+            切换
+          </el-button>
+        </span>
+        <span class="app-layout__user">
+          <span>{{ login.realName }}</span>
+          <el-button link type="primary" @click="handleLogout">退出</el-button>
+        </span>
+      </el-header>
+
+      <!-- 内容区:嵌套路由出口(业务域页面渲染于此) -->
+      <el-main class="app-layout__main">
+        <router-view />
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<style scoped>
+.app-layout {
+  height: 100%;
+}
+.app-layout__aside {
+  background: var(--app-bg-card);
+  border-right: 1px solid var(--app-bg-page);
+  display: flex;
+  flex-direction: column;
+}
+.app-layout__logo {
+  height: 56px;
+  line-height: 56px;
+  padding: 0 var(--app-spacing-md);
+  font-size: var(--app-font-lg);
+  font-weight: 600;
+  color: var(--app-text-primary);
+}
+.app-layout__menu {
+  border-right: none;
+}
+.app-layout__body {
+  height: 100%;
+}
+.app-layout__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: var(--app-bg-card);
+  border-bottom: 1px solid var(--app-bg-page);
+}
+.app-layout__scope,
+.app-layout__user {
+  font-size: var(--app-font-md);
+  color: var(--app-text-primary);
+}
+.app-layout__main {
+  background: var(--app-bg-page);
+  padding: var(--app-spacing-md);
+  overflow-y: auto;
+}
+</style>

+ 151 - 23
src/views/login/index.vue

@@ -2,13 +2,21 @@
 /**
  * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
  *
- * 职责:账号密码登录 → 成功则写 token-store + 跳机构列表;失败展示后端 message(直显,不包装)。
+ * 职责:两种登录方式 tab 切换——① 账号密码(POST /api/auth/login)② 微信扫码(占位接口,
+ * 后端端点未就绪,UI 骨架完整可联调);成功则写 token-store + router.push 跳机构;
+ * 失败展示后端 message(直显,不包装)。
  * SFC 骨架顺序(README §3.2):imports → props/emits(无)→ store 注入 → 状态 → 生命周期 → 方法。
  */
 import { login } from '@/api/auth/login';
+import { fetchWechatQrcode, pollWechatQrcodeStatus } from '@/api/auth/wechat';
 import { useLogin } from '@/store/login';
 
 const loginStore = useLogin();
+/** 路由实例(SPA 跳转,免全页刷新;裸标识符经 vite define 注入为 VueRouter.useRouter) */
+const router = useRouter();
+
+/** 登录方式 tab('password' 账号密码 / 'wechat' 微信扫码) */
+const activeTab = ref<'password' | 'wechat'>('password');
 
 /** 表单数据(单一数据源;rules 校验只拦格式,业务错误由后端 code 裁决) */
 const form = reactive({
@@ -16,11 +24,25 @@ const form = reactive({
   password: '',
 });
 
-/** 提交中(防重复提交) */
+/** 提交中(防重复提交;密码 tab 用) */
 const submitting = ref(false);
-/** 登录失败文案(后端 message 直显;空串 = 无错误) */
+/** 登录失败文案(后端 message 直显;空串 = 无错误;两 tab 共用) */
 const errorMsg = ref('');
 
+/**
+ * 微信扫码态(占位,后端端点未就绪——接口路径/字段见 api/auth/wechat.ts 占位注释)。
+ * 入参:无。
+ * 出参:
+ *   - qrcodeUrl string:二维码图片 URL('' = 未取到);
+ *   - polling boolean:是否正在轮询(驱动"扫码中"动效/文案)。
+ * 异常场景:
+ *   - 后端未就绪(fetchWechatQrcode reject):qrcodeUrl 保持 '',扫码区展示"暂未开放"占位。
+ */
+const qrcodeUrl = ref('');
+const polling = ref(false);
+/** 轮询定时器句柄(组件卸载时必须清,防泄漏;占位接口下逻辑完整仍保留) */
+let pollTimer: ReturnType<typeof setInterval> | undefined;
+
 /** 表单校验规则(显式 rules 对象,README §3.3-5) */
 const rules = {
   username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
@@ -47,7 +69,7 @@ async function handleSubmit(): Promise<void> {
     const res = await login({ username: form.username, password: form.password });
     if (res.code === 200 && res.data) {
       loginStore.applyLogin(res.data);
-      window.location.href = '/org/institutions';
+      await router.push('/org/scope');
       return;
     }
     errorMsg.value = res.message || '登录失败';
@@ -57,6 +79,66 @@ async function handleSubmit(): Promise<void> {
     submitting.value = false;
   }
 }
+
+/** 停止扫码轮询(切 tab / 组件卸载 / 终态时调用;幂等)。入参:无。出参:无。 */
+function stopPolling(): void {
+  if (pollTimer !== undefined) {
+    clearInterval(pollTimer);
+    pollTimer = undefined;
+  }
+  polling.value = false;
+}
+
+/**
+ * 切换登录方式 tab(切到微信时取二维码 + 启动轮询;切走时停轮询)。
+ *
+ * 入参:tab 'password' | 'wechat'(el-tabs @tab-change 回调值)。
+ * 出参:无(异步启动扫码流程)。
+ * 异常场景:
+ *   - 后端未就绪:fetchWechatQrcode reject → 停轮询,扫码区展示占位文案。
+ */
+async function handleTabChange(tab: 'password' | 'wechat'): Promise<void> {
+  activeTab.value = tab;
+  stopPolling();
+  errorMsg.value = '';
+  if (tab === 'wechat') {
+    qrcodeUrl.value = '';
+    try {
+      const q = await fetchWechatQrcode();
+      if (q.code === 200 && q.data) {
+        qrcodeUrl.value = q.data.url;
+        // 轮询扫码状态(1.5s 间隔;占位节奏,后端就绪后可调)
+        const state = q.data.state;
+        polling.value = true;
+        pollTimer = setInterval(async () => {
+          try {
+            const s = await pollWechatQrcodeStatus(state);
+            if (s.code !== 200 || !s.data) return; // 单次轮询业务错误:静默,下轮重试
+            const { status, token } = s.data;
+            if (status === 'success' && token) {
+              stopPolling();
+              loginStore.applyLogin(token);
+              await router.push('/org/scope');
+            } else if (status === 'cancel' || status === 'expire') {
+              stopPolling();
+              qrcodeUrl.value = ''; // 终态:清空二维码,提示重新扫码
+            }
+          } catch {
+            // 单次轮询网络故障:静默(不中断轮询,下轮重试)
+          }
+        }, 1500);
+      }
+    } catch {
+      // 后端未就绪 / 取码失败:占位(保持 qrcodeUrl 空,扫码区显"暂未开放")
+      stopPolling();
+    }
+  }
+}
+
+// 组件卸载清轮询定时器(防内存泄漏;登录页被守卫跳转销毁时必清)
+onUnmounted(() => {
+  stopPolling();
+});
 </script>
 
 <template>
@@ -68,25 +150,40 @@ async function handleSubmit(): Promise<void> {
 
       <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-error" />
 
-      <el-form :model="form" :rules="rules" label-width="72px" @submit.prevent="handleSubmit">
-        <el-form-item label="用户名" prop="username">
-          <el-input v-model="form.username" placeholder="请输入用户名" autocomplete="username" />
-        </el-form-item>
-        <el-form-item label="密码" prop="password">
-          <el-input
-            v-model="form.password"
-            type="password"
-            placeholder="请输入密码"
-            autocomplete="current-password"
-            show-password
-          />
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" :loading="submitting" native-type="submit" class="login-submit">
-            登 录
-          </el-button>
-        </el-form-item>
-      </el-form>
+      <!-- 登录方式切换(账号密码 / 微信扫码):@tab-change 切到微信时取码 + 启动轮询 -->
+      <el-tabs v-model="activeTab" class="login-tabs" @tab-change="handleTabChange">
+        <!-- 账号密码 -->
+        <el-tab-pane label="账号密码" name="password">
+          <el-form :model="form" :rules="rules" label-width="72px" @submit.prevent="handleSubmit">
+            <el-form-item label="用户名" prop="username">
+              <el-input v-model="form.username" placeholder="请输入用户名" autocomplete="username" />
+            </el-form-item>
+            <el-form-item label="密码" prop="password">
+              <el-input
+                v-model="form.password"
+                type="password"
+                placeholder="请输入密码"
+                autocomplete="current-password"
+                show-password
+              />
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" :loading="submitting" native-type="submit" class="login-submit">
+                登 录
+              </el-button>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 微信扫码(占位:后端端点未就绪,UI 骨架完整;二维码取到前显占位框) -->
+        <el-tab-pane label="微信扫码" name="wechat">
+          <div class="login-wechat">
+            <img v-if="qrcodeUrl" :src="qrcodeUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
+            <div v-else class="login-wechat__placeholder">微信扫码暂未开放</div>
+            <p class="login-wechat__tip">{{ polling ? '请使用微信扫描二维码…' : '切换至此自动刷新二维码' }}</p>
+          </div>
+        </el-tab-pane>
+      </el-tabs>
     </el-card>
   </div>
 </template>
@@ -113,4 +210,35 @@ async function handleSubmit(): Promise<void> {
 .login-submit {
   width: 100%;
 }
+.login-tabs {
+  margin-top: var(--app-spacing-sm);
+}
+.login-wechat {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: var(--app-spacing-md) 0;
+}
+.login-wechat__qrcode {
+  width: 200px;
+  height: 200px;
+  border: 1px solid var(--app-bg-page);
+  border-radius: var(--app-radius-md);
+}
+.login-wechat__placeholder {
+  width: 200px;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--app-bg-page);
+  color: var(--app-text-secondary);
+  font-size: var(--app-font-md);
+  border-radius: var(--app-radius-md);
+}
+.login-wechat__tip {
+  margin: var(--app-spacing-sm) 0 0;
+  font-size: var(--app-font-sm);
+  color: var(--app-text-secondary);
+}
 </style>