统一项目角色展示与预设文案

This commit is contained in:
Cheng Zhou
2026-05-26 14:46:01 +08:00
parent 3c168565ce
commit 41bd423be0
25 changed files with 384 additions and 122 deletions
@@ -88,40 +88,40 @@ def _insert_system_templates() -> None:
templates = [ templates = [
( (
"3078efe6-60d6-4623-b7b3-862aac0084cd", "3078efe6-60d6-4623-b7b3-862aac0084cd",
"项目经理", "PM",
"项目管理员,拥有所有权限", "项目负责人,统筹项目全局,协调进度、资源与关键决策。",
"PM", "PM",
"PM", "PM",
'{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "budget:create": true, "budget:list": true, "budget:read": true, "budget:update": true, "budget:delete": true, "timeline:create": true, "timeline:list": true, "timeline:read": true, "timeline:update": true, "timeline:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}', '{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "budget:create": true, "budget:list": true, "budget:read": true, "budget:update": true, "budget:delete": true, "timeline:create": true, "timeline:list": true, "timeline:read": true, "timeline:update": true, "timeline:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}',
), ),
( (
"a17e6fd5-f0c6-4723-b166-6c25a595e28d", "a17e6fd5-f0c6-4723-b166-6c25a595e28d",
"临床研究协调员", "CRA",
"数据输入和日常管理人员", "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。",
"CRA", "CRA",
"CRA", "CRA",
'{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "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": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "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": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "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": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "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": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}', '{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "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": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "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": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "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": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "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": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}',
), ),
( (
"f45fbfda-faed-4b83-b4a8-24ca4011894b", "f45fbfda-faed-4b83-b4a8-24ca4011894b",
"访视员", "PV",
"访视和参与者管理人员", "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。",
"PV", "PV",
"PV", "PV",
'{"PV": {"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": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "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": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "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": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "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": true, "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}}', '{"PV": {"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": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "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": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "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": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "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": true, "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}}',
), ),
( (
"b43d06e4-2ee2-4e80-b733-85964d738f40", "b43d06e4-2ee2-4e80-b733-85964d738f40",
"医学审核", "QA",
"医学审核人员", "负责医学审核与稽查,关注质量风险、合规性和医学一致性。",
"MEDICAL_REVIEW", "MEDICAL_REVIEW",
"MEDICAL_REVIEW", "MEDICAL_REVIEW",
'{"MEDICAL_REVIEW": {"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": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "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": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "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": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "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": true, "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}}', '{"MEDICAL_REVIEW": {"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": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "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": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "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": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "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": true, "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}}',
), ),
( (
"1e38cffb-a7f2-4243-a31c-1a4f8ce66a05", "1e38cffb-a7f2-4243-a31c-1a4f8ce66a05",
"物资管理员", "CTA",
"物资和设备管理人员", "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。",
"IMP", "IMP",
"IMP", "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}}', '{"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}}',
@@ -41,14 +41,14 @@ def upgrade() -> None:
DELETE FROM permission_template_versions DELETE FROM permission_template_versions
WHERE template_id IN ( WHERE template_id IN (
SELECT id FROM permission_templates SELECT id FROM permission_templates
WHERE category = 'QA' OR recommended_roles = 'QA' OR permissions ? 'QA' WHERE category = 'QA' OR recommended_roles = 'QA' OR (permissions::jsonb ? 'QA')
) )
""" """
) )
op.execute( op.execute(
""" """
DELETE FROM permission_templates DELETE FROM permission_templates
WHERE category = 'QA' OR recommended_roles = 'QA' OR permissions ? 'QA' WHERE category = 'QA' OR recommended_roles = 'QA' OR (permissions::jsonb ? 'QA')
""" """
) )
op.execute( op.execute(
@@ -0,0 +1,58 @@
"""update role template copy
Revision ID: 20260522_01
Revises: 20260521_01
Create Date: 2026-05-22 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260522_01"
down_revision: Union[str, None] = "20260521_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ROLE_TEMPLATE_COPY = {
"PM": ("PM", "项目负责人,统筹项目全局,协调进度、资源与关键决策。"),
"CRA": ("CRA", "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。"),
"IMP": ("CTA", "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。"),
"MEDICAL_REVIEW": ("QA", "负责医学审核与稽查,关注质量风险、合规性和医学一致性。"),
"PV": ("PV", "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。"),
}
PREVIOUS_ROLE_TEMPLATE_COPY = {
"PM": ("项目经理", "项目管理员,拥有所有权限"),
"CRA": ("临床研究协调员", "数据输入和日常管理人员"),
"IMP": ("物资管理员", "物资和设备管理人员"),
"MEDICAL_REVIEW": ("医学审核", "医学审核人员"),
"PV": ("访视员", "访视和参与者管理人员"),
}
def _update_role_copy(category: str, name: str, description: str) -> None:
op.execute(
f"""
UPDATE permission_templates
SET name = '{name}',
description = '{description}',
updated_at = NOW()
WHERE category = '{category}'
AND template_type = 'ROLE'
AND is_system = true
"""
)
def upgrade() -> None:
for category, (name, description) in ROLE_TEMPLATE_COPY.items():
_update_role_copy(category, name, description)
def downgrade() -> None:
for category, (name, description) in PREVIOUS_ROLE_TEMPLATE_COPY.items():
_update_role_copy(category, name, description)
+1 -1
View File
@@ -79,7 +79,7 @@ async def create_site(
module="sites", module="sites",
action="read", action="read",
description="查询中心列表", description="查询中心列表",
default_roles=["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"], default_roles=["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"],
) )
async def list_sites( async def list_sites(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -317,35 +317,35 @@ async def test_subject_pds_list_with_permission(db_session: AsyncSession):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audit_logs_list_with_permission(db_session: AsyncSession): async def test_audit_logs_list_with_permission(db_session: AsyncSession):
"""验证有权限的QA可以查询审计日志列表""" """验证有权限的PM可以查询审计日志列表"""
study_id = uuid.uuid4() study_id = uuid.uuid4()
perm = ApiEndpointPermission( perm = ApiEndpointPermission(
study_id=study_id, study_id=study_id,
role="QA", role="PM",
endpoint_key="GET:/studies/{study_id}/audit-logs", endpoint_key="GET:/studies/{study_id}/audit-logs",
allowed=True, allowed=True,
) )
db_session.add(perm) db_session.add(perm)
await db_session.commit() await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "QA", "GET:/studies/{study_id}/audit-logs") allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/audit-logs")
assert allowed is True assert allowed is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audit_logs_export_with_permission(db_session: AsyncSession): async def test_audit_logs_export_with_permission(db_session: AsyncSession):
"""验证有权限的QA可以导出审计日志""" """验证有权限的PM可以导出审计日志"""
study_id = uuid.uuid4() study_id = uuid.uuid4()
perm = ApiEndpointPermission( perm = ApiEndpointPermission(
study_id=study_id, study_id=study_id,
role="QA", role="PM",
endpoint_key="POST:/studies/{study_id}/audit-logs/export", endpoint_key="POST:/studies/{study_id}/audit-logs/export",
allowed=True, allowed=True,
) )
db_session.add(perm) db_session.add(perm)
await db_session.commit() await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "QA", "POST:/studies/{study_id}/audit-logs/export") allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/audit-logs/export")
assert allowed is True assert allowed is True
+20
View File
@@ -24,3 +24,23 @@ def test_migration_state_check_script_exists():
assert "studies" in source assert "studies" in source
assert "subjects" in source assert "subjects" in source
assert "monitoring_visit_issues" in source assert "monitoring_visit_issues" in source
def test_remove_qa_role_migration_casts_json_permissions_for_key_lookup():
source = Path("alembic/versions/20260521_01_remove_qa_role.py").read_text(encoding="utf-8")
assert "permissions ? 'QA'" not in source
assert "permissions::jsonb ? 'QA'" in source
def test_role_template_copy_migration_updates_display_copy_without_qa_role_key():
source = Path("alembic/versions/20260522_01_update_role_template_copy.py").read_text(encoding="utf-8")
assert "项目负责人,统筹项目全局,协调进度、资源与关键决策。" in source
assert "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" in source
assert "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" in source
assert "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" in source
assert "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" in source
assert '"IMP": ("CTA"' in source
assert '"MEDICAL_REVIEW": ("QA"' in source
assert "category = 'QA'" not in source
+1 -1
View File
@@ -166,7 +166,7 @@ async def test_list_templates_excludes_qa_system_preset(db_session):
permissions={"QA": {"subjects:read": True}}, permissions={"QA": {"subjects:read": True}},
) )
pm_template = PermissionTemplate( pm_template = PermissionTemplate(
name="项目经理", name="PM",
template_type=TemplateType.ROLE, template_type=TemplateType.ROLE,
is_system=True, is_system=True,
category="PM", category="PM",
@@ -8,6 +8,15 @@ import type { ApiEndpointPermissionsResponse } from "@/types/api";
vi.mock("@/api/projectPermissions", () => ({ vi.mock("@/api/projectPermissions", () => ({
fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }), fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }),
fetchPermissionTemplates: vi.fn().mockResolvedValue({
data: [
{ category: "PM", name: "PM", description: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
{ category: "CRA", name: "CRA", description: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
{ category: "IMP", name: "CTA", description: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
{ category: "MEDICAL_REVIEW", name: "QA", description: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
{ category: "PV", name: "PV", description: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
],
}),
})); }));
describe("ApiEndpointPermissions.vue", () => { describe("ApiEndpointPermissions.vue", () => {
@@ -70,6 +79,17 @@ describe("ApiEndpointPermissions.vue", () => {
expect(source).toContain("updatedMatrix[role][operation_key] = allowed"); expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
}); });
it("renders role headers from template display names while keeping matrix role keys", () => {
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain(':label="roleLabel(role)"');
expect(source).toContain(':key="role"');
expect(source).toContain("isOperationAllowed(row.operation_key, role)");
expect(source).toContain("onPermissionChange(row.operation_key, role, val)");
expect(source).not.toContain(':label="role"');
});
it("filters endpoints by search text", async () => { it("filters endpoints by search text", async () => {
const wrapper = mount(ApiEndpointPermissions, { const wrapper = mount(ApiEndpointPermissions, {
props: { props: {
@@ -55,7 +55,7 @@
<el-table-column <el-table-column
v-for="role in roles" v-for="role in roles"
:key="role" :key="role"
:label="role" :label="roleLabel(role)"
:width="90" :width="90"
align="center" align="center"
> >
@@ -81,6 +81,7 @@ import { fetchApiOperations } from "@/api/projectPermissions";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue"; import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { isSystemAdmin } from "@/utils/roles"; import { isSystemAdmin } from "@/utils/roles";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
interface Operation { interface Operation {
operation_key: string; operation_key: string;
@@ -103,6 +104,7 @@ const props = defineProps<Props>();
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const auth = useAuthStore(); const auth = useAuthStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const isAdmin = computed(() => isSystemAdmin(auth.user)); const isAdmin = computed(() => isSystemAdmin(auth.user));
const isRoleEditable = (role: string) => { const isRoleEditable = (role: string) => {
if (role === "ADMIN") return false; if (role === "ADMIN") return false;
@@ -267,6 +269,7 @@ const onPermissionChange = (operation_key: string, role: string, allowed: boolea
onMounted(() => { onMounted(() => {
loadOperations(); loadOperations();
loadRoleTemplates();
}); });
defineExpose({ searchText }); defineExpose({ searchText });
@@ -36,6 +36,18 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain('QA: "QA"'); expect(source).not.toContain('QA: "QA"');
}); });
it("uses permission template names for role filters and log lines", () => {
const source = readSource();
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));");
expect(source).toContain('v-for="role in roleFilterOptions"');
expect(source).toContain("role=${roleLabel(row.role)}");
expect(source).not.toContain("const ROLE_LABELS");
expect(source).not.toContain('label="医学审核" value="MEDICAL_REVIEW"');
});
it("uses narrow metric cards without helper subtitles", () => { it("uses narrow metric cards without helper subtitles", () => {
const source = readSource(); const source = readSource();
@@ -12,12 +12,7 @@
@change="onFilterChange" @change="onFilterChange"
/> />
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange"> <el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
<el-option label="ADMIN" value="ADMIN" /> <el-option v-for="role in roleFilterOptions" :key="role.value" :label="role.label" :value="role.value" />
<el-option label="PM" value="PM" />
<el-option label="CRA" value="CRA" />
<el-option label="PV" value="PV" />
<el-option label="医学审核" value="MEDICAL_REVIEW" />
<el-option label="IMP" value="IMP" />
</el-select> </el-select>
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange"> <el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
<el-option label="允许" :value="true" /> <el-option label="允许" :value="true" />
@@ -157,11 +152,13 @@ import type {
SecurityAccessLogsResponse, SecurityAccessLogsResponse,
} from "@/types/api"; } from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue"; import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), { const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false, showSecurityLog: false,
}); });
const showSecurityLog = computed(() => props.showSecurityLog); const showSecurityLog = computed(() => props.showSecurityLog);
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [ const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }), h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
@@ -222,14 +219,8 @@ const filters = reactive({
allowed: undefined as boolean | undefined, allowed: undefined as boolean | undefined,
}); });
const ROLE_LABELS: Record<string, string> = { const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"];
ADMIN: "管理员", const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
PM: "项目负责人",
CRA: "CRA",
PV: "PV",
MEDICAL_REVIEW: "医学审核",
IMP: "药品管理",
};
const SECURITY_AUTH_LABELS: Record<string, string> = { const SECURITY_AUTH_LABELS: Record<string, string> = {
ANONYMOUS: "匿名", ANONYMOUS: "匿名",
@@ -243,8 +234,6 @@ const formatTerminalTime = (iso: string) => {
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}; };
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
const formatIpLocation = (row: AccessLogItem) => { const formatIpLocation = (row: AccessLogItem) => {
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean); const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
return parts.length ? parts.join(" / ") : row.ip_location; return parts.length ? parts.join(" / ") : row.ip_location;
@@ -437,7 +426,8 @@ const downloadSecurityLog = () => {
downloadLogFile("security-event-audit.log", securityTerminalLines.value); downloadLogFile("security-event-audit.log", securityTerminalLines.value);
}; };
onMounted(() => { onMounted(async () => {
await loadRoleTemplates();
refresh(); refresh();
startRealtimePolling(); startRealtimePolling();
}); });
@@ -20,6 +20,10 @@ describe("PermissionTemplateSelector", () => {
it("reads current project permissions by role key, not by role label", () => { it("reads current project permissions by role key, not by role label", () => {
const source = readSource(); const source = readSource();
expect(source).toContain('<span class="card-name">{{ template.name }}</span>');
expect(source).toContain("refreshKey?: number;");
expect(source).toContain("watch(() => props.refreshKey, loadTemplates);");
expect(source).not.toContain("ROLE_LABELS");
expect(source).toContain("template.category && props.currentPermissions[template.category]"); expect(source).toContain("template.category && props.currentPermissions[template.category]");
expect(source).toContain("enabledPercent(template.category)"); expect(source).toContain("enabledPercent(template.category)");
expect(source).toContain("countCurrentEnabled(template.category)"); expect(source).toContain("countCurrentEnabled(template.category)");
@@ -19,7 +19,7 @@
<div class="card-top"> <div class="card-top">
<span class="card-icon">{{ roleIcon(template.category) }}</span> <span class="card-icon">{{ roleIcon(template.category) }}</span>
<div class="card-title-wrap"> <div class="card-title-wrap">
<span class="card-name">{{ template.category ? (ROLE_LABELS[template.category] || template.name) : template.name }}</span> <span class="card-name">{{ template.name }}</span>
<span v-if="template.description" class="card-desc">{{ template.description }}</span> <span v-if="template.description" class="card-desc">{{ template.description }}</span>
</div> </div>
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag> <el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
@@ -62,7 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { import {
fetchPermissionTemplates, fetchPermissionTemplates,
@@ -73,6 +73,7 @@ const props = defineProps<{
studyId: string; studyId: string;
currentPermissions?: Record<string, Record<string, boolean>>; currentPermissions?: Record<string, Record<string, boolean>>;
activeRoles?: string[]; activeRoles?: string[];
refreshKey?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -81,11 +82,6 @@ const emit = defineEmits<{
const templates = ref<PermissionTemplate[]>([]); const templates = ref<PermissionTemplate[]>([]);
const ROLE_LABELS: Record<string, string> = {
PM: "项目负责人", CRA: "CRA", PV: "PV",
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员",
};
const roleIcon = (category: string | null) => { const roleIcon = (category: string | null) => {
const icons: Record<string, string> = { const icons: Record<string, string> = {
PM: "👑", CRA: "📋", PV: "🔍", PM: "👑", CRA: "📋", PV: "🔍",
@@ -144,6 +140,7 @@ const loadTemplates = async () => {
}; };
onMounted(loadTemplates); onMounted(loadTemplates);
watch(() => props.refreshKey, loadTemplates);
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./useRoleTemplateMeta.ts"), "utf8");
describe("useRoleTemplateMeta fallback copy", () => {
it("uses polished preset role labels and descriptions without introducing QA role key", () => {
const source = readSource();
expect(source).toContain('PM: { label: "PM"');
expect(source).toContain("项目负责人,统筹项目全局,协调进度、资源与关键决策。");
expect(source).toContain("负责各中心临床监查执行,跟进现场质量、数据和问题闭环。");
expect(source).toContain("负责合同、药品及相关项目事务管理,保障执行支持与物资协同。");
expect(source).toContain('MEDICAL_REVIEW: { label: "QA"');
expect(source).toContain("负责医学审核与稽查,关注质量风险、合规性和医学一致性。");
expect(source).toContain('IMP: { label: "CTA"');
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
expect(source).not.toContain("QA: {");
});
});
@@ -0,0 +1,74 @@
import { computed, ref } from "vue";
import { fetchPermissionTemplates, type PermissionTemplate } from "@/api/projectPermissions";
const FALLBACK_ROLE_META: Record<string, { label: string; icon: string; desc: string }> = {
ADMIN: { label: "管理员", icon: "🛡️", desc: "系统管理员" },
PM: { label: "PM", icon: "👑", desc: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
CRA: { label: "CRA", icon: "📋", desc: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
PV: { label: "PV", icon: "🔍", desc: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
MEDICAL_REVIEW: { label: "QA", icon: "🏥", desc: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
IMP: { label: "CTA", icon: "📦", desc: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
};
const templates = ref<PermissionTemplate[]>([]);
const loading = ref(false);
let pendingLoad: Promise<void> | null = null;
export const useRoleTemplateMeta = () => {
const templateByRole = computed(() => {
const result: Record<string, PermissionTemplate> = {};
for (const template of templates.value) {
if (template.category) result[template.category] = template;
}
return result;
});
const roleLabel = (role?: string | null) => {
if (!role) return "";
return templateByRole.value[role]?.name || FALLBACK_ROLE_META[role]?.label || role;
};
const roleDescription = (role?: string | null) => {
if (!role) return "";
return templateByRole.value[role]?.description || FALLBACK_ROLE_META[role]?.desc || "自定义项目角色";
};
const roleIcon = (role?: string | null) => {
if (!role) return "🔧";
return FALLBACK_ROLE_META[role]?.icon || "🔧";
};
const roleOptionsFor = (roles: string[]) =>
roles.map((role) => ({
value: role,
key: role,
label: roleLabel(role),
icon: roleIcon(role),
desc: roleDescription(role),
}));
const loadRoleTemplates = async () => {
if (pendingLoad) return pendingLoad;
loading.value = true;
pendingLoad = fetchPermissionTemplates()
.then((res) => {
templates.value = res.data;
})
.finally(() => {
loading.value = false;
pendingLoad = null;
});
return pendingLoad;
};
return {
templates,
loading,
templateByRole,
roleLabel,
roleDescription,
roleIcon,
roleOptionsFor,
loadRoleTemplates,
};
};
+2 -2
View File
@@ -8,7 +8,7 @@ export const serviceTermsSections: AuthProtocolSection[] = [
title: "1. 服务条款", title: "1. 服务条款",
paragraphs: [ paragraphs: [
"本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。", "本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。",
"用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、医学审核、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。", "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、QA、CTA 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。",
], ],
}, },
{ {
@@ -28,7 +28,7 @@ export const serviceTermsSections: AuthProtocolSection[] = [
{ {
title: "4. 权限与审计", title: "4. 权限与审计",
paragraphs: [ paragraphs: [
"系统按照管理员、PM、CRA、PV、医学审核、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。", "系统按照管理员、PM、CRA、PV、QA、CTA 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。",
"系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。", "系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。",
], ],
}, },
+3 -3
View File
@@ -958,11 +958,11 @@ export const TEXT = {
enums: { enums: {
userRole: { userRole: {
ADMIN: "管理员", ADMIN: "管理员",
PM: "项目负责人", PM: "PM",
CRA: "CRA", CRA: "CRA",
PV: "PV", PV: "PV",
MEDICAL_REVIEW: "医学审核", MEDICAL_REVIEW: "QA",
IMP: "药品管理员", IMP: "CTA",
}, },
userStatus: { userStatus: {
PENDING: "待审核", PENDING: "待审核",
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./StudyHome.vue"), "utf8");
describe("StudyHome role labels", () => {
it("uses permission template names for the current project role label", () => {
const source = readSource();
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const roleLabel = computed(() => displayRoleLabel(projectRole.value));");
expect(source).toContain("loadRoleTemplates();");
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, projectRole.value)");
});
});
+4 -2
View File
@@ -115,8 +115,8 @@ import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue"; import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue"; import { List, Warning, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue"; import StateEmpty from "../components/StateEmpty.vue";
import { displayEnum } from "../utils/display";
import { getProjectRole, isSystemAdmin } from "../utils/roles"; import { getProjectRole, isSystemAdmin } from "../utils/roles";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import type { NotificationItem } from "../types/notifications"; import type { NotificationItem } from "../types/notifications";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
@@ -124,6 +124,7 @@ import { useRouter } from "vue-router";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const router = useRouter(); const router = useRouter();
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const progress = ref<any>(null); const progress = ref<any>(null);
const financeSummary = ref<any>(null); const financeSummary = ref<any>(null);
@@ -148,7 +149,7 @@ const projectRole = computed(() => {
if (isSystemAdmin(auth.user)) return "ADMIN"; if (isSystemAdmin(auth.user)) return "ADMIN";
return getProjectRole(study.currentStudy, study.currentStudyRole); return getProjectRole(study.currentStudy, study.currentStudyRole);
}); });
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.value)); const roleLabel = computed(() => displayRoleLabel(projectRole.value));
const formatAmount = (val: number) => { const formatAmount = (val: number) => {
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val); return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val);
@@ -199,6 +200,7 @@ const openDocument = (documentId: string) => {
}; };
onMounted(() => { onMounted(() => {
loadRoleTemplates();
loadData(); loadData();
}); });
@@ -7,16 +7,26 @@ const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement
describe("permission management custom roles", () => { describe("permission management custom roles", () => {
it("uses role management wording in the drawer", () => { it("uses role management wording in the drawer", () => {
const source = readSource(); const source = readSource();
const listTabStart = source.indexOf("<!-- 标签页:角色列表 -->");
const listTabEnd = source.indexOf("<!-- 标签页:生效管理 -->", listTabStart);
const listTabSource = source.slice(listTabStart, listTabEnd);
expect(source).toContain('title="角色管理"'); expect(source).toContain('title="角色管理"');
expect(source).toContain('label="角色列表"'); expect(source).toContain('label="角色列表"');
expect(source).toContain('placeholder="角色类型"'); expect(listTabSource).not.toContain('placeholder="角色类型"');
expect(listTabSource).not.toContain("typeFilter");
expect(listTabSource).not.toContain("loadTemplates");
expect(listTabSource).not.toContain('<el-option label="预设角色" value="ROLE" />');
expect(listTabSource).not.toContain('<el-option label="自定义角色" value="CUSTOM" />');
expect(listTabSource).not.toContain('<el-option label="场景角色" value="SCENARIO" />');
expect(source).toContain("新增角色"); expect(source).toContain("新增角色");
expect(source).toContain('label="角色名称"'); expect(source).toContain('label="角色名称"');
expect(source).toContain("roleDisplayName(row)"); expect(source).toContain("roleDisplayName(row)");
expect(source).not.toContain('title="权限模板管理"'); expect(source).not.toContain('title="权限模板管理"');
expect(source).not.toContain('label="模板列表"'); expect(source).not.toContain('label="模板列表"');
expect(source).not.toContain("新增模板"); expect(source).not.toContain("新增模板");
expect(source).not.toContain("const typeFilter");
expect(source).toContain("const res = await fetchPermissionTemplates();");
}); });
it("keeps active roles configurable for permissions and members without inline role creation", () => { it("keeps active roles configurable for permissions and members without inline role creation", () => {
@@ -27,11 +37,15 @@ describe("permission management custom roles", () => {
expect(source).not.toContain("custom-role-entry"); expect(source).not.toContain("custom-role-entry");
expect(source).not.toContain("输入自定义角色标识"); expect(source).not.toContain("输入自定义角色标识");
expect(source).toContain("roleOptions"); expect(source).toContain("roleOptions");
expect(source).toContain("ROLE_LABELS[role] || role"); expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("row.category ? roleLabel(row.category) : row.name"); expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("await loadRoleTemplates();");
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("await loadPermissionData();"); expect(source).toContain("await loadPermissionData();");
expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""'); expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""');
expect(source).toContain('role === "ADMIN"'); expect(source).toContain('role === "ADMIN"');
expect(source).not.toContain("const ALL_ROLES = [");
expect(source).not.toContain("const ROLE_LABELS");
}); });
it("counts role list permissions from current project permissions when available", () => { it("counts role list permissions from current project permissions when available", () => {
@@ -42,11 +56,13 @@ describe("permission management custom roles", () => {
expect(source).toContain("if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);"); expect(source).toContain("if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);");
}); });
it("uses canonical role labels when opening preset roles for editing", () => { it("uses database role names when listing and editing preset roles", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("const roleLabel = (role: string) => ROLE_LABELS[role] || role;"); expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;"); expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("name: row.name, description: row.description ?? \"\",");
expect(source).not.toContain("templateRoleName(row)");
expect(source).toContain("roleDisplayName(row)"); expect(source).toContain("roleDisplayName(row)");
}); });
@@ -55,8 +71,14 @@ describe("permission management custom roles", () => {
const dialogStart = source.indexOf("<!-- 新增/编辑角色对话框 -->"); const dialogStart = source.indexOf("<!-- 新增/编辑角色对话框 -->");
const dialogEnd = source.indexOf("<!-- 新增成员对话框 -->", dialogStart); const dialogEnd = source.indexOf("<!-- 新增成员对话框 -->", dialogStart);
const dialogSource = source.slice(dialogStart, dialogEnd); const dialogSource = source.slice(dialogStart, dialogEnd);
const typeIndex = dialogSource.indexOf('label="角色类型"');
const nameIndex = dialogSource.indexOf('label="角色名称"');
const descriptionIndex = dialogSource.indexOf('label="描述"');
expect(dialogSource).toContain(':disabled="Boolean(editingTemplateId)"'); expect(dialogSource).toContain(':disabled="Boolean(editingTemplateId)"');
expect(typeIndex).toBeGreaterThanOrEqual(0);
expect(nameIndex).toBeGreaterThan(typeIndex);
expect(descriptionIndex).toBeGreaterThan(nameIndex);
expect(source).toContain("const openCreateTemplate = () =>"); expect(source).toContain("const openCreateTemplate = () =>");
expect(source).toContain("const openEditTemplate = (row: PermissionTemplate) =>"); expect(source).toContain("const openEditTemplate = (row: PermissionTemplate) =>");
expect(source).toContain('template_type: "CUSTOM"'); expect(source).toContain('template_type: "CUSTOM"');
@@ -77,6 +99,9 @@ describe("permission management custom roles", () => {
expect(source).toContain('v-for="(label, val) in memberRoleLabels(row)"'); expect(source).toContain('v-for="(label, val) in memberRoleLabels(row)"');
expect(source).toContain("for (const role of activeRolesInStudy.value)"); expect(source).toContain("for (const role of activeRolesInStudy.value)");
expect(source).toContain("result[role] = roleLabel(role);"); expect(source).toContain("result[role] = roleLabel(role);");
expect(source).toContain("{{ role.label }}");
expect(source).toContain("{{ role.desc }}");
expect(source).toContain("保存 {{ roleLabel(editingRole) }} 权限");
expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });"); expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);"); expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
}); });
@@ -84,11 +109,7 @@ describe("permission management custom roles", () => {
it("does not offer QA as a preset project permission role", () => { it("does not offer QA as a preset project permission role", () => {
const source = readSource(); const source = readSource();
const allRolesStart = source.indexOf("const ALL_ROLES = ["); expect(source).not.toContain('key: "QA"');
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: "QA"');
expect(source).not.toContain("QA: 60"); expect(source).not.toContain("QA: 60");
}); });
@@ -63,6 +63,7 @@
:study-id="selectedStudyId" :study-id="selectedStudyId"
:current-permissions="currentPermissionsForTemplate" :current-permissions="currentPermissionsForTemplate"
:active-roles="activeRolesInStudy" :active-roles="activeRolesInStudy"
:refresh-key="templateRefreshKey"
@edit-role="openRoleEditor" @edit-role="openRoleEditor"
/> />
<ApiEndpointPermissions <ApiEndpointPermissions
@@ -242,11 +243,6 @@
<!-- 标签页角色列表 --> <!-- 标签页角色列表 -->
<el-tab-pane label="角色列表" name="list"> <el-tab-pane label="角色列表" name="list">
<div class="drawer-toolbar"> <div class="drawer-toolbar">
<el-select v-model="typeFilter" placeholder="角色类型" clearable style="width: 140px" @change="loadTemplates">
<el-option label="预设角色" value="ROLE" />
<el-option label="场景角色" value="SCENARIO" />
<el-option label="自定义角色" value="CUSTOM" />
</el-select>
<div class="filter-spacer" /> <div class="filter-spacer" />
<el-button type="primary" @click="openCreateTemplate">新增角色</el-button> <el-button type="primary" @click="openCreateTemplate">新增角色</el-button>
</div> </div>
@@ -378,18 +374,18 @@
<!-- 新增/编辑角色对话框 --> <!-- 新增/编辑角色对话框 -->
<el-dialog v-model="templateDialogVisible" :title="editingTemplateId ? '编辑角色' : '新增角色'" width="520px" @close="resetTemplateForm"> <el-dialog v-model="templateDialogVisible" :title="editingTemplateId ? '编辑角色' : '新增角色'" width="520px" @close="resetTemplateForm">
<el-form :model="templateForm" :rules="templateRules" ref="templateFormRef" label-width="90px"> <el-form :model="templateForm" :rules="templateRules" ref="templateFormRef" label-width="90px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="templateForm.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
<el-form-item label="角色类型" prop="template_type"> <el-form-item label="角色类型" prop="template_type">
<el-select v-model="templateForm.template_type" style="width: 100%" :disabled="Boolean(editingTemplateId)"> <el-select v-model="templateForm.template_type" style="width: 100%" :disabled="Boolean(editingTemplateId)">
<el-option label="预设角色" value="ROLE" :disabled="!editingTemplateId" /> <el-option label="预设角色" value="ROLE" :disabled="!editingTemplateId" />
<el-option label="自定义角色" value="CUSTOM" :disabled="Boolean(editingTemplateId)" /> <el-option label="自定义角色" value="CUSTOM" :disabled="Boolean(editingTemplateId)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="角色名称" prop="name">
<el-input v-model="templateForm.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="templateDialogVisible = false">取消</el-button> <el-button @click="templateDialogVisible = false">取消</el-button>
@@ -460,6 +456,7 @@ import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display"; import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue"; import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles"; import { isSystemAdmin } from "@/utils/roles";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue"; import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
@@ -472,19 +469,7 @@ const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study ||
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM"); const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value); const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const ROLE_LABELS: Record<string, string> = { const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();
PM: "项目负责人", CRA: "CRA", PV: "PV",
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员",
};
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
const activeRoleLabels = computed(() => {
const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) {
result[role] = roleLabel(role);
}
return result;
});
const ROLE_RANK: Record<string, number> = { const ROLE_RANK: Record<string, number> = {
ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40, ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
}; };
@@ -757,33 +742,31 @@ const templateDrawerVisible = ref(false);
const templateDrawerTab = ref<"list" | "active" | "edit-role">("list"); const templateDrawerTab = ref<"list" | "active" | "edit-role">("list");
const templates = ref<PermissionTemplate[]>([]); const templates = ref<PermissionTemplate[]>([]);
const templatesLoading = ref(false); const templatesLoading = ref(false);
const typeFilter = ref<string | undefined>(undefined); const templateRefreshKey = ref(0);
const templateDialogVisible = ref(false); const templateDialogVisible = ref(false);
const editingTemplateId = ref<string | null>(null); const editingTemplateId = ref<string | null>(null);
const templateFormRef = ref<FormInstance>(); const templateFormRef = ref<FormInstance>();
const templateSaving = ref(false); const templateSaving = ref(false);
const ALL_ROLES = [
{ key: "PM", label: "项目负责人", icon: "👑", desc: "项目管理员,拥有所有权限" },
{ key: "CRA", label: "CRA", icon: "📋", desc: "数据输入和日常管理人员" },
{ key: "PV", label: "PV", icon: "🔍", desc: "访视和参与者管理人员" },
{ key: "MEDICAL_REVIEW", label: "医学审核", icon: "🏥", desc: "医学审核人员" },
{ key: "IMP", label: "药品管理员", icon: "📦", desc: "物资和设备管理人员" },
];
const activeRolesDraft = ref<string[]>([]); const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false); const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false); const activeRolesSaving = ref(false);
const roleOptions = computed(() => { const roleOptions = computed(() => {
const options = [...ALL_ROLES]; const options = templates.value.filter((template) => template.category).map((template) => ({
key: template.category!,
label: template.name,
icon: roleIcon(template.category),
desc: template.description || roleDescription(template.category),
}));
const known = new Set(options.map((role) => role.key)); const known = new Set(options.map((role) => role.key));
for (const role of activeRolesDraft.value) { for (const role of activeRolesDraft.value) {
if (!known.has(role)) { if (!known.has(role)) {
options.push({ key: role, label: role, icon: "🔧", desc: "自定义项目角色" }); options.push({ key: role, label: roleLabel(role), icon: roleIcon(role), desc: roleDescription(role) });
known.add(role); known.add(role);
} }
} }
options.sort((a, b) => (ROLE_RANK[b.key] ?? 0) - (ROLE_RANK[a.key] ?? 0) || a.key.localeCompare(b.key));
return options; return options;
}); });
@@ -840,8 +823,7 @@ const templateRules: FormRules = {
const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t); const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t);
const typeTagType = (t: string) => const typeTagType = (t: string) =>
({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info"; ({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info";
const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name; const roleDisplayName = (row: PermissionTemplate) => row.name;
const roleDisplayName = (row: PermissionTemplate) => templateRoleName(row);
const countEnabledPermissions = (permissions: Record<string, boolean>) => const countEnabledPermissions = (permissions: Record<string, boolean>) =>
Object.values(permissions).filter(Boolean).length; Object.values(permissions).filter(Boolean).length;
const countPermissions = (row: PermissionTemplate) => { const countPermissions = (row: PermissionTemplate) => {
@@ -858,8 +840,10 @@ const countPermissions = (row: PermissionTemplate) => {
const loadTemplates = async () => { const loadTemplates = async () => {
templatesLoading.value = true; templatesLoading.value = true;
try { try {
const res = await fetchPermissionTemplates(typeFilter.value ? { template_type: typeFilter.value } : undefined); const res = await fetchPermissionTemplates();
templates.value = res.data; templates.value = res.data;
await loadRoleTemplates();
templateRefreshKey.value += 1;
} catch { } catch {
ElMessage.error("加载角色失败"); ElMessage.error("加载角色失败");
} finally { } finally {
@@ -876,7 +860,7 @@ const openCreateTemplate = () => {
const openEditTemplate = (row: PermissionTemplate) => { const openEditTemplate = (row: PermissionTemplate) => {
editingTemplateId.value = row.id; editingTemplateId.value = row.id;
templateForm.value = { templateForm.value = {
name: templateRoleName(row), description: row.description ?? "", name: row.name, description: row.description ?? "",
template_type: row.template_type, category: row.category ?? "", template_type: row.template_type, category: row.category ?? "",
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "", recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
}; };
@@ -32,10 +32,21 @@ describe("ProjectMembers user directory access", () => {
expect(source).toContain("if (row.user_id === auth.user?.id) return false;"); expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
expect(source).toContain("if (row.user?.is_admin) return false;"); expect(source).toContain("if (row.user?.is_admin) return false;");
expect(source).toContain("const canAssignRole = (role: string)"); expect(source).toContain("const canAssignRole = (role: string)");
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"'); expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
expect(source).toContain(':disabled="!canEditMember(scope.row)'); expect(source).toContain(':disabled="!canEditMember(scope.row)');
}); });
it("uses permission template names for project role display options", () => {
const source = readProjectMembers();
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));");
expect(source).toContain("{{ roleLabel(scope.row.role_in_study) }}");
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, scope.row.role_in_study)");
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
});
it("does not offer QA as a project member role preset", () => { it("does not offer QA as a project member role preset", () => {
const source = readProjectMembers(); const source = readProjectMembers();
+21 -15
View File
@@ -12,7 +12,7 @@
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" /> <el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140"> <el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
<template #default="scope"> <template #default="scope">
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag> <el-tag>{{ roleLabel(scope.row.role_in_study) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100"> <el-table-column :label="TEXT.common.fields.status" width="100">
@@ -41,12 +41,13 @@
@change="(val: string) => updateRole(scope.row.id, val)" @change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
> >
<el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" /> <el-option
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" /> v-for="role in roleOptions"
<el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" /> :key="role.value"
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" /> :label="role.label"
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" /> :value="role.value"
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" /> :disabled="!canAssignRole(role.value)"
/>
</el-select> </el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span> <span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" :disabled="!canEditMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button link type="danger" size="small" :disabled="!canEditMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
@@ -80,12 +81,13 @@
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study"> <el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
<el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder"> <el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
<el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" /> <el-option
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" /> v-for="role in roleOptions"
<el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" /> :key="role.value"
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" /> :label="role.label"
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" /> :value="role.value"
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" /> :disabled="!canAssignRole(role.value)"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -107,13 +109,15 @@ import type { Study, StudyMember, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard"; import { evaluateAction } from "../../guards/actionGuard";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import { displayDateTime, displayEnum } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
import { TEXT, requiredMessage } from "../../locales"; import { TEXT, requiredMessage } from "../../locales";
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
const route = useRoute(); const route = useRoute();
const projectId = computed(() => route.params.projectId as string); const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore(); const auth = useAuthStore();
const permission = usePermission(); const permission = usePermission();
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
const project = ref<Study | null>(null); const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]); const members = ref<StudyMember[]>([]);
@@ -128,6 +132,8 @@ const newMember = reactive({
}); });
const canManageMembers = computed(() => permission.can("project.members.manage")); const canManageMembers = computed(() => permission.can("project.members.manage"));
const projectRole = computed(() => project.value?.role_in_study || ""); const projectRole = computed(() => project.value?.role_in_study || "");
const ROLE_KEYS = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "ADMIN"];
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
const roleRank: Record<string, number> = { const roleRank: Record<string, number> = {
ADMIN: 100, ADMIN: 100,
PM: 80, PM: 80,
@@ -343,7 +349,7 @@ const availableUsers = computed(() => {
}); });
onMounted(async () => { onMounted(async () => {
await Promise.all([loadProject(), loadMembers()]); await Promise.all([loadRoleTemplates(), loadProject(), loadMembers()]);
loadUsers(); loadUsers();
}); });
</script> </script>
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./DocumentDetail.vue"), "utf8");
describe("DocumentDetail role labels", () => {
it("uses permission template names for distribution role labels", () => {
const source = readSource();
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("label: roleLabel(value)");
expect(source).toContain("label: `${name}${roleLabel(m.role_in_study)}`");
expect(source).toContain("return roleLabel(row.target_id);");
expect(source).toContain("await loadRoleTemplates();");
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, value)");
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, m.role_in_study)");
});
});
@@ -243,10 +243,12 @@ import type { UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display"; import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const documentId = computed(() => route.params.id as string); const documentId = computed(() => route.params.id as string);
const loading = ref(false); const loading = ref(false);
@@ -365,17 +367,16 @@ const roleOptions = computed(() => {
const values = roles.size ? Array.from(roles) : fallbackRoles; const values = roles.size ? Array.from(roles) : fallbackRoles;
return values.map((value) => ({ return values.map((value) => ({
value, value,
label: displayEnum(TEXT.enums.userRole, value), label: roleLabel(value),
})); }));
}); });
const memberOptions = computed(() => const memberOptions = computed(() =>
members.value.map((m) => { members.value.map((m) => {
const name = getUserDisplayName((m as any).user) || (m as any).user?.email || m.user_id; const name = getUserDisplayName((m as any).user) || (m as any).user?.email || m.user_id;
const roleLabel = displayEnum(TEXT.enums.userRole, m.role_in_study);
return { return {
value: m.user_id, value: m.user_id,
label: `${name}${roleLabel}`, label: `${name}${roleLabel(m.role_in_study)}`,
role: m.role_in_study, role: m.role_in_study,
}; };
}) })
@@ -383,7 +384,7 @@ const memberOptions = computed(() =>
const displayTarget = (row: Distribution) => { const displayTarget = (row: Distribution) => {
if (row.target_type === "ROLE") { if (row.target_type === "ROLE") {
return displayEnum(TEXT.enums.userRole, row.target_id); return roleLabel(row.target_id);
} }
if (row.target_type === "USER") { if (row.target_type === "USER") {
const hit = memberOptions.value.find((m) => m.value === row.target_id); const hit = memberOptions.value.find((m) => m.value === row.target_id);
@@ -658,6 +659,7 @@ const formatDateOnly = (value?: string | null) => {
}; };
onMounted(async () => { onMounted(async () => {
await loadRoleTemplates();
await loadDetail(); await loadDetail();
await loadDistributions(); await loadDistributions();
}); });