ソースを参照

feat(ui): 登录/作用域页对齐原型 + 记住上次作用域

- 登录页:左墨蓝品牌区(logo+slogan)+ 右白登录卡,grid 分栏贴原型;脚本逻辑不动
- 作用域页:墨蓝全屏 + 白卡 + 步骤条 + 卡片式单选(替换原显示 0 的下拉框)
- 作用域记忆:localStorage 持久化 ysw:last-scope,applyLogin 写入/登出不清除,
  scope-select onMounted 默认选中上次并打"上次"角标;无记录默认首项
- 样式复用 variables.scss 品牌 token,窄屏 @media 隐藏品牌区
Q 12 時間 前
親
コミット
d4b563853f
3 ファイル変更、478 行追加、136 行削除
  1. 51 0
      src/store/login.ts
  2. 184 68
      src/views/login/index.vue
  3. 243 68
      src/views/org/scope-select.vue

+ 51 - 0
src/store/login.ts

@@ -69,12 +69,22 @@ export interface LoginStore {
    *   - access 过期:http 拦截器自动 refresh 重放,恢复失败才返回 false。
    */
   restoreMe: () => Promise<boolean>;
+  /** 记住上次作用域(applyLogin 时调用;写 localStorage,下次登录作用域选择页默认选中) */
+  rememberScope: (institutionId: number, campusId: number) => void;
+  /** 读取上次作用域(作用域选择页初始化默认选中;无记录返回 null) */
+  lastScope: () => { institutionId: number; campusId: number } | null;
   /** 执行登出(调后端吊销 → 清本地态) */
   doLogout: () => Promise<void>;
   /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
   hydrate: () => void;
 }
 
+/**
+ * 作用域偏好存储键(记住"上次进入的机构/校区",下次登录的作用域选择页默认选中该项)。
+ * 非凭证(不涉 token/票据),可安全持久化到 localStorage;doLogout 不清除(偏好跨登录保留)。
+ */
+export const LAST_SCOPE_KEY = 'ysw:last-scope';
+
 // /auth/me 恢复的 in-flight 单例(并发导航只发一次;模块级,store 实例唯一,语义等价)。
 let restoreInflight: Promise<boolean> | undefined;
 
@@ -161,6 +171,45 @@ export const useLogin = defineStore('login', (): LoginStore => {
     pendingTicket.value = null;
     pendingMemberships.value = [];
     restored.value = true;
+    // 记住本次作用域(机构>0 才记;campus 0 = 机构级也记,作为下次默认)
+    if (data.institutionId > 0) rememberScope(data.institutionId, data.campusId ?? 0);
+  }
+
+  /**
+   * 记住上次作用域(写 localStorage;下次登录的作用域选择页据此默认选中)。
+   *
+   * 入参:institutionId(>0 才记)/ campusId(0 = 机构级)。
+   * 出参:无。
+   * 异常场景:localStorage 不可用(隐私模式禁用):静默跳过,不影响登录流程。
+   */
+  function rememberScope(institutionId: number, campusId: number): void {
+    if (!institutionId || institutionId <= 0) return;
+    try {
+      localStorage.setItem(LAST_SCOPE_KEY, JSON.stringify({ institutionId, campusId: campusId ?? 0 }));
+    } catch {
+      /* 隐私模式等 storage 禁用:忽略(偏好丢失不阻断业务) */
+    }
+  }
+
+  /**
+   * 读取上次作用域(作用域选择页初始化用)。
+   *
+   * 入参:无。
+   * 出参:{ institutionId, campusId } | null(无记录 / 解析失败 = null,调用方回退首项)。
+   * 异常场景:JSON 解析失败 / 结构不符:返回 null(fail-open 回退默认首项)。
+   */
+  function lastScope(): { institutionId: number; campusId: number } | null {
+    try {
+      const raw = localStorage.getItem(LAST_SCOPE_KEY);
+      if (!raw) return null;
+      const p = JSON.parse(raw) as { institutionId?: number; campusId?: number };
+      if (typeof p.institutionId === 'number' && p.institutionId > 0) {
+        return { institutionId: p.institutionId, campusId: typeof p.campusId === 'number' ? p.campusId : 0 };
+      }
+      return null;
+    } catch {
+      return null;
+    }
   }
 
   /**
@@ -278,6 +327,8 @@ export const useLogin = defineStore('login', (): LoginStore => {
     hasPerm,
     applyStep1,
     applyLogin,
+    rememberScope,
+    lastScope,
     restoreMe,
     doLogout,
     hydrate,

+ 184 - 68
src/views/login/index.vue

@@ -247,97 +247,205 @@ onUnmounted(() => {
 </script>
 
 <template>
-  <div class="login-page">
-    <el-card class="login-card">
-      <template #header>
-        <span class="login-title">统一管理平台</span>
-      </template>
+  <div class="login">
+    <!-- 左:品牌区(墨蓝;窄屏隐藏)—— 对齐原型 .login-brand -->
+    <aside class="login-brand">
+      <div class="brand-logo">
+        <div class="brand-mark">◍</div>
+        <div class="brand-text"><b>源思维 · 统一后台</b><span>教务 × 教学 · 多机构多校区</span></div>
+      </div>
+      <p class="login-slogan">一个账号,<br>跨校区、跨角色<em>无缝切换</em>。</p>
+      <div class="login-foot">© 2026 源思维 · 登录即代表同意《管理端使用协议》</div>
+    </aside>
 
-      <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-error" />
+    <!-- 右:登录面板 -->
+    <div class="login-panel">
+      <div class="login-card">
+        <h2 class="login-card__title">登录</h2>
+        <p class="login-card__sub">输入机构账号或使用微信扫码进入管理端</p>
 
-      <!-- 登录方式切换(账号密码 / 微信扫码):@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>
+        <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-err" />
+
+        <!-- 登录方式切换(账号密码 / 微信扫码):@tab-change 切到微信时取码 + 启动轮询 -->
+        <el-tabs v-model="activeTab" class="login-tabs" @tab-change="handleTabChange">
+          <!-- 账号密码(两步:① /login 取票据 → ② /issue 签 token;单条作用域自动②,多条进选择页) -->
+          <el-tab-pane label="账号密码" name="password">
+            <el-form :model="form" :rules="rules" label-position="top" @submit.prevent="handleSubmit">
+              <el-form-item label="账号(用户名或手机号)" prop="username">
+                <el-input v-model="form.username" placeholder="如 admin 或 138****8888" 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-cta">
+                  登 录
+                </el-button>
+              </el-form-item>
+            </el-form>
+            <p class="login-tip">登录成功后将进入机构 / 校区作用域选择</p>
+          </el-tab-pane>
 
-        <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
-        <el-tab-pane label="微信扫码" name="wechat">
-          <div class="login-wechat">
-            <img v-if="qrDataUrl" :src="qrDataUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
-            <div v-else class="login-wechat__placeholder">
+          <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
+          <el-tab-pane label="微信扫码" name="wechat">
+            <div class="login-wechat">
+              <img v-if="qrDataUrl" :src="qrDataUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
+              <div v-else class="login-wechat__placeholder">
+                <el-button :loading="qrcodeLoading" class="login-wechat__btn" @click="getQrcode">
+                  {{ qrDataUrl ? '重新获取二维码' : '获取二维码' }}
+                </el-button>
+              </div>
               <el-button
+                v-if="qrDataUrl"
+                link
+                type="primary"
                 :loading="qrcodeLoading"
-                class="login-wechat__btn"
+                class="login-wechat__refresh"
                 @click="getQrcode"
               >
-                {{ qrDataUrl ? '重新获取二维码' : '获取二维码' }}
+                重新获取二维码
               </el-button>
+              <p class="login-wechat__tip">
+                {{ polling ? '请使用微信扫描二维码…' : qrDataUrl ? '请用微信扫描上方二维码' : '点击下方按钮获取二维码' }}
+              </p>
             </div>
-            <el-button
-              v-if="qrDataUrl"
-              link
-              type="primary"
-              :loading="qrcodeLoading"
-              class="login-wechat__refresh"
-              @click="getQrcode"
-            >
-              重新获取二维码
-            </el-button>
-            <p class="login-wechat__tip">
-              {{ polling ? '请使用微信扫描二维码…' : qrDataUrl ? '请用微信扫描上方二维码' : '点击下方按钮获取二维码' }}
-            </p>
-          </div>
-        </el-tab-pane>
-      </el-tabs>
-    </el-card>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+    </div>
   </div>
 </template>
 
 <style scoped>
-.login-page {
+/* 对齐原型 .login(左墨蓝品牌区 + 右白登录面板;窄屏只留面板) */
+.login {
   min-height: 100vh;
+  display: grid;
+  grid-template-columns: 1fr 460px;
+  background: var(--app-color-page);
+}
+.login-brand {
+  background: var(--app-brand-navy);
+  color: var(--app-side-text);
+  padding: 48px 56px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  position: relative;
+  overflow: hidden;
+}
+.login-brand::after {
+  content: '';
+  position: absolute;
+  right: -120px;
+  top: -120px;
+  width: 420px;
+  height: 420px;
+  border-radius: 50%;
+  background: radial-gradient(circle at center, rgba(75, 184, 200, 0.35), transparent 65%);
+}
+.brand-logo {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  position: relative;
+  z-index: 1;
+}
+.brand-mark {
+  width: 44px;
+  height: 44px;
+  border-radius: 10px;
+  background: var(--app-brand-teal);
+  display: grid;
+  place-items: center;
+  font-size: 22px;
+  box-shadow: 0 0 0 4px rgba(75, 184, 200, 0.25);
+}
+.brand-text b {
+  font-size: 17px;
+  font-weight: 600;
+  color: #fff;
+}
+.brand-text span {
+  display: block;
+  font-size: 12px;
+  color: var(--app-side-dim);
+}
+.login-slogan {
+  font-size: 26px;
+  font-weight: 600;
+  line-height: 1.5;
+  max-width: 420px;
+  color: var(--app-side-text);
+  position: relative;
+  z-index: 1;
+  margin: 0;
+}
+.login-slogan em {
+  font-style: normal;
+  color: var(--app-brand-yellow);
+}
+.login-foot {
+  font-size: 12px;
+  color: var(--app-side-dim);
+  position: relative;
+  z-index: 1;
+}
+.login-panel {
+  background: var(--app-color-card);
   display: flex;
   align-items: center;
   justify-content: center;
-  background: var(--app-bg-page);
+  padding: 40px;
 }
 .login-card {
-  width: 400px;
+  width: 100%;
+  max-width: 340px;
 }
-.login-title {
-  font-size: var(--app-font-lg);
+.login-card__title {
+  font-size: 20px;
   font-weight: 600;
-  color: var(--app-text-primary);
+  margin: 0 0 4px;
+  color: var(--app-color-text);
+}
+.login-card__sub {
+  color: var(--app-color-text-2);
+  font-size: 13px;
+  margin: 0 0 24px;
 }
-.login-error {
-  margin-bottom: var(--app-spacing-md);
+.login-err {
+  margin-bottom: 14px;
 }
-.login-submit {
+/* EP 表单项:标签置上 + 次要色 + 常规字重(贴原型 .field label) */
+.login-card :deep(.el-form-item__label) {
+  font-size: 13px;
+  color: var(--app-color-text-2);
+  font-weight: 400;
+  line-height: 1.4;
+  padding-bottom: 4px;
+}
+.login-card :deep(.el-form-item) {
+  margin-bottom: 16px;
+}
+.login-cta {
   width: 100%;
+  margin-top: 6px;
+}
+.login-tip {
+  font-size: 12px;
+  color: var(--app-color-text-3);
+  margin: 12px 0 0;
 }
 .login-tabs {
-  margin-top: var(--app-spacing-sm);
+  margin-top: 4px;
 }
+/* 微信扫码区(占位 / 二维码布局保持) */
 .login-wechat {
   display: flex;
   flex-direction: column;
@@ -347,7 +455,7 @@ onUnmounted(() => {
 .login-wechat__qrcode {
   width: 200px;
   height: 200px;
-  border: 1px solid var(--app-bg-page);
+  border: 1px solid var(--app-color-border);
   border-radius: var(--app-radius-md);
 }
 .login-wechat__placeholder {
@@ -356,8 +464,8 @@ onUnmounted(() => {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: var(--app-bg-page);
-  color: var(--app-text-secondary);
+  background: var(--app-color-page);
+  color: var(--app-color-text-2);
   font-size: var(--app-font-md);
   border-radius: var(--app-radius-md);
 }
@@ -370,6 +478,14 @@ onUnmounted(() => {
 .login-wechat__tip {
   margin: var(--app-spacing-sm) 0 0;
   font-size: var(--app-font-sm);
-  color: var(--app-text-secondary);
+  color: var(--app-color-text-2);
+}
+@media (max-width: 1080px) {
+  .login {
+    grid-template-columns: 1fr;
+  }
+  .login-brand {
+    display: none;
+  }
 }
 </style>

+ 243 - 68
src/views/org/scope-select.vue

@@ -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>