From 8611bd66f8128777677e25aa7a1d83e27f79a36a Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 28 May 2026 10:56:28 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=9A=E5=8A=A1=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E4=B8=8E=E9=A1=B5=E9=9D=A2=E6=9D=83=E9=99=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/aes.py | 83 ++++++-- backend/app/api/v1/dashboard.py | 4 +- backend/app/api/v1/members.py | 4 +- backend/app/api/v1/monitoring_visit_issues.py | 14 +- backend/app/api/v1/overview.py | 2 +- backend/app/api/v1/router.py | 7 +- backend/app/api/v1/sites.py | 2 +- backend/app/api/v1/startup.py | 20 +- backend/app/api/v1/study_subject_pds.py | 4 +- backend/app/api/v1/subject_histories.py | 2 +- backend/app/api/v1/subject_pds.py | 2 +- backend/app/api/v1/visits.py | 2 +- backend/app/crud/acknowledgement.py | 2 + backend/app/crud/distribution.py | 2 + backend/app/crud/document_version.py | 2 + backend/app/crud/member.py | 2 + backend/app/crud/overview.py | 28 ++- backend/app/crud/site.py | 36 ++-- backend/app/crud/study.py | 10 +- backend/app/crud/user.py | 2 + backend/app/db/base.py | 4 +- backend/app/models/kickoff_meeting.py | 2 +- backend/app/models/study_setup_config.py | 4 +- .../app/models/study_setup_config_version.py | 2 +- backend/app/schemas/notification.py | 2 + frontend/src/api/aes.ts | 3 + frontend/src/components/Layout.vue | 17 +- frontend/src/router/index.ts | 72 ++----- frontend/src/views/FaqDetail.vue | 16 +- .../src/views/documents/DocumentDetail.vue | 2 +- frontend/src/views/ia/AuditPlaceholder.vue | 13 -- .../views/ia/MonitoringAuditPlaceholder.vue | 13 -- .../src/views/ia/MonitoringPlaceholder.vue | 13 -- .../ia/RiskIssueMonitoringVisits.test.ts | 31 +++ .../views/ia/RiskIssueMonitoringVisits.vue | 52 +++-- frontend/src/views/ia/RiskIssueSae.vue | 4 +- .../ia/project-overview/overview.adapter.ts | 2 +- .../src/views/subjects/SubjectDetail.test.ts | 41 ++++ frontend/src/views/subjects/SubjectDetail.vue | 185 ++++++++++++++++-- 39 files changed, 458 insertions(+), 250 deletions(-) delete mode 100644 frontend/src/views/ia/AuditPlaceholder.vue delete mode 100644 frontend/src/views/ia/MonitoringAuditPlaceholder.vue delete mode 100644 frontend/src/views/ia/MonitoringPlaceholder.vue create mode 100644 frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index 4b72cd04..a625f0fc 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -48,7 +48,7 @@ def _is_overdue(ae: AERead) -> bool: "/", response_model=AERead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_api_permission("risk_issues:create")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("subject_aes:create")), Depends(require_study_not_locked())], ) async def create_ae( study_id: uuid.UUID, @@ -86,20 +86,15 @@ async def create_ae( return data -@router.get( - "/", - response_model=list[AERead], - dependencies=[Depends(require_api_permission("risk_issues:read"))], -) -async def list_ae( +async def _list_ae_records( study_id: uuid.UUID, - status_filter: str | None = None, - seriousness: str | None = None, - site_id: uuid.UUID | None = None, - subject_id: uuid.UUID | None = None, - overdue: bool | None = None, - db: AsyncSession = Depends(get_db_session), - current_user=Depends(get_current_user), + status_filter: str | None, + seriousness: str | None, + site_id: uuid.UUID | None, + subject_id: uuid.UUID | None, + overdue: bool | None, + db: AsyncSession, + current_user, ) -> list[AERead]: await _ensure_study_exists(db, study_id) cra_scope = await get_cra_site_scope(db, study_id, current_user) @@ -124,10 +119,64 @@ async def list_ae( return result +@router.get( + "/summary", + response_model=list[AERead], + dependencies=[Depends(require_api_permission("subject_aes:list"))], +) +async def list_risk_issue_ae( + study_id: uuid.UUID, + status_filter: str | None = None, + seriousness: str | None = None, + site_id: uuid.UUID | None = None, + subject_id: uuid.UUID | None = None, + overdue: bool | None = None, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[AERead]: + return await _list_ae_records( + study_id, + status_filter, + seriousness, + site_id, + subject_id, + overdue, + db, + current_user, + ) + + +@router.get( + "/", + response_model=list[AERead], + dependencies=[Depends(require_api_permission("subject_aes:list"))], +) +async def list_ae( + study_id: uuid.UUID, + status_filter: str | None = None, + seriousness: str | None = None, + site_id: uuid.UUID | None = None, + subject_id: uuid.UUID | None = None, + overdue: bool | None = None, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[AERead]: + return await _list_ae_records( + study_id, + status_filter, + seriousness, + site_id, + subject_id, + overdue, + db, + current_user, + ) + + @router.get( "/{ae_id}", response_model=AERead, - dependencies=[Depends(require_api_permission("risk_issues:read"))], + dependencies=[Depends(require_api_permission("subject_aes:read"))], ) async def get_ae( study_id: uuid.UUID, @@ -155,7 +204,7 @@ async def get_ae( @router.patch( "/{ae_id}", response_model=AERead, - dependencies=[Depends(require_api_permission("risk_issues:update")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("subject_aes:update")), Depends(require_study_not_locked())], ) async def update_ae( study_id: uuid.UUID, @@ -210,7 +259,7 @@ async def update_ae( @router.delete( "/{ae_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_api_permission("risk_issues:delete")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("subject_aes:delete")), Depends(require_study_not_locked())], ) async def delete_ae( study_id: uuid.UUID, diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 410dfa11..e677d483 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -70,7 +70,7 @@ async def list_lost_visits( @router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[ Depends(require_study_member()), - Depends(require_api_permission("dashboard:read")) + Depends(require_api_permission("project_overview:read")) ]) async def get_center_summary( study_id: uuid.UUID, @@ -91,7 +91,7 @@ async def get_center_summary( stage_order = [ ("institution_initiation_status", "机构立项"), - ("ethics_status", "伦理审批"), + ("ethics_status", "伦理记录"), ("contract_sign_status", "合同签署"), ("startup_status", "启动"), ("enrollment_status", "入组"), diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 770f9e07..e722600a 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -19,9 +19,9 @@ PROJECT_ROLE_RANK = { "ADMIN": 100, "PM": 80, "PV": 50, - "MEDICAL_REVIEW": 50, + "QA": 50, "CRA": 40, - "IMP": 40, + "CTA": 40, } diff --git a/backend/app/api/v1/monitoring_visit_issues.py b/backend/app/api/v1/monitoring_visit_issues.py index bdd03bab..7d7d0f85 100644 --- a/backend/app/api/v1/monitoring_visit_issues.py +++ b/backend/app/api/v1/monitoring_visit_issues.py @@ -332,7 +332,7 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object @router.get( "/issues", response_model=list[MonitoringVisitIssueRead], - dependencies=[Depends(require_api_permission("monitoring_audit:read"))], + dependencies=[Depends(require_api_permission("monitoring_issues:list"))], ) async def list_monitoring_visit_issues( study_id: uuid.UUID, @@ -382,7 +382,7 @@ async def list_monitoring_visit_issues( "/issues", response_model=MonitoringVisitIssueRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_api_permission("monitoring_audit:create")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("monitoring_issues:create")), Depends(require_study_not_locked())], ) async def create_monitoring_visit_issue( study_id: uuid.UUID, @@ -421,7 +421,7 @@ async def create_monitoring_visit_issue( @router.get( "/issues/export", response_class=StreamingResponse, - dependencies=[Depends(require_api_permission("monitoring_audit:read"))], + dependencies=[Depends(require_api_permission("monitoring_issues:list"))], ) async def export_monitoring_visit_issues( study_id: uuid.UUID, @@ -553,7 +553,7 @@ async def export_monitoring_visit_issues( @router.get( "/issues/{issue_id}", response_model=MonitoringVisitIssueRead, - dependencies=[Depends(require_api_permission("monitoring_audit:read"))], + dependencies=[Depends(require_api_permission("monitoring_issues:read"))], ) async def get_monitoring_visit_issue( study_id: uuid.UUID, @@ -570,7 +570,7 @@ async def get_monitoring_visit_issue( @router.patch( "/issues/{issue_id}", response_model=MonitoringVisitIssueRead, - dependencies=[Depends(require_api_permission("monitoring_audit:update")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("monitoring_issues:update")), Depends(require_study_not_locked())], ) async def update_monitoring_visit_issue( study_id: uuid.UUID, @@ -625,7 +625,7 @@ async def update_monitoring_visit_issue( @router.delete( "/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_api_permission("monitoring_audit:delete")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("monitoring_issues:delete")), Depends(require_study_not_locked())], ) async def delete_monitoring_visit_issue( study_id: uuid.UUID, @@ -655,7 +655,7 @@ async def delete_monitoring_visit_issue( @router.post( "/issues/import", response_model=MonitoringVisitIssueImportSummary, - dependencies=[Depends(require_api_permission("monitoring_audit:create")), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("monitoring_issues:create")), Depends(require_study_not_locked())], ) async def import_monitoring_visit_issues( study_id: uuid.UUID, diff --git a/backend/app/api/v1/overview.py b/backend/app/api/v1/overview.py index 825c303c..8f9d9489 100644 --- a/backend/app/api/v1/overview.py +++ b/backend/app/api/v1/overview.py @@ -25,7 +25,7 @@ async def get_project_overview( 返回项目各中心的进度情况,包括: - 机构立项状态 - - 伦理审批状态 + - 伦理记录状态 - 合同签署状态 - 启动会状态 - 入组进度 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 6e46e99d..76ae40e2 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles +from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles api_router = APIRouter() @@ -22,14 +22,12 @@ api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects" api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"]) api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"]) api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"]) -api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"]) api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"]) -api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"]) api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"]) api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"]) api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"]) api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"]) -api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"]) +api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"]) api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"]) api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"]) @@ -37,6 +35,7 @@ api_router.include_router(study_subject_pds.router, prefix="/studies/{study_id}" api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) api_router.include_router(documents.router, prefix="", tags=["documents"]) +api_router.include_router(etmf.router, prefix="/etmf", tags=["etmf"]) api_router.include_router(permission_monitoring.router) api_router.include_router(permission_templates.router) api_router.include_router(permission_templates.study_router, prefix="/studies/{study_id}", tags=["permission-templates"]) diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index e061a26f..cebeb6aa 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -79,7 +79,7 @@ async def create_site( module="sites", action="read", description="查询中心列表", - default_roles=["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"], + default_roles=["PM", "CRA", "PV", "QA", "CTA"], ) async def list_sites( study_id: uuid.UUID, diff --git a/backend/app/api/v1/startup.py b/backend/app/api/v1/startup.py index f0f77501..89e852d4 100644 --- a/backend/app/api/v1/startup.py +++ b/backend/app/api/v1/startup.py @@ -53,7 +53,7 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None): "/feasibility", response_model=StartupFeasibilityRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_api_permission("ethics:create"))], + dependencies=[Depends(require_api_permission("startup_initiation:create"))], ) async def create_feasibility( study_id: uuid.UUID, @@ -83,7 +83,7 @@ async def create_feasibility( @router.get( "/feasibility", response_model=list[StartupFeasibilityRead], - dependencies=[Depends(require_api_permission("ethics:read"))], + dependencies=[Depends(require_api_permission("startup_initiation:read"))], ) async def list_feasibilities( study_id: uuid.UUID, @@ -102,7 +102,7 @@ async def list_feasibilities( @router.get( "/feasibility/{record_id}", response_model=StartupFeasibilityRead, - dependencies=[Depends(require_api_permission("ethics:read"))], + dependencies=[Depends(require_api_permission("startup_initiation:read"))], ) async def get_feasibility( study_id: uuid.UUID, @@ -123,7 +123,7 @@ async def get_feasibility( @router.patch( "/feasibility/{record_id}", response_model=StartupFeasibilityRead, - dependencies=[Depends(require_api_permission("ethics:update"))], + dependencies=[Depends(require_api_permission("startup_initiation:update"))], ) async def update_feasibility( study_id: uuid.UUID, @@ -157,7 +157,7 @@ async def update_feasibility( @router.delete( "/feasibility/{record_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_api_permission("ethics:delete"))], + dependencies=[Depends(require_api_permission("startup_initiation:delete"))], ) async def delete_feasibility( study_id: uuid.UUID, @@ -190,7 +190,7 @@ async def delete_feasibility( "/ethics", response_model=StartupEthicsRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_api_permission("ethics:create"))], + dependencies=[Depends(require_api_permission("startup_ethics:create"))], ) async def create_ethics( study_id: uuid.UUID, @@ -220,7 +220,7 @@ async def create_ethics( @router.get( "/ethics", response_model=list[StartupEthicsRead], - dependencies=[Depends(require_api_permission("ethics:read"))], + dependencies=[Depends(require_api_permission("startup_ethics:read"))], ) async def list_ethics( study_id: uuid.UUID, @@ -239,7 +239,7 @@ async def list_ethics( @router.get( "/ethics/{record_id}", response_model=StartupEthicsRead, - dependencies=[Depends(require_api_permission("ethics:read"))], + dependencies=[Depends(require_api_permission("startup_ethics:read"))], ) async def get_ethics( study_id: uuid.UUID, @@ -260,7 +260,7 @@ async def get_ethics( @router.patch( "/ethics/{record_id}", response_model=StartupEthicsRead, - dependencies=[Depends(require_api_permission("ethics:update"))], + dependencies=[Depends(require_api_permission("startup_ethics:update"))], ) async def update_ethics( study_id: uuid.UUID, @@ -294,7 +294,7 @@ async def update_ethics( @router.delete( "/ethics/{record_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_api_permission("ethics:delete"))], + dependencies=[Depends(require_api_permission("startup_ethics:delete"))], ) async def delete_ethics( study_id: uuid.UUID, diff --git a/backend/app/api/v1/study_subject_pds.py b/backend/app/api/v1/study_subject_pds.py index 4f30fd46..bd5a418f 100644 --- a/backend/app/api/v1/study_subject_pds.py +++ b/backend/app/api/v1/study_subject_pds.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_api_permission from app.crud import study as study_crud from app.crud import subject_pd as subject_pd_crud from app.schemas.subject_pd import SubjectPdSummaryRead @@ -21,7 +21,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): @router.get( "/subject-pds", response_model=list[SubjectPdSummaryRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_api_permission("subject_pds:list"))], ) async def list_study_subject_pds( study_id: uuid.UUID, diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index 700031a3..fd1d658e 100644 --- a/backend/app/api/v1/subject_histories.py +++ b/backend/app/api/v1/subject_histories.py @@ -66,7 +66,7 @@ async def create_history( @router.get( "/histories", response_model=list[SubjectHistoryRead], - dependencies=[Depends(require_api_permission("subject_histories:read"))], + dependencies=[Depends(require_api_permission("subject_histories:list"))], ) async def list_histories( study_id: uuid.UUID, diff --git a/backend/app/api/v1/subject_pds.py b/backend/app/api/v1/subject_pds.py index 77e4e147..4650c081 100644 --- a/backend/app/api/v1/subject_pds.py +++ b/backend/app/api/v1/subject_pds.py @@ -64,7 +64,7 @@ def _normalize_choice(value: str | None, allowed: set[str], field_name: str) -> @router.get( "/pds", response_model=list[SubjectPdRead], - dependencies=[Depends(require_api_permission("subject_pds:read"))], + dependencies=[Depends(require_api_permission("subject_pds:list"))], ) async def list_subject_pds( study_id: uuid.UUID, diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index e6a561fd..68921050 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -33,7 +33,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None: @router.get( "/", response_model=list[VisitRead], - dependencies=[Depends(require_api_permission("visits:read"))], + dependencies=[Depends(require_api_permission("visits:list"))], ) async def list_visits( study_id: uuid.UUID, diff --git a/backend/app/crud/acknowledgement.py b/backend/app/crud/acknowledgement.py index 9921afee..c6e369ef 100644 --- a/backend/app/crud/acknowledgement.py +++ b/backend/app/crud/acknowledgement.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/crud/distribution.py b/backend/app/crud/distribution.py index f98cc32d..4b87573a 100644 --- a/backend/app/crud/distribution.py +++ b/backend/app/crud/distribution.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/crud/document_version.py b/backend/app/crud/document_version.py index 9c52dd70..e5432eaf 100644 --- a/backend/app/crud/document_version.py +++ b/backend/app/crud/document_version.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/crud/member.py b/backend/app/crud/member.py index 79de0638..e68ac03e 100644 --- a/backend/app/crud/member.py +++ b/backend/app/crud/member.py @@ -1,5 +1,7 @@ from __future__ import annotations +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/crud/overview.py b/backend/app/crud/overview.py index 217c1d22..bf08dfe1 100644 --- a/backend/app/crud/overview.py +++ b/backend/app/crud/overview.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.site import Site from app.models.startup_feasibility import StartupFeasibility from app.models.startup_ethics import StartupEthics -from app.models.finance_contract import FinanceContract +from app.models.contract_fee import ContractFee from app.models.kickoff_meeting import KickoffMeeting from app.models.subject import Subject @@ -59,7 +59,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict: institution_status = "COMPLETED" if feas.get("approved_date") else "IN_PROGRESS" if feas.get("submit_date") else "NOT_STARTED" institution_completed = feas.get("approved_date") - # 伦理审批状态 - 依赖机构立项完成 + # 伦理记录状态 - 依赖机构立项完成 eth = ethics_data.get(site_id_str, {}) if institution_status == "COMPLETED": ethics_status = "COMPLETED" if eth.get("approved_date") else "IN_PROGRESS" if eth.get("submit_date") else "NOT_STARTED" @@ -68,7 +68,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict: ethics_status = "NOT_STARTED" ethics_completed = eth.get("approved_date") if institution_status == "COMPLETED" else None - # 合同签署状态 - 依赖伦理审批完成 + # 合同签署状态 - 依赖伦理记录完成 contract = contract_data.get(site_id_str, {}) if ethics_status == "COMPLETED": contract_status = "COMPLETED" if contract.get("signed_date") else "NOT_STARTED" @@ -202,28 +202,22 @@ async def _get_ethics_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List async def _get_contract_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict: """获取各中心的合同签署数据""" - # FinanceContract 通过 site_name 关联,需要先获取site名称映射 - stmt_sites = select(Site.id, Site.name).where(Site.id.in_(site_ids)) - result = await db.execute(stmt_sites) - site_name_map = {row.name: str(row.id) for row in result.all()} - stmt = select( - FinanceContract.site_name, - func.min(FinanceContract.signed_date).label("signed_date") + ContractFee.center_id, + func.min(ContractFee.signed_date).label("signed_date") ).where( - FinanceContract.study_id == study_id - ).group_by(FinanceContract.site_name) + ContractFee.project_id == study_id, + ContractFee.center_id.in_(site_ids), + ).group_by(ContractFee.center_id) result = await db.execute(stmt) rows = result.all() data = {} for row in rows: - site_id_str = site_name_map.get(row.site_name) - if site_id_str: - data[site_id_str] = { - "signed_date": row.signed_date.isoformat() if row.signed_date else None, - } + data[str(row.center_id)] = { + "signed_date": row.signed_date.isoformat() if row.signed_date else None, + } return data diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py index f0013b22..29c16f40 100644 --- a/backend/app/crud/site.py +++ b/backend/app/crud/site.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from pathlib import Path from typing import Iterable, Sequence @@ -16,9 +18,8 @@ from app.models.distribution import Distribution from app.models.drug_shipment import DrugShipment from app.models.fee_attachment import FeeAttachment from app.models.finance import FinanceItem -from app.models.finance_contract import FinanceContract from app.models.kickoff_meeting import KickoffMeeting -from app.models.knowledge_note import KnowledgeNote +from app.models.precaution import Precaution from app.models.milestone import Milestone from app.models.monitoring_visit_issue import MonitoringVisitIssue from app.models.site import Site @@ -177,19 +178,11 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None: ) ) ).scalars().all() - finance_contract_ids = ( + precaution_ids = ( await db.execute( - select(FinanceContract.id).where( - FinanceContract.study_id == study_id, - FinanceContract.site_name == site_name, - ) - ) - ).scalars().all() - knowledge_note_ids = ( - await db.execute( - select(KnowledgeNote.id).where( - KnowledgeNote.study_id == study_id, - KnowledgeNote.site_name == site_name, + select(Precaution.id).where( + Precaution.study_id == study_id, + Precaution.site_name == site_name, ) ) ).scalars().all() @@ -220,8 +213,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None: "startup_kickoff_signin": kickoff_ids, "startup_kickoff_ppt": kickoff_ids, "training_authorization": training_ids, - "finance_contract": finance_contract_ids, - "knowledge_note": knowledge_note_ids, + "precaution": precaution_ids, "drug_shipment": drug_shipment_ids, } attachment_paths: list[str] = [] @@ -311,15 +303,9 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None: ) ) await db.execute( - delete(FinanceContract).where( - FinanceContract.study_id == study_id, - FinanceContract.site_name == site_name, - ) - ) - await db.execute( - delete(KnowledgeNote).where( - KnowledgeNote.study_id == study_id, - KnowledgeNote.site_name == site_name, + delete(Precaution).where( + Precaution.study_id == study_id, + Precaution.site_name == site_name, ) ) diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index f7f020b3..e8cae9ee 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence @@ -99,7 +101,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: from app.models.visit import Visit from app.models.ae import AdverseEvent from app.models.finance import FinanceItem - from app.models.finance_contract import FinanceContract from app.models.contract_fee import ContractFee from app.models.contract_fee_payment import ContractFeePayment from app.models.milestone import Milestone @@ -111,7 +112,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: from app.models.startup_feasibility import StartupFeasibility from app.models.startup_ethics import StartupEthics from app.models.kickoff_meeting import KickoffMeeting - from app.models.knowledge_note import KnowledgeNote + from app.models.precaution import Precaution from app.models.faq_category import FaqCategory from app.models.faq_item import FaqItem from app.models.faq_reply import FaqReply @@ -129,8 +130,8 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id)) await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id)) - # 3. 删除知识库 - await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id)) + # 3. 删除注意事项 + await db.execute(sa_delete(Precaution).where(Precaution.study_id == study_id)) # 4. 删除启动相关 await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id)) @@ -158,7 +159,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: ) ) await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id)) - await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id)) await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id)) # 10. 删除不良事件 diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 03532587..11cf458d 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 95f29bc9..7b7cb324 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -7,6 +7,7 @@ from app.models.site import Site # noqa: F401 from app.models.study_member import StudyMember # noqa: F401 from app.models.attachment import Attachment # noqa: F401 from app.models.audit_log import AuditLog # noqa: F401 +from app.models.etmf import EtmfNode # noqa: F401 from app.models.document import Document # noqa: F401 from app.models.document_version import DocumentVersion # noqa: F401 from app.models.distribution import Distribution # noqa: F401 @@ -17,7 +18,6 @@ from app.models.subject_pd import SubjectPd # noqa: F401 from app.models.visit import Visit # noqa: F401 from app.models.ae import AdverseEvent # noqa: F401 from app.models.finance import FinanceItem # noqa: F401 -from app.models.finance_contract import FinanceContract # noqa: F401 from app.models.contract_fee import ContractFee # noqa: F401 from app.models.contract_fee_payment import ContractFeePayment # noqa: F401 from app.models.fee_attachment import FeeAttachment # noqa: F401 @@ -28,7 +28,7 @@ from app.models.startup_feasibility import StartupFeasibility # noqa: F401 from app.models.startup_ethics import StartupEthics # noqa: F401 from app.models.kickoff_meeting import KickoffMeeting # noqa: F401 from app.models.training_authorization import TrainingAuthorization # noqa: F401 -from app.models.knowledge_note import KnowledgeNote # noqa: F401 +from app.models.precaution import Precaution # noqa: F401 from app.models.subject_history import SubjectHistory # noqa: F401 from app.models.faq_category import FaqCategory # noqa: F401 from app.models.faq_item import FaqItem # noqa: F401 diff --git a/backend/app/models/kickoff_meeting.py b/backend/app/models/kickoff_meeting.py index 3dc7e317..53daa688 100644 --- a/backend/app/models/kickoff_meeting.py +++ b/backend/app/models/kickoff_meeting.py @@ -18,7 +18,7 @@ class KickoffMeeting(Base): study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True) kickoff_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) - attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + attendees: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True) created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/study_setup_config.py b/backend/app/models/study_setup_config.py index 26dda9d8..c0ce43bc 100644 --- a/backend/app/models/study_setup_config.py +++ b/backend/app/models/study_setup_config.py @@ -22,8 +22,8 @@ class StudySetupConfig(Base): version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False) publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False) - published_config: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True) - published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True) + published_config: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True) + published_project_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True) current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main") active_branch_base_version_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True diff --git a/backend/app/models/study_setup_config_version.py b/backend/app/models/study_setup_config_version.py index c74a2a25..a0648f60 100644 --- a/backend/app/models/study_setup_config_version.py +++ b/backend/app/models/study_setup_config_version.py @@ -38,7 +38,7 @@ class StudySetupConfigVersion(Base): UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True ) config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False) - published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True) + published_project_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB_TYPE, nullable=True) published_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index a9c12bcc..af6b8912 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from datetime import datetime diff --git a/frontend/src/api/aes.ts b/frontend/src/api/aes.ts index acb72492..665a72cc 100644 --- a/frontend/src/api/aes.ts +++ b/frontend/src/api/aes.ts @@ -4,6 +4,9 @@ import type { ApiListResponse } from "../types/api"; export const fetchAes = (studyId: string, params?: Record) => apiGet>(`/api/v1/studies/${studyId}/aes/`, { params }); +export const fetchRiskIssueAes = (studyId: string, params?: Record) => + apiGet>(`/api/v1/studies/${studyId}/aes/summary`, { params }); + export const createAe = (studyId: string, payload: Record) => apiPost(`/api/v1/studies/${studyId}/aes/`, payload); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 4c2c8fa4..7156c5b8 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -88,21 +88,17 @@ {{ TEXT.menu.riskIssuePd }} {{ TEXT.menu.riskIssueMonitoringVisits }} - - - {{ TEXT.menu.monitoringAudit }} - {{ TEXT.menu.etmf }} - + {{ TEXT.menu.knowledgeMedicalConsult }} - {{ TEXT.menu.knowledgeNotes }} + {{ TEXT.menu.knowledgeNotes }} {{ TEXT.menu.knowledgeSupportFiles }} {{ TEXT.menu.knowledgeInstructionFiles }} @@ -217,7 +213,7 @@ import { fetchStudies } from "../api/studies"; import { fetchSites } from "../api/sites"; import { TEXT } from "../locales"; import { - User, Suitcase, House, Calendar, Flag, ChatDotRound, + User, Suitcase, House, Calendar, Flag, CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key } from "@element-plus/icons-vue"; import { ElMessage } from "element-plus"; @@ -260,7 +256,6 @@ const activeMenu = computed(() => { if (path.startsWith("/project/milestones")) return "/project/milestones"; if (path.startsWith("/project/")) return "/project/overview"; if (path.startsWith("/fees/contracts")) return "/fees/contracts"; - if (path.startsWith("/finance/contracts")) return "/fees/contracts"; if (path.startsWith("/drug/shipments")) return "/drug/shipments"; if (path.startsWith("/materials/equipment")) return "/materials/equipment"; if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions"; @@ -273,10 +268,9 @@ const activeMenu = computed(() => { if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd"; if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits"; if (path.startsWith("/risk-issues")) return "/risk-issues/sae"; - if (path.startsWith("/monitoring-audit") || path.startsWith("/monitoring") || path.startsWith("/audit")) return "/monitoring-audit"; if (path.startsWith("/etmf")) return "/etmf"; if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult"; - if (path.startsWith("/knowledge/notes")) return "/knowledge/notes"; + if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions"; if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files"; if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files"; if (path.startsWith("/projects/")) return "/admin/projects"; @@ -342,14 +336,13 @@ const breadcrumbs = computed(() => { "/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" }, "/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" }, "/risk-issues/monitoring-visits": { label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" }, - "/monitoring-audit": { label: TEXT.menu.monitoringAudit, path: "/monitoring-audit" }, "/etmf": { label: TEXT.menu.etmf, path: "/etmf" }, "/startup/feasibility": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" }, "/startup/ethics": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" }, "/startup/kickoff": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" }, "/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" }, "/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" }, - "/knowledge/notes": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/notes" }, + "/knowledge/precautions": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" }, }; let matchedModule = null; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2fb8d80e..072bd932 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -22,7 +22,6 @@ import ProjectDetail from "../views/admin/ProjectDetail.vue"; import ProfileSettings from "../views/ProfileSettings.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectMilestones from "../views/ia/ProjectMilestones.vue"; -import FinanceContracts from "../views/ia/FinanceContracts.vue"; import FeeContracts from "../views/fees/ContractFees.vue"; import FeeContractForm from "../views/fees/ContractFeeForm.vue"; import FeeContractDetail from "../views/fees/ContractFeeDetail.vue"; @@ -37,14 +36,11 @@ import SubjectManagement from "../views/ia/SubjectManagement.vue"; import RiskIssueSae from "../views/ia/RiskIssueSae.vue"; import RiskIssuePd from "../views/ia/RiskIssuePd.vue"; import RiskIssueMonitoringVisits from "../views/ia/RiskIssueMonitoringVisits.vue"; -import MonitoringAuditPlaceholder from "../views/ia/MonitoringAuditPlaceholder.vue"; import EtmfPlaceholder from "../views/ia/EtmfPlaceholder.vue"; import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue"; -import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue"; +import Precautions from "../views/ia/Precautions.vue"; import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue"; import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue"; -import ContractForm from "../views/finance/ContractForm.vue"; -import ContractDetail from "../views/finance/ContractDetail.vue"; import ShipmentForm from "../views/drug/ShipmentForm.vue"; import ShipmentDetail from "../views/drug/ShipmentDetail.vue"; import FeasibilityForm from "../views/startup/FeasibilityForm.vue"; @@ -55,8 +51,8 @@ import KickoffForm from "../views/startup/KickoffForm.vue"; import KickoffDetail from "../views/startup/KickoffDetail.vue"; import TrainingForm from "../views/startup/TrainingForm.vue"; import TrainingDetail from "../views/startup/TrainingDetail.vue"; -import NoteForm from "../views/knowledge/NoteForm.vue"; -import NoteDetail from "../views/knowledge/NoteDetail.vue"; +import PrecautionForm from "../views/knowledge/PrecautionForm.vue"; +import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue"; import SubjectForm from "../views/subjects/SubjectForm.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import { TEXT } from "../locales"; @@ -103,12 +99,6 @@ const routes: RouteRecordRaw[] = [ component: ProjectMilestones, meta: { title: TEXT.menu.projectMilestones, requiresStudy: true }, }, - { - path: "finance/contracts", - name: "FinanceContracts", - component: FinanceContracts, - meta: { title: TEXT.menu.financeContracts, requiresStudy: true }, - }, { path: "fees/contracts", name: "FeeContracts", @@ -133,24 +123,6 @@ const routes: RouteRecordRaw[] = [ component: FeeContractForm, meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true }, }, - { - path: "finance/contracts/new", - name: "FinanceContractNew", - component: ContractForm, - meta: { title: TEXT.common.actions.add + TEXT.menu.financeContracts, requiresStudy: true }, - }, - { - path: "finance/contracts/:contractId", - name: "FinanceContractDetail", - component: ContractDetail, - meta: { title: TEXT.modules.financeContracts.detailTitle, requiresStudy: true }, - }, - { - path: "finance/contracts/:contractId/edit", - name: "FinanceContractEdit", - component: ContractForm, - meta: { title: TEXT.common.actions.edit + TEXT.menu.financeContracts, requiresStudy: true }, - }, { path: "drug/shipments", name: "DrugShipments", @@ -329,20 +301,6 @@ const routes: RouteRecordRaw[] = [ component: RiskIssueMonitoringVisits, meta: { title: TEXT.menu.riskIssueMonitoringVisits, requiresStudy: true }, }, - { - path: "monitoring-audit", - name: "MonitoringAuditPlaceholder", - component: MonitoringAuditPlaceholder, - meta: { title: TEXT.menu.monitoringAudit, requiresStudy: true }, - }, - { - path: "monitoring", - redirect: "/monitoring-audit", - }, - { - path: "audit", - redirect: "/monitoring-audit", - }, { path: "etmf", name: "EtmfPlaceholder", @@ -362,27 +320,27 @@ const routes: RouteRecordRaw[] = [ meta: { title: TEXT.modules.knowledgeMedicalConsult.detailTitle, requiresStudy: true }, }, { - path: "knowledge/notes", - name: "KnowledgeNotes", - component: KnowledgeNotes, + path: "knowledge/precautions", + name: "Precautions", + component: Precautions, meta: { title: TEXT.menu.knowledgeNotes, requiresStudy: true }, }, { - path: "knowledge/notes/new", - name: "KnowledgeNoteNew", - component: NoteForm, + path: "knowledge/precautions/new", + name: "PrecautionNew", + component: PrecautionForm, meta: { title: TEXT.common.actions.add + TEXT.menu.knowledgeNotes, requiresStudy: true }, }, { - path: "knowledge/notes/:noteId", - name: "KnowledgeNoteDetail", - component: NoteDetail, + path: "knowledge/precautions/:id", + name: "PrecautionDetail", + component: PrecautionDetail, meta: { title: TEXT.modules.knowledgeNotes.detailTitle, requiresStudy: true }, }, { - path: "knowledge/notes/:noteId/edit", - name: "KnowledgeNoteEdit", - component: NoteForm, + path: "knowledge/precautions/:id/edit", + name: "PrecautionEdit", + component: PrecautionForm, meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true }, }, { diff --git a/frontend/src/views/FaqDetail.vue b/frontend/src/views/FaqDetail.vue index 9f61dbef..76533595 100644 --- a/frontend/src/views/FaqDetail.vue +++ b/frontend/src/views/FaqDetail.vue @@ -51,7 +51,7 @@ :quote-item="quoteReply" :member-map="memberMap" :user-map="userMap" - :allow-attachments="true" + :allow-attachments="canUploadReplyAttachment" @submit="submitReply" @clear-quote="clearQuote" /> @@ -107,7 +107,10 @@ import { listMembers } from "../api/members"; import { fetchAttachments, uploadAttachment } from "../api/attachments"; import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display"; import { useAuthStore } from "../store/auth"; +import { useStudyStore } from "../store/study"; import { usePermission } from "../utils/permission"; +import { getAttachmentPermissionKey } from "../utils/attachmentPermissions"; +import { isApiPermissionAllowed } from "../utils/apiPermissionValue"; import FaqItemForm from "../components/FaqItemForm.vue"; import ThreadComposer from "../components/ThreadComposer.vue"; import ThreadList from "../components/ThreadList.vue"; @@ -115,6 +118,7 @@ import { TEXT } from "../locales"; const route = useRoute(); const auth = useAuthStore(); +const study = useStudyStore(); const item = ref(null); const loading = ref(false); const categories = ref([]); @@ -136,6 +140,14 @@ const canReply = computed(() => { return can("faq.reply"); }); +const canUploadReplyAttachment = computed(() => { + if (!item.value?.study_id) return false; + if (auth.user?.is_admin) return true; + const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study; + const operationKey = getAttachmentPermissionKey("faq_replies", "create"); + return !!role && !!operationKey && isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]); +}); + const canDeleteReply = (reply: any) => { if (reply.is_deleted) return false; if (auth.user?.is_admin) return true; @@ -273,7 +285,7 @@ const submitReply = async () => { quote_reply_id: quoteReply.value?.id || null, }); const created = data as any; - if (replyFiles.value.length && created?.id) { + if (canUploadReplyAttachment.value && replyFiles.value.length && created?.id) { try { for (const file of replyFiles.value) { if (!file.raw) continue; diff --git a/frontend/src/views/documents/DocumentDetail.vue b/frontend/src/views/documents/DocumentDetail.vue index bbfae33d..a0d47f1e 100644 --- a/frontend/src/views/documents/DocumentDetail.vue +++ b/frontend/src/views/documents/DocumentDetail.vue @@ -362,7 +362,7 @@ const uploadRules: FormRules = { }; const roleOptions = computed(() => { - const fallbackRoles = ["CRA", "PM", "IMP", "PV", "MEDICAL_REVIEW"]; + const fallbackRoles = ["CRA", "PM", "CTA", "PV", "QA"]; const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean)); const values = roles.size ? Array.from(roles) : fallbackRoles; return values.map((value) => ({ diff --git a/frontend/src/views/ia/AuditPlaceholder.vue b/frontend/src/views/ia/AuditPlaceholder.vue deleted file mode 100644 index 6b571896..00000000 --- a/frontend/src/views/ia/AuditPlaceholder.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/views/ia/MonitoringAuditPlaceholder.vue b/frontend/src/views/ia/MonitoringAuditPlaceholder.vue deleted file mode 100644 index c70a64b6..00000000 --- a/frontend/src/views/ia/MonitoringAuditPlaceholder.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/views/ia/MonitoringPlaceholder.vue b/frontend/src/views/ia/MonitoringPlaceholder.vue deleted file mode 100644 index e63cd8a3..00000000 --- a/frontend/src/views/ia/MonitoringPlaceholder.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts b/frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts new file mode 100644 index 00000000..00f54bc0 --- /dev/null +++ b/frontend/src/views/ia/RiskIssueMonitoringVisits.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readRiskIssueMonitoringVisits = () => readFileSync(resolve(__dirname, "./RiskIssueMonitoringVisits.vue"), "utf8"); + +describe("RiskIssueMonitoringVisits permissions", () => { + it("gates monitoring visit issue actions by concrete API permissions", () => { + const source = readRiskIssueMonitoringVisits(); + + expect(source).toContain("useAuthStore"); + expect(source).toContain("isSystemAdmin"); + expect(source).toContain("getProjectRole"); + expect(source).toContain("isApiPermissionAllowed"); + [ + "monitoring_issues:list", + "monitoring_issues:read", + "monitoring_issues:create", + "monitoring_issues:update", + "monitoring_issues:delete", + ].forEach((operationKey) => { + expect(source).toContain(operationKey); + }); + expect(source).toContain('v-if="canCreateIssue"'); + expect(source).toContain('v-if="canListIssues"'); + expect(source).toContain('v-if="canReadIssue"'); + expect(source).toContain('v-if="canUpdateIssue"'); + expect(source).toContain('v-if="canDeleteIssue"'); + expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));"); + }); +}); diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue index 06edad10..1f7d4aca 100644 --- a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue +++ b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue @@ -105,7 +105,7 @@
- + 新增 @@ -117,7 +117,14 @@ 一键完成 - + 导出 @@ -126,6 +133,7 @@ 问题导入模板 - + @@ -403,7 +411,7 @@ @@ -460,9 +468,12 @@ import { import { fetchSites } from "../../api/sites"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; +import { useAuthStore } from "../../store/auth"; import { useStudyStore } from "../../store/study"; import type { Site } from "../../types/api"; +import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { displayDateTime } from "../../utils/display"; +import { getProjectRole, isSystemAdmin } from "../../utils/roles"; interface MonitoringIssueRow { id: string; @@ -499,6 +510,7 @@ interface MonitoringIssueRow { } type DateRange = [string, string] | []; +const auth = useAuthStore(); const study = useStudyStore(); const loading = ref(false); const saving = ref(false); @@ -515,6 +527,19 @@ const viewIssue = ref(null); const sitesLoading = ref(false); const siteOptions = ref>>([]); const siteMap = ref>({}); +const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); +const canUseApiPermission = (operationKey: string) => { + if (isSystemAdmin(auth.user)) return true; + const role = projectRole.value; + if (!role) return false; + return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]); +}; +const canListIssues = computed(() => canUseApiPermission("monitoring_issues:list")); +const canReadIssue = computed(() => canUseApiPermission("monitoring_issues:read")); +const canCreateIssue = computed(() => canUseApiPermission("monitoring_issues:create")); +const canUpdateIssue = computed(() => canUseApiPermission("monitoring_issues:update")); +const canDeleteIssue = computed(() => canUseApiPermission("monitoring_issues:delete")); +const canSaveIssue = computed(() => (formMode.value === "edit" ? canUpdateIssue.value : canCreateIssue.value)); const currentProjectCenter = computed(() => study.currentStudy?.name || study.currentStudy?.project_full_name || ""); @@ -802,6 +827,7 @@ const resetFiltersForStudy = () => { }; const openCreateDialog = () => { + if (!canCreateIssue.value) return; resetCreateForm(); formMode.value = "create"; editingIssueId.value = ""; @@ -810,7 +836,7 @@ const openCreateDialog = () => { const openEditDialog = async (row: MonitoringIssueRow) => { const studyId = study.currentStudy?.id; - if (!studyId || !row?.id) return; + if (!studyId || !row?.id || !canUpdateIssue.value) return; try { const { data } = await getMonitoringVisitIssue(studyId, row.id); const issue = (data || row) as MonitoringIssueRow; @@ -825,7 +851,7 @@ const openEditDialog = async (row: MonitoringIssueRow) => { const openViewDialog = async (row: MonitoringIssueRow) => { const studyId = study.currentStudy?.id; - if (!studyId || !row?.id) return; + if (!studyId || !row?.id || !canReadIssue.value) return; try { const { data } = await getMonitoringVisitIssue(studyId, row.id); viewIssue.value = (data || row) as MonitoringIssueRow; @@ -837,7 +863,7 @@ const openViewDialog = async (row: MonitoringIssueRow) => { const submitForm = async () => { const studyId = study.currentStudy?.id; - if (!studyId) return; + if (!studyId || !canSaveIssue.value) return; const ok = await createFormRef.value?.validate().catch(() => false); if (!ok) return; @@ -891,7 +917,7 @@ const submitForm = async () => { const removeIssue = async (row: MonitoringIssueRow) => { const studyId = study.currentStudy?.id; - if (!studyId || !row?.id) return; + if (!studyId || !row?.id || !canDeleteIssue.value) return; const confirmed = await ElMessageBox.confirm(`确认删除问题「${row.issue_no}」?`, TEXT.common.labels.tips).catch(() => null); if (!confirmed) return; @@ -914,7 +940,7 @@ const getFilename = (header?: string | null) => { const handleExportExcel = async () => { const studyId = study.currentStudy?.id; - if (!studyId) return; + if (!studyId || !canListIssues.value) return; if (filteredItems.value.length === 0) { ElMessage.warning("暂无可导出数据"); return; @@ -964,7 +990,7 @@ const handleExportExcel = async () => { const onImportChange = async (uploadFile: UploadFile) => { const studyId = study.currentStudy?.id; - if (!studyId) return; + if (!studyId || !canCreateIssue.value) return; const file = uploadFile.raw; if (!file) return; diff --git a/frontend/src/views/ia/RiskIssueSae.vue b/frontend/src/views/ia/RiskIssueSae.vue index 3a2087f0..453a153b 100644 --- a/frontend/src/views/ia/RiskIssueSae.vue +++ b/frontend/src/views/ia/RiskIssueSae.vue @@ -94,7 +94,7 @@ import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { CircleCheck, Location, Search, Warning } from "@element-plus/icons-vue"; import { useStudyStore } from "../../store/study"; -import { fetchAes } from "../../api/aes"; +import { fetchRiskIssueAes } from "../../api/aes"; import { fetchSubjects } from "../../api/subjects"; import { fetchSites } from "../../api/sites"; import { displayDate, displayEnum } from "../../utils/display"; @@ -166,7 +166,7 @@ const load = async () => { loading.value = true; try { await Promise.all([loadSites(studyId), loadSubjects(studyId)]); - const { data } = await fetchAes(studyId); + const { data } = await fetchRiskIssueAes(studyId); items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) => String(b.updated_at || "").localeCompare(String(a.updated_at || "")) ); diff --git a/frontend/src/views/ia/project-overview/overview.adapter.ts b/frontend/src/views/ia/project-overview/overview.adapter.ts index c42305d1..765cc265 100644 --- a/frontend/src/views/ia/project-overview/overview.adapter.ts +++ b/frontend/src/views/ia/project-overview/overview.adapter.ts @@ -69,7 +69,7 @@ export const STAGE_ORDER: Array<{ completedKey: StageCompletionKey; }> = [ { key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" }, - { key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" }, + { key: "ethics_status", label: "伦理记录", completedKey: "ethics_completed_at" }, { key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" }, { key: "startup_status", label: "启动", completedKey: "startup_completed_at" }, { key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" }, diff --git a/frontend/src/views/subjects/SubjectDetail.test.ts b/frontend/src/views/subjects/SubjectDetail.test.ts index 9925437e..34177af2 100644 --- a/frontend/src/views/subjects/SubjectDetail.test.ts +++ b/frontend/src/views/subjects/SubjectDetail.test.ts @@ -48,4 +48,45 @@ describe("SubjectDetail medication adherence", () => { expect(source).toContain("row.window_start === row.planned_date && row.window_end === row.planned_date"); expect(source).toContain("return \"不适用\";"); }); + + it("gates participant subresource mutations by concrete API permissions", () => { + const source = readSubjectDetail(); + + expect(source).toContain("isApiPermissionAllowed"); + expect(source).toContain("getProjectRole"); + expect(source).toContain("isSystemAdmin"); + [ + "subjects:update", + "subject_histories:create", + "subject_histories:update", + "subject_histories:delete", + "visits:create", + "visits:update", + "visits:delete", + "subject_aes:create", + "subject_aes:list", + "subject_aes:update", + "subject_aes:delete", + "subject_pds:create", + "subject_pds:list", + "subject_pds:update", + "subject_pds:delete", + ].forEach((operationKey) => { + expect(source).toContain(operationKey); + }); + expect(source).toContain("v-if=\"currentTabAction.allowed\""); + expect(source).toContain("v-if=\"canUpdateHistory || canDeleteHistory\""); + expect(source).toContain("v-if=\"canUpdateVisit || canDeleteVisit\""); + expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\""); + expect(source).toContain("v-if=\"canUpdatePd || canDeletePd\""); + expect(source).toContain("const canSaveHistory = computed(() => (historyEditingId.value ? canUpdateHistory.value : canCreateHistory.value));"); + expect(source).toContain("const canSaveVisit = computed(() => (visitEditingId.value ? canUpdateVisit.value : canCreateVisit.value));"); + expect(source).toContain("const canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));"); + expect(source).toContain("const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));"); + expect(source).toContain("const canCreateAe = computed(() => canUseApiPermission(\"subject_aes:create\"));"); + expect(source).toContain("const canUpdateAe = computed(() => canUseApiPermission(\"subject_aes:update\"));"); + expect(source).toContain("const canDeleteAe = computed(() => canUseApiPermission(\"subject_aes:delete\"));"); + expect(source).toContain("const canListAe = computed(() => canUseApiPermission(\"subject_aes:list\"));"); + expect(source).toContain("const canListPd = computed(() => canUseApiPermission(\"subject_pds:list\"));"); + }); }); diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index fb884f81..09ed519d 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -10,7 +10,7 @@ v-if="subjectEditing" type="primary" :loading="subjectSaving" - :disabled="isReadOnly" + :disabled="isReadOnly || !canUpdateSubject" @click="saveSubjectEdit" > {{ TEXT.common.actions.save }} @@ -18,7 +18,7 @@ {{ TEXT.common.actions.cancel }} - + {{ TEXT.common.actions.edit }} {{ TEXT.common.actions.back }} @@ -91,10 +91,22 @@
- + {{ currentTabAction.label }} - + 提前终止 @@ -108,9 +120,10 @@ - + - +