index.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. <script setup lang="ts">
  2. /**
  3. * layout/index.vue —— 管理端页面骨架(EP 标准布局:侧边菜单 + 顶栏 + 内容区,README §2 layout 壳)。
  4. *
  5. * 职责边界:
  6. * - 提供**统一骨架**:el-aside(侧边导航,递归渲染 login.menuTree)+ el-header(机构/用户/退出)
  7. * + el-main(内容区,<router-view/> 渲染嵌套路由);
  8. * - **不持有业务状态**:菜单树读 useLogin().menuTree、用户读 useLogin().realName、
  9. * 机构读 useScope().current——全部从 store 读,本组件零数据逻辑;
  10. * - 作为**父路由 component** 使用(router/index.ts 嵌套路由):App.vue 只放一个 <router-view/>,
  11. * 套壳与否由路由树决定(登录页/404 是顶层路由,不套壳;业务域路由是 layout 的子路由,套壳)。
  12. *
  13. * 菜单高亮:el-menu 的 :default-active 绑当前路由 path(useRoute().path,裸标识符 define 注入),
  14. * 路由切换时 el-menu 自动高亮对应项(el-menu 内部 watch route 实现,无需手动同步)。
  15. *
  16. * 退出:doLogout(调后端吊销 + 清本地态)→ router.push('/login')(SPA,免全页刷新)。
  17. * ElMessageBox.confirm 二次确认(README §3.3-9 敏感操作)。
  18. */
  19. import { useLogin } from '@/store/login';
  20. import { useScope } from '@/store/scope';
  21. import { listInstitutions } from '@/api/org/institution';
  22. import type { MenuNode } from '@/types/api';
  23. import SideMenu from '@/components/side-menu.vue';
  24. const login = useLogin();
  25. const scope = useScope();
  26. const route = useRoute();
  27. const router = useRouter();
  28. /**
  29. * 顶栏面包屑(设计源 §3:顶栏 = 作用域 pill + 面包屑 + 用户)。
  30. * 从 menuTree 反查当前 route.path 的祖先 label 链(域分组 → 页),渲染为 el-breadcrumb。
  31. * 入参:无(读 login.menuTree + route.path)。出参:{ label }[];路径不在菜单中(占位页等)→ [](顶栏不显示面包屑,优雅降级)。
  32. */
  33. const breadcrumb = computed<{ label: string }[]>(() => {
  34. const target = route.path;
  35. if (!target) return [];
  36. const walk = (nodes: MenuNode[], trail: string[]): string[] | null => {
  37. for (const n of nodes) {
  38. if (n.path === target) return [...trail, n.label];
  39. if (n.children && n.children.length) {
  40. const hit = walk(n.children, [...trail, n.label]);
  41. if (hit) return hit;
  42. }
  43. }
  44. return null;
  45. };
  46. return (walk(login.menuTree, []) ?? []).map((label) => ({ label }));
  47. });
  48. /**
  49. * 解析当前机构名称(展示用)。
  50. *
  51. * 作用域 store 只存 institutionId/campusId(数字),顶栏需要显示机构名。
  52. * 在作用域就绪(institutionId>0)时按 institutionId 反查机构列表,取匹配项的名称回填
  53. * scope.institutionName;解析失败/未匹配则保持空串(顶栏回退显示 id,不影响功能)。
  54. *
  55. * 竞态防护:切换机构时旧请求若晚返回会覆盖新机构名——用 seq 令牌,仅最新一次请求可写。
  56. * 本逻辑纯展示,不触碰作用域判定与守卫链。
  57. */
  58. let nameSeq = 0;
  59. watch(
  60. () => scope.current.institutionId,
  61. async (id) => {
  62. if (id <= 0) return; // 未就绪:不请求
  63. const seq = ++nameSeq;
  64. try {
  65. const r = await listInstitutions({ institutionId: id, page: 1, pageSize: 200 });
  66. if (seq !== nameSeq) return; // 已有更新的作用域切换:丢弃过期结果
  67. if (r.code === 200 && r.data?.list) {
  68. const hit = r.data.list.find((x) => x.institutionId === id);
  69. scope.setInstitutionName(hit?.institutionName ?? '');
  70. }
  71. } catch {
  72. /* 网络/作用域越界:保持空名(顶栏回退显示 id;不阻断主流程) */
  73. }
  74. },
  75. { immediate: true },
  76. );
  77. /** 退出二次确认(敏感操作,README §3.3-9)→ 登出 → 跳登录页 */
  78. async function handleLogout(): Promise<void> {
  79. const ElMessageBox = (globalThis.ElementPlus ?? {}).ElMessageBox;
  80. try {
  81. await ElMessageBox.confirm('确认退出当前账号?', '退出登录', {
  82. confirmButtonText: '退出',
  83. cancelButtonText: '取消',
  84. type: 'warning',
  85. });
  86. } catch {
  87. return; // 用户点取消:不退出
  88. }
  89. await login.doLogout();
  90. await router.push('/login');
  91. }
  92. </script>
  93. <template>
  94. <el-container class="app-layout">
  95. <!-- 侧边导航:递归渲染权限菜单(el-menu router 模式:点击叶子节点 index=path 即跳转) -->
  96. <el-aside width="220px" class="app-layout__aside">
  97. <div class="app-layout__logo">统一管理平台</div>
  98. <el-menu :default-active="route.path" router class="app-layout__menu">
  99. <side-menu :menu-tree="login.menuTree" />
  100. </el-menu>
  101. </el-aside>
  102. <el-container class="app-layout__body">
  103. <!-- 顶栏:左组(机构作用域 pill + 面包屑) + 右组(用户 + 退出) -->
  104. <el-header class="app-layout__header">
  105. <div class="app-layout__header-left">
  106. <span class="app-layout__scope" v-if="scope.ready">
  107. 机构:{{ scope.institutionName || `#${scope.current.institutionId}` }}
  108. <el-button link type="primary" @click="router.push('/org/scope')" class="app-layout__switch">
  109. 切换
  110. </el-button>
  111. </span>
  112. <!-- 面包屑:域分组 → 当前页(menuTree 反查;占位页不在菜单则不显示) -->
  113. <el-breadcrumb v-if="breadcrumb.length" separator="/" class="app-layout__crumb">
  114. <el-breadcrumb-item v-for="(item, i) in breadcrumb" :key="i">
  115. {{ item.label }}
  116. </el-breadcrumb-item>
  117. </el-breadcrumb>
  118. </div>
  119. <span class="app-layout__user">
  120. <span>{{ login.realName }}</span>
  121. <el-button link type="primary" @click="handleLogout">退出</el-button>
  122. </span>
  123. </el-header>
  124. <!-- 内容区:嵌套路由出口(业务域页面渲染于此);宽屏限宽 1440 居中(token §3 max-width 1200-1440) -->
  125. <el-main class="app-layout__main">
  126. <div class="app-layout__content">
  127. <router-view />
  128. </div>
  129. </el-main>
  130. </el-container>
  131. </el-container>
  132. </template>
  133. <style scoped>
  134. .app-layout {
  135. height: 100%;
  136. }
  137. /* 左侧栏:墨蓝深色底(设计源「青屿」--app-side-*),与右侧纸白主区域形成层次 */
  138. .app-layout__aside {
  139. background: var(--app-side-bg);
  140. border-right: none;
  141. display: flex;
  142. flex-direction: column;
  143. }
  144. .app-layout__logo {
  145. height: 56px;
  146. line-height: 56px;
  147. padding: 0 var(--app-spacing-md);
  148. font-size: var(--app-font-lg);
  149. font-weight: 600;
  150. color: var(--app-side-text);
  151. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  152. }
  153. .app-layout__menu {
  154. border-right: none;
  155. background-color: var(--app-side-bg);
  156. /* EP 菜单 CSS 变量(深色侧栏):背景=墨蓝、常态字=次亮、激活/高亮=亮白 */
  157. --el-menu-bg-color: var(--app-side-bg);
  158. --el-menu-text-color: var(--app-side-dim);
  159. --el-menu-active-color: var(--app-side-text);
  160. --el-menu-hover-bg-color: var(--app-side-active);
  161. }
  162. /* 域分组标题(el-sub-menu__title):深色底 + 高亮亮字、加粗突出 */
  163. .app-layout__menu :deep(.el-sub-menu__title) {
  164. color: var(--app-side-text);
  165. font-weight: 600;
  166. background-color: transparent;
  167. }
  168. .app-layout__menu :deep(.el-sub-menu__title:hover) {
  169. background-color: var(--app-side-active);
  170. }
  171. /* 叶子菜单项:常态偏暗(dim),hover/激活时高亮亮字 + 浅底 */
  172. .app-layout__menu :deep(.el-menu-item) {
  173. color: var(--app-side-dim);
  174. background-color: transparent;
  175. }
  176. .app-layout__menu :deep(.el-menu-item:hover),
  177. .app-layout__menu :deep(.el-menu-item.is-active) {
  178. color: var(--app-side-text);
  179. background-color: var(--app-side-active);
  180. }
  181. .app-layout__body {
  182. height: 100%;
  183. }
  184. .app-layout__header {
  185. display: flex;
  186. align-items: center;
  187. justify-content: space-between;
  188. background: var(--app-bg-card);
  189. border-bottom: 1px solid var(--app-bg-page);
  190. }
  191. .app-layout__header-left {
  192. display: flex;
  193. align-items: center;
  194. gap: var(--app-spacing-md);
  195. min-width: 0;
  196. }
  197. .app-layout__scope,
  198. .app-layout__user {
  199. font-size: var(--app-font-md);
  200. color: var(--app-text-primary);
  201. }
  202. .app-layout__crumb {
  203. font-size: var(--app-font-md);
  204. }
  205. /* 内容区限宽 1440 居中(token §3 宽屏布局升级:超宽屏时表格不拉满整屏) */
  206. .app-layout__main {
  207. background: var(--app-bg-page);
  208. padding: var(--app-spacing-md);
  209. overflow-y: auto;
  210. }
  211. .app-layout__content {
  212. max-width: 1440px;
  213. margin: 0 auto;
  214. }
  215. </style>