feat(auth,admin): 修复角色权限菜单显示问题
1. 后端添加获取当前用户权限接口 2. 前端增加权限校验和动态菜单渲染 3. 优化表单验证错误处理 4. 完善gitignore和项目配置更新
This commit is contained in:
parent
6620aec5a2
commit
c4aac6979a
16
.gitignore
vendored
16
.gitignore
vendored
@ -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
|
||||
44
.trae/documents/permission_menu_fix_plan.md
Normal file
44
.trae/documents/permission_menu_fix_plan.md
Normal 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`
|
||||
|
||||
## 预期效果
|
||||
|
||||
- 用户登录后,系统会根据其角色权限动态显示菜单项
|
||||
- "大会员"角色只能看到"用户管理"菜单
|
||||
- "管理员"角色可以看到所有菜单
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 低风险:修改范围明确,不影响核心业务逻辑
|
||||
- 需要确保权限编码与菜单的对应关系正确
|
||||
@ -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]
|
||||
)
|
||||
@ -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))
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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):
|
||||
|
||||
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -50,15 +50,22 @@ 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 (typeof detail === "string") {
|
||||
if (detail.includes("at least 6 characters")) {
|
||||
message = "密码长度至少为6个字符";
|
||||
} else {
|
||||
message = 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个字符";
|
||||
} else {
|
||||
message = detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (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: {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
6
frontend/src/vite-env.d.ts
vendored
6
frontend/src/vite-env.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user