统一项目权限读取模型

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
@@ -237,24 +237,24 @@ describe("ApiEndpointPermissions.vue", () => {
});
(wrapper.vm as any).operations = [
{ operation_key: "subject_aes:list", module: "subjects", action: "read", description: "查询参与者AE列表", default_roles: [] },
{ operation_key: "subject_aes:read", module: "subjects", action: "read", description: "查询参与者AE列表", default_roles: [] },
{ operation_key: "subject_pds:list", module: "subjects", action: "read", description: "查询参与者PD列表", default_roles: [] },
{ operation_key: "monitoring_issues:list", module: "risk_issues", action: "read", description: "查询监查访视问题列表", default_roles: [] },
{ operation_key: "monitoring_issues:read", module: "risk_issues", action: "read", description: "查询监查访视问题列表", default_roles: [] },
];
const riskRows = (wrapper.vm as any).filteredOperations.filter((operation: any) => operation.module === "risk_issues");
expect(riskRows.map((operation: any) => operation.operation_key)).toEqual([
"subject_aes:list",
"subject_aes:read",
"subject_pds:list",
"monitoring_issues:list",
"monitoring_issues:read",
]);
expect(riskRows.map((operation: any) => (wrapper.vm as any).getOperationSection(operation))).toEqual([
"AE/SAE",
"PD",
"监查访视问题",
]);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:list")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
});
@@ -309,6 +309,13 @@ describe("ApiEndpointPermissions.vue", () => {
description: "查询药品流向详情",
default_roles: [],
})).toBe("药品流向管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "drug_shipments_attachments:delete",
module: "materials",
action: "write",
description: "删除药物发货附件",
default_roles: [],
})).toBe("药品流向管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "material_equipments:read",
module: "materials",
@@ -316,9 +323,16 @@ describe("ApiEndpointPermissions.vue", () => {
description: "查询设备详情",
default_roles: [],
})).toBe("设备管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "material_equipments_attachments:delete",
module: "materials",
action: "write",
description: "删除物资设备附件",
default_roles: [],
})).toBe("设备管理");
});
it("groups startup ethics permissions with visible business names", () => {
it("groups startup ethics permissions into initiation and ethics sections", () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
@@ -338,14 +352,28 @@ describe("ApiEndpointPermissions.vue", () => {
action: "read",
description: "查询立项记录详情",
default_roles: [],
})).toBe("立项记录");
})).toBe("立项");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_initiation_attachments:delete",
module: "startup_ethics",
action: "write",
description: "删除立项记录附件",
default_roles: [],
})).toBe("立项");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_ethics:read",
module: "startup_ethics",
action: "read",
description: "查询伦理记录详情",
default_roles: [],
})).toBe("伦理记录");
})).toBe("伦理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_ethics_attachments:delete",
module: "startup_ethics",
action: "write",
description: "删除伦理记录附件",
default_roles: [],
})).toBe("伦理");
});
it("filters endpoints by search text", async () => {
@@ -340,9 +340,13 @@ const SECTION_LABELS: Record<string, string> = {
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
drug_shipments: "药品流向管理",
drug_shipments_attachments: "药品流向管理",
material_equipments: "设备管理",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
material_equipments_attachments: "设备管理",
startup_initiation: "立项",
startup_initiation_attachments: "立项",
startup_ethics: "伦理",
startup_ethics_attachments: "伦理",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
@@ -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 = (
@@ -95,6 +95,35 @@ describe("permission management custom roles", () => {
expect(dialogSource).not.toContain("可选标签");
});
it("shows role template create edit and delete actions only to system admins", () => {
const source = readSource();
expect(source).toContain('<el-table-column v-if="isAdmin" label="操作"');
expect(source).toContain('v-if="templateDrawerTab === \'list\' && isAdmin"');
expect(source).toContain('<el-button v-if="isAdmin" link type="primary" @click="openEditTemplate(row)">编辑</el-button>');
expect(source).toContain('<el-button v-if="isAdmin" link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>');
});
it("prevents project PMs from disabling the PM active role", () => {
const source = readSource();
expect(source).toContain(':disabled="!canToggleActiveRole(role.key)"');
expect(source).toContain('if (!isAdmin.value && role === "PM") return false;');
expect(source).toContain("if (!canToggleActiveRole(role)) return;");
expect(source).toContain('if (!isAdmin.value && !roles.includes("PM")) roles.unshift("PM");');
});
it("prevents disabling active roles that are still assigned to active members", () => {
const source = readSource();
expect(source).toContain("const activeRoleMemberCounts = computed(() =>");
expect(source).toContain("if (!member.is_active) continue;");
expect(source).toContain("const count = activeRoleMemberCount(role);");
expect(source).toContain("roleActiveDisableReason(role.key)");
expect(source).toContain('该角色仍有关联成员,需先调整成员角色后才能停用');
expect(source).toContain("if (isActiveRoleInUse(role)) return false;");
});
it("keeps member role selects bound to role keys used by the permission matrix", () => {
const source = readSource();
@@ -111,8 +140,8 @@ describe("permission management custom roles", () => {
it("uses granular project member permissions for member management actions", () => {
const source = readSource();
expect(source).toContain('selectedProjectPermissionAllowed("project_members:list")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:candidates")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
@@ -222,6 +251,7 @@ describe("permission management custom roles", () => {
expect(moduleSource).not.toContain('precautions: "注意事项"');
expect(source).toContain('precautions: "注意事项"');
expect(source).toContain('faq: "医学咨询/FAQ"');
expect(source).toContain('material_equipments_attachments: "设备管理"');
});
it("labels contract fees without the removed finance contract prefix in role editor", () => {
@@ -231,7 +231,15 @@
<!--
角色管理抽屉
-->
<el-drawer v-model="templateDrawerVisible" title="角色管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
<el-drawer
v-model="templateDrawerVisible"
title="角色管理"
size="720px"
direction="rtl"
:close-on-click-modal="true"
:before-close="handleTemplateDrawerBeforeClose"
@open="onTemplateDrawerOpen"
>
<div class="drawer-tabs-shell">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
@@ -252,10 +260,10 @@
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column v-if="isAdmin" label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
<el-button v-if="isAdmin" link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -279,12 +287,24 @@
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
<div v-if="roleActiveStatusLabel(role.key)" class="active-role-meta">
{{ roleActiveStatusLabel(role.key) }}
</div>
</div>
</div>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
<el-tooltip
:disabled="!roleActiveDisableReason(role.key)"
:content="roleActiveDisableReason(role.key)"
placement="top"
>
<span>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
:disabled="!canToggleActiveRole(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</span>
</el-tooltip>
</div>
</div>
</el-tab-pane>
@@ -362,7 +382,7 @@
</el-tabs>
<div class="drawer-tab-actions">
<el-button v-if="templateDrawerTab === 'list'" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-if="templateDrawerTab === 'list' && isAdmin" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
保存
</el-button>
@@ -464,6 +484,7 @@ import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import { useDrawerDirtyGuard } from "@/utils/drawerDirtyGuard";
import {
compareProjectPermissionModules,
projectPermissionModuleLabel,
@@ -486,8 +507,8 @@ const selectedProjectPermissionAllowed = (operationKey: string) => {
if (!role || !apiMatrix.value) return false;
return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]);
};
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:list"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:candidates"));
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create"));
const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update"));
const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete"));
@@ -775,6 +796,7 @@ const templateSaving = ref(false);
const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false);
const activeRolesDirtyGuard = useDrawerDirtyGuard(() => activeRolesDraft.value);
const roleOptions = computed(() => {
const options = templates.value.filter((template) => template.category).map((template) => ({
@@ -805,7 +827,8 @@ const loadActiveRoles = async () => {
activeRolesLoading.value = true;
try {
const res = await fetchActiveRoles(selectedStudyId.value);
activeRolesDraft.value = res.data.active_roles;
activeRolesDraft.value = protectPmActiveRole([...res.data.active_roles]);
activeRolesDirtyGuard.syncBaseline();
} catch {
ElMessage.error("加载生效角色失败");
} finally {
@@ -813,7 +836,44 @@ const loadActiveRoles = async () => {
}
};
const activeRoleMemberCounts = computed(() => {
const counts: Record<string, number> = {};
for (const member of members.value) {
if (!member.is_active) continue;
const role = member.role_in_study;
if (!role) continue;
counts[role] = (counts[role] ?? 0) + 1;
}
return counts;
});
const activeRoleMemberCount = (role: string) => activeRoleMemberCounts.value[role] ?? 0;
const isActiveRoleInUse = (role: string) => activeRolesDraft.value.includes(role) && activeRoleMemberCount(role) > 0;
const canToggleActiveRole = (role: string) => {
if (!isAdmin.value && role === "PM") return false;
if (isActiveRoleInUse(role)) return false;
return true;
};
const roleActiveStatusLabel = (role: string) => {
if (role === "PM" && !isAdmin.value) return "项目负责人必需";
const count = activeRoleMemberCount(role);
return count > 0 ? `${count} 名成员使用中` : "";
};
const roleActiveDisableReason = (role: string) => {
if (role === "PM" && !isAdmin.value) return "PM 为项目必需角色,项目 PM 不可停用";
if (isActiveRoleInUse(role)) return "该角色仍有关联成员,需先调整成员角色后才能停用";
return "";
};
const protectPmActiveRole = (roles: string[]) => {
if (!isAdmin.value && !roles.includes("PM")) roles.unshift("PM");
return roles;
};
const toggleActiveRole = (role: string, active: boolean) => {
if (!canToggleActiveRole(role)) return;
if (active) {
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
} else {
@@ -825,7 +885,10 @@ const saveActiveRoles = async () => {
if (!canManageSelectedProject.value) return;
activeRolesSaving.value = true;
try {
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
const roles = protectPmActiveRole([...activeRolesDraft.value]);
await updateActiveRoles(selectedStudyId.value, roles);
activeRolesDraft.value = roles;
activeRolesDirtyGuard.syncBaseline();
await loadPermissionData();
ElMessage.success("生效角色已保存");
} catch {
@@ -996,6 +1059,15 @@ const roleEditorDraft = ref<Record<string, boolean>>({});
const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const roleEditorDirtyGuard = useDrawerDirtyGuard(() => roleEditorDraft.value);
const handleTemplateDrawerBeforeClose = (done: () => void) => {
if (activeRolesDirtyGuard.isDirty.value || (editingRole.value && roleEditorDirtyGuard.isDirty.value)) {
ElMessage.warning("请先保存或取消编辑");
return;
}
done();
};
const roleEditorModuleLabel = projectPermissionModuleLabel;
@@ -1009,6 +1081,7 @@ const PERMISSION_SECTION_LABELS: Record<string, string> = {
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
material_equipments_attachments: "设备管理",
startup_initiation: "立项",
startup_initiation_attachments: "立项",
startup_ethics: "伦理",
@@ -1145,6 +1218,7 @@ const initRoleEditor = () => {
}
}
roleEditorDraft.value = draft;
roleEditorDirtyGuard.syncBaseline();
};
const saveRoleEditor = async () => {
@@ -1160,6 +1234,7 @@ const saveRoleEditor = async () => {
apiMatrix.value = res.data;
const savedRole = editingRole.value;
editingRole.value = "";
roleEditorDirtyGuard.syncBaseline();
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch {
ElMessage.error("保存失败");
@@ -1667,6 +1742,13 @@ onMounted(async () => {
margin-top: 2px;
}
.active-role-meta {
font-size: 12px;
color: #b45309;
margin-top: 4px;
font-weight: 500;
}
.template-name { font-weight: 600; color: #1a2332; }
/* ── 角色编辑器 ── */