这是「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(),没有任何一套自己实现提交逻辑。
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"),
// ...
};
这样做的收益很具体:
- 加一个页面只改一处:往
menuTree里加节点 + 往viewMap里加一行 import,菜单、路由、面包屑三处自动同步; - 路由天然按角色生成:
filterMenuTree过滤掉没权限的节点,buildRouteRecords只给剩下的建路由——没权限的页面根本没注册,不存在「页面在、只是藏起来了」这种自欺欺人的状态; - 分组自动保留:
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 之后菜单不会消失。
再往下是组件示例页面的能力展示,这里挑两个最容易被问「是不是引了第三方库」的:高级表格和高级表单。
图表也是同一套思路——零第三方依赖,手写 SVG,颜色走 CSS 变量,所以换主题时图表跟着换:
4. 菜单管理:让「菜单即路由」可被看见
既然菜单是路由的唯一数据源,那它就应该能在界面上被看见、被理解,而不是藏在代码里。
这个页面存在的意义不是真的让人增删菜单(那需要后端配合),而是让「菜单 = 路由」这件事对团队可见。新人看到 /system/users → users → UsersView.vue 这条链,比读十页文档都快。
5. 角色与权限码:截图抓出来的第一个真 bug
角色管理页是权限模型的可视化:
编辑角色时可以给角色勾选具体权限码,左侧「未分配权限 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)登录,侧边栏被正确裁剪了——「系统管理」下只剩「用户管理」,角色/菜单/部门/字典/日志全都不见了:
但打开用户管理页,每一行都还有「删除」按钮——而产品角色并没有 system:user:delete 权限。下面这张是已发布版本 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);
}
修复后同一个页面、同一个产品角色,行内只剩「查看 / 编辑」,「删除」消失:
管理员视图不受影响,仍然三件套齐全——这也是验证「裁剪没过度」的反向证据。
一句话原则:菜单过滤解决「看不见」,权限指令解决「点不到」。只做前者,等于把门锁装在墙上却没装门。
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:
而真的不存在的地址仍然是 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 → 逐行检查按钮 → 关标签看缓存 → 切主题看有没有白底。五步走完,权限这块基本就真的做完了。
想直接体验这套后台(切角色、切主题、开标签):
- 在线演示:ui.ruanno.top/apps/admin/
- 文档站:ui.ruanno.top
- 组件库源码:gitee.com/ruanno-top/…
演示环境账号:admin / 123456(超级管理员)、pm / 123456(产品,用来验证按钮裁剪)。