---
title: "Vue 3 后台权限路由：我漏掉了按钮级裁剪，还把 403 写成了 404 - Angryecho"
description: "这是「ruanno-ui 实战」系列的第二篇。上一篇讲了我怎么让 AI 维护一个 97 组件的 Vue 3 组件库；这一篇换个角度，把组件库真正拼成一个能交付的后台：动态权限路由 + 按钮级权限裁剪 + 多标签 + 主题系统，并且把过程中被截图暴露出来的两个真实缺陷一起交代清楚。"
canonical_url: "https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2C3otbig7imgeHj0"
meta:
  "og:description": "这是「ruanno-ui 实战」系列的第二篇。上一篇讲了我怎么让 AI 维护一个 97 组件的 Vue 3 组件库；这一篇换个角度，把组件库真正拼成一个能交付的后台：动态权限路由 + 按钮级权限裁剪 + 多标签 + 主题系统，并且把过程中被截图暴露出来的两个真实缺陷一起交代清楚。"
  "og:image": "https://www.angryecho.com/_og/d/c_Post%2Ctitle_~5ZCQ5qe9572R%2Cdescription_~5ZCQ5qe95LiA5LiL77yM5bCx5LiK5ZCQ5qe9572R44CC%2CpostId_P2TBC2C3otbig7imgeHj0%2Crevision_~MjAyNi0xMS0wM1QwMjo1NTo1Ni4wMDQ2NzRa%2Cp_Ii9jL3FpYW5fZHVhbl9zaGVfcXUvcC9QMlRCQzJDM290YmlnN2ltZ2VIajAi%2Cs_53eOvbQmibfqVcMs.png"
  "og:image:height": "630"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Vue 3 后台权限路由：我漏掉了按钮级裁剪，还把 403 写成了 404"
---

> Index of every page on this site: [llms.txt](https://www.angryecho.com/llms.txt)

# **Vue 3 后台权限路由：我漏掉了按钮级裁剪，还把 403 写成了 404**

> 这是「ruanno-ui 实战」系列的第二篇。上一篇讲了我怎么让 AI 维护一个 97 组件的 Vue 3 组件库；这一篇换个角度，把组件库真正拼成一个能交付的后台：**动态权限路由 + 按钮级权限裁剪 + 多标签 + 主题系统**，并且把过程中被截图暴露出来的两个真实缺陷一起交代清楚。
>
> 全文所有截图都来自跑起来的真实应用（`1440 × 900`，登录态走的真实 store 与路由守卫），不是设计稿。

---

1. 先说结论：做了 5 件事，踩了 2 个坑

---

一个后台的骨架其实只有五件事：**登录态 → 角色 → 菜单树 → 动态路由 → 按钮级权限**。听起来简单，但真正跑起来以后，决定用户体感的是这些细节：

| 环节 | 关键设计 | 一个写错就出事的点 |
| --- | --- | --- |
| 登录态 | token + 用户信息 + roles + permissions 四件套落 localStorage | 登录后守卫立即重新解析同一路径，否则命中的永远是 catch-all |
| 权限模型 | 角色码（admin/ops/dev/pm/guest）→ 权限码集合 | 菜单过滤了，按钮没过滤，等于没做 |
| 菜单 + 路由 | 同一棵 `menuTree` 同时产出侧边菜单与 `router.addRoute` | 两处各写一份，早晚不一致 |
| 多标签 | 标签即缓存名单（`keep-alive include`） | 关闭当前页时跳错页 |
| 主题 | 只改 `data-rn-theme` | 组件里写死颜色，换主题就花 |

下面逐条拆，每一条都配对应截图。

---

1. 登录页：7 套设计模板，共用一套逻辑

---

后台第一个被低估的地方是登录页。它承担了两件事：挡住不该进的人，以及让用户第一眼判断「这系统靠不靠谱」。

我给登录页做了 7 套视觉方案（极光 / 霓虹 / 悬浮 / 分割 / 蓝图 / 粗野 / 墨印），切换器在右下角，选择记进 localStorage。**关键点是：7 套模板共用同一个** `**useLogin()**`，没有任何一套自己实现提交逻辑。

![极光风格登录页：左侧品牌区，右侧表单区，右下角是 7 套设计切换器（当前高亮「极光」）](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/912b1b67a8544c7a98c200225adb7406~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=OgcW0r4VVBKpXXYvV9yTVrvuHTY%3D)

![蓝图（Blueprint）风格的登录页：网格蓝图底纹，同一套表单逻辑，完全不同的版式](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/38b36697d75f415fa81b635a7406cb32~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=UJjvWp2pFI%2Fvsl94r8QY%2FNhu1V8%3D)

![墨印（Ink）风格的登录页：深色纸墨质感，仍然是同一份 useLogin()](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/59f0814eb08541cea0285210fb0cebc8~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=AD%2BOPhOa%2FmZXfJHVqEUtj25xqYg%3D)

`useLogin()` 里只有四件事：

```
export function useLogin() {
  const username = ref("admin");          // 演示环境预填，方便直接体验
  const password = ref("123456");
  const captchaCode = ref("");            // 图形验证码：组件自己 emit 的正确答案
  const captchaInput = ref("");
  const captchaPassed = ref(false);       // 滑块 / 点选验证码是否已通过
  const loading = ref(false);
  const error = ref("");

  function captchaValid(): boolean {
    if (captchaPassed.value) return true;                        // 交互式验证码已通过
    return !!captchaCode.value &&
      captchaInput.value.toLowerCase() === captchaCode.value.toLowerCase();
  }

  async function submit() {
    if (!username.value || !password.value) return (error.value = t("login.empty"));
    if (!captchaValid()) {                                        // 注意：验证码不过就直接返回
      error.value = t("login.captchaFail");
      captchaInput.value = "";
      return;
    }
    loading.value = true;
    const res = await login({ username: username.value, password: password.value });
    userStore.setToken(res.token);
    userStore.setUserInfo(res.userInfo);
    userStore.setRoles(res.roles);              // ← 角色决定菜单
    userStore.setPermissions(res.permissions);  // ← 权限码决定按钮
    router.push((route.query.redirect as string) || "/");
  }
  // ...
}
```

这里有一个容易被忽略的语义：**角色和权限码是两套东西**。角色决定「你能看到哪些菜单」，权限码决定「你能点哪些按钮」。很多人把这两件事合成一个，结果就是要么菜单过粗、要么按钮过细，两边都不对。

`router.push(route.query.redirect || "/")` 里的 `redirect` 是登录前的目标地址——用户直接访问某个内页被拦截时，登录完要回到原地，而不是回首页。

---

2. 菜单与路由：一棵树，两个产物

---

这是我在这套后台里最坚持的一点：**菜单、路由、面包屑，必须来自同一份数据**。

`router/menus.ts` 里只维护一棵 `menuTree`，每个节点带 `path`、`component`（字符串 key）、`meta.titleKey`、`meta.icon`、`meta.roles`、`meta.keepAlive`、`meta.affix`：

```
export const menuTree: MenuNode[] = [
  { name: "dashboard", path: "/dashboard", component: "dashboard",
    meta: { titleKey: "menu.dashboard", icon: "home", keepAlive: true, affix: true } },
  {
    name: "system", path: "/system", meta: { titleKey: "menu.system", icon: "settings" },
    children: [
      { name: "system-users", path: "/system/users", component: "users",
        meta: { titleKey: "menu.users", icon: "user", roles: ["admin", "ops", "pm"], keepAlive: true } },
      { name: "system-roles", path: "/system/roles", component: "roles",
        meta: { titleKey: "menu.roles", icon: "lock", roles: ["admin"], keepAlive: true } },
      // ...
    ],
  },
  // ...
];
```

然后一个函数同时产出两样东西：

```
export function buildMenuRoutes(roles: string[]) {
  const menus = filterMenuTree(menuTree, roles);           // 侧边菜单（已按角色裁剪）
  return { routes: buildRouteRecords(menus), menus };      // 动态子路由 + 菜单
}
```

`buildRouteRecords` 把叶子节点转成 `RouteRecordRaw`，`component` 字符串通过 `viewMap` 懒加载：

```
const viewMap: Record<string, () => Promise<Component>> = {
  dashboard: () => import("@/views/DashboardView.vue"),
  users: () => import("@/views/UsersView.vue"),
  // ...
};
```

这样做的收益很具体：

1. **加一个页面只改一处**：往 `menuTree` 里加节点 + 往 `viewMap` 里加一行 import，菜单、路由、面包屑三处自动同步；
2. **路由天然按角色生成**： `filterMenuTree` 过滤掉没权限的节点， `buildRouteRecords` 只给剩下的建路由——**没权限的页面根本没注册**，不存在「页面在、只是藏起来了」这种自欺欺人的状态；
3. **分组自动保留**： `filterMenuTree` 递归时，如果子项全被过滤光，父分组也不显示，不会出现空的「系统管理」。

管理员登录后的仪表盘，是这套路由跑通之后的样子——侧边栏、面包屑、多标签、图表卡片全部来自同一份菜单数据：

![管理员登录后的仪表盘：四个统计卡（总用户/今日订单/今日营收/转化率）、营收趋势折线、项目进度、订单柱状图、渠道环形图与最近动态](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/077ccc0b18f543b4bcee3d9b3001b106~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=%2BZdo%2FcltfeO2djQsFiK2Ot5mcII%3D)

管理员登录后侧边栏是完整的两级结构：

![管理员视角的侧边栏：「系统管理」展开为 用户管理/角色管理/菜单管理/部门管理/字典管理/操作日志，「组件示例」同时展开](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/153ca82e84074deb882d7c666bc77d86~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=xJozy6t2IE%2Bp0c1s1idXE3xiODc%3D)

---

3. 路由守卫：那个「登录后必然 404」的坑

---

动态路由最容易翻车的地方在守卫。`addRoute` 之后当前导航还停在旧解析结果上，必须**重新进入一次**：

```
router.beforeEach(async (to) => {
  const userStore = useUserStore();
  const permissionStore = usePermissionStore();

  if (!userStore.isLoggedIn && to.name !== "login") {
    return { path: "/login", query: { redirect: to.fullPath } };
  }
  if (to.name === "login" && userStore.isLoggedIn) {
    return { path: "/" };
  }
  if (userStore.isLoggedIn && !permissionStore.routesGenerated) {
    const { routes, menus } = buildMenuRoutes(userStore.roles);
    routes.forEach((r) => router.addRoute("layout", r));
    permissionStore.setMenuRoutes(menus);
    permissionStore.setGenerated();
    // 关键：不能 spread to，否则 name 还是 catch-all 的 not-found，会再次 404
    return { path: to.fullPath, replace: true };
  }
  // 已登录但路由未命中 → 区分「地址写错」和「没有权限」
  if (userStore.isLoggedIn && to.name === "not-found" && permissionStore.knowsPath(to.path)) {
    return { path: "/403", replace: true };
  }
  return true;
});
```

第一个坑写错了会很惨：`return { ...to }` 看起来更「保守」，但 `to.name` 此时已经被解析成 catch-all 路由的 `not-found`，重新进入后仍然命中 404。**必须显式构造** `**{ path: to.fullPath }**`，让 router 按路径重新匹配一次。

刷新页面时，`routesGenerated` 是内存态，会重置为 `false`，守卫重建一遍动态路由——所以 F5 之后菜单不会消失。

再往下是组件示例页面的能力展示，这里挑两个最容易被问「是不是引了第三方库」的：高级表格和高级表单。

![高级表格：固定列、排序、筛选、展开行、树形数据、1 万行虚拟滚动，全部由 ruanno-ui 的 Table 一个组件承担](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/95b22372595c4cd38a843f8925be2ace~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=veelIxmrNFj%2BHUEFJ%2FBongNhzIw%3D)

![高级表单：三步分步表单（基本信息 / 成员信息 / 确认提交），支持动态增删表单项](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c8978cd4660145c2ab753d20adcaa479~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=QUEJNOnohizRaeqJ%2Fy33o2KRmsk%3D)

图表也是同一套思路——**零第三方依赖**，手写 SVG，颜色走 CSS 变量，所以换主题时图表跟着换：

![组件示例里的图表页：柱状图、堆叠柱状、折线、环形、散点、热力图、关系图，全部随主题换色](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/abee3da06fb942f8af3e27358dc1b981~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=WNbuRhlMG9CYbuaaRJZ1y%2Fq4pOw%3D)

---

4. 菜单管理：让「菜单即路由」可被看见

---

既然菜单是路由的唯一数据源，那它就应该能在界面上被看见、被理解，而不是藏在代码里。

![菜单管理页：每行显示菜单名称、类型（目录/菜单）、路由路径（如 /system/users）、排序与状态，副标题直接写着「菜单为运行期路由的数据源」](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ecd662e4ae7b4bb18ac5e1ba6405d1ac~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=FMAErIxC05HEEDMxMj3hljSz4Z4%3D)

这个页面存在的意义不是真的让人增删菜单（那需要后端配合），而是**让「菜单 = 路由」这件事对团队可见**。新人看到 `/system/users → users → UsersView.vue` 这条链，比读十页文档都快。

---

5. 角色与权限码：截图抓出来的第一个真 bug

---

角色管理页是权限模型的可视化：

![角色管理：5 个角色，权限列直接显示权限码数量——超级管理员 20、运营 7、开发 5、产品 5、访客 0](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/294c194870244bd2a10aceed920d40b0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=nQueJKEAYRjzhuJEy1o9eqfzvPg%3D)

编辑角色时可以给角色勾选具体权限码，左侧「未分配权限 0/13」，右侧「已分配权限 0/7」（截图里是运营角色的编辑态）：

![编辑运营角色：权限码按业务分组展示（新增/编辑/删除用户、角色、菜单、部门、字典、文章……），可点选分配](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/a6f81a2610ad4c038cdbda74cfe2353d~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Son4cIsWsva1pTVVMtZ9WmtHhRE%3D)

权限码集中定义在 `enums/permission.ts`，角色 → 权限码的映射也在同一处：

```
export const PERMISSION = {
  DASHBOARD_EXPORT: "dashboard:export",
  USER_CREATE: "system:user:create",
  USER_UPDATE: "system:user:update",
  USER_DELETE: "system:user:delete",
  // ...
} as const;

export const ROLE_PERMISSIONS: Record<string, string[]> = {
  admin: ALL_PERMISSIONS,
  ops: [PERMISSION.DASHBOARD_EXPORT, PERMISSION.USER_CREATE, PERMISSION.USER_UPDATE, /* ... */],
  dev: [PERMISSION.USER_UPDATE, PERMISSION.DICT_CREATE, /* ... */],
  pm:  [PERMISSION.USER_CREATE, PERMISSION.USER_UPDATE, PERMISSION.ARTICLE_CREATE, /* ... */],
  guest: [],
};
```

### 5.1 菜单对了，按钮却是错的

给产品角色（pm）登录，侧边栏被正确裁剪了——「系统管理」下只剩「用户管理」，角色/菜单/部门/字典/日志全都不见了：

![产品角色（Ruan PM）的侧边栏：「系统管理」下只剩「用户管理」，其余管理项已被过滤](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/5452b15fa3144963bd7db338d7cbf047~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=JQI%2BqsDFygAk%2B%2BHC9N9dydJVqJk%3D)

但打开用户管理页，**每一行都还有「删除」按钮**——而产品角色并没有 `system:user:delete` 权限。下面这张是**已发布版本 0.2.6 上的实际截图**，行内三件套「查看 / 编辑 / 删除」齐全：

![产品角色进入用户管理：表格每行仍然渲染了「删除」按钮（0.2.6 已发布版本的真实截图）](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c4a630fe45114285ad85f419e1c4d065~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Y0VK4JNISBU2iCzM5eP8oXmGVIo%3D)

原因很直白：`UsersView.vue` 里的操作按钮压根没接权限指令。

```
<!-- 修复前：谁都能看到 -->
<Button size="sm" variant="ghost" @click.stop="openEdit(asUser(row))">{{ t("common.edit") }}</Button>
<Button size="sm" variant="ghost" @click.stop="confirmDelete(asUser(row))">{{ t("common.delete") }}</Button>
```

排查下来只有 `RoleView`、`MenuView`、`DeptView`、`DictView`、`LogView`、`ArticleView` 接了 `v-permission`，`**UsersView**`** 漏了**。而这类问题最典型的形态就是：菜单做得很细，按钮一个没管——因为菜单是「看得见」的，按钮是「没人特意去点的」。

修复就是把权限码补上（包括新建、批量删除）：

```
<Button v-permission="PERMISSION.USER_CREATE" @click="openCreate">新建用户</Button>

<Button v-permission="PERMISSION.USER_DELETE" size="sm" variant="danger" @click="confirmBatchDelete">批量删除</Button>

<template #actions="{ row }">
  <Button size="sm" variant="ghost" @click.stop="viewUser(asUser(row))">查看</Button>
  <Button v-permission="PERMISSION.USER_UPDATE" size="sm" variant="ghost" @click.stop="openEdit(asUser(row))">编辑</Button>
  <Button v-permission="PERMISSION.USER_DELETE" size="sm" variant="ghost" @click.stop="confirmDelete(asUser(row))">删除</Button>
</template>
```

指令本身很短，没权限就把元素从 DOM 里摘掉（而不是 `display:none`，避免还能被脚本点到）：

```
function apply(el: HTMLElement, binding: DirectiveBinding<PermissionValue>) {
  const codes = Array.isArray(binding.value) ? binding.value : [binding.value!];
  const allowed = codes.some((c) => usePermissionStore().hasPermission(c));
  if (!allowed && el.parentNode) el.parentNode.removeChild(el);
}
```

修复后同一个页面、同一个产品角色，行内只剩「查看 / 编辑」，「删除」消失：

![同一个产品角色、同一个页面，修复后行内只剩「查看 / 编辑」，「删除」已从 DOM 移除](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/edb122d6ba184f8aa81d695528208c86~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Ot%2FWAI87AFfOrZ0SLWLQAnni17g%3D)

管理员视图不受影响，仍然三件套齐全——这也是验证「裁剪没过度」的反向证据。

> **一句话原则**：菜单过滤解决「看不见」，权限指令解决「点不到」。只做前者，等于把门锁装在墙上却没装门。

---

6. 越权访问该给 403，不是 404

---

第二个坑更隐蔽。因为无权限的页面根本没注册路由，用户手输或从收藏夹点进 `/system/roles` 时，匹配到的是 catch-all，于是——**落到 404**。

用户看到「页面不存在」，第一反应是「这功能是不是被删了 / 链接失效了」，而不是「我没有权限」。这在真实项目里会变成一堆无效工单。

修复思路是：**判断这个路径是不是「在全量菜单里存在」，存在但当前角色没有 → 403；根本不存在 → 404**。

```
// stores/permission.ts
function collectPaths(nodes: MenuNode[], out = new Set<string>()) {
  for (const node of nodes) {
    if (node.children) collectPaths(node.children, out);
    else if (node.path) out.add(node.path);
  }
  return out;
}
const ALL_MENU_PATHS = collectPaths(menuTree);   // 不按角色过滤的全量路径

function knowsPath(path: string): boolean {
  const normalized = path.replace(/\/+$/, "") || "/";
  return ALL_MENU_PATHS.has(normalized);
}
```

配合第 3 节守卫里的那四行，效果就分开了。产品角色访问 `/system/roles`：

![产品角色访问无权限的 /system/roles：落到 403「抱歉，你没有权限访问该页面」](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9e04825ab5814680b076aaf24d5964c0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=jPgzpDxZzlXs1R0vsQeOVUwWAEo%3D)

而真的不存在的地址仍然是 404：

![访问不存在的地址 /not-exist-page：仍然是 404「你访问的页面不存在」](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/96e2fe4cbcc449789bab6a19692f725b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Z2ERrLlrC2u2eZo%2BATRHuLOlNxE%3D)

两个页面长得像，但语义完全不同——**权限问题不该伪装成「页面没了」**。

---

7. 多标签 + keep-alive：缓存名单就是标签本身

---

后台用户最烦的两件事之一，是「切走再回来，表格筛选条件全没了」。用多标签 + keep-alive 解决，但实现上有个坑：`**keep-alive**`** 的** `**include**`** 名单不能手写**，它必须等于「当前打开的、且声明了 `keepAlive` 的标签」。

```
// stores/tabs.ts
const cachedViews = computed(() =>
  tabs.value.filter((t) => t.keepAlive).map((t) => t.name),
);
```

`addTab` 时把 `route.meta.keepAlive` 带进来，布局里直接绑：

```
<keep-alive :include="tabsStore.cachedViews">
  <RouterView />
</keep-alive>
```

标签的 `affix` 来自 `meta.affix`——仪表盘设为固定页，`closeAll` / `closeOthers` 都会保留它：

```
function closeAll(): string | null {
  tabs.value = tabs.value.filter((t) => t.affix);   // 固定页保留
  const first = tabs.value[0];
  activeName.value = first?.name ?? "";
  return first?.path ?? null;                        // 返回应跳转的 path
}
```

`closeTab` 返回「下一个应该去哪」，由布局统一 `router.push`——**把跳转决定权留在 store 外面**，避免 store 里出现 `router` 依赖。关闭当前标签时跳到原位置的下一个（`Math.min(idx, length - 1)`），关最后一个就跳到固定页，不会出现白屏。

---

8. 主题：一次属性切换，整套界面跟着换

---

5 套主题（经典蓝 / 墨浅 / 墨深 / 曜浅 / 曜深）靠 `<html data-rn-theme="...">` 一个属性切换，组件里没有任何 `if (dark)` 分支：

```
function setTheme(name: string) {
  setRnTheme(name);                          // 内部只做 documentElement.dataset.rnTheme = name
  localStorage.setItem("ruanno-admin-theme", name);
}
```

之所以能这么干净，是因为所有颜色都来自 tokens 的 `--rn-*` 变量，主题文件只写与默认值的差异组。暗色下图表、标签、骨架屏、水印全部自动适配：

![曜 · 深主题下的仪表盘：整页深色，图表、卡片边框、状态色同步切换，没有一块是「忘了换色」的白底](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9094f9440f884d31a3d88378cc7833b4~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=TSvt5K6vgbGOzrcA%2BeHQyt0gUnE%3D)

系统设置页把主题、布局、外观放在一起，改完即时生效并持久化：

![系统设置：主题方案、主题色、布局模式（侧边导航/顶部导航）、侧边栏宽度、多标签页、面包屑、固定顶栏、界面圆角、灰色模式、紧凑模式、水印与页脚](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/64936204d7734a518eaf453e3f24790e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=7mMKzn4CP6l%2BEh6DeZrcFxryq4Y%3D)

顺带说一句水印：它在布局里包住整个内容区，文字取当前登录用户名，作为内部系统的防截图手段，成本几乎为零。

---

9. 把权限模型画成一张表

---

到这里五件事都落地了，汇总成一张表更清楚：

| 层级 | 数据来源 | 拦截点 | 失败表现 |
| --- | --- | --- | --- |
| 未登录 | `userStore.isLoggedIn` | 全局守卫 | 跳登录页并记录 `redirect` |
| 菜单可见性 | `filterMenuTree(menuTree, roles)` | 渲染侧边栏 | 菜单项不出现 |
| 路由可达性 | `buildRouteRecords()` 只注册有权限的 | `router.addRoute` | 走 403（路径存在）/ 404（不存在） |
| 按钮可用性 | `v-permission="PERMISSION.xxx"` | 指令 `mounted/updated` | 元素被移出 DOM |
| 接口鉴权 | 后端 | 服务端 | **前端不做这层，也不该假装做了** |

最后一行必须强调：**前端权限是体验，不是安全**。`v-permission` 把按钮藏了，用户照样可以用 devtools 手工发请求。真正的鉴权必须在服务端按权限码校验，前端这套只是「别让用户点了才发现不行」。

---

10. 复盘：这次暴露的到底是什么问题

---

两个 bug 有一个共同形状：**「声明层」做对了，「执行层」漏了一环**。

- 权限码定义得很齐（20 个码、5 个角色）→ 但 `UsersView` 没消费它；
- 403 页面早就写好了 → 但没有任何路径能把用户导过去。

这类缺陷靠读代码很难发现，因为每一部分单独看都是对的；靠写文档也发现不了，因为文档描述的是「应该怎样」。**它是被真实截图逼出来的**：产品角色登录 → 点进用户管理 → 截图，才看到那个不该存在的「删除」按钮；手工输入 `/system/roles` → 截图，才看到本该 403 的地方写着 404。

所以我把「用真实浏览器把每个角色都走一遍、每一步都截图并断言 DOM」变成了这个项目的固定环节。截图不只是给文章用的插图，它是**验收证据**——一张能看到「删除」按钮的截图，比十行「已实现按钮级权限」的说明有力得多。

如果你也在搭后台，建议按这个顺序验收：**换角色登录看菜单 → 手输无权限地址看 403 → 逐行检查按钮 → 关标签看缓存 → 切主题看有没有白底**。五步走完，权限这块基本就真的做完了。

---

想直接体验这套后台（切角色、切主题、开标签）：

- 在线演示： [ui.ruanno.top/apps/admin/](https://link.juejin.cn/?target=https%3A%2F%2Fui.ruanno.top%2Fapps%2Fadmin%2F "https://ui.ruanno.top/apps/admin/")
- 文档站： [ui.ruanno.top](https://link.juejin.cn/?target=https%3A%2F%2Fui.ruanno.top "https://ui.ruanno.top")
- 组件库源码： [gitee.com/ruanno-top/…](https://link.juejin.cn/?target=https%3A%2F%2Fgitee.com%2Fruanno-top%2Fruanno-ui "https://gitee.com/ruanno-top/ruanno-ui")

演示环境账号：`admin` / `123456`（超级管理员）、`pm` / `123456`（产品，用来验证按钮裁剪）。

排序方式 :

### **成为第一条评论！**

Nobody's responded to this post yet. Add your thoughts and get the conversation going.

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2C3otbig7imgeHj0#post","@type":"DiscussionForumPosting","commentCount":0,"dateModified":"2026-11-03T02:55:56.004Z","datePublished":"2026-11-02T04:37:16.359Z","description":"这是「ruanno-ui 实战」系列的第二篇。上一篇讲了我怎么让 AI 维护一个 97 组件的 Vue 3 组件库；这一篇换个角度，把组件库真正拼成一个能交付的后台：动态权限路由 + 按钮级权限裁剪 + 多标签 + 主题系统，并且把过程中被截图暴露出来的两个真实缺陷一起交代清楚。","headline":"Vue 3 后台权限路由：我漏掉了按钮级裁剪，还把 403 写成了 404","isPartOf":"https://www.angryecho.com/c/qian_duan_she_qu","mainEntityOfPage":"https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2C3otbig7imgeHj0","text":"这是「ruanno-ui 实战」系列的第二篇。上一篇讲了我怎么让 AI 维护一个 97 组件的 Vue 3 组件库；这一篇换个角度，把组件库真正拼成一个能交付的后台：动态权限路由 + 按钮级权限裁剪 + 多标签 + 主题系统，并且把过程中被截图暴露出来的两个真实缺陷一起交代清楚。\n\n全文所有截图都来自跑起来的真实应用（1440 × 900，登录态走的真实 store 与路由守卫），不是设计稿。\n\n\n\n\n\n\n\n先说结论：做了 5 件事，踩了 2 个坑\n\n\n\n一个后台的骨架其实只有五件事：登录态 → 角色 → 菜单树 → 动态路由 → 按钮级权限。听起来简单，但真正跑起来以后，决定用户体感的是这些细节：\n\n\n\n\n\n\n\n环节\n\n\n\n关键设计\n\n\n\n一个写错就出事的点\n\n\n\n\n\n登录态\n\n\n\ntoken + 用户信息 + roles + permissions 四件套落 localStorage\n\n\n\n登录后守卫立即重新解析同一路径，否则命中的永远是 catch-all\n\n\n\n\n\n权限模型\n\n\n\n角色码（admin/ops/dev/pm/guest）→ 权限码集合\n\n\n\n菜单过滤了，按钮没过滤，等于没做\n\n\n\n\n\n菜单 + 路由\n\n\n\n同一棵 menuTree 同时产出侧边菜单与 router.addRoute\n\n\n\n两处各写一份，早晚不一致\n\n\n\n\n\n多标签\n\n\n\n标签即缓存名单（keep-alive include）\n\n\n\n关闭当前页时跳错页\n\n\n\n\n\n主题\n\n\n\n只改 data-rn-theme\n\n\n\n组件里写死颜色，换主题就花\n\n下面逐条拆，每一条都配对应截图。\n\n\n\n\n\n\n\n登录页：7 套设计模板，共用一套逻辑\n\n\n\n后台第一个被低估的地方是登录页。它承担了两件事：挡住不该进的人，以及让用户第一眼判断「这系统靠不靠谱」。\n\n我给登录页做了 7 套视觉方案（极光 / 霓虹 / 悬浮 / 分割 / 蓝图 / 粗野 / 墨印），切换器在右下角，选择记进 localStorage。关键点是：7 套模板共用同一个 useLogin()，没有任何一套自己实现提交逻辑。\n\n\n\n\n\n\n\nuseLogin() 里只有四件事：\n\nexport function useLogin() {\n  const username = ref(\"admin\");          // 演示环境预填，方便直接体验\n  const password = ref(\"123456\");\n  const captchaCode = ref(\"\");            // 图形验证码：组件自己 emit 的正确答案\n  const captchaInput = ref(\"\");\n  const captchaPassed = ref(false);       // 滑块 / 点选验证码是否已通过\n  const loading = ref(false);\n  const error = ref(\"\");\n\n  function captchaValid(): boolean {\n    if (captchaPassed.value) return true;                        // 交互式验证码已通过\n    return !!captchaCode.value &&\n      captchaInput.value.toLowerCase() === captchaCode.value.toLowerCase();\n  }\n\n  async function submit() {\n    if (!username.value || !password.value) return (error.value = t(\"login.empty\"));\n    if (!captchaValid()) {                                        // 注意：验证码不过就直接返回\n      error.value = t(\"login.captchaFail\");\n      captchaInput.value = \"\";\n      return;\n    }\n    loading.value = true;\n    const res = await login({ username: username.value, password: password.value });\n    userStore.setToken(res.token);\n    userStore.setUserInfo(res.userInfo);\n    userStore.setRoles(res.roles);              // ← 角色决定菜单\n    userStore.setPermissions(res.permissions);  // ← 权限码决定按钮\n    router.push((route.query.redirect as string) || \"/\");\n  }\n  // ...\n}\n\n这里有一个容易被忽略的语义：角色和权限码是两套东西。角色决定「你能看到哪些菜单」，权限码决定「你能点哪些按钮」。很多人把这两件事合成一个，结果就是要么菜单过粗、要么按钮过细，两边都不对。\n\nrouter.push(route.query.redirect || \"/\") 里的 redirect 是登录前的目标地址——用户直接访问某个内页被拦截时，登录完要回到原地，而不是回首页。\n\n\n\n\n\n\n\n菜单与路由：一棵树，两个产物\n\n\n\n这是我在这套后台里最坚持的一点：菜单、路由、面包屑，必须来自同一份数据。\n\nrouter/menus.ts 里只维护一棵 menuTree，每个节点带 path、component（字符串 key）、meta.titleKey、meta.icon、meta.roles、meta.keepAlive、meta.affix：\n\nexport const menuTree: MenuNode[] = [\n  { name: \"dashboard\", path: \"/dashboard\", component: \"dashboard\",\n    meta: { titleKey: \"menu.dashboard\", icon: \"home\", keepAlive: true, affix: true } },\n  {\n    name: \"system\", path: \"/system\", meta: { titleKey: \"menu.system\", icon: \"settings\" },\n    children: [\n      { name: \"system-users\", path: \"/system/users\", component: \"users\",\n        meta: { titleKey: \"menu.users\", icon: \"user\", roles: [\"admin\", \"ops\", \"pm\"], keepAlive: true } },\n      { name: \"system-roles\", path: \"/system/roles\", component: \"roles\",\n        meta: { titleKey: \"menu.roles\", icon: \"lock\", roles: [\"admin\"], keepAlive: true } },\n      // ...\n    ],\n  },\n  // ...\n];\n\n然后一个函数同时产出两样东西：\n\nexport function buildMenuRoutes(roles: string[]) {\n  const menus = filterMenuTree(menuTree, roles);           // 侧边菜单（已按角色裁剪）\n  return { routes: buildRouteRecords(menus), menus };      // 动态子路由 + 菜单\n}\n\nbuildRouteRecords 把叶子节点转成 RouteRecordRaw，component 字符串通过 viewMap 懒加载：\n\nconst viewMap: Record\u003Cstring, () => Promise\u003CComponent>> = {\n  dashboard: () => import(\"@/views/DashboardView.vue\"),\n  users: () => import(\"@/views/UsersView.vue\"),\n  // ...\n};\n\n这样做的收益很具体：\n\n\n\n\n\n加一个页面只改一处：往 menuTree 里加节点 + 往 viewMap 里加一行 import，菜单、路由、面包屑三处自动同步；\n\n\n\n路由天然按角色生成：filterMenuTree 过滤掉没权限的节点，buildRouteRecords 只给剩下的建路由——没权限的页面根本没注册，不存在「页面在、只是藏起来了」这种自欺欺人的状态；\n\n\n\n分组自动保留：filterMenuTree 递归时，如果子项全被过滤光，父分组也不显示，不会出现空的「系统管理」。\n\n管理员登录后的仪表盘，是这套路由跑通之后的样子——侧边栏、面包屑、多标签、图表卡片全部来自同一份菜单数据：\n\n\n\n管理员登录后侧边栏是完整的两级结构：\n\n\n\n\n\n\n\n\n\n路由守卫：那个「登录后必然 404」的坑\n\n\n\n动态路由最容易翻车的地方在守卫。addRoute 之后当前导航还停在旧解析结果上，必须重新进入一次：\n\nrouter.beforeEach(async (to) => {\n  const userStore = useUserStore();\n  const permissionStore = usePermissionStore();\n\n  if (!userStore.isLoggedIn && to.name !== \"login\") {\n    return { path: \"/login\", query: { redirect: to.fullPath } };\n  }\n  if (to.name === \"login\" && userStore.isLoggedIn) {\n    return { path: \"/\" };\n  }\n  if (userStore.isLoggedIn && !permissionStore.routesGenerated) {\n    const { routes, menus } = buildMenuRoutes(userStore.roles);\n    routes.forEach((r) => router.addRoute(\"layout\", r));\n    permissionStore.setMenuRoutes(menus);\n    permissionStore.setGenerated();\n    // 关键：不能 spread to，否则 name 还是 catch-all 的 not-found，会再次 404\n    return { path: to.fullPath, replace: true };\n  }\n  // 已登录但路由未命中 → 区分「地址写错」和「没有权限」\n  if (userStore.isLoggedIn && to.name === \"not-found\" && permissionStore.knowsPath(to.path)) {\n    return { path: \"/403\", replace: true };\n  }\n  return true;\n});\n\n第一个坑写错了会很惨：return { ...to } 看起来更「保守」，但 to.name 此时已经被解析成 catch-all 路由的 not-found，重新进入后仍然命中 404。必须显式构造 { path: to.fullPath }，让 router 按路径重新匹配一次。\n\n刷新页面时，routesGenerated 是内存态，会重置为 false，守卫重建一遍动态路由——所以 F5 之后菜单不会消失。\n\n再往下是组件示例页面的能力展示，这里挑两个最容易被问「是不是引了第三方库」的：高级表格和高级表单。\n\n\n\n\n\n图表也是同一套思路——零第三方依赖，手写 SVG，颜色走 CSS 变量，所以换主题时图表跟着换：\n\n\n\n\n\n\n\n\n\n菜单管理：让「菜单即路由」可被看见\n\n\n\n既然菜单是路由的唯一数据源，那它就应该能在界面上被看见、被理解，而不是藏在代码里。\n\n\n\n这个页面存在的意义不是真的让人增删菜单（那需要后端配合），而是让「菜单 = 路由」这件事对团队可见。新人看到 /system/users → users → UsersView.vue 这条链，比读十页文档都快。\n\n\n\n\n\n\n\n角色与权限码：截图抓出来的第一个真 bug\n\n\n\n角色管理页是权限模型的可视化：\n\n\n\n编辑角色时可以给角色勾选具体权限码，左侧「未分配权限 0/13」，右侧「已分配权限 0/7」（截图里是运营角色的编辑态）：\n\n\n\n权限码集中定义在 enums/permission.ts，角色 → 权限码的映射也在同一处：\n\nexport const PERMISSION = {\n  DASHBOARD_EXPORT: \"dashboard:export\",\n  USER_CREATE: \"system:user:create\",\n  USER_UPDATE: \"system:user:update\",\n  USER_DELETE: \"system:user:delete\",\n  // ...\n} as const;\n\nexport const ROLE_PERMISSIONS: Record\u003Cstring, string[]> = {\n  admin: ALL_PERMISSIONS,\n  ops: [PERMISSION.DASHBOARD_EXPORT, PERMISSION.USER_CREATE, PERMISSION.USER_UPDATE, /* ... */],\n  dev: [PERMISSION.USER_UPDATE, PERMISSION.DICT_CREATE, /* ... */],\n  pm:  [PERMISSION.USER_CREATE, PERMISSION.USER_UPDATE, PERMISSION.ARTICLE_CREATE, /* ... */],\n  guest: [],\n};\n\n5.1 菜单对了，按钮却是错的\n\n给产品角色（pm）登录，侧边栏被正确裁剪了——「系统管理」下只剩「用户管理」，角色/菜单/部门/字典/日志全都不见了：\n\n\n\n但打开用户管理页，每一行都还有「删除」按钮——而产品角色并没有 system:user:delete 权限。下面这张是已发布版本 0.2.6 上的实际截图，行内三件套「查看 / 编辑 / 删除」齐全：\n\n\n\n原因很直白：UsersView.vue 里的操作按钮压根没接权限指令。\n\n\u003C!-- 修复前：谁都能看到 -->\n\u003CButton size=\"sm\" variant=\"ghost\" @click.stop=\"openEdit(asUser(row))\">{{ t(\"common.edit\") }}\u003C/Button>\n\u003CButton size=\"sm\" variant=\"ghost\" @click.stop=\"confirmDelete(asUser(row))\">{{ t(\"common.delete\") }}\u003C/Button>\n\n排查下来只有 RoleView、MenuView、DeptView、DictView、LogView、ArticleView 接了 v-permission，UsersView 漏了。而这类问题最典型的形态就是：菜单做得很细，按钮一个没管——因为菜单是「看得见」的，按钮是「没人特意去点的」。\n\n修复就是把权限码补上（包括新建、批量删除）：\n\n\u003CButton v-permission=\"PERMISSION.USER_CREATE\" @click=\"openCreate\">新建用户\u003C/Button>\n\n\u003CButton v-permission=\"PERMISSION.USER_DELETE\" size=\"sm\" variant=\"danger\" @click=\"confirmBatchDelete\">批量删除\u003C/Button>\n\n\u003Ctemplate #actions=\"{ row }\">\n  \u003CButton size=\"sm\" variant=\"ghost\" @click.stop=\"viewUser(asUser(row))\">查看\u003C/Button>\n  \u003CButton v-permission=\"PERMISSION.USER_UPDATE\" size=\"sm\" variant=\"ghost\" @click.stop=\"openEdit(asUser(row))\">编辑\u003C/Button>\n  \u003CButton v-permission=\"PERMISSION.USER_DELETE\" size=\"sm\" variant=\"ghost\" @click.stop=\"confirmDelete(asUser(row))\">删除\u003C/Button>\n\u003C/template>\n\n指令本身很短，没权限就把元素从 DOM 里摘掉（而不是 display:none，避免还能被脚本点到）：\n\nfunction apply(el: HTMLElement, binding: DirectiveBinding\u003CPermissionValue>) {\n  const codes = Array.isArray(binding.value) ? binding.value : [binding.value!];\n  const allowed = codes.some((c) => usePermissionStore().hasPermission(c));\n  if (!allowed && el.parentNode) el.parentNode.removeChild(el);\n}\n\n修复后同一个页面、同一个产品角色，行内只剩「查看 / 编辑」，「删除」消失：\n\n\n\n管理员视图不受影响，仍然三件套齐全——这也是验证「裁剪没过度」的反向证据。\n\n\n\n一句话原则：菜单过滤解决「看不见」，权限指令解决「点不到」。只做前者，等于把门锁装在墙上却没装门。\n\n\n\n\n\n\n\n越权访问该给 403，不是 404\n\n\n\n第二个坑更隐蔽。因为无权限的页面根本没注册路由，用户手输或从收藏夹点进 /system/roles 时，匹配到的是 catch-all，于是——落到 404。\n\n用户看到「页面不存在」，第一反应是「这功能是不是被删了 / 链接失效了」，而不是「我没有权限」。这在真实项目里会变成一堆无效工单。\n\n修复思路是：判断这个路径是不是「在全量菜单里存在」，存在但当前角色没有 → 403；根本不存在 → 404。\n\n// stores/permission.ts\nfunction collectPaths(nodes: MenuNode[], out = new Set\u003Cstring>()) {\n  for (const node of nodes) {\n    if (node.children) collectPaths(node.children, out);\n    else if (node.path) out.add(node.path);\n  }\n  return out;\n}\nconst ALL_MENU_PATHS = collectPaths(menuTree);   // 不按角色过滤的全量路径\n\nfunction knowsPath(path: string): boolean {\n  const normalized = path.replace(/\\/+$/, \"\") || \"/\";\n  return ALL_MENU_PATHS.has(normalized);\n}\n\n配合第 3 节守卫里的那四行，效果就分开了。产品角色访问 /system/roles：\n\n\n\n而真的不存在的地址仍然是 404：\n\n\n\n两个页面长得像，但语义完全不同——权限问题不该伪装成「页面没了」。\n\n\n\n\n\n\n\n多标签 + keep-alive：缓存名单就是标签本身\n\n\n\n后台用户最烦的两件事之一，是「切走再回来，表格筛选条件全没了」。用多标签 + keep-alive 解决，但实现上有个坑：keep-alive 的 include 名单不能手写，它必须等于「当前打开的、且声明了 keepAlive 的标签」。\n\n// stores/tabs.ts\nconst cachedViews = computed(() =>\n  tabs.value.filter((t) => t.keepAlive).map((t) => t.name),\n);\n\naddTab 时把 route.meta.keepAlive 带进来，布局里直接绑：\n\n\u003Ckeep-alive :include=\"tabsStore.cachedViews\">\n  \u003CRouterView />\n\u003C/keep-alive>\n\n标签的 affix 来自 meta.affix——仪表盘设为固定页，closeAll / closeOthers 都会保留它：\n\nfunction closeAll(): string | null {\n  tabs.value = tabs.value.filter((t) => t.affix);   // 固定页保留\n  const first = tabs.value[0];\n  activeName.value = first?.name ?? \"\";\n  return first?.path ?? null;                        // 返回应跳转的 path\n}\n\ncloseTab 返回「下一个应该去哪」，由布局统一 router.push——把跳转决定权留在 store 外面，避免 store 里出现 router 依赖。关闭当前标签时跳到原位置的下一个（Math.min(idx, length - 1)），关最后一个就跳到固定页，不会出现白屏。\n\n\n\n\n\n\n\n主题：一次属性切换，整套界面跟着换\n\n\n\n5 套主题（经典蓝 / 墨浅 / 墨深 / 曜浅 / 曜深）靠 \u003Chtml data-rn-theme=\"...\"> 一个属性切换，组件里没有任何 if (dark) 分支：\n\nfunction setTheme(name: string) {\n  setRnTheme(name);                          // 内部只做 documentElement.dataset.rnTheme = name\n  localStorage.setItem(\"ruanno-admin-theme\", name);\n}\n\n之所以能这么干净，是因为所有颜色都来自 tokens 的 --rn-* 变量，主题文件只写与默认值的差异组。暗色下图表、标签、骨架屏、水印全部自动适配：\n\n\n\n系统设置页把主题、布局、外观放在一起，改完即时生效并持久化：\n\n\n\n顺带说一句水印：它在布局里包住整个内容区，文字取当前登录用户名，作为内部系统的防截图手段，成本几乎为零。\n\n\n\n\n\n\n\n把权限模型画成一张表\n\n\n\n到这里五件事都落地了，汇总成一张表更清楚：\n\n\n\n\n\n\n\n层级\n\n\n\n数据来源\n\n\n\n拦截点\n\n\n\n失败表现\n\n\n\n\n\n未登录\n\n\n\nuserStore.isLoggedIn\n\n\n\n全局守卫\n\n\n\n跳登录页并记录 redirect\n\n\n\n\n\n菜单可见性\n\n\n\nfilterMenuTree(menuTree, roles)\n\n\n\n渲染侧边栏\n\n\n\n菜单项不出现\n\n\n\n\n\n路由可达性\n\n\n\nbuildRouteRecords() 只注册有权限的\n\n\n\nrouter.addRoute\n\n\n\n走 403（路径存在）/ 404（不存在）\n\n\n\n\n\n按钮可用性\n\n\n\nv-permission=\"PERMISSION.xxx\"\n\n\n\n指令 mounted/updated\n\n\n\n元素被移出 DOM\n\n\n\n\n\n接口鉴权\n\n\n\n后端\n\n\n\n服务端\n\n\n\n前端不做这层，也不该假装做了\n\n最后一行必须强调：前端权限是体验，不是安全。v-permission 把按钮藏了，用户照样可以用 devtools 手工发请求。真正的鉴权必须在服务端按权限码校验，前端这套只是「别让用户点了才发现不行」。\n\n\n\n\n\n\n\n复盘：这次暴露的到底是什么问题\n\n\n\n两个 bug 有一个共同形状：「声明层」做对了，「执行层」漏了一环。\n\n\n\n\n\n权限码定义得很齐（20 个码、5 个角色）→ 但 UsersView 没消费它；\n\n\n\n403 页面早就写好了 → 但没有任何路径能把用户导过去。\n\n这类缺陷靠读代码很难发现，因为每一部分单独看都是对的；靠写文档也发现不了，因为文档描述的是「应该怎样」。它是被真实截图逼出来的：产品角色登录 → 点进用户管理 → 截图，才看到那个不该存在的「删除」按钮；手工输入 /system/roles → 截图，才看到本该 403 的地方写着 404。\n\n所以我把「用真实浏览器把每个角色都走一遍、每一步都截图并断言 DOM」变成了这个项目的固定环节。截图不只是给文章用的插图，它是验收证据——一张能看到「删除」按钮的截图，比十行「已实现按钮级权限」的说明有力得多。\n\n如果你也在搭后台，建议按这个顺序验收：换角色登录看菜单 → 手输无权限地址看 403 → 逐行检查按钮 → 关标签看缓存 → 切主题看有没有白底。五步走完，权限这块基本就真的做完了。\n\n\n\n想直接体验这套后台（切角色、切主题、开标签）：\n\n\n\n\n\n在线演示：ui.ruanno.top/apps/admin/\n\n\n\n文档站：ui.ruanno.top\n\n\n\n组件库源码：gitee.com/ruanno-top/…\n\n演示环境账号：admin / 123456（超级管理员）、pm / 123456（产品，用来验证按钮裁剪）。","url":"https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2C3otbig7imgeHj0","author":{"@type":"Person","name":"ruanno_top","url":"https://www.angryecho.com/u/F2Oruanno_top"},"image":["https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/912b1b67a8544c7a98c200225adb7406~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=OgcW0r4VVBKpXXYvV9yTVrvuHTY%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/38b36697d75f415fa81b635a7406cb32~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=UJjvWp2pFI%2Fvsl94r8QY%2FNhu1V8%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/59f0814eb08541cea0285210fb0cebc8~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=AD%2BOPhOa%2FmZXfJHVqEUtj25xqYg%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/077ccc0b18f543b4bcee3d9b3001b106~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=%2BZdo%2FcltfeO2djQsFiK2Ot5mcII%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/153ca82e84074deb882d7c666bc77d86~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=xJozy6t2IE%2Bp0c1s1idXE3xiODc%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/95b22372595c4cd38a843f8925be2ace~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=veelIxmrNFj%2BHUEFJ%2FBongNhzIw%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c8978cd4660145c2ab753d20adcaa479~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=QUEJNOnohizRaeqJ%2Fy33o2KRmsk%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/abee3da06fb942f8af3e27358dc1b981~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=WNbuRhlMG9CYbuaaRJZ1y%2Fq4pOw%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ecd662e4ae7b4bb18ac5e1ba6405d1ac~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=FMAErIxC05HEEDMxMj3hljSz4Z4%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/294c194870244bd2a10aceed920d40b0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=nQueJKEAYRjzhuJEy1o9eqfzvPg%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/a6f81a2610ad4c038cdbda74cfe2353d~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Son4cIsWsva1pTVVMtZ9WmtHhRE%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/5452b15fa3144963bd7db338d7cbf047~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=JQI%2BqsDFygAk%2B%2BHC9N9dydJVqJk%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c4a630fe45114285ad85f419e1c4d065~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Y0VK4JNISBU2iCzM5eP8oXmGVIo%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/edb122d6ba184f8aa81d695528208c86~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Ot%2FWAI87AFfOrZ0SLWLQAnni17g%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9e04825ab5814680b076aaf24d5964c0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=jPgzpDxZzlXs1R0vsQeOVUwWAEo%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/96e2fe4cbcc449789bab6a19692f725b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=Z2ERrLlrC2u2eZo%2BATRHuLOlNxE%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9094f9440f884d31a3d88378cc7833b4~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=TSvt5K6vgbGOzrcA%2BeHQyt0gUnE%3D","https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/64936204d7734a518eaf453e3f24790e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcnVhbm5vX3RvcA==:q75.awebp?rk3s=f64ab15b&x-expires=1794199037&x-signature=7mMKzn4CP6l%2BEh6DeZrcFxryq4Y%3D"],"interactionStatistic":[{"@type":"InteractionCounter","interactionType":"https://schema.org/LikeAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/DislikeAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/ViewAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/ShareAction","userInteractionCount":0}]}]}
```

## Sitemap

See the full [sitemap](https://www.angryecho.com/sitemap.md) for all pages.
