diff --git a/backend/alembic/versions/20260514_01_add_permission_templates.py b/backend/alembic/versions/20260514_01_add_permission_templates.py
index b2ad4c18..f64ce94a 100644
--- a/backend/alembic/versions/20260514_01_add_permission_templates.py
+++ b/backend/alembic/versions/20260514_01_add_permission_templates.py
@@ -126,14 +126,6 @@ def _insert_system_templates() -> None:
"IMP",
'{"IMP": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": true, "budget:read": true, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": false, "monitoring_audit:create": false, "monitoring_audit:read": false, "monitoring_audit:update": false, "monitoring_audit:delete": false, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": false, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
- (
- "0c907f29-dc88-4878-9c66-15cbc22f4e13",
- "质量保证",
- "质量检查和审计人员,以查询权限为主",
- "QA",
- "QA",
- '{"QA": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": false, "budget:read": false, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": false, "monitoring_audit:create": false, "monitoring_audit:read": true, "monitoring_audit:update": false, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
- ),
]
for tid, name, desc, category, recommended_roles, perms_json in templates:
diff --git a/backend/alembic/versions/20260521_01_remove_qa_role.py b/backend/alembic/versions/20260521_01_remove_qa_role.py
new file mode 100644
index 00000000..1407e4ff
--- /dev/null
+++ b/backend/alembic/versions/20260521_01_remove_qa_role.py
@@ -0,0 +1,153 @@
+"""remove qa role
+
+Revision ID: 20260521_01
+Revises: 20260520_01
+Create Date: 2026-05-21 09:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "20260521_01"
+down_revision: Union[str, None] = "20260520_01"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+USER_ROLE_VALUES = ("ADMIN", "PM", "CRA", "PV", "IMP", "MEDICAL_REVIEW")
+
+
+def upgrade() -> None:
+ op.execute("CREATE TEMP TABLE qa_user_ids AS SELECT id FROM users WHERE role = 'QA'")
+ op.execute("DELETE FROM permission_access_logs WHERE role = 'QA'")
+ op.execute("DELETE FROM api_endpoint_permissions WHERE role = 'QA'")
+ op.execute("DELETE FROM study_members WHERE role_in_study = 'QA'")
+ op.execute(
+ """
+ UPDATE studies
+ SET active_roles = (
+ SELECT COALESCE(json_agg(role), '[]'::json)
+ FROM json_array_elements_text(active_roles) AS role
+ WHERE role <> 'QA'
+ )
+ WHERE active_roles::text LIKE '%QA%'
+ """
+ )
+ op.execute(
+ """
+ DELETE FROM permission_template_versions
+ WHERE template_id IN (
+ SELECT id FROM permission_templates
+ WHERE category = 'QA' OR recommended_roles = 'QA' OR permissions ? 'QA'
+ )
+ """
+ )
+ op.execute(
+ """
+ DELETE FROM permission_templates
+ WHERE category = 'QA' OR recommended_roles = 'QA' OR permissions ? 'QA'
+ """
+ )
+ op.execute(
+ """
+ UPDATE permission_templates
+ SET created_by = NULL
+ WHERE created_by IN (SELECT id FROM qa_user_ids)
+ """
+ )
+ op.execute(
+ """
+ DELETE FROM audit_logs
+ WHERE operator_role = 'QA'
+ OR operator_id IN (SELECT id FROM users WHERE role = 'QA')
+ """
+ )
+ op.execute(
+ """
+ DO $$
+ DECLARE
+ rel record;
+ BEGIN
+ FOR rel IN
+ SELECT
+ quote_ident(ns.nspname) AS schema_name,
+ quote_ident(cls.relname) AS table_name,
+ quote_ident(att.attname) AS column_name,
+ att.attnotnull AS not_null
+ FROM pg_constraint con
+ JOIN pg_class cls ON cls.oid = con.conrelid
+ JOIN pg_namespace ns ON ns.oid = cls.relnamespace
+ JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
+ WHERE con.contype = 'f'
+ AND con.confrelid = 'users'::regclass
+ AND array_length(con.conkey, 1) = 1
+ LOOP
+ IF rel.table_name = 'users' AND rel.column_name = 'id' THEN
+ CONTINUE;
+ END IF;
+
+ IF rel.not_null THEN
+ EXECUTE format(
+ 'DELETE FROM %s.%s WHERE %s IN (SELECT id FROM qa_user_ids)',
+ rel.schema_name,
+ rel.table_name,
+ rel.column_name
+ );
+ ELSE
+ EXECUTE format(
+ 'UPDATE %s.%s SET %s = NULL WHERE %s IN (SELECT id FROM qa_user_ids)',
+ rel.schema_name,
+ rel.table_name,
+ rel.column_name,
+ rel.column_name
+ );
+ END IF;
+ END LOOP;
+ END $$;
+ """
+ )
+
+ # Delete QA users after dependent project-role and audit rows have been removed.
+ op.execute("DELETE FROM users WHERE id IN (SELECT id FROM qa_user_ids)")
+ op.execute("DROP TABLE qa_user_ids")
+
+ allowed = ", ".join(f"'{role}'" for role in USER_ROLE_VALUES)
+ op.execute(
+ f"""
+ ALTER TABLE users
+ ALTER COLUMN role TYPE text
+ USING role::text
+ """
+ )
+ op.execute("DROP TYPE user_role")
+ op.execute(f"CREATE TYPE user_role AS ENUM ({allowed})")
+ op.execute(
+ """
+ ALTER TABLE users
+ ALTER COLUMN role TYPE user_role
+ USING role::user_role
+ """
+ )
+
+
+def downgrade() -> None:
+ allowed = ", ".join(f"'{role}'" for role in (*USER_ROLE_VALUES, "QA"))
+ op.execute(
+ """
+ ALTER TABLE users
+ ALTER COLUMN role TYPE text
+ USING role::text
+ """
+ )
+ op.execute("DROP TYPE user_role")
+ op.execute(f"CREATE TYPE user_role AS ENUM ({allowed})")
+ op.execute(
+ """
+ ALTER TABLE users
+ ALTER COLUMN role TYPE user_role
+ USING role::user_role
+ """
+ )
diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py
index 0f344568..770f9e07 100644
--- a/backend/app/api/v1/members.py
+++ b/backend/app/api/v1/members.py
@@ -18,7 +18,6 @@ router = APIRouter()
PROJECT_ROLE_RANK = {
"ADMIN": 100,
"PM": 80,
- "QA": 60,
"PV": 50,
"MEDICAL_REVIEW": 50,
"CRA": 40,
diff --git a/backend/app/api/v1/study_active_roles.py b/backend/app/api/v1/study_active_roles.py
index 895f3ca5..4d2560d3 100644
--- a/backend/app/api/v1/study_active_roles.py
+++ b/backend/app/api/v1/study_active_roles.py
@@ -25,6 +25,8 @@ def _normalize_active_roles(value: object) -> list[str]:
continue
if role == "ADMIN":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
+ if role == "QA":
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="QA 不能作为项目角色")
if len(role) > 20:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目角色长度不能超过20个字符")
if role not in roles:
diff --git a/backend/app/core/api_permissions.py b/backend/app/core/api_permissions.py
index 6cad6a4e..4f03adb4 100644
--- a/backend/app/core/api_permissions.py
+++ b/backend/app/core/api_permissions.py
@@ -20,14 +20,14 @@ API_ENDPOINT_PERMISSIONS = {
"module": "subjects",
"action": "read",
"description": "查询参与者列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"subjects:read": {
"module": "subjects",
"action": "read",
"description": "查询参与者详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"subjects:update": {
@@ -56,14 +56,14 @@ API_ENDPOINT_PERMISSIONS = {
"module": "subjects",
"action": "read",
"description": "查询访视列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"visits:read": {
"module": "subjects",
"action": "read",
"description": "查询访视详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"visits:update": {
@@ -92,14 +92,14 @@ API_ENDPOINT_PERMISSIONS = {
"module": "risk_issues",
"action": "read",
"description": "查询不良事件列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"risk_issues:read": {
"module": "risk_issues",
"action": "read",
"description": "查询不良事件详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"risk_issues:update": {
@@ -251,13 +251,13 @@ API_ENDPOINT_PERMISSIONS = {
"module": "sites",
"action": "read",
"description": "查询中心列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"sites:read": {
"module": "sites",
"action": "read",
"description": "查询中心详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"sites:update": {
"module": "sites",
@@ -413,7 +413,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "project_overview",
"action": "read",
"description": "查询项目概览",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
# 监查问题管理
"monitoring_issues:create": {
@@ -470,7 +470,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "monitoring_audit",
"action": "read",
"description": "查询监查访视问题",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
"prerequisite_permissions": [],
},
"monitoring_audit:update": {
@@ -499,14 +499,14 @@ API_ENDPOINT_PERMISSIONS = {
"module": "materials",
"action": "read",
"description": "查询药物发货列表",
- "default_roles": ["PM", "CRA", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "IMP"],
"prerequisite_permissions": [],
},
"drug_shipments:read": {
"module": "materials",
"action": "read",
"description": "查询药物发货详情",
- "default_roles": ["PM", "CRA", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "IMP"],
"prerequisite_permissions": [],
},
"drug_shipments:update": {
@@ -534,13 +534,13 @@ API_ENDPOINT_PERMISSIONS = {
"module": "materials",
"action": "read",
"description": "查询物资列表",
- "default_roles": ["PM", "CRA", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "IMP"],
},
"materials:read": {
"module": "materials",
"action": "read",
"description": "查询物资详情",
- "default_roles": ["PM", "CRA", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "IMP"],
},
"materials:update": {
"module": "materials",
@@ -595,19 +595,19 @@ API_ENDPOINT_PERMISSIONS = {
"module": "audit_export",
"action": "read",
"description": "查询审计日志列表",
- "default_roles": ["PM", "QA"],
+ "default_roles": ["PM"],
},
"audit_logs:read": {
"module": "audit_export",
"action": "read",
"description": "查询审计日志详情",
- "default_roles": ["PM", "QA"],
+ "default_roles": ["PM"],
},
"audit_logs:export": {
"module": "audit_export",
"action": "read",
"description": "导出审计日志",
- "default_roles": ["PM", "QA"],
+ "default_roles": ["PM"],
},
# 知识库笔记管理
"knowledge_notes:create": {
@@ -620,13 +620,13 @@ API_ENDPOINT_PERMISSIONS = {
"module": "shared_library",
"action": "read",
"description": "查询知识库笔记列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"knowledge_notes:read": {
"module": "shared_library",
"action": "read",
"description": "查询知识库笔记详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"knowledge_notes:update": {
"module": "shared_library",
@@ -676,7 +676,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "setup_config",
"action": "read",
"description": "查询立项配置",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"setup_config:update": {
"module": "setup_config",
@@ -707,7 +707,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "project_milestones",
"action": "read",
"description": "查询项目里程碑列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"project_milestones:update": {
"module": "project_milestones",
@@ -751,7 +751,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "attachments",
"action": "read",
"description": "查询附件",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"attachments:update": {
"module": "attachments",
@@ -795,7 +795,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "faq",
"action": "read",
"description": "查询FAQ",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"faq:update": {
"module": "faq",
@@ -820,7 +820,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "faq",
"action": "read",
"description": "查询FAQ分类",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"faq_category:update": {
"module": "faq",
@@ -852,7 +852,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "dashboard",
"action": "read",
"description": "查询仪表板",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": ["sites:read", "subjects:read"],
},
# 参与者历史管理
@@ -867,14 +867,14 @@ API_ENDPOINT_PERMISSIONS = {
"module": "subject_histories",
"action": "read",
"description": "查询参与者历史列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"subject_histories:read": {
"module": "subject_histories",
"action": "read",
"description": "查询参与者历史详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
"prerequisite_permissions": [],
},
"subject_histories:update": {
@@ -902,13 +902,13 @@ API_ENDPOINT_PERMISSIONS = {
"module": "material_equipments",
"action": "read",
"description": "查询物资设备列表",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"material_equipments:read": {
"module": "material_equipments",
"action": "read",
"description": "查询物资设备详情",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"material_equipments:update": {
"module": "material_equipments",
@@ -933,7 +933,7 @@ API_ENDPOINT_PERMISSIONS = {
"module": "documents",
"action": "read",
"description": "查询文档",
- "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
+ "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
},
"documents:update": {
"module": "documents",
@@ -1170,7 +1170,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
}
# 项目级权限角色列表
-PROJECT_PERMISSION_ROLES = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
+PROJECT_PERMISSION_ROLES = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
# 前置权限映射表(用于权限检查)
# 定义每个操作需要的前置权限
diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py
index 78fcc595..1899a6f2 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.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study
-from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES
+from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES, PROJECT_PERMISSION_ROLES
from app.core.permission_cache import get_permission_cache
@@ -114,7 +114,7 @@ async def get_api_endpoint_permissions(
study_result = await db.execute(select(Study).where(Study.id == study_id))
study = study_result.scalar_one_or_none()
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
- roles = list(dict.fromkeys(["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", *active_roles, *overrides.keys()]))
+ roles = list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles, *overrides.keys()]))
matrix: dict[str, dict[str, dict[str, bool]]] = {}
for role in roles:
matrix[role] = {}
diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py
index bfceaac4..28cd2edc 100644
--- a/backend/app/core/rbac.py
+++ b/backend/app/core/rbac.py
@@ -22,7 +22,6 @@ ROLE_ACTIONS: dict[str, set[str]] = {
"ack",
"delete_document",
},
- "QA": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack"},
"PM": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack", "delete_document"},
"CRA": {"view", "create_version", "submit", "ack"},
"PV": {"view", "create_version", "submit", "ack"},
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index a165ba0c..7ac6692e 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -19,7 +19,6 @@ class UserRole(str, enum.Enum):
PV = "PV"
MEDICAL_REVIEW = "MEDICAL_REVIEW"
IMP = "IMP"
- QA = "QA"
class UserStatus(str, enum.Enum):
diff --git a/backend/app/schemas/project_permission.py b/backend/app/schemas/project_permission.py
index bb5640db..d819e6cc 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", "MEDICAL_REVIEW", "IMP", "QA"]
+ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
ProjectPermissionAction = Literal["read", "write"]
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 5d39092e..7f02d939 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -5,7 +5,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
-UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
+UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "ADMIN"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
diff --git a/backend/app/services/permission_template_service.py b/backend/app/services/permission_template_service.py
index bbed4e66..8ac02413 100644
--- a/backend/app/services/permission_template_service.py
+++ b/backend/app/services/permission_template_service.py
@@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion, TemplateType
from app.models.api_endpoint_permission import ApiEndpointPermission
-from app.core.api_permissions import API_ENDPOINT_PERMISSIONS
+from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES
from app.schemas.permission_template import PermissionTemplateCreate, PermissionTemplateUpdate
@@ -21,6 +21,9 @@ class PermissionTemplateService:
created_by: UUID,
) -> PermissionTemplate:
"""创建权限模板"""
+ if payload.template_type != TemplateType.CUSTOM:
+ raise ValueError("只能新增自定义角色")
+
# 验证权限配置
await PermissionTemplateService._validate_permissions(payload.permissions)
@@ -78,6 +81,11 @@ class PermissionTemplateService:
query = query.where(PermissionTemplate.template_type == template_type)
if category:
query = query.where(PermissionTemplate.category == category)
+ elif template_type == TemplateType.ROLE:
+ query = query.where(
+ (PermissionTemplate.is_system.is_(False))
+ | (PermissionTemplate.category.in_(PROJECT_PERMISSION_ROLES))
+ )
query = query.offset(skip).limit(limit)
result = await db.execute(query)
@@ -94,9 +102,11 @@ class PermissionTemplateService:
if not template:
raise ValueError(f"模板 {template_id} 不存在")
- # 不允许修改系统预设模板
+ # 系统预设模板只允许更新展示元数据,权限配置和分类等结构字段保持受保护。
if template.is_system:
- raise ValueError("不允许修改系统预设模板")
+ protected_fields = {"permissions", "tags", "category", "recommended_roles"}
+ if protected_fields.intersection(payload.model_fields_set):
+ raise ValueError("不允许修改系统预设模板")
# 验证权限配置
if payload.permissions:
diff --git a/backend/tests/test_permission_templates.py b/backend/tests/test_permission_templates.py
index 6cba6c21..6912dd72 100644
--- a/backend/tests/test_permission_templates.py
+++ b/backend/tests/test_permission_templates.py
@@ -67,6 +67,18 @@ async def test_create_template_creates_version_1(db_session, user_id):
assert template.versions[0].change_log == "初始版本"
+@pytest.mark.asyncio
+async def test_create_template_rejects_non_custom_role_type(db_session, user_id):
+ payload = PermissionTemplateCreate(
+ name="预设角色不允许新增",
+ template_type=TemplateType.ROLE,
+ permissions=SAMPLE_PERMISSIONS,
+ )
+
+ with pytest.raises(ValueError, match="只能新增自定义角色"):
+ await PermissionTemplateService.create_template(db_session, payload, user_id)
+
+
@pytest.mark.asyncio
async def test_create_template_invalid_endpoint_key(db_session, user_id):
payload = PermissionTemplateCreate(
@@ -95,16 +107,19 @@ async def test_create_template_invalid_permission_value(db_session, user_id):
@pytest.mark.asyncio
async def test_get_template_exists(db_session, user_id):
- payload = PermissionTemplateCreate(
+ template = PermissionTemplate(
name="获取测试",
template_type=TemplateType.ROLE,
permissions=SAMPLE_PERMISSIONS,
)
- created = await PermissionTemplateService.create_template(db_session, payload, user_id)
- fetched = await PermissionTemplateService.get_template(db_session, created.id)
+ db_session.add(template)
+ await db_session.commit()
+ await db_session.refresh(template)
+
+ fetched = await PermissionTemplateService.get_template(db_session, template.id)
assert fetched is not None
- assert fetched.id == created.id
+ assert fetched.id == template.id
assert fetched.name == "获取测试"
@@ -120,22 +135,52 @@ async def test_get_template_not_found(db_session):
@pytest.mark.asyncio
async def test_list_templates_filter_by_type(db_session, user_id):
- await PermissionTemplateService.create_template(
- db_session,
- PermissionTemplateCreate(name="角色模板", template_type=TemplateType.ROLE, permissions=SAMPLE_PERMISSIONS),
- user_id,
+ db_session.add(
+ PermissionTemplate(
+ name="角色模板",
+ template_type=TemplateType.ROLE,
+ is_system=True,
+ category="PM",
+ permissions=SAMPLE_PERMISSIONS,
+ )
)
await PermissionTemplateService.create_template(
db_session,
- PermissionTemplateCreate(name="场景模板", template_type=TemplateType.SCENARIO, permissions=SAMPLE_PERMISSIONS),
+ PermissionTemplateCreate(name="自定义角色", template_type=TemplateType.CUSTOM, permissions=SAMPLE_PERMISSIONS),
user_id,
)
+ await db_session.commit()
role_templates = await PermissionTemplateService.list_templates(db_session, template_type=TemplateType.ROLE)
assert all(t.template_type == TemplateType.ROLE for t in role_templates)
assert len(role_templates) >= 1
+@pytest.mark.asyncio
+async def test_list_templates_excludes_qa_system_preset(db_session):
+ qa_template = PermissionTemplate(
+ name="质量保证",
+ template_type=TemplateType.ROLE,
+ is_system=True,
+ category="QA",
+ permissions={"QA": {"subjects:read": True}},
+ )
+ pm_template = PermissionTemplate(
+ name="项目经理",
+ template_type=TemplateType.ROLE,
+ is_system=True,
+ category="PM",
+ permissions={"PM": {"subjects:read": True}},
+ )
+ db_session.add_all([qa_template, pm_template])
+ await db_session.commit()
+
+ role_templates = await PermissionTemplateService.list_templates(db_session, template_type=TemplateType.ROLE)
+
+ assert "PM" in {template.category for template in role_templates}
+ assert "QA" not in {template.category for template in role_templates if template.is_system}
+
+
# ---------------------------------------------------------------------------
# 更新模板
# ---------------------------------------------------------------------------
@@ -175,7 +220,31 @@ async def test_update_template_permissions_creates_new_version(db_session, user_
@pytest.mark.asyncio
-async def test_update_system_template_raises(db_session):
+async def test_update_system_template_allows_name_and_description(db_session):
+ system_template = PermissionTemplate(
+ name="系统模板",
+ description="旧描述",
+ template_type=TemplateType.ROLE,
+ is_system=True,
+ permissions=SAMPLE_PERMISSIONS,
+ )
+ db_session.add(system_template)
+ await db_session.commit()
+ await db_session.refresh(system_template)
+
+ updated = await PermissionTemplateService.update_template(
+ db_session,
+ system_template.id,
+ PermissionTemplateUpdate(name="新名称", description="新描述"),
+ )
+
+ assert updated.name == "新名称"
+ assert updated.description == "新描述"
+ assert updated.permissions == SAMPLE_PERMISSIONS
+
+
+@pytest.mark.asyncio
+async def test_update_system_template_rejects_protected_fields(db_session):
system_template = PermissionTemplate(
name="系统模板",
template_type=TemplateType.ROLE,
@@ -188,7 +257,9 @@ async def test_update_system_template_raises(db_session):
with pytest.raises(ValueError, match="不允许修改系统预设模板"):
await PermissionTemplateService.update_template(
- db_session, system_template.id, PermissionTemplateUpdate(name="改名")
+ db_session,
+ system_template.id,
+ PermissionTemplateUpdate(permissions={"PM": {"subjects:read": True}}),
)
diff --git a/database/init.sql b/database/init.sql
index 8dfd74c3..18fe3a06 100644
--- a/database/init.sql
+++ b/database/init.sql
@@ -2,14 +2,7 @@
DO $$
BEGIN
- CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP', 'QA');
-EXCEPTION
- WHEN duplicate_object THEN null;
-END $$;
-
-DO $$
-BEGIN
- ALTER TYPE public.user_role ADD VALUE IF NOT EXISTS 'QA';
+ CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP', 'MEDICAL_REVIEW');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -540,7 +533,6 @@ INSERT INTO public.users (
('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'),
('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'),
('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', 'CRA', 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00'),
- ('44444444-4444-4444-4444-444444444444', 'qa@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'QA 用户', 'QA', 'QA', 'ACTIVE', '2025-01-05 08:30:00+00', '2025-01-05 08:30:00+00'),
('55555555-5555-5555-5555-555555555555', 'pv@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'PV 用户', 'PV', 'PV', 'ACTIVE', '2025-01-05 08:40:00+00', '2025-01-05 08:40:00+00'),
('66666666-6666-6666-6666-666666666666', 'imp@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '药品管理员', 'IMP', 'IMP', 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00')
ON CONFLICT (email) DO UPDATE SET
@@ -592,7 +584,6 @@ INSERT INTO public.study_members (
('dddddddd-dddd-dddd-dddd-dddddddddddd', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'admin@example.com'), 'ADMIN', true, '2025-01-06 10:00:00+00'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'pm@example.com'), 'PM', true, '2025-01-06 10:05:00+00'),
('ffffffff-ffff-ffff-ffff-ffffffffffff', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'cra@example.com'), 'CRA', true, '2025-01-06 10:10:00+00'),
- ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'qa@example.com'), 'QA', true, '2025-01-06 10:15:00+00'),
('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'pv@example.com'), 'PV', true, '2025-01-06 10:20:00+00'),
('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'imp@example.com'), 'IMP', true, '2025-01-06 10:25:00+00')
ON CONFLICT (study_id, user_id) DO UPDATE SET
diff --git a/database/seed_documents.sql b/database/seed_documents.sql
index 04f9155b..47ebf45f 100644
--- a/database/seed_documents.sql
+++ b/database/seed_documents.sql
@@ -23,8 +23,8 @@ INSERT INTO public.workflow_templates (
(
'11111111-aaaa-bbbb-cccc-111111111212',
(SELECT id FROM public.studies WHERE code = 'DOC-DEMO'),
- '严格审批(CRA→PM→QA)',
- '三节点审批,适用于关键文件',
+ '严格审批(CRA→PM)',
+ '双节点审批,适用于关键文件',
true,
(SELECT id FROM public.users WHERE email = 'pm@example.com'),
now(),
@@ -67,15 +67,6 @@ INSERT INTO public.workflow_nodes (
'项目经理复审',
true,
now()
- ),
- (
- '22222222-aaaa-bbbb-cccc-222222222314',
- '11111111-aaaa-bbbb-cccc-111111111212',
- 3,
- 'QA',
- 'QA 终审',
- true,
- now()
)
ON CONFLICT (id) DO UPDATE
SET template_id = EXCLUDED.template_id,
diff --git a/docs/reports/docker-fix-report.md b/docs/reports/docker-fix-report.md
index 8cb6fd4e..04da710d 100644
--- a/docs/reports/docker-fix-report.md
+++ b/docs/reports/docker-fix-report.md
@@ -99,7 +99,7 @@ ImportError: cannot import name 'PROJECT_PERMISSION_ROLES' from 'app.core.api_pe
```python
# backend/app/core/api_permissions.py
-PROJECT_PERMISSION_ROLES = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
+PROJECT_PERMISSION_ROLES = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
```
**文件**: `backend/app/core/api_permissions.py`
diff --git a/frontend/src/components/PermissionAccessLogs.test.ts b/frontend/src/components/PermissionAccessLogs.test.ts
index 3f5a08e8..f70228db 100644
--- a/frontend/src/components/PermissionAccessLogs.test.ts
+++ b/frontend/src/components/PermissionAccessLogs.test.ts
@@ -29,6 +29,13 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("fetchIpLocations");
});
+ it("does not expose QA in role filter presets", () => {
+ const source = readSource();
+
+ expect(source).not.toContain('label="QA" value="QA"');
+ expect(source).not.toContain('QA: "QA"');
+ });
+
it("uses narrow metric cards without helper subtitles", () => {
const source = readSource();
diff --git a/frontend/src/components/PermissionAccessLogs.vue b/frontend/src/components/PermissionAccessLogs.vue
index 20a2beb8..c369ea1f 100644
--- a/frontend/src/components/PermissionAccessLogs.vue
+++ b/frontend/src/components/PermissionAccessLogs.vue
@@ -18,7 +18,6 @@
-
@@ -230,7 +229,6 @@ const ROLE_LABELS: Record = {
PV: "PV",
MEDICAL_REVIEW: "医学审核",
IMP: "药品管理",
- QA: "QA",
};
const SECURITY_AUTH_LABELS: Record = {
diff --git a/frontend/src/components/PermissionTemplateSelector.test.ts b/frontend/src/components/PermissionTemplateSelector.test.ts
index 3318617a..2e7c9cf1 100644
--- a/frontend/src/components/PermissionTemplateSelector.test.ts
+++ b/frontend/src/components/PermissionTemplateSelector.test.ts
@@ -27,4 +27,11 @@ describe("PermissionTemplateSelector", () => {
expect(source).toContain("const perms = props.currentPermissions?.[role];");
expect(source).toContain("emit('edit-role', template.category!)");
});
+
+ it("does not define QA as a preset role label or icon", () => {
+ const source = readSource();
+
+ expect(source).not.toContain('QA: "QA"');
+ expect(source).not.toContain('QA: "✅"');
+ });
});
diff --git a/frontend/src/components/PermissionTemplateSelector.vue b/frontend/src/components/PermissionTemplateSelector.vue
index d4b6f735..db2bf526 100644
--- a/frontend/src/components/PermissionTemplateSelector.vue
+++ b/frontend/src/components/PermissionTemplateSelector.vue
@@ -83,13 +83,13 @@ const templates = ref([]);
const ROLE_LABELS: Record = {
PM: "项目负责人", CRA: "CRA", PV: "PV",
- MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA",
+ MEDICAL_REVIEW: "医学审核", IMP: "药品管理员",
};
const roleIcon = (category: string | null) => {
const icons: Record = {
PM: "👑", CRA: "📋", PV: "🔍",
- MEDICAL_REVIEW: "🏥", IMP: "📦", QA: "✅",
+ MEDICAL_REVIEW: "🏥", IMP: "📦",
};
return icons[category ?? ""] ?? "📄";
};
diff --git a/frontend/src/dictionaries/role.dict.ts b/frontend/src/dictionaries/role.dict.ts
index 9c1552ca..8a53754b 100644
--- a/frontend/src/dictionaries/role.dict.ts
+++ b/frontend/src/dictionaries/role.dict.ts
@@ -9,7 +9,6 @@ const items = [
{ value: "PV", label: TEXT.enums.userRole.PV, order: 4 },
{ 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 e6b5d372..9c52e3b3 100644
--- a/frontend/src/locales/zh-CN.ts
+++ b/frontend/src/locales/zh-CN.ts
@@ -963,7 +963,6 @@ export const TEXT = {
PV: "PV",
MEDICAL_REVIEW: "医学审核",
IMP: "药品管理员",
- QA: "QA",
},
userStatus: {
PENDING: "待审核",
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 14e64758..57622801 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" | "MEDICAL_REVIEW" | "IMP" | "QA" | "ADMIN";
+export type UserRole = "PM" | "CRA" | "PV" | "MEDICAL_REVIEW" | "IMP" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export interface UserInfo {
diff --git a/frontend/src/views/admin/PermissionManagement.test.ts b/frontend/src/views/admin/PermissionManagement.test.ts
index fdcc15fa..82a66d40 100644
--- a/frontend/src/views/admin/PermissionManagement.test.ts
+++ b/frontend/src/views/admin/PermissionManagement.test.ts
@@ -47,10 +47,30 @@ describe("permission management custom roles", () => {
expect(source).toContain("const roleLabel = (role: string) => ROLE_LABELS[role] || role;");
expect(source).toContain("const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;");
- expect(source).toContain("name: templateRoleName(row), description: row.description ?? \"\",");
expect(source).toContain("roleDisplayName(row)");
});
+ it("locks role type for preset edits and creates custom roles only", () => {
+ const source = readSource();
+ const dialogStart = source.indexOf("");
+ const dialogEnd = source.indexOf("", dialogStart);
+ const dialogSource = source.slice(dialogStart, dialogEnd);
+
+ expect(dialogSource).toContain(':disabled="Boolean(editingTemplateId)"');
+ expect(source).toContain("const openCreateTemplate = () =>");
+ expect(source).toContain("const openEditTemplate = (row: PermissionTemplate) =>");
+ expect(source).toContain('template_type: "CUSTOM"');
+ expect(dialogSource).toContain('');
+ expect(dialogSource).toContain('');
+ expect(dialogSource).not.toContain('');
+ expect(dialogSource).not.toContain('label="分类"');
+ expect(dialogSource).not.toContain('label="推荐角色"');
+ expect(dialogSource).not.toContain('label="标签"');
+ expect(dialogSource).not.toContain("如 PM / CRA / PV");
+ expect(dialogSource).not.toContain("如 PM,CRA");
+ expect(dialogSource).not.toContain("可选标签");
+ });
+
it("keeps member role selects bound to role keys used by the permission matrix", () => {
const source = readSource();
@@ -61,6 +81,18 @@ describe("permission management custom roles", () => {
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
});
+ it("does not offer QA as a preset project permission role", () => {
+ const source = readSource();
+
+ const allRolesStart = source.indexOf("const ALL_ROLES = [");
+ const allRolesEnd = source.indexOf("];", allRolesStart);
+ const allRolesSource = source.slice(allRolesStart, allRolesEnd);
+
+ expect(allRolesSource).not.toContain('key: "QA"');
+ expect(source).not.toContain('QA: "QA"');
+ expect(source).not.toContain("QA: 60");
+ });
+
it("submits only the selected role from role editor saves", () => {
const source = readSource();
@@ -69,6 +101,24 @@ describe("permission management custom roles", () => {
expect(source).not.toContain("const payload = flattenMatrix(apiMatrix.value, { includePm: isAdmin.value });");
});
+ it("submits only editable metadata when saving an existing role template", () => {
+ const source = readSource();
+ const updateBuilderStart = source.indexOf("const buildTemplateUpdatePayload = () =>");
+ const createBuilderStart = source.indexOf("const buildTemplateCreatePayload = ()");
+ const submitStart = source.indexOf("const submitTemplateForm = async () =>", createBuilderStart);
+ const updateBuilderSource = source.slice(updateBuilderStart, createBuilderStart);
+ const createBuilderSource = source.slice(createBuilderStart, submitStart);
+
+ expect(updateBuilderSource).toContain("name: templateForm.value.name");
+ expect(updateBuilderSource).toContain("description: templateForm.value.description || undefined");
+ expect(updateBuilderSource).not.toContain("template_type");
+ expect(updateBuilderSource).not.toContain("permissions");
+ expect(updateBuilderSource).not.toContain("category");
+ expect(createBuilderSource).toContain("permissions: {}");
+ expect(source).toContain("const payload = buildTemplateUpdatePayload();");
+ expect(source).toContain("const payload = buildTemplateCreatePayload();");
+ });
+
it("renders setup_config as a Chinese module name in the role editor", () => {
const source = readSource();
diff --git a/frontend/src/views/admin/PermissionManagement.vue b/frontend/src/views/admin/PermissionManagement.vue
index 8eacd2cb..23c5b0fc 100644
--- a/frontend/src/views/admin/PermissionManagement.vue
+++ b/frontend/src/views/admin/PermissionManagement.vue
@@ -385,21 +385,11 @@
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
取消
@@ -461,6 +451,7 @@ import {
fetchActiveRoles,
updateActiveRoles,
type PermissionTemplate,
+ type PermissionTemplateCreate,
type SystemPermissionItem,
} from "@/api/projectPermissions";
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "@/api/members";
@@ -483,7 +474,7 @@ const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProje
const ROLE_LABELS: Record = {
PM: "项目负责人", CRA: "CRA", PV: "PV",
- MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA",
+ MEDICAL_REVIEW: "医学审核", IMP: "药品管理员",
};
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
@@ -495,7 +486,7 @@ const activeRoleLabels = computed(() => {
return result;
});
const ROLE_RANK: Record = {
- ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
+ ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
};
type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };
const canAssignProjectRole = (role: string) => isAdmin.value || (ROLE_RANK[role] ?? 0) < ROLE_RANK.PM;
@@ -778,7 +769,6 @@ const ALL_ROLES = [
{ key: "PV", label: "PV", icon: "🔍", desc: "访视和参与者管理人员" },
{ key: "MEDICAL_REVIEW", label: "医学审核", icon: "🏥", desc: "医学审核人员" },
{ key: "IMP", label: "药品管理员", icon: "📦", desc: "物资和设备管理人员" },
- { key: "QA", label: "QA", icon: "✅", desc: "质量检查和审计人员" },
];
const activeRolesDraft = ref([]);
@@ -898,25 +888,31 @@ const resetTemplateForm = () => {
templateFormRef.value?.clearValidate();
};
+const buildTemplateUpdatePayload = () => ({
+ name: templateForm.value.name,
+ description: templateForm.value.description || undefined,
+});
+
+const buildTemplateCreatePayload = (): PermissionTemplateCreate => ({
+ ...templateForm.value,
+ description: templateForm.value.description || undefined,
+ category: templateForm.value.category || undefined,
+ recommended_roles: templateForm.value.recommended_roles || undefined,
+ tags: templateForm.value.tags || undefined,
+ permissions: {},
+});
+
const submitTemplateForm = async () => {
const valid = await templateFormRef.value?.validate().catch(() => false);
if (!valid) return;
templateSaving.value = true;
try {
- const payload = {
- ...templateForm.value,
- description: templateForm.value.description || undefined,
- category: templateForm.value.category || undefined,
- recommended_roles: templateForm.value.recommended_roles || undefined,
- tags: templateForm.value.tags || undefined,
- permissions: editingTemplateId.value
- ? (templates.value.find((t) => t.id === editingTemplateId.value)?.permissions ?? {})
- : {},
- };
if (editingTemplateId.value) {
+ const payload = buildTemplateUpdatePayload();
await updatePermissionTemplate(editingTemplateId.value, payload);
ElMessage.success("角色已更新");
} else {
+ const payload = buildTemplateCreatePayload();
await createPermissionTemplate(payload);
ElMessage.success("角色已创建");
}
diff --git a/frontend/src/views/admin/ProjectMembers.test.ts b/frontend/src/views/admin/ProjectMembers.test.ts
index 24c0943f..8f97c821 100644
--- a/frontend/src/views/admin/ProjectMembers.test.ts
+++ b/frontend/src/views/admin/ProjectMembers.test.ts
@@ -35,4 +35,12 @@ describe("ProjectMembers user directory access", () => {
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"');
expect(source).toContain(':disabled="!canEditMember(scope.row)');
});
+
+ it("does not offer QA as a project member role preset", () => {
+ const source = readProjectMembers();
+
+ expect(source).not.toContain('value="QA"');
+ expect(source).not.toContain("canAssignRole('QA')");
+ expect(source).not.toContain("QA: 60");
+ });
});
diff --git a/frontend/src/views/admin/ProjectMembers.vue b/frontend/src/views/admin/ProjectMembers.vue
index 8099a4f2..04ce323f 100644
--- a/frontend/src/views/admin/ProjectMembers.vue
+++ b/frontend/src/views/admin/ProjectMembers.vue
@@ -46,7 +46,6 @@
-
{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}
@@ -86,7 +85,6 @@
-
@@ -133,7 +131,6 @@ const projectRole = computed(() => project.value?.role_in_study || "");
const roleRank: Record = {
ADMIN: 100,
PM: 80,
- QA: 60,
PV: 50,
MEDICAL_REVIEW: 50,
CRA: 40,