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 .trae/documents/add_traefik_config_plan.md
redis/data/dump.rdb redis/data/dump.rdb
redis/logs/redis-server.log redis/logs/redis-server.log
backend/auth/__pycache__/* backend/auth/__pycache__/
backend/auth/apps/__pycache__/* backend/auth/apps/__pycache__/
backend/auth/apps/permissions/__pycache__/* backend/auth/apps/permissions/__pycache__/
backend/auth/apps/roles/__pycache__/* backend/auth/apps/roles/__pycache__/
backend/auth/utils/__pycache__/* backend/auth/utils/__pycache__/
postgres_data/* 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

@ -215,6 +215,31 @@ async def get_role_permissions(
raise HTTPException(status_code=404, detail="角色不存在") 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]
)
@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( return RolePermissionResponse(
role_id=role.id, role_id=role.id,
role_name=role.name, role_name=role.name,

View File

@ -92,7 +92,11 @@ async def login(
@router.post("/", response_model=UserResponse) @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)) 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.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.exceptions import RequestValidationError
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from apps.urls import api_router from apps.urls import api_router
from database import engine, get_db from database import engine, get_db
from models import Base from models import Base
from init_data import init_all_data from init_data import init_all_data
from pathlib import Path from pathlib import Path
from starlette.requests import Request
from starlette.responses import JSONResponse
@asynccontextmanager @asynccontextmanager
@ -122,6 +125,33 @@ async def home():
return {"message": "Hello World!!!"} 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启动即可下面命令就不用写 # 如果使用命令行启动使用uvicorn 文件名:app --reload启动即可下面命令就不用写
# 如果写下面的命令就不需要命令行启动了直接用IDE运行即可 # 如果写下面的命令就不需要命令行启动了直接用IDE运行即可
if __name__ == "__main__": 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 typing import Optional
from datetime import datetime from datetime import datetime
@ -6,19 +6,43 @@ from datetime import datetime
class UserBase(BaseModel): 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="昵称") 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="手机号码") phone: Optional[str] = Field(None, max_length=11, description="手机号码")
wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID") wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID")
avatar: Optional[str] = Field(None, max_length=255, description="头像路径") 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): 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): class UserCreate(UserBase):
@ -34,7 +58,7 @@ class UserUpdate(BaseModel):
username: str = Field(..., min_length=3, max_length=50, description="登录名") username: str = Field(..., min_length=3, max_length=50, description="登录名")
nickname: str = Field(..., 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="手机号码") phone: Optional[str] = Field(None, max_length=11, description="手机号码")
wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID") wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID")
avatar: Optional[str] = Field(None, max_length=255, description="头像路径") avatar: Optional[str] = Field(None, max_length=255, description="头像路径")
@ -57,7 +81,7 @@ class UserResponse(BaseModel):
id: int = Field(..., description="用户ID") id: int = Field(..., description="用户ID")
username: str = Field(..., description="登录名") username: str = Field(..., description="登录名")
nickname: 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="手机号码") phone: Optional[str] = Field(None, description="手机号码")
wx_openid: Optional[str] = Field(None, description="微信OpenID") wx_openid: Optional[str] = Field(None, description="微信OpenID")
avatar: Optional[str] = Field(None, description="头像路径") avatar: Optional[str] = Field(None, description="头像路径")
@ -81,7 +105,7 @@ class TokenData(BaseModel):
"""令牌数据模型""" """令牌数据模型"""
user_id: Optional[int] = None user_id: Optional[int] = None
email: Optional[EmailStr] = None email: Optional[str] = None
class RoleBase(BaseModel): class RoleBase(BaseModel):

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BookSystem</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-Cn04Rt7y.css">
</head> </head>
<body> <body>

View File

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

View File

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

View File

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

View File

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

View File

@ -28,19 +28,20 @@ const router = createRouter({
{ {
path: "users", path: "users",
name: "admin-users", name: "admin-users",
meta: { title: "用户管理" }, meta: { title: "用户管理", permission: "perm:user" },
component: () => import("@/modules/admin/views/users/UserManage.vue"), component: () => import("@/modules/admin/views/users/UserManage.vue"),
}, },
{ {
path: "roles", path: "roles",
name: "admin-roles", name: "admin-roles",
meta: { title: "角色管理" }, meta: { title: "角色管理", permission: "perm:role" },
component: () => import("@/modules/admin/views/roles/RoleManage.vue"), component: () => import("@/modules/admin/views/roles/RoleManage.vue"),
}, },
{ {
path: "permissions", path: "permissions",
name: "admin-permissions", name: "admin-permissions",
meta: { title: "权限管理" }, meta: { title: "权限管理", permission: "perm:config" },
component: () => component: () =>
import("@/modules/admin/views/permissions/PermissionManage.vue"), 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 auth = useAuthStore();
const title = (to.meta.title as string) || "BookSystem"; const title = (to.meta.title as string) || "BookSystem";
document.title = `${title} · BookSystem`; document.title = `${title} · BookSystem`;
@ -63,10 +64,31 @@ router.beforeEach((to, _from, next) => {
} }
if (to.meta.guestOnly && auth.isAuthenticated) { 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" }); next({ name: "admin-users" });
return; 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(); next();
}); });

View File

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