Преглед на файлове

auth: PC 登录契约对齐后端两步协议 + roles string[]

对齐后端 identity ADR-014 / T4 两步登录契约(原前端按单步假设,roles 误为 number[]):
- api/auth/login.ts 拆两步:login()→LoginStep1{loginTicket,user,memberships[]}、
  issue()→TokenPair{accessToken,refreshToken,roles:[]string,institutionId,campusId,
  menuTree,permissions};删旧单步 LoginResult(number[] roles)
- store/login.ts:roles: number[]→string[];新增 applyStep1(暂存票据+作用域列表,
  内存过渡态不落盘)+ pendingTicket/pendingMemberships;applyLogin(TokenPair) 同步
  roles/permissions/menuTree + 设 scope;doLogout/hydrate 清过渡态
- views/login/index.vue:① 验账密后单条 membership 自动 ② issue→跳机构;多条→
  applyStep1→跳作用域选择页;空 memberships 提示联系管理员
- views/org/scope-select.vue:由硬编码机构选项改为接 pendingMemberships,选定后
  issue(pendingTicket, 作用域)→applyLogin;pendingTicket 丢失引导回登录页
- router/index.ts:守卫 ① 加 ①→② 过渡态例外(pendingTicket 在手未签 token 时放行
  ScopeSelect),避免票据在手却被 isLoggedIn 判定踢回登录页
- api/auth/wechat.ts:占位 token 类型 LoginResult→TokenPair(后端就绪前占位)

门禁:vue-tsc typecheck 0 错 + eslint 0 错 + vite build 通过(dist 无 Vue 运行时打包,
define 注入 Vue.* 全局生效)。
Q преди 3 дни
родител
ревизия
bf9931c8cd
променени са 6 файла, в които са добавени 307 реда и са изтрити 86 реда
  1. 97 32
      src/api/auth/login.ts
  2. 3 3
      src/api/auth/wechat.ts
  3. 5 0
      src/router/index.ts
  4. 69 14
      src/store/login.ts
  5. 43 11
      src/views/login/index.vue
  6. 90 26
      src/views/org/scope-select.vue

+ 97 - 32
src/api/auth/login.ts

@@ -1,70 +1,135 @@
 /**
- * login.ts —— 登录/登出接口封装(auth 域;对应后端 identity 模块 ADR-014 SSO 最小可行)。
+ * login.ts —— 登录/登出接口封装(auth 域;一域一目录、一资源一文件纪律,README §3.2)。
  *
- * 一域一目录、一资源一文件纪律(README §3.2):本文件只导出登录相关请求函数 + 对应 DTO。
+ * 两步登录协议(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
+ *   ① POST /api/auth/login  验账密 → 一次性票据(loginTicket) + 用户 + 作用域列表(memberships[]),**不签 token**;
+ *   ② POST /api/auth/issue  凭 loginTicket + 选定 (institutionId, campusId) → 凭证对(access+refresh)
+ *      + 选定作用域 + roles + RBAC menuTree/permissions。
+ *   单条 membership 的账号前端在登录页自动完成②;多条 membership 进作用域选择页由用户挑选后再②。
+ *   票据 5min 有效、单次消费(后端 Redis GETDEL 原子),失效需重走①。
  *
- * 典型使用(login store 内):
- *
- *   import { login } from '@/api/auth/login';
- *   const res = await login({ username, password });
- *   if (res.code !== 200) return; // 错误文案 res.message 已由 http 层 toast
- *   this.accessToken = res.data.accessToken;
+ * 本文件只导出登录相关请求函数 + 对应 DTO(与后端 identity/service 出参 DTO 逐字段对齐,camelCase)。
  */
 import { http } from '@/api/http';
 import type { MenuNode, R } from '@/types/api';
 
-/** 登录请求体(后端 username/password 单一口径,兼容期旧端点别名映射在后端完成) */
+/** 登录请求体(后端 username/password 单一口径;密码 6~128 位,后端 validate 校验) */
 export interface LoginReq {
-  /** 用户名(必填) */
+  /** 用户名(必填,≤64 位) */
   username: string;
-  /** 密码(必填;传输层 TLS 保护,前端不明文落盘) */
+  /** 密码(必填,6~128 位;传输层 TLS 保护,前端不明文落盘) */
   password: string;
 }
 
-/** 登录成功返回的用户凭证(后端 ADR-014:access 30min + refresh 30 天) */
-export interface LoginResult {
+/** 用户对外视图(后端 UserView;不含密码三列) */
+export interface LoginUser {
   /** canonical_user_id(ULID,VARCHAR(26),ADR-013) */
   id: string;
-  /** 用户名 */
+  /** 用户名(可空) */
   username: string;
-  /** 真实姓名 */
+  /** 真实姓名(可空) */
   realName: string;
-  /** 角色码数组(7 角色字典;市场 role 9 数据保留但登录入口暂禁用) */
-  roles: number[];
-  /** 所属机构 id(作用域 institution,登录响应即下发) */
+  /** 业务身份:student/teacher/parent/staff */
+  personType: string;
+  /** 账号状态:active/disabled/merged */
+  status: string;
+}
+
+/** 一条成员关系(登录① 作用域选择列表项;后端 MembershipView) */
+export interface MembershipView {
+  /** 成员关系 id */
+  membershipId: number;
+  /** 机构 id(作用域第一级;0 = 平台级超管) */
   institutionId: number;
-  /** 所属校区 id(0 = 机构级,无校区约束) */
+  /** 校区 id(作用域第二级;0 = 机构级/不限) */
   campusId: number;
+  /** 字符串角色码(super_admin/academic/finance/teacher/student/parent/market) */
+  role: string;
+  /** 成员状态:active 在籍 / inactive 离开 */
+  status: 'active' | 'inactive';
+}
+
+/** 两步登录第一步出参(验账密成功,未签 token;后端 LoginStep1) */
+export interface LoginStep1 {
+  /** 一次性票据(② 消费;32 hex,5min 有效,单次) */
+  loginTicket: string;
+  /** 登录用户基础信息 */
+  user: LoginUser;
+  /** 可选作用域列表(空 = 该用户暂无机构归属,前端提示"联系管理员分配机构") */
+  memberships: MembershipView[];
+}
+
+/** 两步登录第二步请求体(选定作用域后换取 token;后端 IssueReq) */
+export interface IssueReq {
+  /** ① 返回的一次性票据(32 hex) */
+  loginTicket: string;
+  /** 选定机构(0 = 平台级超管) */
+  institutionId: number;
+  /** 选定校区(0 = 机构级/不限) */
+  campusId: number;
+}
+
+/** 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码;后端 TokenPair) */
+export interface TokenPair {
   /** access token(RS256 JWT,30 分钟过期) */
   accessToken: string;
-  /** refresh token(不透明串,30 天过期) */
+  /** refresh token(不透明串,64 hex,30 天过期) */
   refreshToken: string;
   /** access 过期时间(Unix 毫秒) */
   expiresAt: number;
-  /** RBAC 权限码数组(`域.资源.动作` 三段式;菜单/按钮显隐唯一依据) */
-  permissions: string[];
-  /** 可见导航菜单树(后端按角色/权限过滤后下发;layout 壳渲染侧边菜单,README §2 layout) */
+  /** 登录用户基础信息 */
+  user: LoginUser;
+  /** 选定作用域的角色码(字符串数组,通常单条;对齐后端 []string) */
+  roles: string[];
+  /** 选定机构 id(作用域 institution) */
+  institutionId: number;
+  /** 选定校区 id(0 = 机构级,无校区约束) */
+  campusId: number;
+  /** 可见导航菜单树(后端按角色过滤后下发;layout 壳渲染侧边菜单,README §2 layout) */
   menuTree: MenuNode[];
+  /** RBAC 权限码数组(`域.资源.动作` 三段式;含 button,菜单/按钮显隐唯一依据) */
+  permissions: string[];
 }
 
 /**
- * 登录(POST /api/auth/login)。
+ * 登录第一步(POST /api/auth/login,公开)。
  *
  * 入参:
  *   - req LoginReq:username/password 必填;字段缺失由前端表单 rules 拦截,
- *     后端兜底 2001(参数缺失)。
+ *     密码 <6 位后端兜底 2001;账号不存在/密码错/禁用统一 1001(防枚举)。
+ *
+ * 出参:
+ *   - Promise<R<LoginStep1>>:
+ *     - code=200:data 为 {loginTicket, user, memberships[]};调用方据此决定自动②还是进选择页;
+ *     - code=1001:账号或密码错误(防枚举统一文案);
+ *     - code=2001:参数错误(密码长度等);
+ *     - code=5002:系统异常(DB/票据写失败)。
+ *
+ * 异常场景:
+ *   - 网络层故障(断网/超时/CORS):Promise reject,调用方 catch 提示"网络连接异常"。
+ */
+export function login(req: LoginReq): Promise<R<LoginStep1>> {
+  return http.post<R<LoginStep1>>('/api/auth/login', req).then((r) => r.data);
+}
+
+/**
+ * 登录第二步(POST /api/auth/issue,公开,凭一次性票据)。
+ *
+ * 入参:
+ *   - req IssueReq:loginTicket 必填(① 返回);institutionId/campusId 为选定的作用域。
  *
  * 出参:
- *   - Promise<R<LoginResult>>:
- *     - code=200:data 为凭证;调用方写入 login store;
- *     - code=1001:未登录语义不复用于此(登录接口本身);账号密码错误返回 1005 段(后端定义);
- *     - code=5001:系统繁忙,展示 message 即可。
+ *   - Promise<R<TokenPair>>:
+ *     - code=200:data 为凭证对 + 作用域 + roles + menuTree/permissions;调用方写 token-store;
+ *     - code=1001:票据失效(已消费/过期/不存在,需重走①);
+ *     - code=1003:该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效;
+ *     - code=5002:签发失败。
  *
  * 异常场景:
- *   - 网络层故障(断网/超时):Promise reject,调用方 catch 提示"网络连接异常"。
+ *   - 网络层故障:Promise reject,调用方 catch 提示"网络连接异常"。
  */
-export function login(req: LoginReq): Promise<R<LoginResult>> {
-  return http.post<R<LoginResult>>('/api/auth/login', req).then((r) => r.data);
+export function issue(req: IssueReq): Promise<R<TokenPair>> {
+  return http.post<R<TokenPair>>('/api/auth/issue', req).then((r) => r.data);
 }
 
 /**

+ 3 - 3
src/api/auth/wechat.ts

@@ -36,12 +36,12 @@ export interface WechatQrcodeResult {
   expireSeconds: number;
 }
 
-/** 轮询扫码状态返回体(占位 DTO;success 时携带登录凭证,结构同账号密码的 LoginResult) */
+/** 轮询扫码状态返回体(占位 DTO;success 时携带登录凭证,结构同账号密码两步②的 TokenPair) */
 export interface WechatStatusResult {
   /** 当前扫码状态 */
   status: WechatQrcodeStatus;
-  /** 登录凭证(仅 status === 'success' 时非空;其余状态省略) */
-  token?: import('./login').LoginResult;
+  /** 登录凭证(仅 status === 'success' 时非空;其余状态省略;后端就绪后核对是否两步或直发) */
+  token?: import('./login').TokenPair;
 }
 
 /**

+ 5 - 0
src/router/index.ts

@@ -121,6 +121,11 @@ export function setupGuards(router: ReturnType<typeof buildRouter>): void {
 
     // ① 登录态
     if (!login.isLoggedIn) {
+      // 两步登录①→② 过渡态:票据在手(pendingTicket)但未签 token,允许进入作用域选择页
+      // (scope-select);未签发前刷新会丢失过渡态,需回登录页重走①(守卫拦截自然兜底)。
+      if (to.name === 'ScopeSelect' && login.pendingTicket) {
+        return true;
+      }
       // ② 有 refresh 则静默刷新(单例;并发多路由切换只刷一次)
       const refreshed = await refreshAccessToken();
       if (refreshed && login.accessToken) {

+ 69 - 14
src/store/login.ts

@@ -2,9 +2,16 @@
  * login.ts —— 登录态 store(Pinia setup 风格;README §3.2 状态最小化)。
  *
  * 职责边界:
- *   - 持有**响应式视图**(token/角色/权限码),供组件 `useLogin()` 读取渲染菜单/按钮;
+ *   - 持有**响应式视图**(token/角色/权限码/菜单树),供组件 `useLogin()` 读取渲染菜单/按钮;
  *   - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
- *   - 登录/登出动作封装在此(调 api/auth/login.ts → 写 token-store → 同步视图)。
+ *   - 登录/登出动作封装在此(调 api/auth/login.ts 两步 → 写 token-store → 同步视图 + scope)。
+ *
+ * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
+ *   ① 验账密成功 → applyStep1():暂存一次性票据 + 作用域列表 + 用户名(**不落 token**);
+ *      单条作用域:登录页自动 applyLogin(②);多条:进作用域选择页由用户挑选。
+ *   ② 选定作用域签发 → applyLogin(TokenPair):写 token + 同步 roles/permissions/menuTree + 设 scope。
+ *   pendingTicket/pendingMemberships 为 ①→② 之间的**内存过渡态**(票据 5min 有效、单次消费;
+ *   不落 localStorage——泄露的 ticket 可被换 token,落盘徒增风险面;刷新即丢,需重走①)。
  *
  * 典型使用(组件内):
  *
@@ -12,8 +19,9 @@
  *   v-perm="'academic.fee.refund'" 指令读 login.hasPerm;
  *   <template>{{ login.realName }}</template>
  */
-import { logout as apiLogout, type LoginResult } from '@/api/auth/login';
+import { logout as apiLogout, type LoginStep1, type MembershipView, type TokenPair } from '@/api/auth/login';
 import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
+import { useScope } from '@/store/scope';
 import type { ComputedRef, Ref } from 'vue';
 import type { MenuNode } from '@/types/api';
 
@@ -27,20 +35,26 @@ export interface LoginStore {
   accessToken: Ref<string | null>;
   /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
   expiresAt: Ref<number | null>;
-  /** 当前用户真实姓名(未登录为 '') */
+  /** 当前用户真实姓名(未登录为 '';① 验账密成功后即有值) */
   realName: Ref<string>;
-  /** 角色码数组(7 角色字典;未登录为 []) */
-  roles: Ref<number[]>;
+  /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
+  roles: Ref<string[]>;
   /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */
   permissions: Ref<Set<string>>;
-  /** 可见导航菜单树(登录响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
+  /** 可见导航菜单树(登录② 响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
   menuTree: Ref<MenuNode[]>;
+  /** 两步登录① 暂存:一次性票据(null = 未处于 ①→② 过渡态) */
+  pendingTicket: Ref<string | null>;
+  /** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
+  pendingMemberships: Ref<MembershipView[]>;
   /** 是否已登录(有 access token 且未过期;守卫链第一步) */
   isLoggedIn: ComputedRef<boolean>;
   /** 判断是否持有指定权限码(v-perm 指令用) */
   hasPerm: (perm: string) => boolean;
-  /** 登录成功后的凭证落盘(http 拦截器/组件不得自行写) */
-  applyLogin: (data: LoginResult) => void;
+  /** 登录① 成功后的过渡态落盘(暂存票据 + 作用域列表 + 用户名;不写 token) */
+  applyStep1: (data: LoginStep1) => void;
+  /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
+  applyLogin: (data: TokenPair) => void;
   /** 执行登出(调后端吊销 → 清本地态) */
   doLogout: () => Promise<void>;
   /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
@@ -51,11 +65,14 @@ export const useLogin = defineStore('login', (): LoginStore => {
   const accessToken = ref<string | null>(null);
   const expiresAt = ref<number | null>(null);
   const realName = ref('');
-  const roles = ref<number[]>([]);
+  const roles = ref<string[]>([]);
   const permissions = ref(new Set<string>());
   // 可见导航菜单树(仅内存态,不落 localStorage——menuTree 是响应式视图非凭证,
   // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
   const menuTree = ref<MenuNode[]>([]);
+  // 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
+  const pendingTicket = ref<string | null>(null);
+  const pendingMemberships = ref<MembershipView[]>([]);
 
   /** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
   const isLoggedIn = computed(() => {
@@ -78,24 +95,51 @@ export const useLogin = defineStore('login', (): LoginStore => {
   }
 
   /**
-   * 登录成功凭证落盘(token-store 物理写入 + 响应式视图同步)。
+   * 登录① 成功过渡态落盘(暂存票据 + 作用域列表 + 用户名;**不写 token**)。
    *
    * 入参:
-   *   - data LoginResult:api/auth/login.ts 返回的 data(code=200 时)。
+   *   - data LoginStep1:api/auth/login.ts ① 返回的 data(code=200 时)。
+   *
+   * 出参:无。
+   * 异常场景:
+   *   - data 缺 loginTicket:视为后端契约违约,过渡态不落盘(pendingTicket 保持 null)。
+   */
+  function applyStep1(data: LoginStep1): void {
+    if (!data.loginTicket) return;
+    pendingTicket.value = data.loginTicket;
+    pendingMemberships.value = data.memberships ?? [];
+    realName.value = data.user?.realName ?? '';
+  }
+
+  /**
+   * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步 + scope 设置)。
+   *
+   * 入参:
+   *   - data TokenPair:api/auth/login.ts ② 返回的 data(code=200 时)。
    *
    * 出参:无。
    * 异常场景:
    *   - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
+   *   - institutionId ≤ 0(平台级超管):scope.set 拒绝(机构必选),作用域保持未就绪——
+   *     平台级角色的作用域语义为独立待办,本处不特殊处理(对齐现状)。
    */
-  function applyLogin(data: LoginResult): void {
+  function applyLogin(data: TokenPair): void {
     if (!data.accessToken) return;
+    const scope = useScope();
     setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
     accessToken.value = data.accessToken;
     expiresAt.value = data.expiresAt;
-    realName.value = data.realName;
+    realName.value = data.user?.realName ?? realName.value;
     roles.value = data.roles ?? [];
     permissions.value = new Set(data.permissions ?? []);
     menuTree.value = data.menuTree ?? [];
+    // 选定作用域写入 scope store(守卫链第四步据此放行;institutionId>0 才合法)
+    if (data.institutionId > 0) {
+      scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
+    }
+    // ② 完成:清过渡态(票据已消费,留着反成泄露面)
+    pendingTicket.value = null;
+    pendingMemberships.value = [];
   }
 
   /**
@@ -113,6 +157,8 @@ export const useLogin = defineStore('login', (): LoginStore => {
     } catch {
       // 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效)
     }
+    const scope = useScope();
+    scope.reset();
     clearTokens();
     accessToken.value = null;
     expiresAt.value = null;
@@ -120,6 +166,8 @@ export const useLogin = defineStore('login', (): LoginStore => {
     roles.value = [];
     permissions.value = new Set();
     menuTree.value = [];
+    pendingTicket.value = null;
+    pendingMemberships.value = [];
   }
 
   /**
@@ -130,10 +178,14 @@ export const useLogin = defineStore('login', (): LoginStore => {
    * 出参:无。
    * 异常场景:
    *   - 无 token(未登录):视图置空,守卫链引导到登录页。
+   *   - 有 token(刷新后):roles/permissions/menuTree 未知(② 过渡态不落盘,刷新即丢),
+   *     完整恢复依赖后续 /api/auth/me 端点(规划项);过渡态恒置空。
    */
   function hydrate(): void {
     accessToken.value = getAccessToken();
     expiresAt.value = getExpiresAt();
+    pendingTicket.value = null;
+    pendingMemberships.value = [];
     // 刷新后 roles/permissions 未知(后端未提供"凭 access 取用户中心资料"端点前),
     // 首次请求 401 触发 refresh 流程;完整恢复依赖后续 /api/auth/me 端点(规划项)。
     if (!accessToken.value) {
@@ -151,8 +203,11 @@ export const useLogin = defineStore('login', (): LoginStore => {
     roles,
     permissions,
     menuTree,
+    pendingTicket,
+    pendingMemberships,
     isLoggedIn,
     hasPerm,
+    applyStep1,
     applyLogin,
     doLogout,
     hydrate,

+ 43 - 11
src/views/login/index.vue

@@ -2,12 +2,19 @@
 /**
  * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
  *
- * 职责:两种登录方式 tab 切换——① 账号密码(POST /api/auth/login)② 微信扫码(占位接口,
- * 后端端点未就绪,UI 骨架完整可联调);成功则写 token-store + router.push 跳机构;
+ * 职责:两种登录方式 tab 切换——① 账号密码(两步:/api/auth/login → /api/auth/issue)② 微信扫码
+ *(占位接口,后端端点未就绪,UI 骨架完整可联调);成功则写 token-store + router.push 跳机构;
  * 失败展示后端 message(直显,不包装)。
+ *
+ * 两步登录流程(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
+ *   账号密码提交 → ① /login(验账密):
+ *     - code=200 且 memberships 恰 1 条:自动 ② /issue(取该条作用域)→ applyLogin → 跳机构列表;
+ *     - code=200 且 memberships 多条:applyStep1 暂存票据/作用域列表 → 跳作用域选择页由用户挑选后 ②;
+ *     - code=200 且 memberships 空:提示"该账号暂无机构归属,请联系管理员";
+ *     - code≠200(1001 账号密码错 / 2001 参数 / 5002 系统):errorMsg 直显后端 message。
  * SFC 骨架顺序(README §3.2):imports → props/emits(无)→ store 注入 → 状态 → 生命周期 → 方法。
  */
-import { login } from '@/api/auth/login';
+import { issue, login } from '@/api/auth/login';
 import { fetchWechatQrcode, pollWechatQrcodeStatus } from '@/api/auth/wechat';
 import { useLogin } from '@/store/login';
 
@@ -24,7 +31,7 @@ const form = reactive({
   password: '',
 });
 
-/** 提交中(防重复提交;密码 tab 用) */
+/** 提交中(防重复提交;密码 tab 用,覆盖 ①② 两步全程) */
 const submitting = ref(false);
 /** 登录失败文案(后端 message 直显;空串 = 无错误;两 tab 共用) */
 const errorMsg = ref('');
@@ -58,7 +65,9 @@ const rules = {
  *   - Promise<void>:恒 resolve;成功跳机构列表,失败写 errorMsg。
  * 异常场景:
  *   - 表单校验失败:不发请求(ElForm 标红);
- *   - code !== 200(如 1005 账号密码错误):errorMsg = 后端 message;
+ *   - ① code≠200(如 1001 账号密码错):errorMsg = 后端 message;
+ *   - ① 成功但 memberships 空:errorMsg 提示无机构归属;
+ *   - ② code≠200(1001 票据失效 / 1003 越权):errorMsg = 后端 message;
  *   - 网络层 reject:errorMsg = "网络连接异常"。
  */
 async function handleSubmit(): Promise<void> {
@@ -66,13 +75,36 @@ async function handleSubmit(): Promise<void> {
   errorMsg.value = '';
   submitting.value = true;
   try {
-    const res = await login({ username: form.username, password: form.password });
-    if (res.code === 200 && res.data) {
-      loginStore.applyLogin(res.data);
-      await router.push('/org/scope');
+    // ① 验账密:拿一次性票据 + 作用域列表(不签 token)
+    const step1 = await login({ username: form.username, password: form.password });
+    if (step1.code !== 200 || !step1.data) {
+      errorMsg.value = step1.message || '登录失败';
       return;
     }
-    errorMsg.value = res.message || '登录失败';
+    const memberships = step1.data.memberships ?? [];
+
+    // 无机构归属:提示联系管理员(该账号无法进入业务域)
+    if (memberships.length === 0) {
+      errorMsg.value = '该账号暂无机构归属,请联系管理员分配机构';
+      return;
+    }
+
+    // 单条作用域:自动完成 ②(取该条 institution/campus),免进选择页
+    if (memberships.length === 1) {
+      const m = memberships[0];
+      const pair = await issue({ loginTicket: step1.data.loginTicket, institutionId: m.institutionId, campusId: m.campusId });
+      if (pair.code === 200 && pair.data) {
+        loginStore.applyLogin(pair.data);
+        await router.push('/org/institutions');
+        return;
+      }
+      errorMsg.value = pair.message || '登录失败';
+      return;
+    }
+
+    // 多条作用域:暂存票据 + 作用域列表,进作用域选择页由用户挑选后 ②
+    loginStore.applyStep1(step1.data);
+    await router.push('/org/scope');
   } catch {
     errorMsg.value = '网络连接异常';
   } finally {
@@ -118,7 +150,7 @@ async function handleTabChange(tab: 'password' | 'wechat'): Promise<void> {
             if (status === 'success' && token) {
               stopPolling();
               loginStore.applyLogin(token);
-              await router.push('/org/scope');
+              await router.push('/org/institutions');
             } else if (status === 'cancel' || status === 'expire') {
               stopPolling();
               qrcodeUrl.value = ''; // 终态:清空二维码,提示重新扫码

+ 90 - 26
src/views/org/scope-select.vue

@@ -1,44 +1,84 @@
 <script setup lang="ts">
 /**
- * 作用域选择页(views/org/scope-select.vue;守卫链第④步的落点,meta.scopeRequired=false)。
+ * 作用域选择页(views/org/scope-select.vue;两步登录②的落点,meta.scopeRequired=false)。
  *
- * 职责:登录态有效但 scope 未就绪时,由守卫重定向到此页;
- *       样例阶段仅做"机构就绪"占位交互(真实实现:调 org 域接口拉机构/校区列表联动选择,
- *       属 org 域开发范围,本样例不实现业务请求——页面结构 + store 写入口已示范)。
+ * 职责:登录① 验账密成功后,账号拥有**多条**机构/校区成员关系(membership)时,
+ *       守卫/登录页重定向到此页由用户挑选作用域;选定后调 ② /auth/issue 凭暂存票据签发 token。
+ *       选项来自 login store 的 pendingMemberships(登录① 响应下发,非接口二次拉取——
+ *       票据一次性,重拉机构列表既无权限(未签 token)也多余)。
  *
- * 典型使用(由路由守卫自动进入,页面不主动跳转):
+ * 状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
+ *   进入本页时 pendingTicket/pendingMemberships 已由登录页 applyStep1 暂存;
+ *   选定 (institutionId, campusId) → ② issue → applyLogin(写 token + scope)→ 跳机构列表。
+ *   票据 5min 有效、单次消费;本页未签发前刷新会丢失过渡态,需回登录页重走①。
  *
- *   scope.set({ institutionId, campusId })  →  scope.ready = true → 守卫放行后续路由
+ * 典型使用(由登录页/守卫自动进入,页面不主动跳转):
+ *
+ *   handleConfirm → issue(pendingTicket, 选定作用域) → loginStore.applyLogin → 跳 /org/institutions
  */
+import { issue } from '@/api/auth/login';
 import { useLogin } from '@/store/login';
-import { useScope } from '@/store/scope';
 
-const scope = useScope();
 const login = useLogin();
+const router = useRouter();
 
-/** 选择中的机构 id(真实实现替换为接口拉取的机构列表选项) */
-const institutionId = ref(0);
+/** 提交中(防重复提交;② issue 全程) */
+const submitting = ref(false);
+/** 选定中的成员关系 id(pendingMemberships 内唯一定位;0 = 未选) */
+const selectedMembershipId = ref(0);
+/** ② 失败文案(后端 message 直显;票据失效 1001 / 越权 1003) */
+const errorMsg = ref('');
 
 /**
  * 确认选择(handle 前缀:用户触发提交类)。
  *
- * 入参:无(读 institutionId 当前值;campusId 样例固定 0 = 机构级)。
+ * 入参:无(读 selectedMembershipId 当前值,定位 pendingMemberships 内对应作用域)。
  *
  * 出参:
- *   - 无返回值;成功后路由回退到原目标(此处简化为跳机构列表)。
+ *   - 无返回值;成功后 ② 签发 + applyLogin + 跳机构列表,失败写 errorMsg。
  * 异常场景:
- *   - institutionId 未选(=0):scope.set 内部拒绝并 console.error(store 层 fail-fast)。
+ *   - pendingTicket 为空(过渡态丢失,如刷新):提示"登录已失效,请重新登录"并跳登录页;
+ *   - 未选作用域(selectedMembershipId=0):按钮 disabled,不触发;
+ *   - ② code=1001(票据失效/已消费/过期):errorMsg 直显,引导重走①;
+ *   - ② code=1003(越权/归属失效):errorMsg 直显;
+ *   - 网络层 reject:errorMsg = "网络连接异常"。
  */
-function handleConfirm(): void {
-  if (!institutionId.value) return;
-  scope.set({ institutionId: institutionId.value, campusId: 0 });
-  window.location.href = '/org/institutions';
+async function handleConfirm(): Promise<void> {
+  if (submitting.value) return;
+  const m = login.pendingMemberships.find((x) => x.membershipId === selectedMembershipId.value);
+  if (!m) return; // 未选:按钮 disabled,双保险
+  if (!login.pendingTicket) {
+    // 过渡态丢失(典型:刷新后票据未持久化):引导回登录页重走①
+    await router.push('/login');
+    return;
+  }
+  errorMsg.value = '';
+  submitting.value = true;
+  try {
+    const pair = await issue({
+      loginTicket: login.pendingTicket,
+      institutionId: m.institutionId,
+      campusId: m.campusId,
+    });
+    if (pair.code === 200 && pair.data) {
+      login.applyLogin(pair.data); // 写 token + roles/permissions/menuTree + scope
+      await router.push('/org/institutions');
+      return;
+    }
+    // 1001 票据失效 / 1003 越权:直显后端 message(http 拦截器对 1001 已会尝试 refresh+重放,
+    // 但此处票据为登录过渡态非 refresh 链,失败即直显)
+    errorMsg.value = pair.message || '登录失败';
+  } catch {
+    errorMsg.value = '网络连接异常';
+  } finally {
+    submitting.value = false;
+  }
 }
 
-/** 登出入口(页面右上角;调 login store 统一登出流程) */
+/** 登出入口(页面右上角;调 login store 统一登出流程,清过渡态 + token + scope) */
 async function handleLogout(): Promise<void> {
   await login.doLogout();
-  window.location.href = '/login';
+  await router.push('/login');
 }
 </script>
 
@@ -52,16 +92,37 @@ async function handleLogout(): Promise<void> {
         </div>
       </template>
 
-      <el-form label-width="72px">
-        <el-form-item label="机构">
-          <el-select v-model="institutionId" placeholder="请选择机构" style="width: 320px">
-            <!-- 样例占位选项;org 域开发时替换为接口数据 -->
-            <el-option :value="1" label="示例机构 A" />
-            <el-option :value="2" label="示例机构 B" />
+      <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="scope-error" />
+      <el-alert
+        v-if="login.pendingMemberships.length === 0"
+        title="该账号暂无可用机构,请联系管理员分配"
+        type="warning"
+        show-icon
+        :closable="false"
+        class="scope-error"
+      />
+
+      <el-form label-width="72px" v-if="login.pendingMemberships.length > 0">
+        <el-form-item label="机构/校区">
+          <el-select
+            v-model="selectedMembershipId"
+            placeholder="请选择"
+            style="width: 320px"
+            :disabled="login.pendingMemberships.length === 1"
+          >
+            <!-- 选项来自登录① 下发的 pendingMemberships(非硬编码、非二次接口拉取) -->
+            <el-option
+              v-for="m in login.pendingMemberships"
+              :key="m.membershipId"
+              :value="m.membershipId"
+              :label="`${m.role} · 机构#${m.institutionId}${m.campusId ? ' · 校区#' + m.campusId : ''}`"
+            />
           </el-select>
         </el-form-item>
         <el-form-item>
-          <el-button type="primary" :disabled="!institutionId" @click="handleConfirm">进入</el-button>
+          <el-button type="primary" :loading="submitting" :disabled="!selectedMembershipId" @click="handleConfirm">
+            进入
+          </el-button>
         </el-form-item>
       </el-form>
     </el-card>
@@ -89,4 +150,7 @@ async function handleLogout(): Promise<void> {
   font-weight: 600;
   color: var(--app-text-primary);
 }
+.scope-error {
+  margin-bottom: var(--app-spacing-md);
+}
 </style>