feat(auth,admin): 修复角色权限菜单显示问题

1.  后端添加获取当前用户权限接口
2.  前端增加权限校验和动态菜单渲染
3.  优化表单验证错误处理
4.  完善gitignore和项目配置更新
This commit is contained in:
jayhgq 2026-05-27 11:42:23 +08:00
parent 6620aec5a2
commit c4aac6979a
13 changed files with 274 additions and 33 deletions

16
.gitignore vendored
View File

@ -1,9 +1,13 @@
.trae/documents/add_traefik_config_plan.md
redis/data/dump.rdb
redis/logs/redis-server.log
backend/auth/__pycache__/*
backend/auth/apps/__pycache__/*
backend/auth/apps/permissions/__pycache__/*
backend/auth/apps/roles/__pycache__/*
backend/auth/utils/__pycache__/*
postgres_data/*
backend/auth/__pycache__/
backend/auth/apps/__pycache__/
backend/auth/apps/permissions/__pycache__/
backend/auth/apps/roles/__pycache__/
backend/auth/utils/__pycache__/
postgres_data/
frontend/node_modules/
frontend/dist/
*.pyc

View File

@ -0,0 +1,44 @@
# 角色权限菜单显示问题修复计划
## 问题分析
用户反馈:创建了 `jayhgq` 用户,分配了"大会员"角色(该角色只能看到用户管理),但登录后仍然能看到角色和权限管理菜单。
**根本原因**:前端 `AdminLayout.vue` 中的菜单项是硬编码的,没有根据用户权限动态过滤。
## 修复方案
### 步骤 1后端添加获取当前用户权限接口
`/permissions` 路由中添加一个新接口,用于获取当前登录用户的权限列表。
**文件**`backend/auth/apps/permissions/urls.py`
### 步骤 2前端认证 Store 添加权限获取方法
`auth.ts` 中添加获取用户权限的方法,并存储权限列表。
**文件**`frontend/src/modules/admin/stores/auth.ts`
### 步骤 3修改前端布局组件动态渲染菜单
修改 `AdminLayout.vue`,根据用户权限动态显示菜单项。
**文件**`frontend/src/modules/admin/layout/AdminLayout.vue`
### 步骤 4路由权限控制可选增强
在路由守卫中添加权限验证,防止无权限用户直接访问受保护的路由。
**文件**`frontend/src/router/index.ts`
## 预期效果
- 用户登录后,系统会根据其角色权限动态显示菜单项
- "大会员"角色只能看到"用户管理"菜单
- "管理员"角色可以看到所有菜单
## 风险评估
- 低风险:修改范围明确,不影响核心业务逻辑
- 需要确保权限编码与菜单的对应关系正确

View File

@ -220,3 +220,28 @@ async def get_role_permissions(
role_name=role.name,
permissions=[PermissionResponse.from_orm(p) for p in role.permissions]
)
@router.get("/user/me", response_model=RolePermissionResponse)
async def get_current_user_permissions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取当前登录用户的权限列表"""
# 获取用户角色的权限
result = await db.execute(
select(Role)
.options(selectinload(Role.permissions))
.filter(Role.id == current_user.role_id)
)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 返回用户权限信息
return RolePermissionResponse(
role_id=role.id,
role_name=role.name,
permissions=[PermissionResponse.from_orm(p) for p in role.permissions]
)

View File

@ -92,7 +92,11 @@ async def login(
@router.post("/", response_model=UserResponse)
async def create_user(user_data: UserCreateRequest, db: AsyncSession = Depends(get_db)):
async def create_user(
user_data: UserCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建用户"""
# 检查用户名是否已存在
result = await db.execute(select(User).filter(User.username == user_data.username))

View File

@ -1,13 +1,16 @@
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.exceptions import RequestValidationError
from contextlib import asynccontextmanager
from apps.urls import api_router
from database import engine, get_db
from models import Base
from init_data import init_all_data
from pathlib import Path
from starlette.requests import Request
from starlette.responses import JSONResponse
@asynccontextmanager
@ -122,6 +125,33 @@ async def home():
return {"message": "Hello World!!!"}
# 自定义验证错误处理器
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for err in exc.errors():
field = err.get("loc", ["unknown"])[-1]
msg = err.get("msg", "验证失败")
if "min_length" in msg.lower():
if field == "username":
msg = "用户名长度至少为3个字符"
elif field == "password":
msg = "密码长度至少为6个字符"
elif "max_length" in msg.lower():
if field == "username":
msg = "用户名长度不能超过50个字符"
elif field == "password":
msg = "密码长度不能超过50个字符"
errors.append({"field": field, "message": msg})
return JSONResponse(
status_code=422,
content={"code": 422, "message": "验证失败", "errors": errors}
)
# 如果使用命令行启动使用uvicorn 文件名:app --reload启动即可下面命令就不用写
# 如果写下面的命令就不需要命令行启动了直接用IDE运行即可
if __name__ == "__main__":

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
@ -6,19 +6,43 @@ from datetime import datetime
class UserBase(BaseModel):
"""用户基础模型"""
username: str = Field(..., min_length=3, max_length=50, description="登录名")
username: str = Field(
...,
min_length=3,
max_length=50,
description="登录名",
json_schema_extra={
"error_messages": {
"min_length": "用户名长度至少为3个字符",
"max_length": "用户名长度不能超过50个字符"
}
}
)
nickname: str = Field(..., max_length=50, description="昵称")
email: Optional[EmailStr] = Field(None, max_length=50, description="电子邮箱")
email: Optional[str] = Field(None, max_length=50, description="电子邮箱")
phone: Optional[str] = Field(None, max_length=11, description="手机号码")
wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID")
avatar: Optional[str] = Field(None, max_length=255, description="头像路径")
role_id: int = Field(2, description="角色ID")
role_id: Optional[int] = Field(2, description="角色ID")
model_config = {"extra": "ignore"}
class UserCreateRequest(UserBase):
"""用户创建请求模型"""
password: str = Field(..., min_length=6, max_length=50, description="密码")
password: str = Field(
...,
min_length=6,
max_length=50,
description="密码",
json_schema_extra={
"error_messages": {
"min_length": "密码长度至少为6个字符",
"max_length": "密码长度不能超过50个字符"
}
}
)
class UserCreate(UserBase):
@ -34,7 +58,7 @@ class UserUpdate(BaseModel):
username: str = Field(..., min_length=3, max_length=50, description="登录名")
nickname: str = Field(..., max_length=50, description="昵称")
email: Optional[EmailStr] = Field(None, max_length=50, description="电子邮箱")
email: Optional[str] = Field(None, max_length=50, description="电子邮箱")
phone: Optional[str] = Field(None, max_length=11, description="手机号码")
wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID")
avatar: Optional[str] = Field(None, max_length=255, description="头像路径")
@ -57,7 +81,7 @@ class UserResponse(BaseModel):
id: int = Field(..., description="用户ID")
username: str = Field(..., description="登录名")
nickname: str = Field(..., description="昵称")
email: Optional[EmailStr] = Field(None, description="电子邮箱")
email: Optional[str] = Field(None, description="电子邮箱")
phone: Optional[str] = Field(None, description="手机号码")
wx_openid: Optional[str] = Field(None, description="微信OpenID")
avatar: Optional[str] = Field(None, description="头像路径")
@ -81,7 +105,7 @@ class TokenData(BaseModel):
"""令牌数据模型"""
user_id: Optional[int] = None
email: Optional[EmailStr] = None
email: Optional[str] = None
class RoleBase(BaseModel):

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BookSystem</title>
<script type="module" crossorigin src="/assets/index-BtuGmbJn.js"></script>
<script type="module" crossorigin src="/assets/index-DXkIJgFR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cn04Rt7y.css">
</head>
<body>

View File

@ -9,15 +9,24 @@
text-color="#e2e8f0"
active-text-color="#38bdf8"
>
<el-menu-item index="/admin/users" :route="{ name: 'admin-users' }">
<el-menu-item
v-if="hasUserPermission"
index="/admin/users"
:route="{ name: 'admin-users' }"
>
<el-icon><User /></el-icon>
<span>用户</span>
</el-menu-item>
<el-menu-item index="/admin/roles" :route="{ name: 'admin-roles' }">
<el-menu-item
v-if="hasRolePermission"
index="/admin/roles"
:route="{ name: 'admin-roles' }"
>
<el-icon><UserFilled /></el-icon>
<span>角色</span>
</el-menu-item>
<el-menu-item
v-if="hasPermissionPermission"
index="/admin/permissions"
:route="{ name: 'admin-permissions' }"
>
@ -59,11 +68,24 @@ const auth = useAuthStore();
const active = computed(() => route.path);
const pageTitle = computed(() => (route.meta.title as string) || "");
const hasUserPermission = computed(() => {
return auth.permissions.some(p => p.code.startsWith("perm:user"));
});
const hasRolePermission = computed(() => {
return auth.permissions.some(p => p.code.startsWith("perm:role"));
});
const hasPermissionPermission = computed(() => {
return auth.permissions.some(p => p.code.startsWith("perm:config"));
});
onMounted(async () => {
try {
if (!auth.user) {
await auth.fetchProfile();
}
await auth.fetchPermissions();
} catch {
ElMessage.error("获取用户信息失败,请重新登录");
await auth.logout();

View File

@ -11,12 +11,29 @@ export type UserProfile = {
role_id: number;
};
export type Permission = {
id: number;
name: string;
code: string;
parentid: number;
description?: string | null;
};
export type RolePermissionResponse = {
role_id: number;
role_name: string;
permissions: Permission[];
};
export const useAuthStore = defineStore("auth", () => {
const token = ref<string>(localStorage.getItem("access_token") || "");
const user = ref<UserProfile | null>(null);
const permissions = ref<Permission[]>([]);
const isAuthenticated = computed(() => Boolean(token.value));
const permissionCodes = computed(() => permissions.value.map(p => p.code));
function setToken(t: string) {
token.value = t;
if (t) {
@ -32,6 +49,25 @@ export const useAuthStore = defineStore("auth", () => {
return data;
}
async function fetchPermissions() {
const { data } = await http.get<RolePermissionResponse>("/permissions/user/me");
permissions.value = data.permissions || [];
return permissions.value;
}
async function loginAndFetchData() {
await fetchProfile();
await fetchPermissions();
}
function hasPermission(code: string): boolean {
return permissionCodes.value.includes(code);
}
function hasAnyPermission(codes: string[]): boolean {
return codes.some(code => permissionCodes.value.includes(code));
}
async function logout() {
try {
await http.post("/users/logout");
@ -39,15 +75,22 @@ export const useAuthStore = defineStore("auth", () => {
/* ignore */
}
user.value = null;
permissions.value = [];
setToken("");
}
return {
token,
user,
permissions,
permissionCodes,
isAuthenticated,
setToken,
fetchProfile,
fetchPermissions,
loginAndFetchData,
hasPermission,
hasAnyPermission,
logout,
};
});

View File

@ -121,6 +121,7 @@ async function onSubmit() {
);
auth.setToken(data.access_token);
await auth.fetchProfile();
await auth.fetchPermissions();
const redirect = (route.query.redirect as string) || "/admin";
await router.replace(redirect);
} catch (e: unknown) {

View File

@ -50,10 +50,16 @@ export const createUserCrudOptions: CreateCrudOptions<UserRow> = () => {
records,
};
},
onError: (error: { response?: { data?: { detail?: string } }; message?: string }) => {
onError: (error: { response?: { data?: { detail?: string; message?: string; errors?: Array<{ field: string; message: string }> } }; message?: string }) => {
let message = "操作失败";
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
if (error.response?.data) {
const data = error.response.data;
if (data.errors && Array.isArray(data.errors)) {
message = data.errors.map(e => e.message).join("");
} else if (data.message) {
message = data.message;
} else if (data.detail) {
const detail = data.detail;
if (typeof detail === "string") {
if (detail.includes("at least 6 characters")) {
message = "密码长度至少为6个字符";
@ -61,6 +67,7 @@ export const createUserCrudOptions: CreateCrudOptions<UserRow> = () => {
message = detail;
}
}
}
} else if (error.message) {
message = error.message;
}
@ -101,7 +108,10 @@ export const createUserCrudOptions: CreateCrudOptions<UserRow> = () => {
type: "text",
search: { show: true },
form: {
rules: [{ required: true, message: "必填" }],
rules: [
{ required: true, message: "请输入登录名", trigger: "blur" },
{ min: 3, max: 50, message: "用户名长度必须在3-50个字符之间", trigger: "blur" }
],
},
},
nickname: {
@ -121,11 +131,17 @@ export const createUserCrudOptions: CreateCrudOptions<UserRow> = () => {
form: { show: false },
addForm: {
show: true,
rules: [{ required: true, message: "新建用户需设置密码" }],
rules: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 6, max: 50, message: "密码长度必须在6-50个字符之间", trigger: "blur" }
],
},
editForm: {
show: true,
component: { placeholder: "不修改请留空" },
rules: [
{ min: 6, max: 50, message: "密码长度必须在6-50个字符之间", trigger: "blur" }
],
},
},
email: {

View File

@ -28,19 +28,20 @@ const router = createRouter({
{
path: "users",
name: "admin-users",
meta: { title: "用户管理" },
meta: { title: "用户管理", permission: "perm:user" },
component: () => import("@/modules/admin/views/users/UserManage.vue"),
},
{
path: "roles",
name: "admin-roles",
meta: { title: "角色管理" },
meta: { title: "角色管理", permission: "perm:role" },
component: () => import("@/modules/admin/views/roles/RoleManage.vue"),
},
{
path: "permissions",
name: "admin-permissions",
meta: { title: "权限管理" },
meta: { title: "权限管理", permission: "perm:config" },
component: () =>
import("@/modules/admin/views/permissions/PermissionManage.vue"),
},
@ -49,7 +50,7 @@ const router = createRouter({
],
});
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore();
const title = (to.meta.title as string) || "BookSystem";
document.title = `${title} · BookSystem`;
@ -63,10 +64,31 @@ router.beforeEach((to, _from, next) => {
}
if (to.meta.guestOnly && auth.isAuthenticated) {
if (!auth.user || auth.permissions.length === 0) {
try {
await auth.fetchProfile();
await auth.fetchPermissions();
} catch {
auth.logout();
next({ name: "admin-login" });
return;
}
}
next({ name: "admin-users" });
return;
}
const requiredPermission = to.meta.permission as string;
if (requiredPermission && auth.isAuthenticated && auth.permissions.length > 0) {
const hasPermission = auth.permissions.some(p =>
p.code.startsWith(requiredPermission)
);
if (!hasPermission) {
next({ name: "admin-users" });
return;
}
}
next();
});

View File

@ -1,5 +1,11 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
}