统一项目权限读取模型

1、合并列表与详情读取权限,统一使用 subjects:read、project_members:read、monitoring_issues:read 等业务读取 key。

2、增加历史读取权限 key 的兼容映射,确保存量项目权限覆盖值不会丢失。

3、同步前端权限工具、路由权限与权限管理页面,避免继续引用已合并的旧权限 key。

4、补充项目角色启停保护与抽屉未保存变更守卫,防止停用 PM 或仍有关联成员的角色。
This commit is contained in:
Cheng Zhou
2026-06-04 11:06:03 +08:00
parent 44d69c2d7b
commit ab4f0d93ed
22 changed files with 852 additions and 349 deletions
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { reactive } from "vue";
import { ElMessage } from "element-plus";
import { useDrawerDirtyGuard } from "./drawerDirtyGuard";
vi.mock("element-plus", () => ({
ElMessage: {
warning: vi.fn(),
},
}));
describe("useDrawerDirtyGuard", () => {
it("allows drawer close when the form has no changes", () => {
const form = reactive({ name: "设备A", status: "启用" });
const guard = useDrawerDirtyGuard(() => form);
const done = vi.fn();
guard.syncBaseline();
guard.beforeClose(done);
expect(done).toHaveBeenCalledTimes(1);
expect(ElMessage.warning).not.toHaveBeenCalled();
});
it("blocks drawer close when the form has unsaved changes", () => {
const form = reactive({ name: "设备A", status: "启用" });
const guard = useDrawerDirtyGuard(() => form);
const done = vi.fn();
guard.syncBaseline();
form.name = "设备B";
guard.beforeClose(done);
expect(done).not.toHaveBeenCalled();
expect(ElMessage.warning).toHaveBeenCalledWith("请先保存或取消编辑");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { computed, ref } from "vue";
import { ElMessage } from "element-plus";
const snapshot = (value: unknown) => JSON.stringify(value);
export const useDrawerDirtyGuard = (getValue: () => unknown) => {
const baseline = ref("");
const syncBaseline = () => {
baseline.value = snapshot(getValue());
};
const isDirty = computed(() => baseline.value !== snapshot(getValue()));
const beforeClose = (done: () => void) => {
if (isDirty.value) {
ElMessage.warning("请先保存或取消编辑");
return;
}
done();
};
return {
isDirty,
syncBaseline,
beforeClose,
};
};
+23 -2
View File
@@ -12,25 +12,46 @@ describe("permission project role model", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"subject.delete": "subjects:delete"');
expect(source).toContain('"faq.edit": "faq:update"');
expect(source).toContain('"faq.update": "faq:update"');
expect(source).toContain('"faq.delete": "faq:delete"');
expect(source).toContain('"ae.create": "subject_aes:create"');
expect(source).toContain('"ae.close": "subject_aes:update"');
expect(source).toContain('"fees.contract.create": "fees_contracts:create"');
expect(source).toContain('"fees.contract.update": "fees_contracts:update"');
expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"');
expect(source).toContain('"project.members.list": "project_members:list"');
expect(source).toContain('"project.members.candidates": "project_members:candidates"');
expect(source).toContain('"project.members.list": "project_members:read"');
expect(source).toContain('"project.members.candidates": "project_members:read"');
expect(source).toContain('"project.members.create": "project_members:create"');
expect(source).toContain('"project.members.update": "project_members:update"');
expect(source).toContain('"project.members.delete": "project_members:delete"');
expect(source).toContain('"documents.create": "documents:create"');
expect(source).toContain('"documents.delete": "documents:delete"');
expect(source).toContain('"precautions.create": "precautions:create"');
expect(source).toContain('"precautions.update": "precautions:update"');
expect(source).toContain('"precautions.delete": "precautions:delete"');
expect(source).toContain('"faq.category.create": "faq_category:create"');
expect(source).toContain('"faq.category.update": "faq_category:update"');
expect(source).toContain('"faq.category.delete": "faq_category:delete"');
expect(source).toContain('"faq.reply.delete": "faq_reply:delete"');
expect(source).toContain('"startup.initiation.create": "startup_initiation:create"');
expect(source).toContain('"startup.initiation.update": "startup_initiation:update"');
expect(source).toContain('"startup.initiation.delete": "startup_initiation:delete"');
expect(source).toContain('"startup.ethics.create": "startup_ethics:create"');
expect(source).toContain('"startup.ethics.update": "startup_ethics:update"');
expect(source).toContain('"startup.ethics.delete": "startup_ethics:delete"');
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
expect(source).toContain('"project.milestones.update": "project_milestones:update"');
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
expect(source).not.toContain("projectPermissions.value?.roles");
expect(source).not.toContain('"faq.edit": "etmf');
expect(source).not.toContain('"ADMIN", "PM"');
expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"');
expect(source).not.toContain('"precautions.write": "precautions:create"');
expect(source).not.toContain("project.members.manage");
});
});
+64 -3
View File
@@ -10,11 +10,32 @@ const PERMISSIONS: Record<string, string[]> = {
"subject.enroll": ["ADMIN"],
"subject.complete": ["ADMIN"],
"subject.drop": ["ADMIN"],
"subject.delete": ["ADMIN"],
"ae.create": ["ADMIN"],
"ae.close": ["ADMIN"],
"faq.edit": ["ADMIN"],
"faq.update": ["ADMIN"],
"faq.delete": ["ADMIN"],
"faq.create": ["ADMIN"],
"faq.reply": ["ADMIN"],
"faq.reply.delete": ["ADMIN"],
"faq.category.read": ["ADMIN"],
"faq.category.create": ["ADMIN"],
"faq.category.update": ["ADMIN"],
"faq.category.delete": ["ADMIN"],
"precautions.create": ["ADMIN"],
"precautions.update": ["ADMIN"],
"precautions.delete": ["ADMIN"],
"startup.initiation.create": ["ADMIN"],
"startup.initiation.update": ["ADMIN"],
"startup.initiation.delete": ["ADMIN"],
"startup.ethics.create": ["ADMIN"],
"startup.ethics.update": ["ADMIN"],
"startup.ethics.delete": ["ADMIN"],
"startup.auth.create": ["ADMIN"],
"startup.auth.update": ["ADMIN"],
"startup.auth.delete": ["ADMIN"],
"project.milestones.update": ["ADMIN"],
"project.members.list": ["ADMIN"],
"project.members.candidates": ["ADMIN"],
"project.members.create": ["ADMIN"],
@@ -37,8 +58,28 @@ const REASONS: Record<string, string> = {
"subject.drop": TEXT.modules.permissions.subjectDrop,
"ae.close": TEXT.modules.permissions.aeClose,
"faq.edit": TEXT.modules.permissions.faqEdit,
"faq.update": TEXT.modules.permissions.faqEdit,
"faq.delete": TEXT.modules.permissions.faqEdit,
"faq.create": TEXT.modules.permissions.faqCreate,
"faq.reply": TEXT.modules.permissions.faqReply,
"faq.reply.delete": TEXT.modules.permissions.faqReply,
"faq.category.read": TEXT.modules.permissions.default,
"faq.category.create": TEXT.modules.permissions.faqEdit,
"faq.category.update": TEXT.modules.permissions.faqEdit,
"faq.category.delete": TEXT.modules.permissions.faqEdit,
"precautions.create": TEXT.modules.permissions.default,
"precautions.update": TEXT.modules.permissions.default,
"precautions.delete": TEXT.modules.permissions.default,
"startup.initiation.create": TEXT.modules.permissions.default,
"startup.initiation.update": TEXT.modules.permissions.default,
"startup.initiation.delete": TEXT.modules.permissions.default,
"startup.ethics.create": TEXT.modules.permissions.default,
"startup.ethics.update": TEXT.modules.permissions.default,
"startup.ethics.delete": TEXT.modules.permissions.default,
"startup.auth.create": TEXT.modules.permissions.default,
"startup.auth.update": TEXT.modules.permissions.default,
"startup.auth.delete": TEXT.modules.permissions.default,
"project.milestones.update": TEXT.modules.permissions.default,
"project.members.list": TEXT.modules.permissions.projectMembersManage,
"project.members.candidates": TEXT.modules.permissions.projectMembersManage,
"project.members.create": TEXT.modules.permissions.projectMembersManage,
@@ -71,16 +112,36 @@ export const usePermission = () => {
"subject.enroll": "subjects:update",
"subject.complete": "subjects:update",
"subject.drop": "subjects:update",
"subject.delete": "subjects:delete",
"ae.create": "subject_aes:create",
"ae.close": "subject_aes:update",
"project.overview.read": "project_overview:read",
"faq.edit": "faq:update",
"faq.update": "faq:update",
"faq.delete": "faq:delete",
"faq.create": "faq:create",
"faq.reply": "faq_reply:create",
"faq.reply.delete": "faq_reply:delete",
"faq.category.read": "faq_category:read",
"faq.category.create": "faq_category:create",
"faq.category.update": "faq_category:update",
"faq.category.delete": "faq_category:delete",
"precautions.read": "precautions:read",
"precautions.write": "precautions:create",
"project.members.list": "project_members:list",
"project.members.candidates": "project_members:candidates",
"precautions.create": "precautions:create",
"precautions.update": "precautions:update",
"precautions.delete": "precautions:delete",
"startup.initiation.create": "startup_initiation:create",
"startup.initiation.update": "startup_initiation:update",
"startup.initiation.delete": "startup_initiation:delete",
"startup.ethics.create": "startup_ethics:create",
"startup.ethics.update": "startup_ethics:update",
"startup.ethics.delete": "startup_ethics:delete",
"startup.auth.create": "startup_auth:create",
"startup.auth.update": "startup_auth:update",
"startup.auth.delete": "startup_auth:delete",
"project.milestones.update": "project_milestones:update",
"project.members.list": "project_members:read",
"project.members.candidates": "project_members:read",
"project.members.create": "project_members:create",
"project.members.update": "project_members:update",
"project.members.delete": "project_members:delete",
@@ -5,25 +5,30 @@ describe("project route permissions", () => {
it("maps project routes to backend operation keys", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
expect(getProjectRoutePermission("/fees/contracts")).toEqual({ operationKey: "fees_contracts:read" });
expect(getProjectRoutePermission("/finance/contracts")).toBeNull();
expect(getProjectRoutePermission("/finance/contracts/new")).toBeNull();
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/drug/shipments/new")).toEqual({ operationKey: "drug_shipments:create" });
expect(getProjectRoutePermission("/drug/shipments/abc/edit")).toEqual({ operationKey: "drug_shipments:update" });
expect(getProjectRoutePermission("/drug/shipments/abc")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/subjects/new")).toEqual({ operationKey: "subjects:create" });
expect(getProjectRoutePermission("/subjects/abc/edit")).toEqual({ operationKey: "subjects:update" });
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:read" });
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:read" });
expect(getProjectRoutePermission("/risk-issues/pd")).toEqual({ operationKey: "subject_pds:list" });
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:list" });
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:read" });
expect(getProjectRoutePermission("/etmf")).toEqual({ operationKey: "documents:read" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "startup_initiation:create" });
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "startup_initiation:update" });
expect(getProjectRoutePermission("/startup/ethics/new")).toEqual({ operationKey: "startup_ethics:create" });
expect(getProjectRoutePermission("/startup/ethics/abc/edit")).toEqual({ operationKey: "startup_ethics:update" });
expect(getProjectRoutePermission("/startup/feasibility-ethics")).toEqual({
operationKeys: ["startup_initiation:read", "startup_ethics:read"],
});
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:create" });
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toEqual({ operationKey: "startup_auth:update" });
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/startup/training/new")).toBeNull();
expect(getProjectRoutePermission("/startup/training/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
@@ -42,4 +47,46 @@ describe("project route permissions", () => {
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/precautions"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/precautions");
});
it("can fall back to every accessible risk issue child module", () => {
expect(
findFirstAccessibleProjectPath(
{
QA: {
"subject_aes:read": { allowed: false },
"subject_pds:list": { allowed: true },
"monitoring_issues:read": { allowed: false },
},
} as any,
"QA",
false,
),
).toBe("/risk-issues/pd");
expect(
findFirstAccessibleProjectPath(
{
QA: {
"subject_aes:read": { allowed: false },
"subject_pds:list": { allowed: false },
"monitoring_issues:read": { allowed: true },
},
} as any,
"QA",
false,
),
).toBe("/risk-issues/monitoring-visits");
});
it("allows combined landing pages when any child module is readable", () => {
const matrix = {
CRA: {
"startup_initiation:read": { allowed: false },
"startup_ethics:read": { allowed: true },
},
} as any;
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/startup/feasibility-ethics"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/startup/feasibility-ethics");
});
});
+20 -12
View File
@@ -2,15 +2,14 @@ import type { ApiEndpointPermissionsResponse } from "../types/api";
import { isApiPermissionAllowed } from "./apiPermissionValue";
export type ProjectRoutePermission = {
operationKey: string;
operationKey?: string;
operationKeys?: string[];
};
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/fees/contracts/new"], permission: { operationKey: "fees_contracts:create" } },
{ prefixes: ["/drug/shipments/new"], permission: { operationKey: "drug_shipments:create" } },
{ prefixes: ["/startup/feasibility/new"], permission: { operationKey: "startup_initiation:create" } },
{ prefixes: ["/startup/ethics/new"], permission: { operationKey: "startup_ethics:create" } },
{ prefixes: ["/startup/kickoff/new", "/startup/training/new"], permission: { operationKey: "startup_auth:create" } },
{ prefixes: ["/startup/kickoff/new"], permission: { operationKey: "startup_auth:create" } },
{ prefixes: ["/subjects/new"], permission: { operationKey: "subjects:create" } },
{ prefixes: ["/knowledge/precautions/new"], permission: { operationKey: "precautions:create" } },
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
@@ -19,29 +18,32 @@ const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePerm
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility"], permission: { operationKey: "startup_initiation:read" } },
{ prefixes: ["/startup/feasibility-ethics"], permission: { operationKeys: ["startup_initiation:read", "startup_ethics:read"] } },
{ prefixes: ["/startup/feasibility"], permission: { operationKey: "startup_initiation:read" } },
{ prefixes: ["/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:list" } },
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:read" } },
{ prefixes: ["/risk-issues/pd"], permission: { operationKey: "subject_pds:list" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:list" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:read" } },
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
];
const editRoutePermissions: Array<{ pattern: RegExp; permission: ProjectRoutePermission }> = [
{ pattern: /^\/fees\/contracts\/[^/]+\/edit$/, permission: { operationKey: "fees_contracts:update" } },
{ pattern: /^\/drug\/shipments\/[^/]+\/edit$/, permission: { operationKey: "drug_shipments:update" } },
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "startup_initiation:update" } },
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "startup_ethics:update" } },
{ pattern: /^\/startup\/kickoff\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
{ pattern: /^\/startup\/training\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
{ pattern: /^\/subjects\/[^/]+\/edit$/, permission: { operationKey: "subjects:update" } },
{ pattern: /^\/knowledge\/precautions\/[^/]+\/edit$/, permission: { operationKey: "precautions:update" } },
];
const removedRoutePatterns = [
/^\/startup\/kickoff\/[^/]+\/edit$/,
/^\/startup\/training\/new$/,
/^\/startup\/training\/[^/]+\/edit$/,
];
export const projectRouteLandingPaths = [
"/project/overview",
"/project/milestones",
@@ -53,6 +55,8 @@ export const projectRouteLandingPaths = [
"/startup/meeting-auth",
"/subjects",
"/risk-issues/sae",
"/risk-issues/pd",
"/risk-issues/monitoring-visits",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/precautions",
@@ -62,6 +66,9 @@ export const projectRouteLandingPaths = [
export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => {
const normalized = path || "";
if (removedRoutePatterns.some((pattern) => pattern.test(normalized))) {
return null;
}
for (const item of editRoutePermissions) {
if (item.pattern.test(normalized)) {
return item.permission;
@@ -83,7 +90,8 @@ export const hasProjectPermission = (
) => {
if (!permission || isAdmin) return true;
if (!role || !permissions) return false;
return isApiPermissionAllowed(permissions[role]?.[permission.operationKey]);
const operationKeys = permission.operationKeys || (permission.operationKey ? [permission.operationKey] : []);
return operationKeys.some((operationKey) => isApiPermissionAllowed(permissions[role]?.[operationKey]));
};
export const findFirstAccessibleProjectPath = (