From 7431f1f41e12d8fb1af7e3af08f104eb06a4ae26 Mon Sep 17 00:00:00 2001 From: dxc Date: Wed, 25 Feb 2026 16:10:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E9=A1=B5=E9=9D=A2=E4=BF=AE=E6=94=B9=E4=B9=8B?= =?UTF-8?q?=E5=89=8D=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inventory-backend/app/__init__.py | 23 +- inventory-backend/app/api/v1/auth.py | 19 +- inventory-backend/app/api/v1/permission.py | 48 ++ inventory-backend/app/models/system.py | 78 ++- .../app/services/auth_service.py | 38 +- .../app/services/permission_service.py | 89 +++ inventory-web/src/api/system/permission.ts | 26 + .../src/components/BaseTable/index.vue | 134 ++++ inventory-web/src/router/index.ts | 16 +- inventory-web/src/stores/permission.ts | 49 ++ inventory-web/src/views/login/index.vue | 20 +- .../src/views/system/PermissionConfig.vue | 627 ++++++++++++++++++ 12 files changed, 1135 insertions(+), 32 deletions(-) create mode 100644 inventory-backend/app/api/v1/permission.py create mode 100644 inventory-backend/app/services/permission_service.py create mode 100644 inventory-web/src/api/system/permission.ts create mode 100644 inventory-web/src/components/BaseTable/index.vue create mode 100644 inventory-web/src/stores/permission.ts create mode 100644 inventory-web/src/views/system/PermissionConfig.vue diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index c921b81..4a273cd 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -80,7 +80,6 @@ def create_app(): # ----------------------------------------------------- # 2.4 注册业务操作模块 (Transactions - 借还/维修/报废) - # ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★ # ----------------------------------------------------- try: from app.api.v1.transactions import trans_bp @@ -90,8 +89,7 @@ def create_app(): app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy') print("✅ Transactions 模块注册成功") except ImportError as e: - # 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题 - print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}") + print(f"⚠️ 提示: Transaction 模块导入失败: {e}") # ----------------------------------------------------- # 2.5 注册出库模块 (Outbound) @@ -119,6 +117,19 @@ def create_app(): except ImportError as e: print(f"❌ 错误: BOM 模块导入失败: {e}") + # ----------------------------------------------------- + # 2.7 注册权限管理模块 (Permission) - [新增] + # ----------------------------------------------------- + try: + from app.api.v1.permission import permission_bp + # 标准: /api/v1/permissions/tree + app.register_blueprint(permission_bp, url_prefix='/api/v1/permissions') + # 兼容: /api/permissions/tree + app.register_blueprint(permission_bp, url_prefix='/api/permissions', name='permission_legacy') + print("✅ Permission 模块注册成功") + except ImportError as e: + print(f"❌ 错误: Permission 模块导入失败 (请检查 app/api/v1/permission.py 是否存在): {e}") + # ========================================================= # 3. 预加载数据模型 # ========================================================= @@ -133,8 +144,8 @@ def create_app(): # 出库模型 from app.models.outbound import TransOutbound - # 系统与业务模型 - from app.models.system import SysUser, SysLog + # 系统与业务模型 (SysRolePermission 等在 models.system 中) + from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission # 确保借还模型被加载 from app.models.transaction import TransBorrow, TransRepair, TransScrap @@ -146,4 +157,4 @@ def create_app(): except Exception as e: print(f"⚠️ 模型预加载发生未知错误: {e}") - return app + return app \ No newline at end of file diff --git a/inventory-backend/app/api/v1/auth.py b/inventory-backend/app/api/v1/auth.py index 640c39d..3d2263f 100644 --- a/inventory-backend/app/api/v1/auth.py +++ b/inventory-backend/app/api/v1/auth.py @@ -87,4 +87,21 @@ def delete_user(user_id): return jsonify({'msg': '删除成功'}), 200 except Exception as e: current_app.logger.error(f"Delete User Failed: {str(e)}") - return jsonify({'msg': str(e)}), 400 \ No newline at end of file + return jsonify({'msg': str(e)}), 400 + + +@auth_bp.route('/my-permissions', methods=['GET']) +@jwt_required() +def get_my_permissions(): + """获取当前登录用户的权限列表""" + try: + claims = get_jwt() + role = claims.get('role') + + # 调用 Service 获取权限 + permissions = AuthService.get_user_permissions(role) + + return jsonify({'msg': '获取成功', 'data': permissions}), 200 + except Exception as e: + current_app.logger.error(f"Get Permissions Failed: {str(e)}") + return jsonify({'msg': '获取权限失败'}), 500 \ No newline at end of file diff --git a/inventory-backend/app/api/v1/permission.py b/inventory-backend/app/api/v1/permission.py new file mode 100644 index 0000000..e6efa6d --- /dev/null +++ b/inventory-backend/app/api/v1/permission.py @@ -0,0 +1,48 @@ +# inventory-backend/app/api/v1/permission.py +from flask import Blueprint, request, jsonify, current_app +from flask_jwt_extended import jwt_required +from app.services.permission_service import PermissionService + +permission_bp = Blueprint('permission', __name__) + + +@permission_bp.route('/tree', methods=['GET']) +@jwt_required() +def get_tree(): + """获取权限树""" + try: + data = PermissionService.get_permission_tree() + return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200 + except Exception as e: + # 打印详细错误到控制台,方便调试 + current_app.logger.error(f"Get Tree Failed: {str(e)}") + # 返回 500 时带上错误信息 + return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 + + +@permission_bp.route('/role/', methods=['GET']) +@jwt_required() +def get_role_perms(role_code): + """获取某个角色的权限列表""" + try: + data = PermissionService.get_role_permissions(role_code) + return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200 + except Exception as e: + current_app.logger.error(f"Get Role Perms Failed: {str(e)}") + return jsonify({'code': 500, 'msg': str(e)}), 500 + + +@permission_bp.route('/assign', methods=['POST']) +@jwt_required() +def assign_perms(): + """保存权限分配""" + try: + data = request.get_json() + role_code = data.get('role_code') + permissions = data.get('permissions', []) # list of codes + + PermissionService.assign_permissions(role_code, permissions) + return jsonify({'code': 200, 'msg': '保存成功'}), 200 + except Exception as e: + current_app.logger.error(f"Assign Perms Failed: {str(e)}") + return jsonify({'code': 500, 'msg': str(e)}), 500 \ No newline at end of file diff --git a/inventory-backend/app/models/system.py b/inventory-backend/app/models/system.py index d8104c7..f0b77e5 100644 --- a/inventory-backend/app/models/system.py +++ b/inventory-backend/app/models/system.py @@ -1,14 +1,17 @@ -# app/models/system.py +# inventory-backend/app/models/system.py from app.extensions import db from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime +# ========================================== +# 1. 系统用户表 +# ========================================== class SysUser(db.Model): """ 系统用户表 对应数据库: sys_user - username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan) + username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan01) """ __tablename__ = 'sys_user' @@ -19,8 +22,7 @@ class SysUser(db.Model): role = db.Column(db.String(50)) status = db.Column(db.String(20), default='active') password_hash = db.Column(db.Text) - - # created_at 已在数据库脚本中移除,此处不再定义 + created_at = db.Column(db.DateTime, default=datetime.now) def set_password(self, password): """生成加密密码""" @@ -45,23 +47,27 @@ class SysUser(db.Model): parts = raw_name.split('/') real_name = parts[0] acc_id = parts[1] - # 格式化为前端展示格式: 张三(zhangsan) + # 格式化为前端展示格式: 张三(zhangsan01) display_name = f"{real_name}({acc_id})" # 单独提取账号ID (如果前端需要单独用) account_id = acc_id return { 'id': self.id, - 'username': display_name, # 列表显示: 张三(zhangsan) + 'username': display_name, # 列表显示: 张三(zhangsan01) 'raw_username': self.username, # 原始数据 - 'account_id': account_id, # 纯账号ID: zhangsan + 'account_id': account_id, # 纯账号ID: zhangsan01 'email': self.email, 'department': self.department, 'role': self.role, - 'status': self.status + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None } +# ========================================== +# 2. 系统日志表 +# ========================================== class SysLog(db.Model): """ 系统操作日志表 @@ -88,4 +94,58 @@ class SysLog(db.Model): 'module_name': self.module_name, 'action_type': self.action_type, 'description': self.description - } \ No newline at end of file + } + + +# ========================================== +# 3. 权限管理模型 (RBAC) - [新增] +# ========================================== + +class SysMenu(db.Model): + """系统菜单/页面表""" + __tablename__ = 'sys_menu' + id = db.Column(db.Integer, primary_key=True) + parent_id = db.Column(db.Integer, default=0) + name = db.Column(db.String(50), nullable=False) + code = db.Column(db.String(100), unique=True, nullable=False) + path = db.Column(db.String(200)) + sort_order = db.Column(db.Integer, default=0) + is_visible = db.Column(db.Boolean, default=True) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'code': self.code, + 'path': self.path, + 'type': 'menu' # 前端树形控件图标判断用 + } + + +class SysElement(db.Model): + """页面元素/列定义表""" + __tablename__ = 'sys_element' + id = db.Column(db.Integer, primary_key=True) + menu_code = db.Column(db.String(100), db.ForeignKey('sys_menu.code')) + name = db.Column(db.String(100), nullable=False) + code = db.Column(db.String(100), nullable=False) # 如: unit_price + element_type = db.Column(db.String(20), default='column') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'code': self.code, + 'menu_code': self.menu_code, + 'type': 'element', + 'element_type': self.element_type + } + + +class SysRolePermission(db.Model): + """角色权限关联表""" + __tablename__ = 'sys_role_permission' + id = db.Column(db.Integer, primary_key=True) + role_code = db.Column(db.String(50), nullable=False) + target_code = db.Column(db.String(100), nullable=False) # menu_code 或 element_code + type = db.Column(db.String(20), nullable=False) # 'menu' 或 'element' \ No newline at end of file diff --git a/inventory-backend/app/services/auth_service.py b/inventory-backend/app/services/auth_service.py index a12f102..27d9063 100644 --- a/inventory-backend/app/services/auth_service.py +++ b/inventory-backend/app/services/auth_service.py @@ -1,11 +1,10 @@ # app/services/auth_service.py -from app.models.system import SysUser +from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission from app.extensions import db from flask_jwt_extended import create_access_token from app.utils.constants import UserRole from datetime import timedelta - class AuthService: # 硬编码的超级管理员凭证 SUPER_ADMIN_USER = "IRIS" @@ -211,4 +210,37 @@ class AuthService: db.session.delete(user) db.session.commit() - return True \ No newline at end of file + return True + + @staticmethod + def get_user_permissions(role_code): + """ + 获取指定角色的所有权限代码列表 + 返回格式: { + 'menus': ['inbound_buy', 'system_user'], + 'elements': ['inbound_buy:unit_price', ...] + } + """ + # 1. 查菜单权限 + menu_perms = SysRolePermission.query.filter_by( + role_code=role_code, + type='menu' + ).all() + menu_codes = [p.target_code for p in menu_perms] + + # 2. 查元素(列)权限 + # 注意:这里我们只返回用户拥有的。前端逻辑是:"如果列配置了Key且用户没这个Key,则隐藏" + element_perms = SysRolePermission.query.filter_by( + role_code=role_code, + type='element' + ).all() + + # 这里的 target_code 就是列的 code (如 unit_price) + # 为了防止不同页面有相同列名导致的混淆,我们之前数据库设计是做了隔离的 + # 但为了前端处理方便,我们直接返回列的 code 集合 + element_codes = [p.target_code for p in element_perms] + + return { + 'menus': menu_codes, + 'elements': element_codes + } \ No newline at end of file diff --git a/inventory-backend/app/services/permission_service.py b/inventory-backend/app/services/permission_service.py new file mode 100644 index 0000000..756f7ea --- /dev/null +++ b/inventory-backend/app/services/permission_service.py @@ -0,0 +1,89 @@ +# inventory-backend/app/services/permission_service.py +from app.models.system import SysMenu, SysElement, SysRolePermission +from app.extensions import db + + +class PermissionService: + @staticmethod + def get_permission_tree(): + """ + 获取完整的权限树(菜单 -> 元素) + 供前端权限配置页面展示 + """ + # 1. 获取所有菜单 + menus = SysMenu.query.order_by(SysMenu.sort_order).all() + # 2. 获取所有元素 + elements = SysElement.query.all() + + # 3. 组装树结构 + tree_data = [] + for menu in menus: + menu_dict = menu.to_dict() + + # 找该菜单下的所有元素 + children = [] + for el in elements: + if el.menu_code == menu.code: + children.append(el.to_dict()) + + # 如果有子元素,加到 children + if children: + menu_dict['children'] = children + + tree_data.append(menu_dict) + + return tree_data + + @staticmethod + def get_role_permissions(role_code): + """获取指定角色拥有的所有权限Code""" + perms = SysRolePermission.query.filter_by(role_code=role_code).all() + + # 将结果分为 menus 和 elements (虽然前端目前合并处理,但分开更清晰) + menu_codes = [] + element_codes = [] + + for p in perms: + if p.type == 'menu': + menu_codes.append(p.target_code) + else: + element_codes.append(p.target_code) + + # 返回结构适配前端 + return { + 'menus': menu_codes, + 'elements': element_codes + } + + @staticmethod + def assign_permissions(role_code, permission_codes): + """ + 保存角色的权限 + permission_codes: 前端传来的 list,包含 menu_code 和 element_code + """ + if not role_code: + raise ValueError("角色代码不能为空") + + # 1. 删除该角色旧的所有权限 + SysRolePermission.query.filter_by(role_code=role_code).delete() + + # 2. 批量添加新权限 + if permission_codes and len(permission_codes) > 0: + # 预先获取所有菜单代码,用于判断类型 + all_menu_codes = {m.code for m in SysMenu.query.all()} + + new_records = [] + for code in permission_codes: + # 简单判断:如果在菜单表里有,就是 menu,否则是 element + p_type = 'menu' if code in all_menu_codes else 'element' + + new_records.append(SysRolePermission( + role_code=role_code, + target_code=code, + type=p_type + )) + + db.session.add_all(new_records) + + db.session.commit() + return True \ No newline at end of file diff --git a/inventory-web/src/api/system/permission.ts b/inventory-web/src/api/system/permission.ts new file mode 100644 index 0000000..c02aaeb --- /dev/null +++ b/inventory-web/src/api/system/permission.ts @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 获取所有可用的权限树(菜单+列) +export function getAllPermissionTree() { + return request({ + url: '/v1/permissions/tree', + method: 'get' + }) +} + +// 获取某个角色已拥有的权限列表 +export function getRolePermissions(roleCode: string) { + return request({ + url: '/v1/permissions/role/' + roleCode, + method: 'get' + }) +} + +// 保存角色的权限配置 +export function saveRolePermissions(data: any) { + return request({ + url: '/v1/permissions/assign', + method: 'post', + data + }) +} \ No newline at end of file diff --git a/inventory-web/src/components/BaseTable/index.vue b/inventory-web/src/components/BaseTable/index.vue new file mode 100644 index 0000000..9c44c03 --- /dev/null +++ b/inventory-web/src/components/BaseTable/index.vue @@ -0,0 +1,134 @@ + + + + + \ No newline at end of file diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index 1fed409..190e703 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -195,9 +195,19 @@ const routes: Array = [ meta: { title: '账号开通', icon: 'User', - // 子路由也建议加上权限限制 roles: ['SUPER_ADMIN', 'SUPERVISOR'] } + }, + // [新增] 权限分配页面,只有超级管理员可进 + { + path: 'permission', + name: 'PermissionConfig', + component: () => import('@/views/system/PermissionConfig.vue'), + meta: { + title: '权限分配', + icon: 'Lock', + roles: ['SUPER_ADMIN'] + } } ] }, @@ -224,11 +234,10 @@ router.beforeEach((to, from, next) => { const token = userStore.token || localStorage.getItem('token') // [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效 - // 注意:Store 中存储的可能是 user.role 或者直接是 role,根据你之前的 store 结构适配 const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user' const userRole = String(rawRole).toUpperCase() - // 调试日志:如果跳转有问题,请按 F12 查看控制台输出 + // 调试日志 if (to.path.includes('/system')) { console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`) } @@ -249,7 +258,6 @@ router.beforeEach((to, from, next) => { // 权限检查逻辑 if (to.meta.roles && Array.isArray(to.meta.roles)) { - // [修复] to.meta.roles 里已经是大写了,userRole 也转大写了,现在可以安全比对 if (to.meta.roles.includes(userRole)) { next() } else { diff --git a/inventory-web/src/stores/permission.ts b/inventory-web/src/stores/permission.ts new file mode 100644 index 0000000..e880221 --- /dev/null +++ b/inventory-web/src/stores/permission.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import request from '@/utils/request' + +export const usePermissionStore = defineStore('permission', () => { + // 存储我能看到的页面代码 (如 ['inbound_buy', ...]) + const menuPermissions = ref([]) + + // 存储我能看到的列代码 (如 ['unit_price', 'sale_price']) + const elementPermissions = ref([]) + + // 初始化加载权限 (登录后调用) + const loadPermissions = async () => { + try { + const res: any = await request({ + url: '/v1/auth/my-permissions', + method: 'get' + }) + if (res.code === 200 && res.data) { + menuPermissions.value = res.data.menus || [] + elementPermissions.value = res.data.elements || [] + console.log('权限字典加载完成:', elementPermissions.value.length, '个列权限') + } + } catch (e) { + console.error('加载权限失败', e) + // 失败时清空,防止残留 + menuPermissions.value = [] + elementPermissions.value = [] + } + } + + // ★ 核心判断函数:判断当前用户是否拥有某个列/按钮的权限 + // page: 页面代码 (预留字段,目前全局唯一code,暂不使用page隔离) + // code: 权限标识 (如 'unit_price') + const hasColumnPermission = (page: string, code: string) => { + // 1. 如果列没有配置 permissionKey,说明是公开列,直接放行 + if (!code) return true + + // 2. 检查权限池里是否有这个 code + return elementPermissions.value.includes(code) + } + + return { + menuPermissions, + elementPermissions, + loadPermissions, + hasColumnPermission + } +}) \ No newline at end of file diff --git a/inventory-web/src/views/login/index.vue b/inventory-web/src/views/login/index.vue index b5bc9d5..dd09076 100644 --- a/inventory-web/src/views/login/index.vue +++ b/inventory-web/src/views/login/index.vue @@ -52,11 +52,13 @@ import { ref, reactive } from 'vue' import { useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' -import { ElMessageBox } from 'element-plus' // 引入 ElMessageBox +import { usePermissionStore } from '@/stores/permission' // [新增] 引入权限Store +import { ElMessageBox } from 'element-plus' import { User, Lock } from '@element-plus/icons-vue' const router = useRouter() const userStore = useUserStore() +const permissionStore = usePermissionStore() // [新增] const loading = ref(false) const loginFormRef = ref() @@ -74,23 +76,25 @@ const onLogin = async () => { if (valid) { loading.value = true try { - // 执行登录请求 + // 1. 执行登录请求 const success = await userStore.handleLogin(loginForm) if (success) { - // 成功:跳转 + // [新增] 2. 登录成功后,立即拉取当前用户的权限字典 + // 这样进入 Dashboard 时,所有按钮/列的显示状态就已经确定了 + await permissionStore.loadPermissions() + + // 3. 跳转 router.push('/dashboard') } else { - // 失败(业务逻辑拒绝,如账号密码错):弹出模态框 + // 失败(业务逻辑拒绝):弹出模态框 showLoginFailAlert('用户名或密码错误') } } catch (error: any) { - // 失败(系统错误,如网络断开/500报错):弹出模态框 - // 优先取后端的报错信息,没有则显示默认 + // 失败(系统错误):弹出模态框 const msg = error.response?.data?.msg || error.message || '登录遇到未知错误' showLoginFailAlert(msg) } finally { - // 停止转圈,让用户可以看清弹窗 loading.value = false } } @@ -103,8 +107,6 @@ const showLoginFailAlert = (msg: string) => { confirmButtonText: '确定', type: 'error', callback: () => { - // 点击确定后,清空密码框,让用户重试 - // 页面绝对不会刷新,光标还在 loginForm.password = '' } }) diff --git a/inventory-web/src/views/system/PermissionConfig.vue b/inventory-web/src/views/system/PermissionConfig.vue new file mode 100644 index 0000000..f501778 --- /dev/null +++ b/inventory-web/src/views/system/PermissionConfig.vue @@ -0,0 +1,627 @@ + + + + + \ No newline at end of file