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

新增项目级角色权限矩阵,覆盖 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
+53 -23
View File
@@ -34,73 +34,91 @@
<span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="canAccessPmAdminBackend" class="menu-group">
<template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
</template>
<el-menu-item v-if="canAccessPmAdminModule('project_members')" :index="`/admin/projects/${study.currentStudy?.id}/members`">
<el-icon><User /></el-icon>
<span>{{ TEXT.modules.adminProjectMembers.memberLabel }}</span>
</el-menu-item>
<el-menu-item v-if="canAccessPmAdminModule('sites')" :index="`/admin/projects/${study.currentStudy?.id}/sites`">
<el-icon><Suitcase /></el-icon>
<span>{{ TEXT.modules.adminSites.title }}</span>
</el-menu-item>
<el-menu-item v-if="canAccessPmAdminModule('audit_export')" index="/admin/audit-logs">
<el-icon><Document /></el-icon>
<span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="study.currentStudy" class="menu-group">
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
<template #title>
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
</template>
<el-menu-item index="/project/overview">
<el-menu-item v-if="canAccessProjectPath('/project/overview')" index="/project/overview">
<el-icon><House /></el-icon>
<span>{{ TEXT.menu.projectOverview }}</span>
</el-menu-item>
<el-menu-item index="/project/milestones">
<el-menu-item v-if="canAccessProjectPath('/project/milestones')" index="/project/milestones">
<el-icon><Calendar /></el-icon>
<span>{{ TEXT.menu.projectMilestones }}</span>
</el-menu-item>
<el-menu-item index="/fees/contracts">
<el-menu-item v-if="canAccessProjectPath('/fees/contracts')" index="/fees/contracts">
<el-icon><Coin /></el-icon>
<span>{{ TEXT.menu.feeContracts }}</span>
</el-menu-item>
<el-sub-menu index="materials">
<el-sub-menu v-if="canAccessAnyProjectPath(['/drug/shipments', '/materials/equipment'])" index="materials">
<template #title>
<el-icon><Box /></el-icon>
<span>{{ TEXT.menu.materialManagement }}</span>
</template>
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
<el-menu-item index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/drug/shipments')" index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/materials/equipment')" index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/file-versions">
<el-menu-item v-if="canAccessProjectPath('/file-versions')" index="/file-versions">
<el-icon><Files /></el-icon>
<span>{{ TEXT.menu.fileVersionManagement }}</span>
</el-menu-item>
<el-menu-item index="/startup/feasibility-ethics">
<el-menu-item v-if="canAccessProjectPath('/startup/feasibility-ethics')" index="/startup/feasibility-ethics">
<el-icon><Calendar /></el-icon>
<span>{{ TEXT.menu.startupFeasibilityEthics }}</span>
</el-menu-item>
<el-menu-item index="/startup/meeting-auth">
<el-menu-item v-if="canAccessProjectPath('/startup/meeting-auth')" index="/startup/meeting-auth">
<el-icon><CircleCheck /></el-icon>
<span>{{ TEXT.menu.startupMeetingAuth }}</span>
</el-menu-item>
<el-menu-item index="/subjects">
<el-menu-item v-if="canAccessProjectPath('/subjects')" index="/subjects">
<el-icon><User /></el-icon>
<span>{{ TEXT.menu.subjects }}</span>
</el-menu-item>
<el-sub-menu index="risk-issues">
<el-sub-menu v-if="canAccessAnyProjectPath(['/risk-issues/sae', '/risk-issues/pd', '/risk-issues/monitoring-visits'])" index="risk-issues">
<template #title>
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.riskIssues }}</span>
</template>
<el-menu-item index="/risk-issues/sae">{{ TEXT.menu.riskIssueSae }}</el-menu-item>
<el-menu-item index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
<el-menu-item index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/risk-issues/sae')" index="/risk-issues/sae">{{ TEXT.menu.riskIssueSae }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/risk-issues/pd')" index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/risk-issues/monitoring-visits')" index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/monitoring-audit">
<el-menu-item v-if="canAccessProjectPath('/monitoring-audit')" index="/monitoring-audit">
<el-icon><ChatDotRound /></el-icon>
<span>{{ TEXT.menu.monitoringAudit }}</span>
</el-menu-item>
<el-menu-item index="/etmf">
<el-menu-item v-if="canAccessProjectPath('/etmf')" index="/etmf">
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.etmf }}</span>
</el-menu-item>
<el-sub-menu index="knowledge">
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/notes', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>{{ TEXT.menu.sharedLibrary }}</span>
</template>
<el-menu-item index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
<el-menu-item index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
<el-menu-item index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/medical-consult')" index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/notes')" index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
</el-sub-menu>
</el-menu-item-group>
</el-menu>
@@ -217,6 +235,7 @@ import {
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
const auth = useAuthStore();
const study = useStudyStore();
@@ -224,6 +243,16 @@ const router = useRouter();
const route = useRoute();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessPmAdminModule = (module: string) =>
!isAdmin.value && projectRole.value === "PM" && !!study.currentPermissions?.roles?.PM?.[module]?.read;
const canAccessPmAdminBackend = computed(() =>
!!study.currentStudy && ["project_members", "sites", "audit_export"].some((module) => canAccessPmAdminModule(module))
);
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
@@ -375,9 +404,10 @@ const breadcrumbs = computed(() => {
return items;
});
const handleBreadcrumbCommand = (cmd: any) => {
const handleBreadcrumbCommand = async (cmd: any) => {
if (cmd.type === 'study') {
study.setCurrentStudy(cmd.value);
await study.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview");
} else if (cmd.type === 'site') {
study.setCurrentSite(cmd.value);
+2 -1
View File
@@ -57,7 +57,7 @@ const loadStudies = async () => {
}
};
const onCommand = (cmd: any) => {
const onCommand = async (cmd: any) => {
if (cmd === "clear") {
study.clearCurrentStudy();
router.push("/workbench");
@@ -65,6 +65,7 @@ const onCommand = (cmd: any) => {
}
if (cmd?.type === "switch" && cmd.study) {
study.setCurrentStudy(cmd.study as Study);
await study.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview");
}
};
@@ -67,7 +67,8 @@ import AttachmentUploader from "./AttachmentUploader.vue";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isSystemAdmin } from "../../utils/roles";
import { usePermission } from "../../utils/permission";
import { listMembers } from "../../api/members";
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import { TEXT } from "../../locales";
@@ -84,6 +85,7 @@ const attachments = ref<any[]>([]);
const loading = ref(false);
const auth = useAuthStore();
const study = useStudyStore();
const permission = usePermission();
const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
@@ -203,11 +205,8 @@ const preview = (row: any) => {
const canDelete = (row: any) => {
if (props.readonly) return false;
const userId = auth.user?.id;
const projectRole = getProjectRole(study.currentStudy, study.currentStudyRole);
const ownerId =
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
return userId === ownerId || isSystemAdmin(auth.user) || projectRole === "PM";
const action = props.entityType === "knowledge_note" ? "shared.library.write" : "file.attachment.delete";
return isSystemAdmin(auth.user) || permission.can(action);
};
const remove = async (row: any) => {
@@ -83,7 +83,8 @@ import { fetchAttachments, uploadAttachment, deleteAttachment } from "../../api/
import { listMembers } from "../../api/members";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isSystemAdmin } from "../../utils/roles";
import { usePermission } from "../../utils/permission";
import { formatFileSize } from "../attachments/attachmentUtils";
import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import StateEmpty from "../StateEmpty.vue";
@@ -110,6 +111,7 @@ const grouped = reactive<Record<string, any[]>>({});
const progressMap = reactive<Record<string, number>>({});
const auth = useAuthStore();
const study = useStudyStore();
const permission = usePermission();
const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
@@ -269,11 +271,7 @@ const download = (row: any) => {
const canDelete = (row: any) => {
if (props.readonly) return false;
const userId = auth.user?.id;
const projectRole = getProjectRole(study.currentStudy, study.currentStudyRole);
const ownerId =
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
return userId === ownerId || isSystemAdmin(auth.user) || projectRole === "PM";
return isSystemAdmin(auth.user) || permission.can("fees.attachment.delete");
};
const remove = async (row: any) => {