| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- <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 { listInstitutions } from '@/api/org/institution';
- import type { MenuNode } from '@/types/api';
- import SideMenu from '@/components/side-menu.vue';
- const login = useLogin();
- const scope = useScope();
- const route = useRoute();
- const router = useRouter();
- /**
- * 顶栏面包屑(设计源 §3:顶栏 = 作用域 pill + 面包屑 + 用户)。
- * 从 menuTree 反查当前 route.path 的祖先 label 链(域分组 → 页),渲染为 el-breadcrumb。
- * 入参:无(读 login.menuTree + route.path)。出参:{ label }[];路径不在菜单中(占位页等)→ [](顶栏不显示面包屑,优雅降级)。
- */
- const breadcrumb = computed<{ label: string }[]>(() => {
- const target = route.path;
- if (!target) return [];
- const walk = (nodes: MenuNode[], trail: string[]): string[] | null => {
- for (const n of nodes) {
- if (n.path === target) return [...trail, n.label];
- if (n.children && n.children.length) {
- const hit = walk(n.children, [...trail, n.label]);
- if (hit) return hit;
- }
- }
- return null;
- };
- return (walk(login.menuTree, []) ?? []).map((label) => ({ label }));
- });
- /**
- * 解析当前机构名称(展示用)。
- *
- * 作用域 store 只存 institutionId/campusId(数字),顶栏需要显示机构名。
- * 在作用域就绪(institutionId>0)时按 institutionId 反查机构列表,取匹配项的名称回填
- * scope.institutionName;解析失败/未匹配则保持空串(顶栏回退显示 id,不影响功能)。
- *
- * 竞态防护:切换机构时旧请求若晚返回会覆盖新机构名——用 seq 令牌,仅最新一次请求可写。
- * 本逻辑纯展示,不触碰作用域判定与守卫链。
- */
- let nameSeq = 0;
- watch(
- () => scope.current.institutionId,
- async (id) => {
- if (id <= 0) return; // 未就绪:不请求
- const seq = ++nameSeq;
- try {
- const r = await listInstitutions({ institutionId: id, page: 1, pageSize: 200 });
- if (seq !== nameSeq) return; // 已有更新的作用域切换:丢弃过期结果
- if (r.code === 200 && r.data?.list) {
- const hit = r.data.list.find((x) => x.institutionId === id);
- scope.setInstitutionName(hit?.institutionName ?? '');
- }
- } catch {
- /* 网络/作用域越界:保持空名(顶栏回退显示 id;不阻断主流程) */
- }
- },
- { immediate: true },
- );
- /** 退出二次确认(敏感操作,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">
- <!-- 顶栏:左组(机构作用域 pill + 面包屑) + 右组(用户 + 退出) -->
- <el-header class="app-layout__header">
- <div class="app-layout__header-left">
- <span class="app-layout__scope" v-if="scope.ready">
- 机构:{{ scope.institutionName || `#${scope.current.institutionId}` }}
- <el-button link type="primary" @click="router.push('/org/scope')" class="app-layout__switch">
- 切换
- </el-button>
- </span>
- <!-- 面包屑:域分组 → 当前页(menuTree 反查;占位页不在菜单则不显示) -->
- <el-breadcrumb v-if="breadcrumb.length" separator="/" class="app-layout__crumb">
- <el-breadcrumb-item v-for="(item, i) in breadcrumb" :key="i">
- {{ item.label }}
- </el-breadcrumb-item>
- </el-breadcrumb>
- </div>
- <span class="app-layout__user">
- <span>{{ login.realName }}</span>
- <el-button link type="primary" @click="handleLogout">退出</el-button>
- </span>
- </el-header>
- <!-- 内容区:嵌套路由出口(业务域页面渲染于此);宽屏限宽 1440 居中(token §3 max-width 1200-1440) -->
- <el-main class="app-layout__main">
- <div class="app-layout__content">
- <router-view />
- </div>
- </el-main>
- </el-container>
- </el-container>
- </template>
- <style scoped>
- .app-layout {
- height: 100%;
- }
- /* 左侧栏:墨蓝深色底(设计源「青屿」--app-side-*),与右侧纸白主区域形成层次 */
- .app-layout__aside {
- background: var(--app-side-bg);
- border-right: none;
- 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-side-text);
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- }
- .app-layout__menu {
- border-right: none;
- background-color: var(--app-side-bg);
- /* EP 菜单 CSS 变量(深色侧栏):背景=墨蓝、常态字=次亮、激活/高亮=亮白 */
- --el-menu-bg-color: var(--app-side-bg);
- --el-menu-text-color: var(--app-side-dim);
- --el-menu-active-color: var(--app-side-text);
- --el-menu-hover-bg-color: var(--app-side-active);
- }
- /* 域分组标题(el-sub-menu__title):深色底 + 高亮亮字、加粗突出 */
- .app-layout__menu :deep(.el-sub-menu__title) {
- color: var(--app-side-text);
- font-weight: 600;
- background-color: transparent;
- }
- .app-layout__menu :deep(.el-sub-menu__title:hover) {
- background-color: var(--app-side-active);
- }
- /* 叶子菜单项:常态偏暗(dim),hover/激活时高亮亮字 + 浅底 */
- .app-layout__menu :deep(.el-menu-item) {
- color: var(--app-side-dim);
- background-color: transparent;
- }
- .app-layout__menu :deep(.el-menu-item:hover),
- .app-layout__menu :deep(.el-menu-item.is-active) {
- color: var(--app-side-text);
- background-color: var(--app-side-active);
- }
- .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__header-left {
- display: flex;
- align-items: center;
- gap: var(--app-spacing-md);
- min-width: 0;
- }
- .app-layout__scope,
- .app-layout__user {
- font-size: var(--app-font-md);
- color: var(--app-text-primary);
- }
- .app-layout__crumb {
- font-size: var(--app-font-md);
- }
- /* 内容区限宽 1440 居中(token §3 宽屏布局升级:超宽屏时表格不拉满整屏) */
- .app-layout__main {
- background: var(--app-bg-page);
- padding: var(--app-spacing-md);
- overflow-y: auto;
- }
- .app-layout__content {
- max-width: 1440px;
- margin: 0 auto;
- }
- </style>
|