diff --git a/backend/alembic/versions/20260513_01_add_medical_review_role.py b/backend/alembic/versions/20260513_01_add_medical_review_role.py
new file mode 100644
index 00000000..611151cb
--- /dev/null
+++ b/backend/alembic/versions/20260513_01_add_medical_review_role.py
@@ -0,0 +1,25 @@
+"""add medical review role
+
+Revision ID: 20260513_01
+Revises: 20260512_03
+Create Date: 2026-05-13 09:15:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "20260513_01"
+down_revision: Union[str, None] = "20260512_03"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'MEDICAL_REVIEW'")
+
+
+def downgrade() -> None:
+ pass
diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py
index 3bedd659..0ba9fa42 100644
--- a/backend/app/core/project_permissions.py
+++ b/backend/app/core/project_permissions.py
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_role_permission import StudyRolePermission
-PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "IMP", "QA")
+PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA")
PROJECT_PERMISSION_MODULES = (
{"key": "project_members", "label": "项目成员", "description": "维护项目账号、成员角色与启停状态"},
@@ -93,6 +93,24 @@ DEFAULT_PROJECT_ROLE_PERMISSIONS: dict[str, dict[str, dict[str, bool]]] = {
"sites": {"read": False, "write": False},
"audit_export": {"read": False, "write": False},
},
+ "MEDICAL_REVIEW": {
+ "project_overview": {"read": True, "write": False},
+ "project_milestones": {"read": False, "write": False},
+ "fees": {"read": False, "write": False},
+ "materials": {"read": False, "write": False},
+ "file_versions": {"read": False, "write": False},
+ "startup_ethics": {"read": False, "write": False},
+ "startup_auth": {"read": False, "write": False},
+ "subjects": {"read": True, "write": False},
+ "risk_issues": {"read": True, "write": True},
+ "monitoring_audit": {"read": True, "write": False},
+ "etmf": {"read": False, "write": False},
+ "faq": {"read": True, "write": True},
+ "shared_library": {"read": True, "write": True},
+ "project_members": {"read": False, "write": False},
+ "sites": {"read": False, "write": False},
+ "audit_export": {"read": False, "write": False},
+ },
"IMP": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": True, "write": False},
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index dd0a5570..1d66622a 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -14,6 +14,7 @@ class UserRole(str, enum.Enum):
PM = "PM"
CRA = "CRA"
PV = "PV"
+ MEDICAL_REVIEW = "MEDICAL_REVIEW"
IMP = "IMP"
QA = "QA"
diff --git a/backend/app/schemas/member.py b/backend/app/schemas/member.py
index 63242f4b..fb502dd1 100644
--- a/backend/app/schemas/member.py
+++ b/backend/app/schemas/member.py
@@ -5,7 +5,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.user import UserDisplay
-StudyRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"]
+StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
class StudyMemberCreate(BaseModel):
diff --git a/backend/app/schemas/project_permission.py b/backend/app/schemas/project_permission.py
index 3271d3f4..bb5640db 100644
--- a/backend/app/schemas/project_permission.py
+++ b/backend/app/schemas/project_permission.py
@@ -5,7 +5,7 @@ from typing import Literal
from pydantic import BaseModel, Field
-ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]
+ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
ProjectPermissionAction = Literal["read", "write"]
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 8b0393ac..c2638d9f 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -5,8 +5,8 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
-UserRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"]
-RegisterRole = Literal["PM", "CRA", "PV", "IMP"]
+UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
+RegisterRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
diff --git a/backend/tests/test_project_permissions.py b/backend/tests/test_project_permissions.py
index 094d34e9..d345fc81 100644
--- a/backend/tests/test_project_permissions.py
+++ b/backend/tests/test_project_permissions.py
@@ -75,6 +75,14 @@ def test_default_business_permissions_match_role_responsibilities():
assert matrix["CRA"]["subjects"] == {"read": True, "write": True}
assert matrix["CRA"]["monitoring_audit"] == {"read": True, "write": True}
assert matrix["PV"]["risk_issues"] == {"read": True, "write": True}
+ assert matrix["MEDICAL_REVIEW"]["project_overview"] == {"read": True, "write": False}
+ assert matrix["MEDICAL_REVIEW"]["subjects"] == {"read": True, "write": False}
+ assert matrix["MEDICAL_REVIEW"]["risk_issues"] == {"read": True, "write": True}
+ assert matrix["MEDICAL_REVIEW"]["monitoring_audit"] == {"read": True, "write": False}
+ assert matrix["MEDICAL_REVIEW"]["faq"] == {"read": True, "write": True}
+ assert matrix["MEDICAL_REVIEW"]["shared_library"] == {"read": True, "write": True}
+ assert matrix["MEDICAL_REVIEW"]["fees"] == {"read": False, "write": False}
+ assert matrix["MEDICAL_REVIEW"]["materials"] == {"read": False, "write": False}
assert matrix["IMP"]["materials"] == {"read": True, "write": True}
assert matrix["PM"]["faq"] == {"read": True, "write": True}
assert matrix["CRA"]["faq"] == {"read": True, "write": True}
@@ -115,16 +123,16 @@ def test_non_pm_roles_cannot_be_granted_management_backend_permissions():
module: {"read": True, "write": True}
for module in management_modules
}
- for role in ["CRA", "PV", "IMP", "QA"]
+ for role in ["CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
})
- for role in ["CRA", "PV", "IMP", "QA"]:
+ for role in ["CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]:
for module in management_modules:
assert matrix[role][module] == {"read": False, "write": False}
def test_each_non_admin_role_can_toggle_each_writable_module():
- roles = ["PM", "CRA", "PV", "IMP", "QA"]
+ roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
management_modules = {"project_members", "sites", "audit_export"}
writable_modules = [
module["key"]
@@ -162,11 +170,20 @@ def test_read_only_modules_never_accept_write_for_any_role():
module: {"read": False, "write": True}
for module in read_only_modules
}
- for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]
+ for role in ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
}
matrix = normalize_permission_matrix(payload)
- for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]:
+ for role in ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]:
for module in read_only_modules:
assert matrix[role][module] == {"read": True, "write": False}
+
+
+def test_medical_review_default_permissions_are_independent_from_pv():
+ matrix = normalize_permission_matrix()
+
+ assert matrix["PV"]["monitoring_audit"] == {"read": True, "write": True}
+ assert matrix["MEDICAL_REVIEW"]["monitoring_audit"] == {"read": True, "write": False}
+ assert matrix["PV"]["fees"] == {"read": True, "write": False}
+ assert matrix["MEDICAL_REVIEW"]["fees"] == {"read": False, "write": False}
diff --git a/frontend/src/dictionaries/role.dict.ts b/frontend/src/dictionaries/role.dict.ts
index a4e4d500..9c1552ca 100644
--- a/frontend/src/dictionaries/role.dict.ts
+++ b/frontend/src/dictionaries/role.dict.ts
@@ -7,7 +7,9 @@ const items = [
{ value: "PM", label: TEXT.enums.userRole.PM, order: 2 },
{ value: "CRA", label: TEXT.enums.userRole.CRA, order: 3 },
{ value: "PV", label: TEXT.enums.userRole.PV, order: 4 },
- { value: "IMP", label: TEXT.enums.userRole.IMP, order: 5 },
+ { value: "MEDICAL_REVIEW", label: TEXT.enums.userRole.MEDICAL_REVIEW, order: 5 },
+ { value: "IMP", label: TEXT.enums.userRole.IMP, order: 6 },
+ { value: "QA", label: TEXT.enums.userRole.QA, order: 7 },
];
export const roleDict: Dict = createDict(items);
diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts
index 306a6f82..14e9901b 100644
--- a/frontend/src/locales/zh-CN.ts
+++ b/frontend/src/locales/zh-CN.ts
@@ -989,6 +989,7 @@ export const TEXT = {
PM: "项目负责人",
CRA: "CRA",
PV: "PV",
+ MEDICAL_REVIEW: "医学审核",
IMP: "药品管理员",
QA: "QA",
},
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 5dfa2fd0..e0f5b4a7 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -32,7 +32,7 @@ export interface LoginKeyResponse {
expires_at: string;
}
-export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN";
+export type UserRole = "PM" | "CRA" | "PV" | "MEDICAL_REVIEW" | "IMP" | "QA" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export interface UserInfo {
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index 27ff8f3c..8312d8d5 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -167,7 +167,7 @@ const protocolSections = [
title: "1. 服务条款",
paragraphs: [
"本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。",
- "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。",
+ "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、医学审核、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。",
],
},
{
@@ -187,7 +187,7 @@ const protocolSections = [
{
title: "4. 权限与审计",
paragraphs: [
- "系统按照管理员、PM、CRA、PV、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。",
+ "系统按照管理员、PM、CRA、PV、医学审核、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。",
"系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。",
],
},
diff --git a/frontend/src/views/admin/ProjectMembers.vue b/frontend/src/views/admin/ProjectMembers.vue
index f6621ef1..583957a4 100644
--- a/frontend/src/views/admin/ProjectMembers.vue
+++ b/frontend/src/views/admin/ProjectMembers.vue
@@ -44,7 +44,9 @@
+
+
{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}
@@ -82,7 +84,9 @@
+
+
diff --git a/frontend/src/views/admin/ProjectPermissions.test.ts b/frontend/src/views/admin/ProjectPermissions.test.ts
index 635e21c7..c28ebabf 100644
--- a/frontend/src/views/admin/ProjectPermissions.test.ts
+++ b/frontend/src/views/admin/ProjectPermissions.test.ts
@@ -10,6 +10,7 @@ describe("project permissions matrix", () => {
expect(source).toContain('new Set(["project_members", "sites", "audit_export"])');
expect(source).toContain('role === "PM" || role === "ADMIN"');
+ expect(source).toContain('"MEDICAL_REVIEW"');
expect(source).toContain("!canConfigureAdminModule(role, mod.key)");
expect(source).toContain("!canConfigureAdminModule(role, moduleKey)");
});
diff --git a/frontend/src/views/admin/ProjectPermissions.vue b/frontend/src/views/admin/ProjectPermissions.vue
index 0c9f417d..a7f7c71d 100644
--- a/frontend/src/views/admin/ProjectPermissions.vue
+++ b/frontend/src/views/admin/ProjectPermissions.vue
@@ -86,7 +86,7 @@ import { TEXT } from "../../locales";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
-const roleOrder: UserRole[] = ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"];
+const roleOrder: UserRole[] = ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"];
const loading = ref(false);
const saving = ref(false);
@@ -388,6 +388,11 @@ onMounted(load);
background: #ffe4e6;
}
+.role-badge--medical_review {
+ color: #0f766e;
+ background: #ccfbf1;
+}
+
.role-badge--imp {
color: #b45309;
background: #fef3c7;
diff --git a/frontend/src/views/documents/DocumentDetail.vue b/frontend/src/views/documents/DocumentDetail.vue
index 6e34a5fc..29b04d40 100644
--- a/frontend/src/views/documents/DocumentDetail.vue
+++ b/frontend/src/views/documents/DocumentDetail.vue
@@ -360,7 +360,7 @@ const uploadRules: FormRules = {
};
const roleOptions = computed(() => {
- const fallbackRoles = ["CRA", "PM", "IMP", "PV"];
+ const fallbackRoles = ["CRA", "PM", "IMP", "PV", "MEDICAL_REVIEW"];
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
const values = roles.size ? Array.from(roles) : fallbackRoles;
return values.map((value) => ({