|
|
@@ -7,14 +7,15 @@
|
|
|
* 选项来自 login store 的 pendingMemberships(登录① 响应下发,非接口二次拉取——
|
|
|
* 票据一次性,重拉机构列表既无权限(未签 token)也多余)。
|
|
|
*
|
|
|
+ * 交互(对齐原型 scr-scope):墨蓝全屏 + 白卡 + 欢迎语 + 步骤条(登录→选择作用域→进入工作台)
|
|
|
+ * + **卡片式**作用域单选(非下拉框)+ 返回登录/进入工作台双按钮。
|
|
|
+ * **默认选中上次选择**:进入本页时按 login.lastScope()(localStorage 记住的上次作用域)
|
|
|
+ * 匹配对应 membership 默认选中并打"上次"角标;无记录则默认首项。
|
|
|
+ *
|
|
|
* 状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
|
|
|
* 进入本页时 pendingTicket/pendingMemberships 已由登录页 applyStep1 暂存;
|
|
|
- * 选定 (institutionId, campusId) → ② issue → applyLogin(写 token + scope)→ 跳机构列表。
|
|
|
+ * 选定 (institutionId, campusId) → ② issue → applyLogin(写 token + scope + 记住作用域)→ 跳机构列表。
|
|
|
* 票据 5min 有效、单次消费;本页未签发前刷新会丢失过渡态,需回登录页重走①。
|
|
|
- *
|
|
|
- * 典型使用(由登录页/守卫自动进入,页面不主动跳转):
|
|
|
- *
|
|
|
- * handleConfirm → issue(pendingTicket, 选定作用域) → loginStore.applyLogin → 跳 /org/institutions
|
|
|
*/
|
|
|
import { issue } from '@/api/auth/login';
|
|
|
import { useLogin } from '@/store/login';
|
|
|
@@ -26,47 +27,64 @@ const router = useRouter();
|
|
|
const submitting = ref(false);
|
|
|
/** 选定中的成员关系 id(pendingMemberships 内唯一定位;0 = 未选) */
|
|
|
const selectedMembershipId = ref(0);
|
|
|
+/** 默认选中是否来自"上次选择"(用于"上次"角标展示) */
|
|
|
+const isLastSelected = ref(false);
|
|
|
/** ② 失败文案(后端 message 直显;票据失效 1001 / 越权 1003) */
|
|
|
const errorMsg = ref('');
|
|
|
|
|
|
+/** 欢迎语(登录① 已暂存 realName;缺失时兜底"同学"——原型用"李明",此处用真实姓名) */
|
|
|
+const welcomeName = computed(() => login.realName || '用户');
|
|
|
+
|
|
|
+/** 卡片式选项数据(由 pendingMemberships 派生;展示用文案,提交仍用 membershipId 定位) */
|
|
|
+const options = computed(() =>
|
|
|
+ login.pendingMemberships.map((m) => ({
|
|
|
+ membershipId: m.membershipId,
|
|
|
+ title: m.campusId > 0 ? `机构 #${m.institutionId} · 校区 #${m.campusId}` : `机构 #${m.institutionId}(集团)`,
|
|
|
+ role: m.role,
|
|
|
+ dataScope: m.campusId > 0 ? '本校区' : '全机构',
|
|
|
+ })),
|
|
|
+);
|
|
|
+
|
|
|
+/** 进入本页:默认选中"上次选择"的 membership;无记录则默认首项。 */
|
|
|
+onMounted(() => {
|
|
|
+ const list = login.pendingMemberships;
|
|
|
+ if (list.length === 0) return;
|
|
|
+ const last = login.lastScope();
|
|
|
+ if (last) {
|
|
|
+ const hit = list.find((m) => m.institutionId === last.institutionId && m.campusId === last.campusId);
|
|
|
+ if (hit) {
|
|
|
+ selectedMembershipId.value = hit.membershipId;
|
|
|
+ isLastSelected.value = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 无记忆:默认首项
|
|
|
+ selectedMembershipId.value = list[0].membershipId;
|
|
|
+});
|
|
|
+
|
|
|
/**
|
|
|
- * 确认选择(handle 前缀:用户触发提交类)。
|
|
|
- *
|
|
|
- * 入参:无(读 selectedMembershipId 当前值,定位 pendingMemberships 内对应作用域)。
|
|
|
+ * 确认进入(② issue 签发)。
|
|
|
*
|
|
|
- * 出参:
|
|
|
- * - 无返回值;成功后 ② 签发 + applyLogin + 跳机构列表,失败写 errorMsg。
|
|
|
- * 异常场景:
|
|
|
- * - pendingTicket 为空(过渡态丢失,如刷新):提示"登录已失效,请重新登录"并跳登录页;
|
|
|
- * - 未选作用域(selectedMembershipId=0):按钮 disabled,不触发;
|
|
|
- * - ② code=1001(票据失效/已消费/过期):errorMsg 直显,引导重走①;
|
|
|
- * - ② code=1003(越权/归属失效):errorMsg 直显;
|
|
|
- * - 网络层 reject:errorMsg = "网络连接异常"。
|
|
|
+ * 出参:成功后 ② 签发 + applyLogin(含记住作用域)+ 跳机构列表,失败写 errorMsg。
|
|
|
+ * 异常:pendingTicket 空(过渡态丢失)→ 跳登录;未选 → 不触发;1001/1003 直显。
|
|
|
*/
|
|
|
async function handleConfirm(): Promise<void> {
|
|
|
if (submitting.value) return;
|
|
|
const m = login.pendingMemberships.find((x) => x.membershipId === selectedMembershipId.value);
|
|
|
- if (!m) return; // 未选:按钮 disabled,双保险
|
|
|
+ if (!m) return; // 未选:双保险
|
|
|
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,
|
|
|
- });
|
|
|
+ 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
|
|
|
+ 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 = '网络连接异常';
|
|
|
@@ -75,7 +93,7 @@ async function handleConfirm(): Promise<void> {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/** 登出入口(页面右上角;调 login store 统一登出流程,清过渡态 + token + scope) */
|
|
|
+/** 登出入口(调 login store 统一登出流程;不清除"上次作用域"偏好) */
|
|
|
async function handleLogout(): Promise<void> {
|
|
|
await login.doLogout();
|
|
|
await router.push('/login');
|
|
|
@@ -84,13 +102,22 @@ async function handleLogout(): Promise<void> {
|
|
|
|
|
|
<template>
|
|
|
<div class="scope-page">
|
|
|
- <el-card class="scope-card">
|
|
|
- <template #header>
|
|
|
- <div class="scope-header">
|
|
|
- <span class="scope-title">选择机构</span>
|
|
|
- <el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
|
|
+ <div class="scope-card">
|
|
|
+ <!-- 欢迎区(对齐原型:brand-mark + 欢迎语 + 副标题) -->
|
|
|
+ <div class="scope-welcome">
|
|
|
+ <div class="brand-mark">◍</div>
|
|
|
+ <div class="scope-welcome__text">
|
|
|
+ <b>欢迎回来,{{ welcomeName }}</b>
|
|
|
+ <div class="scope-welcome__sub">请选择进入的机构 / 校区与角色</div>
|
|
|
</div>
|
|
|
- </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 步骤条(登录→选择作用域→进入工作台) -->
|
|
|
+ <div class="steps">
|
|
|
+ <div class="step on">登录</div>
|
|
|
+ <div class="step on">选择作用域</div>
|
|
|
+ <div class="step">进入工作台</div>
|
|
|
+ </div>
|
|
|
|
|
|
<el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="scope-error" />
|
|
|
<el-alert
|
|
|
@@ -102,55 +129,203 @@ async function handleLogout(): Promise<void> {
|
|
|
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" :loading="submitting" :disabled="!selectedMembershipId" @click="handleConfirm">
|
|
|
- 进入
|
|
|
- </el-button>
|
|
|
- </el-form-item>
|
|
|
- </el-form>
|
|
|
- </el-card>
|
|
|
+ <!-- 卡片式作用域单选(对齐原型 .scope-opt;非下拉框) -->
|
|
|
+ <div v-if="login.pendingMemberships.length > 0" class="scope-list">
|
|
|
+ <label
|
|
|
+ v-for="opt in options"
|
|
|
+ :key="opt.membershipId"
|
|
|
+ class="scope-opt"
|
|
|
+ :class="{ on: opt.membershipId === selectedMembershipId }"
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ type="radio"
|
|
|
+ name="scope"
|
|
|
+ class="scope-opt__radio"
|
|
|
+ :checked="opt.membershipId === selectedMembershipId"
|
|
|
+ @change="selectedMembershipId = opt.membershipId"
|
|
|
+ />
|
|
|
+ <div class="scope-opt__text">
|
|
|
+ <b>{{ opt.title }}</b>
|
|
|
+ <div class="scope-opt__meta">角色:{{ opt.role }} · 数据范围:{{ opt.dataScope }}</div>
|
|
|
+ </div>
|
|
|
+ <span v-if="isLastSelected && opt.membershipId === selectedMembershipId" class="badge badge-teal">上次</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 双按钮(返回登录 / 进入工作台) -->
|
|
|
+ <div class="scope-actions">
|
|
|
+ <el-button class="scope-btn scope-btn--ghost" @click="handleLogout">返回登录</el-button>
|
|
|
+ <el-button type="primary" class="scope-btn" :loading="submitting" :disabled="!selectedMembershipId" @click="handleConfirm">
|
|
|
+ 进入工作台
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
+/* 对齐原型 scr-scope:墨蓝全屏 + 居中白卡 */
|
|
|
.scope-page {
|
|
|
min-height: 100vh;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- background: var(--app-bg-page);
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ background: var(--app-brand-navy);
|
|
|
+ padding: 24px;
|
|
|
}
|
|
|
.scope-card {
|
|
|
- width: 480px;
|
|
|
+ width: 460px;
|
|
|
+ max-width: 100%;
|
|
|
+ background: var(--app-color-card);
|
|
|
+ border: 1px solid var(--app-color-border);
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 24px;
|
|
|
+ box-shadow: var(--app-shadow-float);
|
|
|
}
|
|
|
-.scope-header {
|
|
|
+.scope-welcome {
|
|
|
display: flex;
|
|
|
- justify-content: space-between;
|
|
|
align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+.brand-mark {
|
|
|
+ width: 38px;
|
|
|
+ height: 38px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: var(--app-brand-teal);
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ font-size: 19px;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ box-shadow: 0 0 0 4px rgba(75, 184, 200, 0.25);
|
|
|
+}
|
|
|
+.scope-welcome__text b {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--app-color-text);
|
|
|
+}
|
|
|
+.scope-welcome__sub {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--app-color-text-3);
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 步骤条(对齐原型 .steps) */
|
|
|
+.steps {
|
|
|
+ display: flex;
|
|
|
+ margin: 14px 0 4px;
|
|
|
+}
|
|
|
+.step {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ position: relative;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--app-color-text-3);
|
|
|
}
|
|
|
-.scope-title {
|
|
|
- font-size: var(--app-font-lg);
|
|
|
+.step::before {
|
|
|
+ content: '';
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: var(--app-color-border);
|
|
|
+ display: inline-block;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.step.on {
|
|
|
+ color: var(--app-brand-teal-deep);
|
|
|
font-weight: 600;
|
|
|
- color: var(--app-text-primary);
|
|
|
}
|
|
|
+.step.on::before {
|
|
|
+ background: var(--app-brand-teal-deep);
|
|
|
+ box-shadow: 0 0 0 4px var(--app-color-teal-soft);
|
|
|
+}
|
|
|
+.step::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ top: 6px;
|
|
|
+ left: calc(50% + 14px);
|
|
|
+ right: calc(-50% + 14px);
|
|
|
+ height: 1px;
|
|
|
+ background: var(--app-color-border);
|
|
|
+}
|
|
|
+.step:last-child::after {
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+
|
|
|
.scope-error {
|
|
|
- margin-bottom: var(--app-spacing-md);
|
|
|
+ margin: 14px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 卡片式作用域选项(对齐原型 .scope-opt) */
|
|
|
+.scope-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ margin: 18px 0;
|
|
|
+}
|
|
|
+.scope-opt {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ border: 1px solid var(--app-color-border);
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 14px 16px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color 0.15s, background 0.15s;
|
|
|
+}
|
|
|
+.scope-opt:hover {
|
|
|
+ background: var(--app-color-page);
|
|
|
+}
|
|
|
+.scope-opt.on {
|
|
|
+ border-color: var(--app-brand-teal-deep);
|
|
|
+ background: var(--app-color-teal-soft);
|
|
|
+}
|
|
|
+.scope-opt__radio {
|
|
|
+ accent-color: var(--app-brand-teal-deep);
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ flex: 0 0 auto;
|
|
|
+}
|
|
|
+.scope-opt__text {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.scope-opt__text b {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--app-color-text);
|
|
|
+}
|
|
|
+.scope-opt__meta {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--app-color-text-2);
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+.badge {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 2px 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ flex: 0 0 auto;
|
|
|
+}
|
|
|
+.badge-teal {
|
|
|
+ background: var(--app-color-teal-soft);
|
|
|
+ color: var(--app-color-teal-ink);
|
|
|
+}
|
|
|
+
|
|
|
+/* 双按钮(对齐原型:ghost + primary 等宽) */
|
|
|
+.scope-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.scope-btn {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+.scope-btn--ghost {
|
|
|
+ color: var(--app-color-text-2);
|
|
|
+ border-color: var(--app-color-border);
|
|
|
+}
|
|
|
+.scope-btn--ghost:hover {
|
|
|
+ background: var(--app-brand-sun);
|
|
|
}
|
|
|
</style>
|