权限管理:接入项目级权限矩阵

新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。

后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。

前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。

补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
This commit is contained in:
Cheng Zhou
2026-05-13 08:58:03 +08:00
parent 77e842637d
commit 939fc70532
63 changed files with 1952 additions and 387 deletions
+64 -7
View File
@@ -1,7 +1,10 @@
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getProjectRole, isSystemAdmin } from "../utils/roles";
import { isSystemAdmin } from "../utils/roles";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
import { fetchStudyDetail } from "../api/studies";
import { fetchProjectRolePermissions } from "../api/projectPermissions";
import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
@@ -16,6 +19,7 @@ import AdminProjects from "../views/admin/Projects.vue";
import ProjectMembers from "../views/admin/ProjectMembers.vue";
import AdminSites from "../views/admin/Sites.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProjectPermissions from "../views/admin/ProjectPermissions.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue";
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
@@ -435,19 +439,25 @@ const routes: RouteRecordRaw[] = [
path: "projects/:projectId/members",
name: "AdminProjectMembers",
component: ProjectMembers,
meta: { title: TEXT.modules.adminProjectMembers.memberLabel, requiresAdmin: true },
meta: { title: TEXT.modules.adminProjectMembers.memberLabel, adminProjectPermission: { module: "project_members", action: "read" } },
},
{
path: "projects/:projectId/permissions",
name: "AdminProjectPermissions",
component: ProjectPermissions,
meta: { title: "权限管理", requiresAdmin: true },
},
{
path: "projects/:projectId/sites",
name: "AdminSites",
component: AdminSites,
meta: { title: TEXT.modules.adminSites.title, requiresAdmin: true },
meta: { title: TEXT.modules.adminSites.title, adminProjectPermission: { module: "sites", action: "read" } },
},
{
path: "audit-logs",
name: "AdminAuditLogs",
component: AuditLogs,
meta: { title: TEXT.menu.auditLogs, requiresAdmin: true },
meta: { title: TEXT.menu.auditLogs, adminProjectPermission: { module: "audit_export", action: "read" } },
},
],
},
@@ -462,6 +472,29 @@ const router = createRouter({
routes,
});
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (isAdmin) return true;
const studyStore = useStudyStore();
const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined;
if (!permission) return true;
const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : "";
if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) {
const [{ data: project }, { data: permissions }] = await Promise.all([
fetchStudyDetail(routeProjectId),
fetchProjectRolePermissions(routeProjectId),
]);
studyStore.setCurrentStudy(project);
studyStore.currentPermissions = permissions;
} else if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions();
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return false;
return !!studyStore.currentPermissions?.roles?.PM?.[permission.module]?.[permission.action];
};
router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -511,13 +544,24 @@ router.beforeEach(async (to, _from, next) => {
return;
}
}
if (to.meta.adminProjectPermission) {
const allowed = await ensureAdminProjectAccess(to, isAdmin).catch(() => false);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/workbench" });
return;
}
}
if (to.meta.requiresAdmin && !isAdmin) {
next({ path: "/workbench" });
return;
}
if (to.name === "AuditLogs") {
const projectRole = getProjectRole(studyStore.currentStudy, studyStore.currentStudyRole);
if (!(isAdmin || projectRole === "PM")) {
if (to.name === "AdminAuditLogs") {
if (studyStore.currentStudy && !studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const canReadAudit = !!studyStore.currentPermissions?.roles?.[role]?.audit_export?.read;
if (!(isAdmin || (role === "PM" && canReadAudit))) {
next({ path: "/project/overview" });
return;
}
@@ -526,6 +570,19 @@ router.beforeEach(async (to, _from, next) => {
next({ path: isAdmin ? "/admin/users" : "/workbench" });
return;
}
if (to.meta.requiresStudy && studyStore.currentStudy) {
if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
const projectRole = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const permission = getProjectRoutePermission(to.path);
const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions?.roles, projectRole, permission, isAdmin);
if (!canAccessProjectRoute) {
const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions?.roles, projectRole, isAdmin);
next({ path: fallback || "/workbench" });
return;
}
}
next();
});