feat(access): 实现基于用户角色的路由权限控制

添加权限检查功能,包括用户角色定义、路由元信息扩展和权限验证逻辑
重构路由配置和用户存储,支持动态菜单过滤
更新构建配置以支持类型声明生成
This commit is contained in:
2025-11-15 20:11:05 +08:00
parent a98eae385f
commit 8aa1f313af
10 changed files with 144 additions and 24 deletions

View File

@@ -9,7 +9,9 @@
"build": "vue-tsc -b && vite build --mode dev", "build": "vue-tsc -b && vite build --mode dev",
"build:prod": "vue-tsc -b && vite build --mode prod", "build:prod": "vue-tsc -b && vite build --mode prod",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext .ts,.vue" "lint": "eslint . --ext .ts,.vue",
"type-check": "vue-tsc --noEmit",
"build:types": "vue-tsc --declaration --emitDeclarationOnly"
}, },
"dependencies": { "dependencies": {
"@arco-design/web-vue": "^2.57.0", "@arco-design/web-vue": "^2.57.0",

37
src/access/checkAccess.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { LoginUesr } from "../store/types";
import ACCESS_ENUM from "./accessEnum";
/**
* 检查当前用户是否拥有权限
* @param loginUser 登录的用户信息
* @param needAccess 需要的权限
* @returns {boolean}
*/
const checkAccess = (
loginUser: LoginUesr,
needAccess = ACCESS_ENUM.NOT_LOGIN
): boolean => {
// 获取当前登录用户具有的权限
const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN;
// 对比权限是否足够
// 如果需要的权限是随便是个人都能访问
if (needAccess === ACCESS_ENUM.NOT_LOGIN) {
return true;
}
// 如果用户登录才能访问
if (needAccess === ACCESS_ENUM.USER) {
if (loginUserAccess !== ACCESS_ENUM.NOT_LOGIN) {
return false;
}
}
// 如果需要管理员权限
if (needAccess === ACCESS_ENUM.ADMIN) {
// 如果不为管理员
if (loginUserAccess !== ACCESS_ENUM.ADMIN) {
return false;
}
}
// 如果说啥都不符合,那还说啥了,直接拒了
return false;
};
export default checkAccess;

6
src/access/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import router from "../router/router";
import { useUserStore } from "../store/user";
import ACCESS_ENUM from "./accessEnum";
import checkAccess from "./checkAccess";

View File

@@ -1,9 +1,16 @@
<template> <template>
<a-row id="globalHeader" align="center" :wrap="false"> <a-row id="globalHeader" align="center" :wrap="false">
<a-col flex="auto"> <a-col flex="auto">
<a-menu mode="horizontal" :selected-keys="selectedKeys" @menu-item-click="doMenuItemClick"> <a-menu
<a-menu-item key="0" :style="{ padding: 0, marginRight: '38px' }" disabled> mode="horizontal"
:selected-keys="selectedKeys"
@menu-item-click="doMenuItemClick"
>
<a-menu-item
key="0"
:style="{ padding: 0, marginRight: '38px' }"
disabled
>
<div class="title-bar"> <div class="title-bar">
<img class="logo" src="@/assets/logo.webp" /> <img class="logo" src="@/assets/logo.webp" />
<div class="logo-title">AI OJ</div> <div class="logo-title">AI OJ</div>
@@ -18,28 +25,39 @@
<div>MeowRain</div> <div>MeowRain</div>
</a-col> </a-col>
</a-row> </a-row>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { routes } from '../router/index.ts'; import { routes } from "../router/index.ts";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import checkAccess from "../access/checkAccess.ts";
import { useUserStore } from "../store/user.ts";
const router = useRouter(); const router = useRouter();
const userStore = useUserStore();
// 默认主页 // 默认主页
const selectedKeys = ref(["/"]); const selectedKeys = ref(["/"]);
const doMenuItemClick = (key: string) => { const doMenuItemClick = (key: string) => {
// console.info("触发菜单跳转,当前路径: ", key) // console.info("触发菜单跳转,当前路径: ", key)
router.push({ router.push({
path: key path: key,
}) });
} };
// 展示可见的路由
const visibleRoutes = computed(() => { const visibleRoutes = computed(() => {
return routes.filter((item, index) => { return routes.filter((item, index) => {
return true; if (item?.meta?.hideInMenu) {
}) return false;
}) }
// 根据权限过滤菜单
if (!checkAccess(userStore.loginUser, item?.meta?.access)) {
return false;
} else {
return true;
}
});
});
/** /**
* router.afterEach 是 Vue Router 的全局后置钩子global after hook会在每次路由导航完成后触发。它的典型用途是 * router.afterEach 是 Vue Router 的全局后置钩子global after hook会在每次路由导航完成后触发。它的典型用途是
* *
@@ -52,12 +70,13 @@ const visibleRoutes = computed(() => {
* 做一些不影响导航结果的副作用(因为 afterEach 无法取消导航) * 做一些不影响导航结果的副作用(因为 afterEach 无法取消导航)
*/ */
router.afterEach((to, from, failure) => { router.afterEach((to, from, failure) => {
console.log('导航已完成:', from.fullPath, '->', to.fullPath) console.log("导航已完成:", from.fullPath, "->", to.fullPath);
selectedKeys.value = [to.path] selectedKeys.value = [to.path];
}) });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
#globalHeader {} #globalHeader {
}
.title-bar { .title-bar {
display: flex; display: flex;

View File

@@ -1,8 +1,21 @@
import type {RouteRecordRaw} from "vue-router"; import type { RouteRecordRaw } from "vue-router";
import HomeView from "../views/HomeView.vue"; import HomeView from "../views/HomeView.vue";
import AboutView from "../views/AboutView.vue"; import AboutView from "../views/AboutView.vue";
import ACCESS_ENUM from "../access/accessEnum";
/**
* 路由配置
*/
export const routes: Array<RouteRecordRaw> = [ export const routes: Array<RouteRecordRaw> = [
{path: "/home", name: "HomeView", component: HomeView}, {
{path: "/about", name: "AboutView", component: AboutView}, path: "/home",
name: "HomeView",
component: HomeView,
meta: { hideInMenu: false, access: ACCESS_ENUM.NOT_LOGIN },
},
{
path: "/about",
name: "AboutView",
component: AboutView,
meta: { hideInMenu: false, access: ACCESS_ENUM.NOT_LOGIN },
},
]; ];

View File

5
src/store/types.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export interface LoginUesr {
userName: string;
userRole?: string;
}

View File

@@ -1,11 +1,34 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import ACCESS_ENUM from "../access/accessEnum"; import ACCESS_ENUM from "../access/accessEnum";
import type { LoginUesr } from "../store/types";
/**
*
*/
export const useUserStore = defineStore("user", { export const useUserStore = defineStore("user", {
state: () => ({ state: () => ({
loginUser: { loginUser: {
userName: "未登录", userName: "未登录",
userRole: ACCESS_ENUM.NOT_LOGIN, userRole: ACCESS_ENUM.NOT_LOGIN,
}, } as LoginUesr,
}), }),
actions: {}, actions: {
// 获取登录用户
async getLoginUser() {
try {
// 从后端获取当前登录用户信息
}catch(e) {
console.error("获取登录用户失败", e);
// 网络错误情况也视为未登录
this.loginUser = {
...this.loginUser,
userRole: ACCESS_ENUM.NOT_LOGIN,
};
}
},
// 手动更新用户状态
updateUserLoginStatus(user: LoginUesr) {
this.loginUser = user;
}
},
}); });

12
src/types/router.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// src/types/router.d.ts
import 'vue-router'; // 让 TypeScript 知道 vue-router 模块的类型定义
/**
* 路由元信息
*/
declare module 'vue-router' {
interface RouteMeta {
hideInMenu?: boolean;
access?: string;
}
}

View File

@@ -1,6 +1,9 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": { "compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false,
"declarationDir": "./dist/types",
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": [ "types": [
"vite/client" "vite/client"