Vue 3 后台权限路由:我漏掉了按钮级裁剪,还把 403 写成了 404

13 阅读12分钟

这是「ruanno-ui 实战」系列的第二篇。上一篇讲了我怎么让 AI 维护一个 97 组件的 Vue 3 组件库;这一篇换个角度,把组件库真正拼成一个能交付的后台:动态权限路由 + 按钮级权限裁剪 + 多标签 + 主题系统,并且把过程中被截图暴露出来的两个真实缺陷一起交代清楚。

全文所有截图都来自跑起来的真实应用(1440 × 900,登录态走的真实 store 与路由守卫),不是设计稿。


0. 先说结论:做了 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 套设计切换器(当前高亮「极光」)

蓝图(Blueprint)风格的登录页:网格蓝图底纹,同一套表单逻辑,完全不同的版式

墨印(Ink)风格的登录页:深色纸墨质感,仍然是同一份 useLogin()

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 递归时,如果子项全被过滤光,父分组也不显示,不会出现空的「系统管理」。

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

管理员登录后的仪表盘:四个统计卡(总用户/今日订单/今日营收/转化率)、营收趋势折线、项目进度、订单柱状图、渠道环形图与最近动态

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

管理员视角的侧边栏:「系统管理」展开为 用户管理/角色管理/菜单管理/部门管理/字典管理/操作日志,「组件示例」同时展开


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 一个组件承担

高级表单:三步分步表单(基本信息 / 成员信息 / 确认提交),支持动态增删表单项

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

组件示例里的图表页:柱状图、堆叠柱状、折线、环形、散点、热力图、关系图,全部随主题换色


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

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

菜单管理页:每行显示菜单名称、类型(目录/菜单)、路由路径(如 /system/users)、排序与状态,副标题直接写着「菜单为运行期路由的数据源」

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


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

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

角色管理:5 个角色,权限列直接显示权限码数量——超级管理员 20、运营 7、开发 5、产品 5、访客 0

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

编辑运营角色:权限码按业务分组展示(新增/编辑/删除用户、角色、菜单、部门、字典、文章……),可点选分配

权限码集中定义在 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)的侧边栏:「系统管理」下只剩「用户管理」,其余管理项已被过滤

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

产品角色进入用户管理:表格每行仍然渲染了「删除」按钮(0.2.6 已发布版本的真实截图)

原因很直白: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 移除

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

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


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「抱歉,你没有权限访问该页面」

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

访问不存在的地址 /not-exist-page:仍然是 404「你访问的页面不存在」

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


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-* 变量,主题文件只写与默认值的差异组。暗色下图表、标签、骨架屏、水印全部自动适配:

曜 · 深主题下的仪表盘:整页深色,图表、卡片边框、状态色同步切换,没有一块是「忘了换色」的白底

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

系统设置:主题方案、主题色、布局模式(侧边导航/顶部导航)、侧边栏宽度、多标签页、面包屑、固定顶栏、界面圆角、灰色模式、紧凑模式、水印与页脚

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


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 → 逐行检查按钮 → 关标签看缓存 → 切主题看有没有白底。五步走完,权限这块基本就真的做完了。


想直接体验这套后台(切角色、切主题、开标签):

演示环境账号:admin / 123456(超级管理员)、pm / 123456(产品,用来验证按钮裁剪)。