From 8a3b55bf95a17aa24addad1869a785976d9e416b Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 21 Sep 2025 02:16:14 +0800 Subject: [PATCH 01/21] add role permission module. --- components.d.ts | 5 + src/api/administratorapi.js | 57 +++ src/api/menuapi.js | 3 +- src/api/permissionapi.js | 11 + src/api/roleapi.js | 46 ++ src/directives/permission.js | 42 ++ src/i18n.js | 14 + src/layouts/MainLayout.vue | 31 ++ src/main.js | 6 + src/router/index.js | 40 ++ src/utils/icons.js | 4 + src/utils/pageTitle.js | 4 +- src/utils/permission.js | 54 ++ src/views/base/DepartmentView.vue | 12 +- .../AccountPermissionView.vue | 469 ++++++++++++++++++ .../AdministratorManagementView.vue | 16 +- .../systemmanagement/RoleManagementView.vue | 332 ++++++++++++- 17 files changed, 1138 insertions(+), 8 deletions(-) create mode 100644 src/api/permissionapi.js create mode 100644 src/directives/permission.js create mode 100644 src/utils/permission.js create mode 100644 src/views/systemmanagement/AccountPermissionView.vue diff --git a/components.d.ts b/components.d.ts index c4beaf6..1375157 100644 --- a/components.d.ts +++ b/components.d.ts @@ -12,6 +12,8 @@ declare module 'vue' { AAvatar: typeof import('ant-design-vue/es')['Avatar'] AButton: typeof import('ant-design-vue/es')['Button'] ACard: typeof import('ant-design-vue/es')['Card'] + ACheckbox: typeof import('ant-design-vue/es')['Checkbox'] + ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup'] ACol: typeof import('ant-design-vue/es')['Col'] ADatePicker: typeof import('ant-design-vue/es')['DatePicker'] ADescriptions: typeof import('ant-design-vue/es')['Descriptions'] @@ -65,6 +67,9 @@ declare module 'vue' { RetweetOutlined: typeof import('@ant-design/icons-vue')['RetweetOutlined'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SafetyCertificateOutlined: typeof import('@ant-design/icons-vue')['SafetyCertificateOutlined'] + SafetyOutlined: typeof import('@ant-design/icons-vue')['SafetyOutlined'] + SaveOutlined: typeof import('@ant-design/icons-vue')['SaveOutlined'] StopOutlined: typeof import('@ant-design/icons-vue')['StopOutlined'] SyncOutlined: typeof import('@ant-design/icons-vue')['SyncOutlined'] UserOutlined: typeof import('@ant-design/icons-vue')['UserOutlined'] diff --git a/src/api/administratorapi.js b/src/api/administratorapi.js index aa249e1..e009b93 100644 --- a/src/api/administratorapi.js +++ b/src/api/administratorapi.js @@ -39,3 +39,60 @@ export const deleteAdmin = async (data) => { throw error; } }; + +// 为用户分配角色(全量覆盖) +export const assignUserRoles = async (data) => { + try { + const response = await api.post("/Admin/AssignUserRoles", data); + return response.data; + } catch (error) { + throw error; + } +}; + +// 读取指定用户已分配角色编码集合 +export const readUserRoles = async (userNumber) => { + try { + const response = await api.get("/Admin/ReadUserRoles", { + params: { userNumber }, + }); + return (response.data?.Data?.Items) || []; + } catch (error) { + throw error; + } +}; + +// 读取指定用户的角色-权限明细(来自 RolePermission + Permission) +export const readUserRolePermissions = async (userNumber) => { + try { + const response = await api.get("/Admin/ReadUserRolePermissions", { + params: { userNumber }, + }); + return (response.data?.Data?.Items) || []; + } catch (error) { + throw error; + } +}; + + +// 为用户分配“直接权限”(全量覆盖) +export const assignUserPermissions = async (data) => { + try { + const response = await api.post("/Admin/AssignUserPermissions", data); + return response.data; + } catch (error) { + throw error; + } +}; + +// 读取指定用户的“直接权限”权限编码集合 +export const readUserDirectPermissions = async (userNumber) => { + try { + const response = await api.get("/Admin/ReadUserDirectPermissions", { + params: { userNumber }, + }); + return (response.data?.Data?.Items) || []; + } catch (error) { + throw error; + } +}; diff --git a/src/api/menuapi.js b/src/api/menuapi.js index a54e94a..14926a2 100644 --- a/src/api/menuapi.js +++ b/src/api/menuapi.js @@ -5,8 +5,9 @@ export const fetchMenusTree = async (menu) => { try { const response = await api.post("/Menu/BuildMenuAll", menu); + // 后端 BaseResponse 使用 PascalCase 字段(Success/Code/Message/Data) if (!response.data.Success) { - throw new Error(response.data.message || "获取菜单失败"); + throw new Error(response.data.Message || "获取菜单失败"); } return response.data.Data; } catch (error) { diff --git a/src/api/permissionapi.js b/src/api/permissionapi.js new file mode 100644 index 0000000..5b9cbf5 --- /dev/null +++ b/src/api/permissionapi.js @@ -0,0 +1,11 @@ +import api from "../api"; + +export const selectPermissionList = async (params) => { + try { + const response = await api.get("/Permission/SelectPermissionList", { params }); + // 返回标准结构 { Items, TotalCount } + return response.data?.Data || { Items: [], TotalCount: 0 }; + } catch (error) { + throw error; + } +}; \ No newline at end of file diff --git a/src/api/roleapi.js b/src/api/roleapi.js index 51282d9..2392763 100644 --- a/src/api/roleapi.js +++ b/src/api/roleapi.js @@ -39,3 +39,49 @@ export const deleteRole = async (data) => { throw error; } }; + +// 角色-权限:全量覆盖式授予 +export const grantRolePermissions = async (data) => { + try { + const response = await api.post("/Role/GrantRolePermissions", data); + return response.data; + } catch (error) { + throw error; + } +}; + +// 读取指定角色已授予的权限(返回后端 Data,页面逻辑做进一步兼容处理) +export const readRolePermissions = async (roleNumber) => { + try { + const response = await api.get("/Role/ReadRolePermissions", { + params: { roleNumber }, + }); + return response.data?.Data; + } catch (error) { + throw error; + } +}; + + +// 角色-用户:读取指定角色下的管理员编码集合 +export const readRoleUsers = async (roleNumber) => { + try { + const response = await api.get("/Role/ReadRoleUsers", { + params: { roleNumber }, + }); + // 标准结构 { Code, Message, Data: { Items, TotalCount } } + return response.data?.Data?.Items || []; + } catch (error) { + throw error; + } +}; + +// 角色-用户:为角色分配管理员(全量覆盖) +export const assignRoleUsers = async (data) => { + try { + const response = await api.post("/Role/AssignRoleUsers", data); + return response.data; + } catch (error) { + throw error; + } +}; diff --git a/src/directives/permission.js b/src/directives/permission.js new file mode 100644 index 0000000..4c93258 --- /dev/null +++ b/src/directives/permission.js @@ -0,0 +1,42 @@ +import { getAllowedMenuKeys, getAllowedPerms } from "@/utils/permission"; + +/** + * v-perm 指令:支持菜单键或按钮权限码两种来源 + * - 传入 string 或 string[],例如:v-perm="'system:role:grant'" 或 v-perm="['department.create','department.update']" + * - .all 修饰符表示需要全部命中(默认任意命中其一即可) + */ +function isPermitted(required, needAll = false) { + // 未配置要求时默认放行(方便逐步接入) + if (!required || (Array.isArray(required) && required.length === 0)) return true; + + // 合并“菜单键集合 + 按钮权限码集合” + const allowedSet = new Set([ + ...getAllowedMenuKeys(), + ...getAllowedPerms(), + ]); + + // 首屏或尚未拉取权限列表时(两个集合都为空)默认放行,避免按钮被永久隐藏 + if (allowedSet.size === 0) { + return true; + } + + const reqList = Array.isArray(required) ? required : [required]; + + if (needAll) { + return reqList.every((k) => allowedSet.has(k)); + } + return reqList.some((k) => allowedSet.has(k)); +} + +export default { + beforeMount(el, binding) { + const required = binding.value; + const needAll = !!binding.modifiers.all; + + const permitted = isPermitted(required, needAll); + if (!permitted) { + el.style.display = "none"; + el.setAttribute("data-permission-hidden", "true"); + } + }, +}; \ No newline at end of file diff --git a/src/i18n.js b/src/i18n.js index e2fda43..4c28911 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -90,6 +90,13 @@ const messages = { materialmanagement: "Material Management", goodsmanagement: "Goods Management", operationmanagement: "Operation Management", + accountpermission: "Account & Permission", + accountPermissionSettings: "Account & Permission Settings", + waittingchoseAdministrator: "Waitting chose Administrator", + rolePermissionDetail: "Role Permission Detail", + assignPermissions: "Assign Permissions", + choseAll: "Chose All", + save: "Save", operationlog: "Operation Log", requestlog: "Request Log", systemmanagement: "System Management", @@ -626,6 +633,13 @@ const messages = { operationlog: "操作日志", requestlog: "请求日志", systemmanagement: "系统管理", + accountpermission: "账号与权限", + accountPermissionSettings: "账号与权限设置", + waittingchoseAdministrator: "等待选择管理员", + rolePermissionDetail: "角色权限详情", + assignPermissions: "分配权限", + choseAll: "全选", + save: "保存", addadmin: "添加管理员", systemmodule: "系统模块", dashboard: "仪表盘", diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 94af890..7d1e31f 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -147,6 +147,7 @@ const refreshMenu = async () => { [BaseFields.USER_TOKEN]: localStorage.getItem("token"), }); const currentOpenKeys = [...openKeys.value]; + const processMenuItems = (items) => { if (!Array.isArray(items)) { return []; @@ -156,10 +157,37 @@ const refreshMenu = async () => { title: `message.${item.Key}`, path: item.Path?.replace(/\/$/, "") || "", icon: item.Icon, + // 透传后端返回的按钮权限编码,供聚合使用 + perms: Array.isArray(item.Permissions) ? item.Permissions : [], children: item.Children ? processMenuItems(item.Children) : undefined, })); }; + + // 1) 构建菜单渲染数据 menuData.value = processMenuItems(menuItems.Items); + + // 2) 基于后端过滤后的菜单树,收集允许访问的路径/菜单Key与按钮权限编码,用于路由守卫与按钮级控制 + const collect = (items, keys = [], paths = [], perms = []) => { + for (const it of items || []) { + if (it?.key) keys.push(it.key); + if (it?.path) paths.push(it.path); + if (Array.isArray(it?.perms) && it.perms.length) perms.push(...it.perms); + if (it?.children?.length) collect(it.children, keys, paths, perms); + } + return { keys, paths, perms }; + }; + const { keys, paths, perms } = collect(menuData.value, [], [], []); + + const unique = (arr) => Array.from(new Set(arr.filter(Boolean))); + const allowedMenuKeys = unique(keys); + const allowedPaths = unique(paths).map((p) => (p || "").replace(/\/$/, "")); + const allowedPerms = unique(perms); + + localStorage.setItem("allowedMenuKeys", JSON.stringify(allowedMenuKeys)); + localStorage.setItem("allowedPaths", JSON.stringify(allowedPaths)); + localStorage.setItem("allowedPerms", JSON.stringify(allowedPerms)); + + // 3) 还原展开状态 openKeys.value = currentOpenKeys; } catch (error) { showErrorNotification(error.message); @@ -201,6 +229,9 @@ const logout = () => { localStorage.removeItem("token"); localStorage.removeItem("username"); localStorage.removeItem("account"); + localStorage.removeItem("allowedPaths"); + localStorage.removeItem("allowedMenuKeys"); + localStorage.removeItem("allowedPerms"); isLoggedIn.value = false; username.value = ""; router.push("/signin"); diff --git a/src/main.js b/src/main.js index 88027db..cb3c911 100644 --- a/src/main.js +++ b/src/main.js @@ -3,10 +3,16 @@ import App from "./App.vue"; import router from "./router"; import i18n from "./i18n"; import { registerAntdIcons } from "@/utils/icons"; +import permissionDirective from "./directives/permission"; +import { hasAnyPermission } from "@/utils/permission"; import "@/assets/styles/stylesheet.css"; const app = createApp(App); +// 全局注册权限指令与工具 +app.directive("perm", permissionDirective); +app.config.globalProperties.$hasPerm = hasAnyPermission; + app.use(router); app.use(i18n); registerAntdIcons(app); diff --git a/src/router/index.js b/src/router/index.js index f788d3e..5e2cdae 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router"; import { checkTokenValidity } from "../utils/auth"; import { getPageTitle } from "@/utils/pageTitle"; import i18n from "@/i18n"; +import { hasPermission } from "@/utils/permission"; // 路由组件改为懒加载,减少首屏体积 const NotFound = () => import("../views/responsepage/NotFound.vue"); @@ -59,6 +60,8 @@ const AdminTypeManagement = () => import("../views/systemmanagement/AdminTypeManagementView.vue"); const MenuManagement = () => import("../views/systemmanagement/MenuManagementView.vue"); +const AccountPermission = () => + import("../views/systemmanagement/AccountPermissionView.vue"); const Dashboard = () => import("../views/dashboard/DashboardView.vue"); @@ -234,6 +237,12 @@ const routes = [ component: MenuManagement, meta: { requiresAuth: true }, }, + { + path: "/accountpermission", + name: "accountpermission", + component: AccountPermission, + meta: { requiresAuth: true, requiredPerm: "system:user:assign.view" }, + }, { path: "/dashboard", name: "dashboard", @@ -264,6 +273,37 @@ router.beforeEach((to, from, next) => { if (!checkTokenValidity()) { return next({ name: "signin" }); } + + try { + // 支持基于“权限码或菜单键”直达的路由控制(未出现在菜单里的独立页面) + const requiredPerm = to.meta && to.meta.requiredPerm; + if (requiredPerm && hasPermission(requiredPerm)) { + return next(); + } + + // 基于后端返回的菜单(权限)结果做前端路由访问控制(路径白名单) + const raw = localStorage.getItem("allowedPaths"); + const parsed = raw ? JSON.parse(raw) : []; + const allowedPaths = Array.isArray(parsed) ? parsed : []; + + // 尚未拉取菜单(例如刚进入系统时),先放行,由主布局内拉取并写入 + if (allowedPaths.length > 0) { + const normalize = (p) => (p || "").replace(/\/$/, ""); + const current = normalize(to.path); + + // 允许:精确匹配,或允许路径为前缀(覆盖诸如 /employeedetail/:id 这类详情页) + const matchPath = (allowed, cur) => + allowed === cur || (allowed && cur.startsWith(allowed + "/")); + + const permitted = allowedPaths.map(normalize).some((p) => matchPath(p, current)); + + if (!permitted) { + return next({ name: "NotFound" }); + } + } + } catch (e) { + // 本地解析失败时不阻断导航,进入页面后会重新拉取菜单并修复本地缓存 + } } next(); }); diff --git a/src/utils/icons.js b/src/utils/icons.js index 745068d..7367993 100644 --- a/src/utils/icons.js +++ b/src/utils/icons.js @@ -44,6 +44,8 @@ import { UserOutlined, LoadingOutlined, DashboardOutlined, + SaveOutlined, + SafetyOutlined, } from "@ant-design/icons-vue"; export function registerAntdIcons(app) { @@ -91,4 +93,6 @@ export function registerAntdIcons(app) { app.component("UserOutlined", UserOutlined); app.component("LoadingOutlined", LoadingOutlined); app.component("DashboardOutlined", DashboardOutlined); + app.component("SaveOutlined", SaveOutlined); + app.component("SafetyOutlined", SafetyOutlined); } diff --git a/src/utils/pageTitle.js b/src/utils/pageTitle.js index 22159b8..f273f32 100644 --- a/src/utils/pageTitle.js +++ b/src/utils/pageTitle.js @@ -58,7 +58,9 @@ export function getPageTitle(routePath) { return "message.admintypemanagement"; case "/menumanagement": return "message.menumanagement"; + case "/accountpermission": + return "message.accountpermission"; default: - return "message.defaultTitle"; + return "message.systemName"; } } diff --git a/src/utils/permission.js b/src/utils/permission.js new file mode 100644 index 0000000..67fb51a --- /dev/null +++ b/src/utils/permission.js @@ -0,0 +1,54 @@ +export function getAllowedMenuKeys() { + try { + const raw = localStorage.getItem("allowedMenuKeys"); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function getAllowedPerms() { + try { + const raw = localStorage.getItem("allowedPerms"); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * 统一的权限判断:支持菜单键或按钮权限码;支持“任意命中/全部命中”两种模式 + * - required: string | string[] + * - needAll: boolean(true 表示全部命中) + */ +export function hasPermission(required, needAll = false) { + // 未配置要求时默认放行(避免菜单尚未拉取完成期间阻挡) + if (!required || (Array.isArray(required) && required.length === 0)) { + return true; + } + + const allowedPerms = getAllowedPerms(); + const allowedMenuKeys = getAllowedMenuKeys(); + + // 两者都还未初始化(首次进入系统、尚未拉取菜单)时默认放行,避免首屏闪烁/误隐藏 + if (allowedPerms.length === 0 && allowedMenuKeys.length === 0) { + return true; + } + + const allowedSet = new Set([...allowedPerms, ...allowedMenuKeys]); + const requiredList = Array.isArray(required) ? required : [required]; + + if (needAll) { + return requiredList.every((k) => allowedSet.has(k)); + } + return requiredList.some((k) => allowedSet.has(k)); +} + +/** + * 兼容旧用法:任意命中一种权限即可 + */ +export function hasAnyPermission(required) { + return hasPermission(required, false); +} \ No newline at end of file diff --git a/src/views/base/DepartmentView.vue b/src/views/base/DepartmentView.vue index 599d784..ff831b9 100644 --- a/src/views/base/DepartmentView.vue +++ b/src/views/base/DepartmentView.vue @@ -6,7 +6,11 @@ @click="refreshData" > {{ $t("message.refreshData") }} - {{ $t("message.insertDepartment") }}