From c4aac6979ab4df657e32b05f584cfbdd7044ae50 Mon Sep 17 00:00:00 2001 From: jayhgq Date: Wed, 27 May 2026 11:42:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth,admin):=20=E4=BF=AE=E5=A4=8D=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=9D=83=E9=99=90=E8=8F=9C=E5=8D=95=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 后端添加获取当前用户权限接口 2. 前端增加权限校验和动态菜单渲染 3. 优化表单验证错误处理 4. 完善gitignore和项目配置更新 --- .gitignore | 16 ++++--- .trae/documents/permission_menu_fix_plan.md | 44 +++++++++++++++++++ backend/auth/apps/permissions/urls.py | 25 +++++++++++ backend/auth/apps/users/urls.py | 6 ++- backend/auth/main.py | 32 +++++++++++++- backend/auth/schemas.py | 40 +++++++++++++---- frontend/dist/index.html | 2 +- .../src/modules/admin/layout/AdminLayout.vue | 26 ++++++++++- frontend/src/modules/admin/stores/auth.ts | 43 ++++++++++++++++++ .../src/modules/admin/views/LoginView.vue | 1 + .../modules/admin/views/users/user-crud.ts | 36 ++++++++++----- frontend/src/router/index.ts | 30 +++++++++++-- frontend/src/vite-env.d.ts | 6 +++ 13 files changed, 274 insertions(+), 33 deletions(-) create mode 100644 .trae/documents/permission_menu_fix_plan.md diff --git a/.gitignore b/.gitignore index f53fa9f..4887577 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* \ No newline at end of file +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 \ No newline at end of file diff --git a/.trae/documents/permission_menu_fix_plan.md b/.trae/documents/permission_menu_fix_plan.md new file mode 100644 index 0000000..7cbc256 --- /dev/null +++ b/.trae/documents/permission_menu_fix_plan.md @@ -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` + +## 预期效果 + +- 用户登录后,系统会根据其角色权限动态显示菜单项 +- "大会员"角色只能看到"用户管理"菜单 +- "管理员"角色可以看到所有菜单 + +## 风险评估 + +- 低风险:修改范围明确,不影响核心业务逻辑 +- 需要确保权限编码与菜单的对应关系正确 \ No newline at end of file diff --git a/backend/auth/apps/permissions/urls.py b/backend/auth/apps/permissions/urls.py index 0a42551..8835897 100644 --- a/backend/auth/apps/permissions/urls.py +++ b/backend/auth/apps/permissions/urls.py @@ -215,6 +215,31 @@ async def get_role_permissions( 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( role_id=role.id, role_name=role.name, diff --git a/backend/auth/apps/users/urls.py b/backend/auth/apps/users/urls.py index 71d0dfa..8dad310 100644 --- a/backend/auth/apps/users/urls.py +++ b/backend/auth/apps/users/urls.py @@ -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)) diff --git a/backend/auth/main.py b/backend/auth/main.py index 3c81906..861dded 100644 --- a/backend/auth/main.py +++ b/backend/auth/main.py @@ -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__": diff --git a/backend/auth/schemas.py b/backend/auth/schemas.py index e535743..927e66d 100644 --- a/backend/auth/schemas.py +++ b/backend/auth/schemas.py @@ -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): diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 357b681..101bfc0 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -4,7 +4,7 @@ BookSystem - + diff --git a/frontend/src/modules/admin/layout/AdminLayout.vue b/frontend/src/modules/admin/layout/AdminLayout.vue index 3799105..f44f94e 100644 --- a/frontend/src/modules/admin/layout/AdminLayout.vue +++ b/frontend/src/modules/admin/layout/AdminLayout.vue @@ -9,15 +9,24 @@ text-color="#e2e8f0" active-text-color="#38bdf8" > - + 用户 - + 角色 @@ -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(); diff --git a/frontend/src/modules/admin/stores/auth.ts b/frontend/src/modules/admin/stores/auth.ts index 0f5a0dd..310bf97 100644 --- a/frontend/src/modules/admin/stores/auth.ts +++ b/frontend/src/modules/admin/stores/auth.ts @@ -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(localStorage.getItem("access_token") || ""); const user = ref(null); + const permissions = ref([]); 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("/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, }; }); diff --git a/frontend/src/modules/admin/views/LoginView.vue b/frontend/src/modules/admin/views/LoginView.vue index 56c60ae..890e698 100644 --- a/frontend/src/modules/admin/views/LoginView.vue +++ b/frontend/src/modules/admin/views/LoginView.vue @@ -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) { diff --git a/frontend/src/modules/admin/views/users/user-crud.ts b/frontend/src/modules/admin/views/users/user-crud.ts index 39d1794..d96a7c2 100644 --- a/frontend/src/modules/admin/views/users/user-crud.ts +++ b/frontend/src/modules/admin/views/users/user-crud.ts @@ -50,15 +50,22 @@ export const createUserCrudOptions: CreateCrudOptions = () => { 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 = () => { 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 = () => { 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: { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 67e6eed..e0bcf7d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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(); }); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 03e8021..e8cf243 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,5 +1,11 @@ /// +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} + interface ImportMetaEnv { readonly VITE_API_BASE: string; }