中文:收口权限与中心/立项配置改造
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
"""rename user department to clinical_department
|
||||||
|
|
||||||
|
Revision ID: 20260511_02
|
||||||
|
Revises: 20260511_01
|
||||||
|
Create Date: 2026-05-11 17:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260511_02"
|
||||||
|
down_revision: Union[str, None] = "20260511_01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
TABLE_NAME = "users"
|
||||||
|
OLD_COLUMN = "department"
|
||||||
|
NEW_COLUMN = "clinical_department"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
|
||||||
|
return table_name in set(inspector.get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||||
|
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not _has_table(inspector, TABLE_NAME):
|
||||||
|
return
|
||||||
|
if _has_column(inspector, TABLE_NAME, OLD_COLUMN) and not _has_column(inspector, TABLE_NAME, NEW_COLUMN):
|
||||||
|
op.alter_column(TABLE_NAME, OLD_COLUMN, new_column_name=NEW_COLUMN)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not _has_table(inspector, TABLE_NAME):
|
||||||
|
return
|
||||||
|
if _has_column(inspector, TABLE_NAME, NEW_COLUMN) and not _has_column(inspector, TABLE_NAME, OLD_COLUMN):
|
||||||
|
op.alter_column(TABLE_NAME, NEW_COLUMN, new_column_name=OLD_COLUMN)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add phone to sites
|
||||||
|
|
||||||
|
Revision ID: 20260512_01
|
||||||
|
Revises: 20260511_02
|
||||||
|
Create Date: 2026-05-12 09:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260512_01"
|
||||||
|
down_revision: Union[str, None] = "20260511_02"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||||
|
|
||||||
|
if "phone" not in columns:
|
||||||
|
op.add_column("sites", sa.Column("phone", sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||||
|
|
||||||
|
if "phone" in columns:
|
||||||
|
op.drop_column("sites", "phone")
|
||||||
@@ -196,7 +196,9 @@ async def update_me(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
|
||||||
update_data = {
|
update_data = {
|
||||||
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
|
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
|
||||||
"department": payload.department if payload.department is not None else current_user.department,
|
"clinical_department": (
|
||||||
|
payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department
|
||||||
|
),
|
||||||
"password": payload.password if payload.password else None,
|
"password": payload.password if payload.password else None,
|
||||||
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
|
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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_study_member
|
||||||
|
from app.crud import member as member_crud
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
from app.schemas.progress import StudyProgressRead
|
from app.schemas.progress import StudyProgressRead
|
||||||
from app.schemas.visit import VisitLostItem
|
from app.schemas.visit import VisitLostItem
|
||||||
@@ -14,6 +15,10 @@ from app.crud import overview as overview_crud
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _role_value(user) -> str:
|
||||||
|
return user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
|
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
|
||||||
async def get_progress(
|
async def get_progress(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -68,8 +73,12 @@ async def get_center_summary(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> list[CenterSummaryItem]:
|
) -> list[CenterSummaryItem]:
|
||||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
role_value = _role_value(current_user)
|
||||||
if role_value not in {"ADMIN", "PM", "CRA"}:
|
member_role = None
|
||||||
|
if role_value != "ADMIN":
|
||||||
|
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||||
|
member_role = membership.role_in_study if membership and membership.is_active else None
|
||||||
|
if role_value != "ADMIN" and member_role not in {"PM", "CRA"}:
|
||||||
return []
|
return []
|
||||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||||
scope_ids = cra_scope[0] if cra_scope else None
|
scope_ids = cra_scope[0] if cra_scope else None
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
|
|||||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||||
if not membership or not membership.is_active:
|
if not membership or not membership.is_active:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||||
if write and membership.role_in_study != "PM":
|
if write and membership.role_in_study not in {"ADMIN", "PM"}:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
|
|||||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||||
if not membership or not membership.is_active:
|
if not membership or not membership.is_active:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||||
if write and membership.role_in_study != "PM":
|
if write and membership.role_in_study not in {"ADMIN", "PM"}:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
"/",
|
"/",
|
||||||
response_model=StudyMemberRead,
|
response_model=StudyMemberRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def add_member(
|
async def add_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -115,7 +115,7 @@ async def list_members(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/{member_id}",
|
"/{member_id}",
|
||||||
response_model=StudyMemberRead,
|
response_model=StudyMemberRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_member(
|
async def update_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -147,7 +147,7 @@ async def update_member(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{member_id}",
|
"/{member_id}",
|
||||||
response_model=StudyMemberRead,
|
response_model=StudyMemberRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def remove_member(
|
async def remove_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|||||||
"/",
|
"/",
|
||||||
response_model=SiteRead,
|
response_model=SiteRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def create_site(
|
async def create_site(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -91,7 +91,7 @@ async def list_sites(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/{site_id}",
|
"/{site_id}",
|
||||||
response_model=SiteRead,
|
response_model=SiteRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def update_site(
|
async def update_site(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -108,6 +108,7 @@ async def update_site(
|
|||||||
"name": site.name,
|
"name": site.name,
|
||||||
"city": site.city,
|
"city": site.city,
|
||||||
"pi_name": site.pi_name,
|
"pi_name": site.pi_name,
|
||||||
|
"phone": site.phone,
|
||||||
"contact": site.contact,
|
"contact": site.contact,
|
||||||
"is_active": site.is_active,
|
"is_active": site.is_active,
|
||||||
}
|
}
|
||||||
@@ -116,6 +117,7 @@ async def update_site(
|
|||||||
"name": updated.name,
|
"name": updated.name,
|
||||||
"city": updated.city,
|
"city": updated.city,
|
||||||
"pi_name": updated.pi_name,
|
"pi_name": updated.pi_name,
|
||||||
|
"phone": updated.phone,
|
||||||
"contact": updated.contact,
|
"contact": updated.contact,
|
||||||
"is_active": updated.is_active,
|
"is_active": updated.is_active,
|
||||||
}
|
}
|
||||||
@@ -155,6 +157,7 @@ async def delete_site(
|
|||||||
"name": site.name,
|
"name": site.name,
|
||||||
"city": site.city,
|
"city": site.city,
|
||||||
"pi_name": site.pi_name,
|
"pi_name": site.pi_name,
|
||||||
|
"phone": site.phone,
|
||||||
"contact": site.contact,
|
"contact": site.contact,
|
||||||
"is_active": site.is_active,
|
"is_active": site.is_active,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ from app.utils.pagination import paginate
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead:
|
||||||
|
data = StudyRead.model_validate(study)
|
||||||
|
data.role_in_study = role_in_study
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _role_value(user) -> str:
|
||||||
|
return user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||||
|
|
||||||
def _raise_validation_error(errors: list[dict[str, str]]) -> None:
|
def _raise_validation_error(errors: list[dict[str, str]]) -> None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -196,19 +206,6 @@ def _validate_setup_data(
|
|||||||
if total_site_target > plan.totalTarget:
|
if total_site_target > plan.totalTarget:
|
||||||
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
|
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
|
||||||
|
|
||||||
for index, row in enumerate(payload.monitoringStrategies):
|
|
||||||
row_prefix = f"monitoringStrategies[{index}]"
|
|
||||||
if not row.id:
|
|
||||||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
|
||||||
if _is_empty_row([row.strategyType, row.detail, row.frequency]):
|
|
||||||
continue
|
|
||||||
if row.strategyType not in allowed_strategy_types:
|
|
||||||
errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"})
|
|
||||||
if not row.detail.strip():
|
|
||||||
errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"})
|
|
||||||
if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None:
|
|
||||||
errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"})
|
|
||||||
|
|
||||||
for index, row in enumerate(payload.centerConfirm):
|
for index, row in enumerate(payload.centerConfirm):
|
||||||
row_prefix = f"centerConfirm[{index}]"
|
row_prefix = f"centerConfirm[{index}]"
|
||||||
if not row.id:
|
if not row.id:
|
||||||
@@ -253,7 +250,6 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
|
|||||||
},
|
},
|
||||||
siteMilestones=[],
|
siteMilestones=[],
|
||||||
siteEnrollmentPlans=[],
|
siteEnrollmentPlans=[],
|
||||||
monitoringStrategies=[],
|
|
||||||
centerConfirm=[],
|
centerConfirm=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -602,7 +598,6 @@ _SETUP_MODULE_KEYS = (
|
|||||||
"enrollmentPlan",
|
"enrollmentPlan",
|
||||||
"siteMilestones",
|
"siteMilestones",
|
||||||
"siteEnrollmentPlans",
|
"siteEnrollmentPlans",
|
||||||
"monitoringStrategies",
|
|
||||||
"centerConfirm",
|
"centerConfirm",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -611,7 +606,6 @@ _SETUP_MODULE_LABELS = {
|
|||||||
"enrollmentPlan": "项目入组计划",
|
"enrollmentPlan": "项目入组计划",
|
||||||
"siteMilestones": "中心里程碑",
|
"siteMilestones": "中心里程碑",
|
||||||
"siteEnrollmentPlans": "中心入组计划",
|
"siteEnrollmentPlans": "中心入组计划",
|
||||||
"monitoringStrategies": "监查策略",
|
|
||||||
"centerConfirm": "中心确认",
|
"centerConfirm": "中心确认",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,11 +629,6 @@ _SETUP_FIELD_LABELS = {
|
|||||||
"siteName": "中心名称",
|
"siteName": "中心名称",
|
||||||
"target": "计划例数",
|
"target": "计划例数",
|
||||||
"note": "备注",
|
"note": "备注",
|
||||||
"strategyType": "监查类型",
|
|
||||||
"detail": "策略详情",
|
|
||||||
"frequency": "监查频次",
|
|
||||||
"updatedAt": "更新时间",
|
|
||||||
"enabled": "启用状态",
|
|
||||||
"confirmer": "确认人",
|
"confirmer": "确认人",
|
||||||
"confirmStatus": "确认状态",
|
"confirmStatus": "确认状态",
|
||||||
"confirmDate": "确认日期",
|
"confirmDate": "确认日期",
|
||||||
@@ -676,8 +665,6 @@ def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
|
|||||||
return str(row.get("milestone") or "").strip() or f"第{index + 1}行"
|
return str(row.get("milestone") or "").strip() or f"第{index + 1}行"
|
||||||
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
|
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
|
||||||
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"第{index + 1}行"
|
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"第{index + 1}行"
|
||||||
if module_key == "monitoringStrategies":
|
|
||||||
return str(row.get("strategyType") or "").strip() or f"第{index + 1}行"
|
|
||||||
return f"第{index + 1}行"
|
return f"第{index + 1}行"
|
||||||
|
|
||||||
|
|
||||||
@@ -794,30 +781,39 @@ async def list_studies(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
) -> PaginatedResponse[StudyRead]:
|
) -> PaginatedResponse[StudyRead]:
|
||||||
if current_user.role == "ADMIN":
|
if _role_value(current_user) == "ADMIN":
|
||||||
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
|
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
|
||||||
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
|
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
|
||||||
|
items = [_study_read_with_role(study) for study in studies]
|
||||||
else:
|
else:
|
||||||
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
|
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
|
||||||
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
|
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
|
||||||
return paginate(list(studies), total=len(total))
|
items = []
|
||||||
|
for study in studies:
|
||||||
|
member = await member_crud.get_member(db, study.id, current_user.id)
|
||||||
|
items.append(_study_read_with_role(study, member.role_in_study if member else None))
|
||||||
|
return paginate(items, total=len(total))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
|
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
|
||||||
async def get_study(
|
async def get_study(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
) -> StudyRead:
|
) -> StudyRead:
|
||||||
study = await study_crud.get(db, study_id)
|
study = await study_crud.get(db, study_id)
|
||||||
if not study:
|
if not study:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
return study
|
if _role_value(current_user) == "ADMIN":
|
||||||
|
return _study_read_with_role(study)
|
||||||
|
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||||
|
return _study_read_with_role(study, member.role_in_study if member else None)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{study_id}",
|
"/{study_id}",
|
||||||
response_model=StudyRead,
|
response_model=StudyRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"]))],
|
||||||
)
|
)
|
||||||
async def update_study(
|
async def update_study(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -994,7 +990,7 @@ async def get_study_setup_config(
|
|||||||
@router.put(
|
@router.put(
|
||||||
"/{study_id}/setup-config",
|
"/{study_id}/setup-config",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def upsert_study_setup_config(
|
async def upsert_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1072,7 +1068,7 @@ async def upsert_study_setup_config(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/publish",
|
"/{study_id}/setup-config/publish",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def publish_study_setup_config(
|
async def publish_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1225,7 +1221,7 @@ async def list_study_setup_config_versions(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/rollback",
|
"/{study_id}/setup-config/rollback",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def rollback_study_setup_config(
|
async def rollback_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1283,7 +1279,7 @@ async def rollback_study_setup_config(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/draft/checkout-branch",
|
"/{study_id}/setup-config/draft/checkout-branch",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def checkout_study_setup_config_branch_draft(
|
async def checkout_study_setup_config_branch_draft(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1342,7 +1338,7 @@ async def checkout_study_setup_config_branch_draft(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/draft/clear",
|
"/{study_id}/setup-config/draft/clear",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def clear_study_setup_config_draft(
|
async def clear_study_setup_config_draft(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1394,7 +1390,7 @@ async def clear_study_setup_config_draft(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/draft/refill",
|
"/{study_id}/setup-config/draft/refill",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def refill_study_setup_config_draft(
|
async def refill_study_setup_config_draft(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1448,7 +1444,7 @@ async def refill_study_setup_config_draft(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/merge-main",
|
"/{study_id}/setup-config/merge-main",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def merge_study_setup_config_to_main(
|
async def merge_study_setup_config_to_main(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1543,7 +1539,7 @@ async def merge_study_setup_config_to_main(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{study_id}/setup-config/versions/{target_version}",
|
"/{study_id}/setup-config/versions/{target_version}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
|
||||||
)
|
)
|
||||||
async def delete_study_setup_config_version(
|
async def delete_study_setup_config_version(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
PROTECTED_ADMIN_EMAIL = "admin@huapont.cn"
|
PROTECTED_ADMIN_EMAIL = "admin@huapont.cn"
|
||||||
PROTECTED_ADMIN_DEFAULT_PASSWORD = "admin123"
|
PROTECTED_ADMIN_DEFAULT_PASSWORD = "admin123"
|
||||||
PROTECTED_ADMIN_FULL_NAME = "System Admin"
|
PROTECTED_ADMIN_FULL_NAME = "System Admin"
|
||||||
PROTECTED_ADMIN_DEPARTMENT = "SYSTEM"
|
PROTECTED_ADMIN_CLINICAL_DEPARTMENT = "SYSTEM"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def require_study_member():
|
|||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
def require_study_roles(roles: Iterable[str]):
|
def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True):
|
||||||
roles_set = set(roles)
|
roles_set = set(roles)
|
||||||
|
|
||||||
async def dependency(
|
async def dependency(
|
||||||
@@ -104,7 +104,7 @@ def require_study_roles(roles: Iterable[str]):
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||||
if role_value == "ADMIN":
|
if allow_system_admin and role_value == "ADMIN":
|
||||||
return current_user
|
return current_user
|
||||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||||
if not membership or not membership.is_active or membership.role_in_study not in roles_set:
|
if not membership or not membership.is_active or membership.role_in_study not in roles_set:
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ ROLE_ACTIONS: dict[str, set[str]] = {
|
|||||||
|
|
||||||
def is_allowed(action: str, user, membership) -> bool:
|
def is_allowed(action: str, user, membership) -> bool:
|
||||||
role_value = user.role.value if hasattr(user.role, "value") else str(user.role)
|
role_value = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||||
allowed = ROLE_ACTIONS.get(role_value, set())
|
if role_value == "ADMIN":
|
||||||
if action in allowed:
|
return action in ROLE_ACTIONS["ADMIN"]
|
||||||
return True
|
|
||||||
member_role = getattr(membership, "role_in_study", None)
|
member_role = getattr(membership, "role_in_study", None)
|
||||||
if member_role:
|
if member_role:
|
||||||
return action in ROLE_ACTIONS.get(member_role, set())
|
return action in ROLE_ACTIONS.get(member_role, set())
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate
|
|||||||
name=site_in.name,
|
name=site_in.name,
|
||||||
city=site_in.city,
|
city=site_in.city,
|
||||||
pi_name=site_in.pi_name,
|
pi_name=site_in.pi_name,
|
||||||
|
phone=site_in.phone,
|
||||||
contact=site_in.contact,
|
contact=site_in.contact,
|
||||||
is_active=site_in.is_active,
|
is_active=site_in.is_active,
|
||||||
enrollment_plan_start_date=site_in.enrollment_plan_start_date,
|
enrollment_plan_start_date=site_in.enrollment_plan_start_date,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from sqlalchemy import delete, func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import (
|
from app.core.config import (
|
||||||
|
PROTECTED_ADMIN_CLINICAL_DEPARTMENT,
|
||||||
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
||||||
PROTECTED_ADMIN_DEPARTMENT,
|
|
||||||
PROTECTED_ADMIN_EMAIL,
|
PROTECTED_ADMIN_EMAIL,
|
||||||
PROTECTED_ADMIN_FULL_NAME,
|
PROTECTED_ADMIN_FULL_NAME,
|
||||||
)
|
)
|
||||||
@@ -43,7 +43,7 @@ async def create_user(
|
|||||||
password_hash=hash_password(user_in.password),
|
password_hash=hash_password(user_in.password),
|
||||||
full_name=user_in.full_name,
|
full_name=user_in.full_name,
|
||||||
role=UserRole(user_in.role),
|
role=UserRole(user_in.role),
|
||||||
department=user_in.department,
|
clinical_department=user_in.clinical_department,
|
||||||
status=status_value,
|
status=status_value,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -70,8 +70,8 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
|
|||||||
update_data["password_hash"] = hash_password(user_in.password)
|
update_data["password_hash"] = hash_password(user_in.password)
|
||||||
if user_in.full_name is not None:
|
if user_in.full_name is not None:
|
||||||
update_data["full_name"] = user_in.full_name
|
update_data["full_name"] = user_in.full_name
|
||||||
if user_in.department is not None:
|
if user_in.clinical_department is not None:
|
||||||
update_data["department"] = user_in.department
|
update_data["clinical_department"] = user_in.clinical_department
|
||||||
|
|
||||||
if update_data:
|
if update_data:
|
||||||
await db.execute(update(User).where(User.id == user.id).values(**update_data))
|
await db.execute(update(User).where(User.id == user.id).values(**update_data))
|
||||||
@@ -114,7 +114,7 @@ async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTE
|
|||||||
password_hash=hash_password(default_password),
|
password_hash=hash_password(default_password),
|
||||||
full_name=PROTECTED_ADMIN_FULL_NAME,
|
full_name=PROTECTED_ADMIN_FULL_NAME,
|
||||||
role=UserRole.ADMIN,
|
role=UserRole.ADMIN,
|
||||||
department=PROTECTED_ADMIN_DEPARTMENT,
|
clinical_department=PROTECTED_ADMIN_CLINICAL_DEPARTMENT,
|
||||||
status=UserStatus.ACTIVE,
|
status=UserStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
db.add(new_admin)
|
db.add(new_admin)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Site(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
pi_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
pi_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
|
||||||
enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class User(Base):
|
|||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
|
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
|
||||||
department: Mapped[str] = mapped_column(String(255), nullable=False)
|
clinical_department: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
status: Mapped[UserStatus] = mapped_column(
|
status: Mapped[UserStatus] = mapped_column(
|
||||||
Enum(UserStatus, name="user_status"),
|
Enum(UserStatus, name="user_status"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class SiteCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
pi_name: Optional[str] = None
|
pi_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
contact: Optional[str] = None
|
contact: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
enrollment_plan_start_date: Optional[date] = None
|
enrollment_plan_start_date: Optional[date] = None
|
||||||
@@ -20,6 +21,7 @@ class SiteUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
pi_name: Optional[str] = None
|
pi_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
contact: Optional[str] = None
|
contact: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
enrollment_plan_start_date: Optional[date] = None
|
enrollment_plan_start_date: Optional[date] = None
|
||||||
@@ -33,6 +35,7 @@ class SiteRead(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
city: Optional[str]
|
city: Optional[str]
|
||||||
pi_name: Optional[str]
|
pi_name: Optional[str]
|
||||||
|
phone: Optional[str]
|
||||||
contact: Optional[str]
|
contact: Optional[str]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
enrollment_target: Optional[int]
|
enrollment_target: Optional[int]
|
||||||
|
|||||||
@@ -126,5 +126,6 @@ class StudyRead(BaseModel):
|
|||||||
visit_schedule: list[VisitScheduleItem]
|
visit_schedule: list[VisitScheduleItem]
|
||||||
created_by: Optional[uuid.UUID]
|
created_by: Optional[uuid.UUID]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
role_in_study: Optional[str] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -44,15 +44,6 @@ class SiteEnrollmentPlanItem(BaseModel):
|
|||||||
stageBreakdown: str = ""
|
stageBreakdown: str = ""
|
||||||
|
|
||||||
|
|
||||||
class MonitoringStrategyItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
strategyType: str = ""
|
|
||||||
detail: str = ""
|
|
||||||
frequency: str = ""
|
|
||||||
updatedAt: str = ""
|
|
||||||
enabled: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class CenterConfirmItem(BaseModel):
|
class CenterConfirmItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
siteId: str = ""
|
siteId: str = ""
|
||||||
@@ -105,7 +96,6 @@ class StudySetupConfigData(BaseModel):
|
|||||||
enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem)
|
enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem)
|
||||||
siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list)
|
siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list)
|
||||||
siteEnrollmentPlans: list[SiteEnrollmentPlanItem] = Field(default_factory=list)
|
siteEnrollmentPlans: list[SiteEnrollmentPlanItem] = Field(default_factory=list)
|
||||||
monitoringStrategies: list[MonitoringStrategyItem] = Field(default_factory=list)
|
|
||||||
centerConfirm: list[CenterConfirmItem] = Field(default_factory=list)
|
centerConfirm: list[CenterConfirmItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class UserRegisterRequest(_PasswordValidator):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
full_name: str = Field(min_length=1)
|
full_name: str = Field(min_length=1)
|
||||||
role: RegisterRole
|
role: RegisterRole
|
||||||
department: str = Field(min_length=1)
|
clinical_department: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(_PasswordValidator):
|
class UserCreate(_PasswordValidator):
|
||||||
@@ -48,7 +48,7 @@ class UserCreate(_PasswordValidator):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
full_name: str = Field(min_length=1)
|
full_name: str = Field(min_length=1)
|
||||||
role: UserRole
|
role: UserRole
|
||||||
department: str = Field(min_length=1)
|
clinical_department: str = Field(min_length=1)
|
||||||
status: UserStatus = "ACTIVE"
|
status: UserStatus = "ACTIVE"
|
||||||
|
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ class UserRead(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
full_name: str
|
full_name: str
|
||||||
role: UserRole
|
role: UserRole
|
||||||
department: str
|
clinical_department: str
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -73,7 +73,7 @@ class UserUpdate(_PasswordValidator):
|
|||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
role: Optional[UserRole] = None
|
role: Optional[UserRole] = None
|
||||||
department: Optional[str] = None
|
clinical_department: Optional[str] = None
|
||||||
status: Optional[UserStatus] = None
|
status: Optional[UserStatus] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
@@ -95,7 +95,7 @@ class AdminUserListResponse(BaseModel):
|
|||||||
|
|
||||||
class UserSelfUpdate(_PasswordValidator):
|
class UserSelfUpdate(_PasswordValidator):
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
department: Optional[str] = None
|
clinical_department: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
current_password: Optional[str] = None
|
current_password: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from app.models.milestone import Milestone
|
|||||||
from app.models.site import Site
|
from app.models.site import Site
|
||||||
from app.models.study import Study
|
from app.models.study import Study
|
||||||
from app.models.study_center_confirm import StudyCenterConfirm
|
from app.models.study_center_confirm import StudyCenterConfirm
|
||||||
from app.models.study_monitoring_strategy import StudyMonitoringStrategy
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.study_setup_config import StudySetupConfigData
|
from app.schemas.study_setup_config import StudySetupConfigData
|
||||||
|
|
||||||
@@ -207,34 +206,6 @@ async def _replace_site_milestones(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _replace_monitoring_strategies(
|
|
||||||
db: AsyncSession,
|
|
||||||
*,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
setup_data: StudySetupConfigData,
|
|
||||||
result: SetupProjectionResult,
|
|
||||||
) -> None:
|
|
||||||
await db.execute(delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id))
|
|
||||||
for row in setup_data.monitoringStrategies:
|
|
||||||
strategy_type = _normalize_text(row.strategyType)
|
|
||||||
detail = _normalize_text(row.detail)
|
|
||||||
frequency = _normalize_text(row.frequency)
|
|
||||||
if not any([strategy_type, detail, frequency]):
|
|
||||||
continue
|
|
||||||
if not strategy_type or not detail or not frequency:
|
|
||||||
result.warnings.append(f"monitoring_strategy_incomplete:{row.id}")
|
|
||||||
continue
|
|
||||||
db.add(
|
|
||||||
StudyMonitoringStrategy(
|
|
||||||
study_id=study_id,
|
|
||||||
source_setup_item_id=_normalize_text(row.id),
|
|
||||||
strategy_type=strategy_type,
|
|
||||||
detail=detail,
|
|
||||||
frequency=frequency,
|
|
||||||
enabled=bool(row.enabled),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _replace_center_confirms(
|
async def _replace_center_confirms(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -384,7 +355,6 @@ async def apply_setup_projection_on_publish(
|
|||||||
owner_lookup=owner_lookup,
|
owner_lookup=owner_lookup,
|
||||||
result=result,
|
result=result,
|
||||||
)
|
)
|
||||||
await _replace_monitoring_strategies(db, study_id=study_id, setup_data=setup_data, result=result)
|
|
||||||
await _replace_center_confirms(db, study_id=study_id, setup_data=setup_data, site_map=site_map, result=result)
|
await _replace_center_confirms(db, study_id=study_id, setup_data=setup_data, site_map=site_map, result=result)
|
||||||
|
|
||||||
result.site_skipped_count = len(result.skipped_items)
|
result.site_skipped_count = len(result.skipped_items)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_management_write_routes_allow_project_admin_and_pm():
|
||||||
|
for relative_path in (
|
||||||
|
"app/api/v1/studies.py",
|
||||||
|
"app/api/v1/sites.py",
|
||||||
|
"app/api/v1/members.py",
|
||||||
|
):
|
||||||
|
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'require_study_roles(["PM"])' not in source
|
||||||
|
assert 'require_study_roles(["ADMIN", "PM"])' in source
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from app.api.v1 import users as users_api
|
|
||||||
from app.models.user import User, UserRole, UserStatus
|
|
||||||
from app.schemas.user import UserRead, UserUpdate
|
|
||||||
|
|
||||||
|
|
||||||
def _make_user(
|
|
||||||
*,
|
|
||||||
email: str,
|
|
||||||
role: UserRole = UserRole.ADMIN,
|
|
||||||
status: UserStatus = UserStatus.ACTIVE,
|
|
||||||
) -> User:
|
|
||||||
return User(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
email=email,
|
|
||||||
password_hash="hashed",
|
|
||||||
full_name="User",
|
|
||||||
department="SYSTEM",
|
|
||||||
role=role,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_user_blocks_protected_admin(monkeypatch):
|
|
||||||
current_user = _make_user(email="other-admin@huapont.cn")
|
|
||||||
protected_admin = _make_user(email="admin@huapont.cn")
|
|
||||||
|
|
||||||
async def fake_get_by_id(_db, _user_id):
|
|
||||||
return protected_admin
|
|
||||||
|
|
||||||
async def fake_count_active_admins(_db):
|
|
||||||
return 2
|
|
||||||
|
|
||||||
async def fake_user_has_memberships(_db, _user_id):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def fake_delete_user(_db, _user):
|
|
||||||
raise AssertionError("protected admin should not be deleted")
|
|
||||||
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins)
|
|
||||||
monkeypatch.setattr(users_api.member_crud, "user_has_memberships", fake_user_has_memberships)
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "delete_user", fake_delete_user)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await users_api.delete_user(protected_admin.id, db=object(), current_user=current_user)
|
|
||||||
|
|
||||||
assert exc_info.value.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_user_blocks_protected_admin_email_change(monkeypatch):
|
|
||||||
protected_admin = _make_user(email="admin@huapont.cn")
|
|
||||||
|
|
||||||
async def fake_get_by_id(_db, _user_id):
|
|
||||||
return protected_admin
|
|
||||||
|
|
||||||
async def fake_count_active_admins(_db):
|
|
||||||
return 2
|
|
||||||
|
|
||||||
async def fake_update_user(_db, _user, _payload):
|
|
||||||
raise AssertionError("protected admin email should not be changed")
|
|
||||||
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins)
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await users_api.update_user(
|
|
||||||
protected_admin.id,
|
|
||||||
UserUpdate(email="renamed@huapont.cn"),
|
|
||||||
db=object(),
|
|
||||||
current_user=protected_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert exc_info.value.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_user_allows_protected_admin_password_change(monkeypatch):
|
|
||||||
protected_admin = _make_user(email="admin@huapont.cn")
|
|
||||||
captured_payload = {}
|
|
||||||
|
|
||||||
async def fake_get_by_id(_db, _user_id):
|
|
||||||
return protected_admin
|
|
||||||
|
|
||||||
async def fake_update_user(_db, _user, payload):
|
|
||||||
captured_payload["password"] = payload.password
|
|
||||||
return _user
|
|
||||||
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
|
|
||||||
monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user)
|
|
||||||
|
|
||||||
result = await users_api.update_user(
|
|
||||||
protected_admin.id,
|
|
||||||
UserUpdate(password="Password123"),
|
|
||||||
db=object(),
|
|
||||||
current_user=protected_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result, UserRead | User)
|
|
||||||
assert captured_payload["password"] == "Password123"
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core import rbac
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserStub:
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MemberStub:
|
||||||
|
role_in_study: str
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_global_role_does_not_grant_project_document_permission():
|
||||||
|
assert not rbac.is_allowed("delete_document", UserStub(role="PM"), MemberStub(role_in_study="CRA"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_role_grants_project_document_permission():
|
||||||
|
assert rbac.is_allowed("delete_document", UserStub(role="CRA"), MemberStub(role_in_study="PM"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_admin_keeps_system_document_permission_without_membership():
|
||||||
|
assert rbac.is_allowed("delete_document", UserStub(role="ADMIN"), None)
|
||||||
@@ -88,7 +88,7 @@ async def client_and_db():
|
|||||||
email="admin@test.com",
|
email="admin@test.com",
|
||||||
password_hash=hash_password("admin123"),
|
password_hash=hash_password("admin123"),
|
||||||
full_name="Admin",
|
full_name="Admin",
|
||||||
department="Admin",
|
clinical_department="Admin",
|
||||||
role=UserRole.ADMIN,
|
role=UserRole.ADMIN,
|
||||||
status=UserStatus.ACTIVE,
|
status=UserStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
@@ -109,7 +109,7 @@ async def test_register_creates_pending_user(client_and_db):
|
|||||||
"password": "Password123",
|
"password": "Password123",
|
||||||
"full_name": "New User",
|
"full_name": "New User",
|
||||||
"role": "CRA",
|
"role": "CRA",
|
||||||
"department": "Clinical",
|
"clinical_department": "Clinical",
|
||||||
}
|
}
|
||||||
resp = await client.post("/api/v1/auth/register", json=payload)
|
resp = await client.post("/api/v1/auth/register", json=payload)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -141,7 +141,7 @@ async def test_login_blocked_before_approval(client_and_db):
|
|||||||
"password": "Password123",
|
"password": "Password123",
|
||||||
"full_name": "Pending User",
|
"full_name": "Pending User",
|
||||||
"role": "PV",
|
"role": "PV",
|
||||||
"department": "Safety",
|
"clinical_department": "Safety",
|
||||||
}
|
}
|
||||||
await client.post("/api/v1/auth/register", json=payload)
|
await client.post("/api/v1/auth/register", json=payload)
|
||||||
resp = await encrypted_login(client, payload["email"], payload["password"])
|
resp = await encrypted_login(client, payload["email"], payload["password"])
|
||||||
@@ -157,7 +157,7 @@ async def test_admin_can_approve_user(client_and_db):
|
|||||||
"password": "Password123",
|
"password": "Password123",
|
||||||
"full_name": "Approve Target",
|
"full_name": "Approve Target",
|
||||||
"role": "IMP",
|
"role": "IMP",
|
||||||
"department": "Supply",
|
"clinical_department": "Supply",
|
||||||
}
|
}
|
||||||
await client.post("/api/v1/auth/register", json=payload)
|
await client.post("/api/v1/auth/register", json=payload)
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
@@ -184,7 +184,7 @@ async def test_admin_role_cannot_register(client_and_db):
|
|||||||
"password": "Password123",
|
"password": "Password123",
|
||||||
"full_name": "Bad Admin",
|
"full_name": "Bad Admin",
|
||||||
"role": "ADMIN",
|
"role": "ADMIN",
|
||||||
"department": "IT",
|
"clinical_department": "IT",
|
||||||
}
|
}
|
||||||
resp = await client.post("/api/v1/auth/register", json=payload)
|
resp = await client.post("/api/v1/auth/register", json=payload)
|
||||||
assert resp.status_code in (400, 422)
|
assert resp.status_code in (400, 422)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.v1 import studies as studies_api
|
||||||
|
from app.models.study import Study
|
||||||
|
from app.models.study_member import StudyMember
|
||||||
|
from app.models.user import User, UserRole, UserStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(role: UserRole = UserRole.CRA) -> User:
|
||||||
|
return User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="cra-pm@test.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
full_name="CRA PM",
|
||||||
|
clinical_department="Clinical",
|
||||||
|
role=role,
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_study() -> Study:
|
||||||
|
return Study(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
code="STUDY-001",
|
||||||
|
name="测试项目",
|
||||||
|
status="ACTIVE",
|
||||||
|
is_locked=False,
|
||||||
|
visit_schedule=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_studies_returns_project_role_for_non_admin(monkeypatch):
|
||||||
|
current_user = _make_user(UserRole.CRA)
|
||||||
|
study = _make_study()
|
||||||
|
member = StudyMember(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
study_id=study.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
role_in_study="PM",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_list_studies_for_user(_db, _user_id, skip=0, limit=100):
|
||||||
|
return [study]
|
||||||
|
|
||||||
|
async def fake_get_member(_db, _study_id, _user_id):
|
||||||
|
return member
|
||||||
|
|
||||||
|
monkeypatch.setattr(studies_api.study_crud, "list_studies_for_user", fake_list_studies_for_user)
|
||||||
|
monkeypatch.setattr(studies_api.member_crud, "get_member", fake_get_member)
|
||||||
|
|
||||||
|
result = await studies_api.list_studies(db=object(), current_user=current_user)
|
||||||
|
|
||||||
|
assert result["items"][0].role_in_study == "PM"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_studies_does_not_map_system_admin_to_project_admin(monkeypatch):
|
||||||
|
current_user = _make_user(UserRole.ADMIN)
|
||||||
|
study = _make_study()
|
||||||
|
|
||||||
|
async def fake_list_studies(_db, skip=0, limit=100):
|
||||||
|
return [study]
|
||||||
|
|
||||||
|
monkeypatch.setattr(studies_api.study_crud, "list_studies", fake_list_studies)
|
||||||
|
|
||||||
|
result = await studies_api.list_studies(db=object(), current_user=current_user)
|
||||||
|
|
||||||
|
assert result["items"][0].role_in_study is None
|
||||||
+3
-3
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS public.users (
|
|||||||
password_hash character varying(255) NOT NULL,
|
password_hash character varying(255) NOT NULL,
|
||||||
full_name character varying(255) NOT NULL,
|
full_name character varying(255) NOT NULL,
|
||||||
role public.user_role NOT NULL,
|
role public.user_role NOT NULL,
|
||||||
department character varying(255) NOT NULL,
|
clinical_department character varying(255) NOT NULL,
|
||||||
status public.user_status NOT NULL DEFAULT 'PENDING',
|
status public.user_status NOT NULL DEFAULT 'PENDING',
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
@@ -535,7 +535,7 @@ CREATE INDEX IF NOT EXISTS ix_faq_replies_study_id ON public.faq_replies (study_
|
|||||||
-- DEMO SEED DATA (idempotent)
|
-- DEMO SEED DATA (idempotent)
|
||||||
-- =========================================
|
-- =========================================
|
||||||
INSERT INTO public.users (
|
INSERT INTO public.users (
|
||||||
id, email, password_hash, full_name, role, department, status, created_at, updated_at
|
id, email, password_hash, full_name, role, clinical_department, status, created_at, updated_at
|
||||||
) VALUES
|
) VALUES
|
||||||
('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'),
|
('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'),
|
||||||
('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'),
|
('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'),
|
||||||
@@ -547,7 +547,7 @@ ON CONFLICT (email) DO UPDATE SET
|
|||||||
password_hash = EXCLUDED.password_hash,
|
password_hash = EXCLUDED.password_hash,
|
||||||
full_name = EXCLUDED.full_name,
|
full_name = EXCLUDED.full_name,
|
||||||
role = EXCLUDED.role,
|
role = EXCLUDED.role,
|
||||||
department = EXCLUDED.department,
|
clinical_department = EXCLUDED.clinical_department,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
updated_at = EXCLUDED.updated_at;
|
updated_at = EXCLUDED.updated_at;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ mess
|
|||||||
|
|
||||||
export const updateProfile = (payload: {
|
export const updateProfile = (payload: {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
department?: string;
|
clinical_department?: string;
|
||||||
current_password?: string;
|
current_password?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
|
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ export const createUser = (payload: {
|
|||||||
password: string;
|
password: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
role: string;
|
role: string;
|
||||||
department: string;
|
clinical_department: string;
|
||||||
}) =>
|
}) =>
|
||||||
apiPost<UserInfo>("/api/v1/users/", payload);
|
apiPost<UserInfo>("/api/v1/users/", payload);
|
||||||
|
|
||||||
export const updateUser = (
|
export const updateUser = (
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: Partial<{ role: string; status: string; password: string; full_name: string; department: string; is_active: boolean }>
|
payload: Partial<{ role: string; status: string; password: string; full_name: string; clinical_department: string; is_active: boolean }>
|
||||||
) =>
|
) =>
|
||||||
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
|
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const setupModuleLabelMap: Record<string, string> = {
|
|||||||
enrollmentPlan: "项目入组计划",
|
enrollmentPlan: "项目入组计划",
|
||||||
siteMilestones: "中心里程碑",
|
siteMilestones: "中心里程碑",
|
||||||
siteEnrollmentPlans: "中心入组计划",
|
siteEnrollmentPlans: "中心入组计划",
|
||||||
monitoringStrategies: "监查策略",
|
|
||||||
centerConfirm: "中心确认",
|
centerConfirm: "中心确认",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,7 +112,6 @@ const normalizeLegacyDetailLine = (line: string): string => {
|
|||||||
}
|
}
|
||||||
if (text === "立项配置已发布") return "立项配置已发布";
|
if (text === "立项配置已发布") return "立项配置已发布";
|
||||||
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
|
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
|
||||||
if (text.includes("monitoringStrategies")) return text.replaceAll("monitoringStrategies", "监查策略");
|
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,7 +148,7 @@ const auditFieldLabelMap: Record<string, string> = {
|
|||||||
role_in_study: "项目角色",
|
role_in_study: "项目角色",
|
||||||
full_name: "姓名",
|
full_name: "姓名",
|
||||||
username: "账号",
|
username: "账号",
|
||||||
department: "部门",
|
clinical_department: "科室",
|
||||||
email: "邮箱",
|
email: "邮箱",
|
||||||
name: "名称",
|
name: "名称",
|
||||||
code: "编号",
|
code: "编号",
|
||||||
|
|||||||
@@ -47,13 +47,10 @@
|
|||||||
<el-icon><Calendar /></el-icon>
|
<el-icon><Calendar /></el-icon>
|
||||||
<span>{{ TEXT.menu.projectMilestones }}</span>
|
<span>{{ TEXT.menu.projectMilestones }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-sub-menu index="fees">
|
<el-menu-item index="/fees/contracts">
|
||||||
<template #title>
|
<el-icon><Coin /></el-icon>
|
||||||
<el-icon><Coin /></el-icon>
|
<span>{{ TEXT.menu.feeContracts }}</span>
|
||||||
<span>{{ TEXT.menu.finance }}</span>
|
</el-menu-item>
|
||||||
</template>
|
|
||||||
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
|
|
||||||
</el-sub-menu>
|
|
||||||
<el-sub-menu index="materials">
|
<el-sub-menu index="materials">
|
||||||
<template #title>
|
<template #title>
|
||||||
<el-icon><Box /></el-icon>
|
<el-icon><Box /></el-icon>
|
||||||
@@ -226,7 +223,6 @@ const study = useStudyStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||||
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
|
||||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||||
|
|
||||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import AttachmentUploader from "./AttachmentUploader.vue";
|
|||||||
import { formatFileSize } from "./attachmentUtils";
|
import { formatFileSize } from "./attachmentUtils";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
|
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -203,11 +204,10 @@ const preview = (row: any) => {
|
|||||||
const canDelete = (row: any) => {
|
const canDelete = (row: any) => {
|
||||||
if (props.readonly) return false;
|
if (props.readonly) return false;
|
||||||
const userId = auth.user?.id;
|
const userId = auth.user?.id;
|
||||||
const role = auth.user?.role;
|
const projectRole = getProjectRole(study.currentStudy, study.currentStudyRole);
|
||||||
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
|
||||||
const ownerId =
|
const ownerId =
|
||||||
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
|
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
|
||||||
return userId === ownerId || role === "ADMIN" || projectRole === "PM";
|
return userId === ownerId || isSystemAdmin(auth.user) || projectRole === "PM";
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import { fetchAttachments, uploadAttachment, deleteAttachment } from "../../api/
|
|||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||||
import { formatFileSize } from "../attachments/attachmentUtils";
|
import { formatFileSize } from "../attachments/attachmentUtils";
|
||||||
import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
|
import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
|
||||||
import StateEmpty from "../StateEmpty.vue";
|
import StateEmpty from "../StateEmpty.vue";
|
||||||
@@ -269,11 +270,10 @@ const download = (row: any) => {
|
|||||||
const canDelete = (row: any) => {
|
const canDelete = (row: any) => {
|
||||||
if (props.readonly) return false;
|
if (props.readonly) return false;
|
||||||
const userId = auth.user?.id;
|
const userId = auth.user?.id;
|
||||||
const role = auth.user?.role;
|
const projectRole = getProjectRole(study.currentStudy, study.currentStudyRole);
|
||||||
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
|
||||||
const ownerId =
|
const ownerId =
|
||||||
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
|
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
|
||||||
return userId === ownerId || role === "ADMIN" || projectRole === "PM";
|
return userId === ownerId || isSystemAdmin(auth.user) || projectRole === "PM";
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const TEXT = {
|
|||||||
level: "重要等级",
|
level: "重要等级",
|
||||||
name: "姓名",
|
name: "姓名",
|
||||||
email: "帐号",
|
email: "帐号",
|
||||||
department: "部门",
|
clinicalDepartment: "科室",
|
||||||
avatar: "头像",
|
avatar: "头像",
|
||||||
projectName: "项目名称",
|
projectName: "项目名称",
|
||||||
projectCode: "项目编号",
|
projectCode: "项目编号",
|
||||||
@@ -260,9 +260,9 @@ export const TEXT = {
|
|||||||
currentProject: "当前项目",
|
currentProject: "当前项目",
|
||||||
projectOverview: "项目总览",
|
projectOverview: "项目总览",
|
||||||
projectMilestones: "项目里程碑",
|
projectMilestones: "项目里程碑",
|
||||||
finance: "费用管理",
|
finance: "合同费用管理",
|
||||||
financeContracts: "合同管理",
|
financeContracts: "合同费用管理",
|
||||||
feeContracts: "合同管理",
|
feeContracts: "合同费用管理",
|
||||||
drug: "药品管理",
|
drug: "药品管理",
|
||||||
materialManagement: "物资管理",
|
materialManagement: "物资管理",
|
||||||
drugShipments: "药品流向管理",
|
drugShipments: "药品流向管理",
|
||||||
@@ -300,8 +300,8 @@ export const TEXT = {
|
|||||||
confirmPasswordPlaceholder: "再次输入密码",
|
confirmPasswordPlaceholder: "再次输入密码",
|
||||||
fullNameLabel: "姓名",
|
fullNameLabel: "姓名",
|
||||||
fullNamePlaceholder: "请输入姓名",
|
fullNamePlaceholder: "请输入姓名",
|
||||||
departmentLabel: "部门",
|
clinicalDepartmentLabel: "科室",
|
||||||
departmentPlaceholder: "请输入部门",
|
clinicalDepartmentPlaceholder: "请输入科室",
|
||||||
remember: "记住我",
|
remember: "记住我",
|
||||||
forgot: "忘记密码?",
|
forgot: "忘记密码?",
|
||||||
forgotTitle: "找回密码",
|
forgotTitle: "找回密码",
|
||||||
@@ -370,7 +370,7 @@ export const TEXT = {
|
|||||||
detailSubtitle: "查看合同费用与附件",
|
detailSubtitle: "查看合同费用与附件",
|
||||||
},
|
},
|
||||||
feeContracts: {
|
feeContracts: {
|
||||||
title: "合同管理",
|
title: "合同费用管理",
|
||||||
subtitle: "按中心维护合同费用与分期付款",
|
subtitle: "按中心维护合同费用与分期付款",
|
||||||
empty: "暂无合同费用数据,点击创建",
|
empty: "暂无合同费用数据,点击创建",
|
||||||
newTitle: "新增合同费用",
|
newTitle: "新增合同费用",
|
||||||
@@ -762,6 +762,7 @@ export const TEXT = {
|
|||||||
title: "账号管理(系统登录账号)",
|
title: "账号管理(系统登录账号)",
|
||||||
subtitle: "账号用于登录系统,本身不具备项目或角色权限",
|
subtitle: "账号用于登录系统,本身不具备项目或角色权限",
|
||||||
userLabel: "用户",
|
userLabel: "用户",
|
||||||
|
clinicalDepartmentLabel: "科室",
|
||||||
createdAt: "创建时间",
|
createdAt: "创建时间",
|
||||||
newTitle: "新建用户",
|
newTitle: "新建用户",
|
||||||
editTitle: "编辑用户",
|
editTitle: "编辑用户",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "./styles/unified-page.css";
|
|||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
import { getToken } from "./utils/auth";
|
||||||
import { useStudyStore } from "./store/study";
|
import { useStudyStore } from "./store/study";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
@@ -21,7 +22,9 @@ app.use(ElementPlus, { locale: zhCn });
|
|||||||
// 初始化项目上下文
|
// 初始化项目上下文
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
studyStore.loadCurrentStudy();
|
studyStore.loadCurrentStudy();
|
||||||
await studyStore.rehydrateStudyForLastUser();
|
if (getToken()) {
|
||||||
|
await studyStore.rehydrateStudyForLastUser();
|
||||||
|
}
|
||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../utils/roles";
|
||||||
import Layout from "../components/Layout.vue";
|
import Layout from "../components/Layout.vue";
|
||||||
import Login from "../views/Login.vue";
|
import Login from "../views/Login.vue";
|
||||||
import Register from "../views/Register.vue";
|
import Register from "../views/Register.vue";
|
||||||
@@ -477,7 +478,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
token = getToken();
|
token = getToken();
|
||||||
const isAdmin = auth.user?.role === "ADMIN";
|
const isAdmin = isSystemAdmin(auth.user);
|
||||||
if (token && !studyStore.currentStudy) {
|
if (token && !studyStore.currentStudy) {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
await studyStore.ensureDefaultActiveStudy();
|
await studyStore.ensureDefaultActiveStudy();
|
||||||
@@ -510,14 +511,13 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (to.meta.requiresAdmin && auth.user?.role !== "ADMIN") {
|
if (to.meta.requiresAdmin && !isAdmin) {
|
||||||
next({ path: "/workbench" });
|
next({ path: "/workbench" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (to.name === "AuditLogs") {
|
if (to.name === "AuditLogs") {
|
||||||
const role = auth.user?.role;
|
const projectRole = getProjectRole(studyStore.currentStudy, studyStore.currentStudyRole);
|
||||||
const studyRole = studyStore.currentStudyRole;
|
if (!(isAdmin || projectRole === "PM")) {
|
||||||
if (!(role === "ADMIN" || role === "PM" || studyRole === "PM")) {
|
|
||||||
next({ path: "/project/overview" });
|
next({ path: "/project/overview" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,4 +67,20 @@ describe("study store startup rehydration", () => {
|
|||||||
expect(study.currentStudy).toBeNull();
|
expect(study.currentStudy).toBeNull();
|
||||||
expect(window.localStorage.getItem("ctms_current_study")).toBeNull();
|
expect(window.localStorage.getItem("ctms_current_study")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat a study role field as the current project role", async () => {
|
||||||
|
const { useStudyStore } = await import("./study");
|
||||||
|
const study = useStudyStore();
|
||||||
|
|
||||||
|
study.setCurrentStudy({
|
||||||
|
id: "study-with-system-role",
|
||||||
|
code: "SYS-ROLE",
|
||||||
|
name: "系统字段项目",
|
||||||
|
status: "ACTIVE",
|
||||||
|
role: "PM",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(study.currentStudyRole).toBeNull();
|
||||||
|
expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const useStudyStore = defineStore("study", () => {
|
|||||||
currentSite.value = null; // 切换项目时清空中心
|
currentSite.value = null; // 切换项目时清空中心
|
||||||
localStorage.removeItem(SITE_KEY);
|
localStorage.removeItem(SITE_KEY);
|
||||||
currentStudy.value = study;
|
currentStudy.value = study;
|
||||||
const role = (study as any).role_in_study || (study as any).role || null;
|
const role = (study as any).role_in_study || null;
|
||||||
currentStudyRole.value = role;
|
currentStudyRole.value = role;
|
||||||
localStorage.setItem(STUDY_KEY, JSON.stringify(study));
|
localStorage.setItem(STUDY_KEY, JSON.stringify(study));
|
||||||
if (role) {
|
if (role) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface UserInfo {
|
|||||||
email: string;
|
email: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
department: string;
|
clinical_department: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
@@ -57,7 +57,7 @@ export interface RegisterRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
role: Exclude<UserRole, "ADMIN">;
|
role: Exclude<UserRole, "ADMIN">;
|
||||||
department: string;
|
clinical_department: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserListResponse {
|
export interface AdminUserListResponse {
|
||||||
@@ -118,7 +118,9 @@ export interface Site {
|
|||||||
name: string;
|
name: string;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
pi_name?: string | null;
|
pi_name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
contact?: string | null;
|
contact?: string | null;
|
||||||
|
contact_phone?: string | null;
|
||||||
enrollment_target?: number | null;
|
enrollment_target?: number | null;
|
||||||
enrollment_plan_start_date?: string | null;
|
enrollment_plan_start_date?: string | null;
|
||||||
enrollment_plan_end_date?: string | null;
|
enrollment_plan_end_date?: string | null;
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ export interface SiteEnrollmentPlanDraft {
|
|||||||
stageBreakdown: string;
|
stageBreakdown: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringStrategyDraft {
|
|
||||||
id: string;
|
|
||||||
strategyType: string;
|
|
||||||
detail: string;
|
|
||||||
frequency: string;
|
|
||||||
updatedAt: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CenterConfirmDraft {
|
export interface CenterConfirmDraft {
|
||||||
id: string;
|
id: string;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
@@ -63,7 +54,6 @@ export interface SetupConfigDraft {
|
|||||||
enrollmentPlan: EnrollmentPlanDraft;
|
enrollmentPlan: EnrollmentPlanDraft;
|
||||||
siteMilestones: SiteMilestoneDraft[];
|
siteMilestones: SiteMilestoneDraft[];
|
||||||
siteEnrollmentPlans: SiteEnrollmentPlanDraft[];
|
siteEnrollmentPlans: SiteEnrollmentPlanDraft[];
|
||||||
monitoringStrategies: MonitoringStrategyDraft[];
|
|
||||||
centerConfirm: CenterConfirmDraft[];
|
centerConfirm: CenterConfirmDraft[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readPermissionSource = () => readFileSync(resolve(__dirname, "./permission.ts"), "utf8");
|
||||||
|
|
||||||
|
describe("permission project role model", () => {
|
||||||
|
it("does not fall back to non-admin global roles for project permissions", () => {
|
||||||
|
const source = readPermissionSource();
|
||||||
|
|
||||||
|
expect(source).toContain("isSystemAdmin");
|
||||||
|
expect(source).toContain("getProjectRole");
|
||||||
|
expect(source).toContain("study.currentStudyRole");
|
||||||
|
expect(source).not.toContain("role_in_study || auth.user?.role");
|
||||||
|
expect(source).not.toContain("role_in_study || userRole.value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows project administrators and PMs to manage project configuration", () => {
|
||||||
|
const source = readPermissionSource();
|
||||||
|
|
||||||
|
expect(source).toContain('"project.members.manage": ["ADMIN", "PM"]');
|
||||||
|
expect(source).toContain('"site.manage": ["ADMIN", "PM"]');
|
||||||
|
expect(source).toContain('"site.cra.bind": ["ADMIN", "PM"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { computed } from "vue";
|
|||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "./roles";
|
||||||
|
|
||||||
const PERMISSIONS: Record<string, string[]> = {
|
const PERMISSIONS: Record<string, string[]> = {
|
||||||
"subject.create": ["ADMIN", "PM", "CRA"],
|
"subject.create": ["ADMIN", "PM", "CRA"],
|
||||||
@@ -37,13 +38,11 @@ export const usePermission = () => {
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
|
||||||
const userRole = computed(() => auth.user?.role || null);
|
const systemAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
const projectRole = computed(
|
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||||
() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || auth.user?.role || null
|
|
||||||
);
|
|
||||||
|
|
||||||
const can = (action: string): boolean => {
|
const can = (action: string): boolean => {
|
||||||
if (userRole.value === "ADMIN") return true;
|
if (systemAdmin.value) return true;
|
||||||
const allowList = PERMISSIONS[action];
|
const allowList = PERMISSIONS[action];
|
||||||
if (!allowList) return false;
|
if (!allowList) return false;
|
||||||
if (!projectRole.value) return false;
|
if (!projectRole.value) return false;
|
||||||
@@ -51,7 +50,7 @@ export const usePermission = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reason = (action: string): string => {
|
const reason = (action: string): string => {
|
||||||
if (userRole.value === "ADMIN") return "";
|
if (systemAdmin.value) return "";
|
||||||
return REASONS[action] || TEXT.modules.permissions.default;
|
return REASONS[action] || TEXT.modules.permissions.default;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getProjectRole, getSystemRole, isProjectAdminOrPm, isSystemAdmin } from "./roles";
|
||||||
|
|
||||||
|
describe("role helpers", () => {
|
||||||
|
it("keeps system admin separate from project admin", () => {
|
||||||
|
expect(isSystemAdmin({ role: "ADMIN" } as any)).toBe(true);
|
||||||
|
expect(getSystemRole({ role: "ADMIN" } as any)).toBe("ADMIN");
|
||||||
|
expect(getProjectRole({ role_in_study: null } as any, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only project role fields for project permissions", () => {
|
||||||
|
expect(getProjectRole({ role_in_study: "PM" } as any, null)).toBe("PM");
|
||||||
|
expect(getProjectRole({ role_in_study: null } as any, "ADMIN")).toBe("ADMIN");
|
||||||
|
expect(isProjectAdminOrPm("ADMIN")).toBe(true);
|
||||||
|
expect(isProjectAdminOrPm("PM")).toBe(true);
|
||||||
|
expect(isProjectAdminOrPm("CRA")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Study, UserInfo } from "../types/api";
|
||||||
|
|
||||||
|
export const getSystemRole = (user?: Pick<UserInfo, "role"> | null) => user?.role || null;
|
||||||
|
|
||||||
|
export const isSystemAdmin = (user?: Pick<UserInfo, "role"> | null) => getSystemRole(user) === "ADMIN";
|
||||||
|
|
||||||
|
export const getProjectRole = (study?: Pick<Study, "role_in_study"> | null, currentStudyRole?: string | null) =>
|
||||||
|
currentStudyRole || study?.role_in_study || null;
|
||||||
|
|
||||||
|
export const isProjectAdminOrPm = (role?: string | null) => role === "ADMIN" || role === "PM";
|
||||||
@@ -90,14 +90,6 @@ const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>>
|
|||||||
note: "备注",
|
note: "备注",
|
||||||
stageBreakdown: "分阶段计划",
|
stageBreakdown: "分阶段计划",
|
||||||
},
|
},
|
||||||
monitoringStrategies: {
|
|
||||||
id: "ID",
|
|
||||||
strategyType: "监查类型",
|
|
||||||
detail: "策略详情",
|
|
||||||
frequency: "监查次数",
|
|
||||||
updatedAt: "更新时间",
|
|
||||||
enabled: "是否启用",
|
|
||||||
},
|
|
||||||
centerConfirm: {
|
centerConfirm: {
|
||||||
id: "ID",
|
id: "ID",
|
||||||
siteId: "中心ID",
|
siteId: "中心ID",
|
||||||
@@ -124,7 +116,6 @@ const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown):
|
|||||||
if (moduleKey === "projectMilestones") return String(row.name || "").trim();
|
if (moduleKey === "projectMilestones") return String(row.name || "").trim();
|
||||||
if (moduleKey === "siteMilestones") return String(row.milestone || "").trim();
|
if (moduleKey === "siteMilestones") return String(row.milestone || "").trim();
|
||||||
if (moduleKey === "siteEnrollmentPlans") return String(row.siteName || row.siteId || "").trim();
|
if (moduleKey === "siteEnrollmentPlans") return String(row.siteName || row.siteId || "").trim();
|
||||||
if (moduleKey === "monitoringStrategies") return String(row.strategyType || "").trim();
|
|
||||||
if (moduleKey === "centerConfirm") return String(row.siteName || row.siteId || "").trim();
|
if (moduleKey === "centerConfirm") return String(row.siteName || row.siteId || "").trim();
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("Login protocol agreement", () => {
|
|||||||
expect(source).toContain("记住密码");
|
expect(source).toContain("记住密码");
|
||||||
expect(source).toContain('autocomplete="username"');
|
expect(source).toContain('autocomplete="username"');
|
||||||
expect(source).toContain('name="username"');
|
expect(source).toContain('name="username"');
|
||||||
expect(source).toContain('name="password"');
|
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\"");
|
||||||
expect(source).toContain("tryLoadBrowserCredential");
|
expect(source).toContain("tryLoadBrowserCredential");
|
||||||
expect(source).toContain("tryStoreBrowserCredential");
|
expect(source).toContain("tryStoreBrowserCredential");
|
||||||
expect(source).toContain("navigator.credentials");
|
expect(source).toContain("navigator.credentials");
|
||||||
@@ -45,4 +45,21 @@ describe("Login protocol agreement", () => {
|
|||||||
expect(source).toContain("权限与审计");
|
expect(source).toContain("权限与审计");
|
||||||
expect(source).toContain("confirmProtocol");
|
expect(source).toContain("confirmProtocol");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists the protocol agreement checkbox in localStorage", () => {
|
||||||
|
const source = readLoginView();
|
||||||
|
|
||||||
|
expect(source).toContain("const AGREE_PROTOCOL_KEY = \"ctms_agree_protocol\"");
|
||||||
|
expect(source).toContain('localStorage.getItem(AGREE_PROTOCOL_KEY) === "true"');
|
||||||
|
expect(source).toContain('localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked))');
|
||||||
|
expect(source).toContain("confirmProtocol");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids browser password caching when remember password is off", () => {
|
||||||
|
const source = readLoginView();
|
||||||
|
|
||||||
|
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\"");
|
||||||
|
expect(source).toContain(":autocomplete=\"form.rememberPassword ? 'current-password' : 'new-password'\"");
|
||||||
|
expect(source).not.toContain('autocomplete="current-password"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,8 +63,8 @@
|
|||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
show-password
|
show-password
|
||||||
size="large"
|
size="large"
|
||||||
name="password"
|
:name="form.rememberPassword ? 'password' : 'ctms-login-password'"
|
||||||
autocomplete="current-password"
|
:autocomplete="form.rememberPassword ? 'current-password' : 'new-password'"
|
||||||
class="login-input"
|
class="login-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted } from "vue";
|
import { reactive, ref, onMounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
@@ -138,6 +138,7 @@ import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionMa
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
|
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
|
||||||
|
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||||
|
|
||||||
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
|
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
|
||||||
type StoredPasswordCredential = Credential & { id?: string; password?: string };
|
type StoredPasswordCredential = Credential & { id?: string; password?: string };
|
||||||
@@ -213,9 +214,17 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
||||||
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
|
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
|
||||||
|
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||||
await tryLoadBrowserCredential();
|
await tryLoadBrowserCredential();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.agreeProtocol,
|
||||||
|
(checked) => {
|
||||||
|
localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const tryLoadBrowserCredential = async () => {
|
const tryLoadBrowserCredential = async () => {
|
||||||
if (!form.rememberPassword || !navigator.credentials?.get) return;
|
if (!form.rememberPassword || !navigator.credentials?.get) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readProfileView = () => readFileSync(resolve(__dirname, "./ProfileSettings.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("profile settings autocomplete", () => {
|
||||||
|
it("disables browser autofill for profile name, department, and current password", () => {
|
||||||
|
const source = readProfileView();
|
||||||
|
|
||||||
|
expect(source).toContain('<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">');
|
||||||
|
expect(source).toContain('autocomplete="off"');
|
||||||
|
expect(source).toContain('autocomplete="new-password"');
|
||||||
|
expect(source).toContain('name="ctms-profile-current-password"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<el-card class="unified-shell">
|
<el-card class="unified-shell">
|
||||||
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
||||||
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
|
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">
|
||||||
<el-form-item :label="TEXT.common.fields.avatar">
|
<el-form-item :label="TEXT.common.fields.avatar">
|
||||||
<div class="avatar-row">
|
<div class="avatar-row">
|
||||||
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
|
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
|
||||||
@@ -26,14 +26,21 @@
|
|||||||
<el-input v-model="form.email" disabled />
|
<el-input v-model="form.email" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
||||||
<el-input v-model="form.full_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.department" prop="department">
|
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
||||||
<el-input v-model="form.department" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.department" />
|
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-divider />
|
<el-divider />
|
||||||
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
|
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
|
||||||
<el-input v-model="form.current_password" type="password" show-password :placeholder="TEXT.modules.profile.currentPasswordHint" />
|
<el-input
|
||||||
|
v-model="form.current_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="ctms-profile-current-password"
|
||||||
|
:placeholder="TEXT.modules.profile.currentPasswordHint"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
|
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
|
||||||
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
|
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
|
||||||
@@ -64,7 +71,7 @@ const submitting = ref(false);
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
full_name: "",
|
full_name: "",
|
||||||
department: "",
|
clinical_department: "",
|
||||||
current_password: "",
|
current_password: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
@@ -76,7 +83,7 @@ const uploadHeaders = computed<Record<string, string>>(() => ({
|
|||||||
|
|
||||||
const rules: FormRules<typeof form> = {
|
const rules: FormRules<typeof form> = {
|
||||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
||||||
department: [{ required: true, message: requiredMessage(TEXT.common.fields.department), trigger: "blur" }],
|
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
|
||||||
current_password: [
|
current_password: [
|
||||||
{
|
{
|
||||||
validator: (_r, value, cb) => {
|
validator: (_r, value, cb) => {
|
||||||
@@ -120,7 +127,7 @@ const loadProfile = async () => {
|
|||||||
const { data } = await fetchMe();
|
const { data } = await fetchMe();
|
||||||
form.email = data.email;
|
form.email = data.email;
|
||||||
form.full_name = data.full_name;
|
form.full_name = data.full_name;
|
||||||
form.department = data.department;
|
form.clinical_department = data.clinical_department;
|
||||||
avatarPreview.value = data.avatar_url || undefined;
|
avatarPreview.value = data.avatar_url || undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +139,7 @@ const onSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
await updateProfile({
|
await updateProfile({
|
||||||
full_name: form.full_name,
|
full_name: form.full_name,
|
||||||
department: form.department,
|
clinical_department: form.clinical_department,
|
||||||
current_password: form.password ? form.current_password : undefined,
|
current_password: form.password ? form.current_password : undefined,
|
||||||
password: form.password || undefined,
|
password: form.password || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,19 +97,19 @@
|
|||||||
<span v-if="errors.full_name" class="error-text">{{ errors.full_name }}</span>
|
<span v-if="errors.full_name" class="error-text">{{ errors.full_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ TEXT.modules.auth.departmentLabel }}</label>
|
<label class="form-label">{{ TEXT.modules.auth.clinicalDepartmentLabel }}</label>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<div class="input-glass"></div>
|
<div class="input-glass"></div>
|
||||||
<div class="input-overlay"></div>
|
<div class="input-overlay"></div>
|
||||||
<div class="input-border"></div>
|
<div class="input-border"></div>
|
||||||
<input
|
<input
|
||||||
v-model="form.department"
|
v-model="form.clinical_department"
|
||||||
type="text"
|
type="text"
|
||||||
class="glass-input"
|
class="glass-input"
|
||||||
:placeholder="TEXT.modules.auth.departmentPlaceholder"
|
:placeholder="TEXT.modules.auth.clinicalDepartmentPlaceholder"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="errors.department" class="error-text">{{ errors.department }}</span>
|
<span v-if="errors.clinical_department" class="error-text">{{ errors.clinical_department }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-item">
|
<div class="summary-item">
|
||||||
<span class="summary-label">部门</span>
|
<span class="summary-label">部门</span>
|
||||||
<span class="summary-value">{{ form.department || '-' }}</span>
|
<span class="summary-value">{{ form.clinical_department || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +313,7 @@ const form = reactive({
|
|||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
full_name: "",
|
full_name: "",
|
||||||
department: "",
|
clinical_department: "",
|
||||||
agreeTerms: false,
|
agreeTerms: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -366,8 +366,8 @@ const validateStep1 = (): boolean => {
|
|||||||
newErrors.full_name = requiredMessage(TEXT.modules.auth.fullNameLabel);
|
newErrors.full_name = requiredMessage(TEXT.modules.auth.fullNameLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.department) {
|
if (!form.clinical_department) {
|
||||||
newErrors.department = requiredMessage(TEXT.modules.auth.departmentLabel);
|
newErrors.clinical_department = requiredMessage(TEXT.modules.auth.clinicalDepartmentLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(errors, newErrors);
|
Object.assign(errors, newErrors);
|
||||||
@@ -452,7 +452,7 @@ const handleSubmit = async () => {
|
|||||||
password: form.password,
|
password: form.password,
|
||||||
full_name: form.full_name,
|
full_name: form.full_name,
|
||||||
role: defaultRole,
|
role: defaultRole,
|
||||||
department: form.department,
|
clinical_department: form.clinical_department,
|
||||||
});
|
});
|
||||||
ElMessage.success(TEXT.modules.auth.registerSuccess);
|
ElMessage.success(TEXT.modules.auth.registerSuccess);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -464,7 +464,7 @@ const handleSubmit = async () => {
|
|||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
full_name: "",
|
full_name: "",
|
||||||
department: "",
|
clinical_department: "",
|
||||||
agreeTerms: false,
|
agreeTerms: false,
|
||||||
});
|
});
|
||||||
currentStep.value = 1;
|
currentStep.value = 1;
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ 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 { displayEnum } from "../utils/display";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../utils/roles";
|
||||||
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";
|
||||||
@@ -143,7 +144,11 @@ const milestoneCompletionRate = computed(() => {
|
|||||||
return TEXT.modules.projectOverview.progressLabel.replace("{rate}", `${(rate * 100).toFixed(0)}%`);
|
return TEXT.modules.projectOverview.progressLabel.replace("{rate}", `${(rate * 100).toFixed(0)}%`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, auth.user?.role));
|
const projectRole = computed(() => {
|
||||||
|
if (isSystemAdmin(auth.user)) return "ADMIN";
|
||||||
|
return getProjectRole(study.currentStudy, study.currentStudyRole);
|
||||||
|
});
|
||||||
|
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, 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);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<el-table-column prop="role" :label="TEXT.common.labels.role">
|
<el-table-column prop="role" :label="TEXT.common.labels.role">
|
||||||
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
|
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="department" :label="TEXT.common.fields.department" show-overflow-tooltip />
|
<el-table-column prop="clinical_department" :label="TEXT.common.fields.clinicalDepartment" show-overflow-tooltip />
|
||||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" show-overflow-tooltip>
|
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" show-overflow-tooltip>
|
||||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("audit logs access", () => {
|
||||||
|
it("allows current project PM access without relying on global PM role", () => {
|
||||||
|
const source = readAuditLogsView();
|
||||||
|
|
||||||
|
expect(source).toContain("const canAccessAuditLogs = computed");
|
||||||
|
expect(source).toContain("isSystemAdmin");
|
||||||
|
expect(source).toContain("getProjectRole");
|
||||||
|
expect(source).toContain("study.currentStudyRole");
|
||||||
|
expect(source).toContain('projectRole.value === "PM"');
|
||||||
|
expect(source).not.toContain('const role = auth.user?.role');
|
||||||
|
expect(source).not.toContain('role !== "ADMIN"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -175,6 +175,7 @@ import { useAuthStore } from "../../store/auth";
|
|||||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -209,19 +210,20 @@ const resolveUserDisplayName = (u: any): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
|
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
|
||||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN");
|
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||||
|
const canProjectExport = computed(() => !!study.currentStudy && isAdmin.value);
|
||||||
|
const canAccessAuditLogs = computed(() => isAdmin.value || projectRole.value === "PM");
|
||||||
|
|
||||||
const ensureAccess = () => {
|
const ensureAccess = () => {
|
||||||
const role = auth.user?.role;
|
if (!canAccessAuditLogs.value) {
|
||||||
if (role !== "ADMIN") {
|
router.replace(study.currentStudy ? "/project/overview" : "/workbench");
|
||||||
router.replace("/admin/users");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
if (auth.user?.role === "ADMIN") {
|
if (isAdmin.value) {
|
||||||
const { data } = await fetchUsers({ limit: 500 });
|
const { data } = await fetchUsers({ limit: 500 });
|
||||||
users.value = Array.isArray(data) ? data : (data as any).items || [];
|
users.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||||
} else if (study.currentStudy) {
|
} else if (study.currentStudy) {
|
||||||
|
|||||||
@@ -81,3 +81,13 @@ describe("ProjectDetail setup draft publish workflow", () => {
|
|||||||
expect(source).toContain("提前终止不作为常规计划访视配置");
|
expect(source).toContain("提前终止不作为常规计划访视配置");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("project detail management role", () => {
|
||||||
|
it("allows only current project administrators and PMs to manage setup content", () => {
|
||||||
|
const source = readProjectDetail();
|
||||||
|
|
||||||
|
expect(source).toContain("project.value?.role_in_study");
|
||||||
|
expect(source).toContain('["ADMIN", "PM"].includes(setupRole.value)');
|
||||||
|
expect(source).not.toContain("project.value?.role_in_study || authStore.user?.role");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -186,25 +186,6 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="handleAddSiteMilestoneAction">新增</el-button>
|
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="handleAddSiteMilestoneAction">新增</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="stepActionMode === 'monitoring-strategy'">
|
|
||||||
<el-dropdown trigger="click" @command="onMonitoringStrategyTemplateSelect">
|
|
||||||
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup">
|
|
||||||
<span>选择模板</span>
|
|
||||||
</el-button>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item
|
|
||||||
v-for="item in monitoringStrategyTemplates"
|
|
||||||
:key="item.key"
|
|
||||||
:command="item.key"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="addMonitoringStrategy">新增</el-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="drawerVisible" class="setup-drawer-header">
|
<div v-if="drawerVisible" class="setup-drawer-header">
|
||||||
@@ -1210,83 +1191,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-show="activeStep === 5" class="setup-section setup-section--table">
|
<section v-show="activeStep === 5" class="setup-section setup-section--table">
|
||||||
<el-table :data="currentSetupDraft.monitoringStrategies" class="ctms-table">
|
|
||||||
<el-table-column label="监查类型" min-width="180">
|
|
||||||
<template #default="scope">
|
|
||||||
<span class="updated-value-wrap">
|
|
||||||
<span class="milestone-cell-text">{{ scope.row.strategyType || "-" }}</span>
|
|
||||||
<el-tag
|
|
||||||
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'strategyType')"
|
|
||||||
size="small"
|
|
||||||
type="warning"
|
|
||||||
effect="plain"
|
|
||||||
class="field-updated-tag"
|
|
||||||
>
|
|
||||||
已更新
|
|
||||||
</el-tag>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="策略详情" min-width="430">
|
|
||||||
<template #default="scope">
|
|
||||||
<span class="updated-value-wrap">
|
|
||||||
<span class="milestone-cell-text">{{ scope.row.detail || "-" }}</span>
|
|
||||||
<el-tag
|
|
||||||
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'detail')"
|
|
||||||
size="small"
|
|
||||||
type="warning"
|
|
||||||
effect="plain"
|
|
||||||
class="field-updated-tag"
|
|
||||||
>
|
|
||||||
已更新
|
|
||||||
</el-tag>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="监查次数" width="140">
|
|
||||||
<template #default="scope">
|
|
||||||
<span class="updated-value-wrap">
|
|
||||||
<span class="milestone-cell-text">{{ scope.row.frequency || "-" }}</span>
|
|
||||||
<el-tag
|
|
||||||
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'frequency')"
|
|
||||||
size="small"
|
|
||||||
type="warning"
|
|
||||||
effect="plain"
|
|
||||||
class="field-updated-tag"
|
|
||||||
>
|
|
||||||
已更新
|
|
||||||
</el-tag>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="更新时间" width="180">
|
|
||||||
<template #default="scope">
|
|
||||||
<span class="updated-value-wrap">
|
|
||||||
<span class="milestone-cell-text">{{ scope.row.updatedAt || "-" }}</span>
|
|
||||||
<el-tag
|
|
||||||
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'updatedAt')"
|
|
||||||
size="small"
|
|
||||||
type="warning"
|
|
||||||
effect="plain"
|
|
||||||
class="field-updated-tag"
|
|
||||||
>
|
|
||||||
已更新
|
|
||||||
</el-tag>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column v-if="!isPublishedView" label="操作" width="130">
|
|
||||||
<template #default="scope">
|
|
||||||
<div class="milestone-actions-inline">
|
|
||||||
<el-button link type="primary" :disabled="!canEditSetup" @click="openMonitoringStrategyEditor(scope.$index)">编辑</el-button>
|
|
||||||
<el-button link type="danger" :disabled="!canEditSetup" @click="removeMonitoringStrategy(scope.$index)">删除</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-show="activeStep === 6" class="setup-section setup-section--table">
|
|
||||||
<el-table :data="centerConfirmDisplayRows" class="ctms-table">
|
<el-table :data="centerConfirmDisplayRows" class="ctms-table">
|
||||||
<el-table-column label="中心" min-width="180">
|
<el-table-column label="中心" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
@@ -1997,49 +1901,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<el-drawer
|
|
||||||
v-if="monitoringStrategyEditorVisible"
|
|
||||||
v-model="monitoringStrategyEditorVisible"
|
|
||||||
direction="rtl"
|
|
||||||
size="480px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
:show-close="false"
|
|
||||||
class="setup-milestone-editor-drawer"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="sme-header">
|
|
||||||
<div class="sme-header-title">编辑监查策略</div>
|
|
||||||
<div class="sme-header-subtitle">配置监查类型、触发规则与执行频次</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-form label-position="top" class="sme-form">
|
|
||||||
<div class="sme-group">
|
|
||||||
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>策略配置</div>
|
|
||||||
<el-form-item label="监查类型">
|
|
||||||
<el-select v-model="monitoringStrategyEditorForm.strategyType" class="w-full" placeholder="选择类型">
|
|
||||||
<el-option
|
|
||||||
v-for="item in monitoringStrategyTypeOptions"
|
|
||||||
:key="item"
|
|
||||||
:label="item"
|
|
||||||
:value="item"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="策略详情">
|
|
||||||
<el-input v-model="monitoringStrategyEditorForm.detail" type="textarea" :rows="3" placeholder="填写监查触发规则与执行策略" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="监查次数">
|
|
||||||
<el-input v-model="monitoringStrategyEditorForm.frequency" placeholder="如:不限 / 2次" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="sme-footer">
|
|
||||||
<el-button @click="monitoringStrategyEditorVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="saveMonitoringStrategyEditor">保存</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-drawer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2057,7 +1918,6 @@ import { fetchUsers } from "../../api/users";
|
|||||||
import type { Site, Study } from "../../types/api";
|
import type { Site, Study } from "../../types/api";
|
||||||
import type {
|
import type {
|
||||||
CenterConfirmDraft,
|
CenterConfirmDraft,
|
||||||
MonitoringStrategyDraft,
|
|
||||||
ProjectPublishSnapshot,
|
ProjectPublishSnapshot,
|
||||||
ProjectMilestoneDraft,
|
ProjectMilestoneDraft,
|
||||||
SetupConfigDraft,
|
SetupConfigDraft,
|
||||||
@@ -2095,7 +1955,6 @@ type SetupStepKey =
|
|||||||
| "project-enrollment"
|
| "project-enrollment"
|
||||||
| "site-milestone"
|
| "site-milestone"
|
||||||
| "site-enrollment"
|
| "site-enrollment"
|
||||||
| "monitoring-strategy"
|
|
||||||
| "site-confirm";
|
| "site-confirm";
|
||||||
type Step1EditSection = "all" | "project" | "research" | "execution" | "summary";
|
type Step1EditSection = "all" | "project" | "research" | "execution" | "summary";
|
||||||
type StepActionMode =
|
type StepActionMode =
|
||||||
@@ -2103,7 +1962,7 @@ type StepActionMode =
|
|||||||
| "edit"
|
| "edit"
|
||||||
| "project-milestone"
|
| "project-milestone"
|
||||||
| "site-milestone"
|
| "site-milestone"
|
||||||
| "monitoring-strategy";
|
;
|
||||||
type RollbackAxisLane = { branch: string; index: number; x: number };
|
type RollbackAxisLane = { branch: string; index: number; x: number };
|
||||||
type RollbackAxisRow = StudySetupConfigVersionItem & {
|
type RollbackAxisRow = StudySetupConfigVersionItem & {
|
||||||
displayLabel: string;
|
displayLabel: string;
|
||||||
@@ -2138,7 +1997,6 @@ const steps: Array<{ key: SetupStepKey; label: string }> = [
|
|||||||
{ key: "project-enrollment", label: "项目入组计划" },
|
{ key: "project-enrollment", label: "项目入组计划" },
|
||||||
{ key: "site-milestone", label: "中心里程碑" },
|
{ key: "site-milestone", label: "中心里程碑" },
|
||||||
{ key: "site-enrollment", label: "中心入组计划" },
|
{ key: "site-enrollment", label: "中心入组计划" },
|
||||||
{ key: "monitoring-strategy", label: "监查计划策略" },
|
|
||||||
{ key: "site-confirm", label: TEXT.modules.adminProjects.setupStepCenterConfirm || "中心确认" },
|
{ key: "site-confirm", label: TEXT.modules.adminProjects.setupStepCenterConfirm || "中心确认" },
|
||||||
];
|
];
|
||||||
const projectMilestoneTemplates: Array<{ key: string; label: string; milestones: string[] }> = [
|
const projectMilestoneTemplates: Array<{ key: string; label: string; milestones: string[] }> = [
|
||||||
@@ -2178,29 +2036,7 @@ const siteMilestoneTemplates: Array<{ key: string; label: string; milestones: st
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const monitoringStrategyTypeOptions = [
|
|
||||||
"启动访视",
|
|
||||||
"筛选访视",
|
|
||||||
"监查访视",
|
|
||||||
"协同访视",
|
|
||||||
"风险访视",
|
|
||||||
"末次访视",
|
|
||||||
];
|
|
||||||
const monitoringStrategyTemplates: Array<{
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
rows: Array<{ strategyType: string; detail: string; frequency: string }>;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
key: "by-times-default",
|
|
||||||
label: "基础模板",
|
|
||||||
rows: monitoringStrategyTypeOptions.map((type) => ({
|
|
||||||
strategyType: type,
|
|
||||||
detail: `${type}按方案执行`,
|
|
||||||
frequency: "1次",
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -2276,8 +2112,6 @@ const siteMilestoneEditorVisible = ref(false);
|
|||||||
const siteMilestoneEditingIndex = ref<number>(-1);
|
const siteMilestoneEditingIndex = ref<number>(-1);
|
||||||
const siteEnrollmentEditorVisible = ref(false);
|
const siteEnrollmentEditorVisible = ref(false);
|
||||||
const siteEnrollmentEditingIndex = ref<number>(-1);
|
const siteEnrollmentEditingIndex = ref<number>(-1);
|
||||||
const monitoringStrategyEditorVisible = ref(false);
|
|
||||||
const monitoringStrategyEditingIndex = ref<number>(-1);
|
|
||||||
|
|
||||||
type IndexedEditorController = {
|
type IndexedEditorController = {
|
||||||
visible: Ref<boolean>;
|
visible: Ref<boolean>;
|
||||||
@@ -2295,10 +2129,6 @@ const siteEnrollmentEditor: IndexedEditorController = {
|
|||||||
visible: siteEnrollmentEditorVisible,
|
visible: siteEnrollmentEditorVisible,
|
||||||
index: siteEnrollmentEditingIndex,
|
index: siteEnrollmentEditingIndex,
|
||||||
};
|
};
|
||||||
const monitoringStrategyEditor: IndexedEditorController = {
|
|
||||||
visible: monitoringStrategyEditorVisible,
|
|
||||||
index: monitoringStrategyEditingIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
|
const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
|
||||||
id: "",
|
id: "",
|
||||||
@@ -2329,14 +2159,6 @@ const siteEnrollmentEditorForm = reactive<SiteEnrollmentPlanDraft>({
|
|||||||
note: "",
|
note: "",
|
||||||
stageBreakdown: "",
|
stageBreakdown: "",
|
||||||
});
|
});
|
||||||
const monitoringStrategyEditorForm = reactive<MonitoringStrategyDraft>({
|
|
||||||
id: "",
|
|
||||||
strategyType: "",
|
|
||||||
detail: "",
|
|
||||||
frequency: "",
|
|
||||||
updatedAt: "",
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
type EnrollmentCycle = "month" | "quarter";
|
type EnrollmentCycle = "month" | "quarter";
|
||||||
@@ -2575,13 +2397,12 @@ const setupDraft = reactive<SetupConfigDraft>({
|
|||||||
},
|
},
|
||||||
siteMilestones: [],
|
siteMilestones: [],
|
||||||
siteEnrollmentPlans: [],
|
siteEnrollmentPlans: [],
|
||||||
monitoringStrategies: [],
|
|
||||||
centerConfirm: [],
|
centerConfirm: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
|
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
|
||||||
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
|
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
|
||||||
const setupRole = computed(() => String(project.value?.role_in_study || authStore.user?.role || "").toUpperCase());
|
const setupRole = computed(() => String(project.value?.role_in_study || "").toUpperCase());
|
||||||
const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value));
|
const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value));
|
||||||
const isPreviewView = computed(() => setupViewMode.value === "preview");
|
const isPreviewView = computed(() => setupViewMode.value === "preview");
|
||||||
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
|
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
|
||||||
@@ -2618,8 +2439,6 @@ const stepActionMode = computed<StepActionMode>(() => {
|
|||||||
return "site-milestone";
|
return "site-milestone";
|
||||||
case 4:
|
case 4:
|
||||||
return "edit";
|
return "edit";
|
||||||
case 5:
|
|
||||||
return "monitoring-strategy";
|
|
||||||
default:
|
default:
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
@@ -2676,7 +2495,6 @@ const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
|
|||||||
if (draft.projectMilestones.length > 0) return false;
|
if (draft.projectMilestones.length > 0) return false;
|
||||||
if (draft.siteMilestones.length > 0) return false;
|
if (draft.siteMilestones.length > 0) return false;
|
||||||
if (draft.siteEnrollmentPlans.length > 0) return false;
|
if (draft.siteEnrollmentPlans.length > 0) return false;
|
||||||
if (draft.monitoringStrategies.length > 0) return false;
|
|
||||||
if (draft.centerConfirm.length > 0) return false;
|
if (draft.centerConfirm.length > 0) return false;
|
||||||
const plan = draft.enrollmentPlan;
|
const plan = draft.enrollmentPlan;
|
||||||
const normalizedTotal = Number(plan.totalTarget || 0);
|
const normalizedTotal = Number(plan.totalTarget || 0);
|
||||||
@@ -2827,8 +2645,7 @@ const setupSectionStepMap: Record<string, number> = {
|
|||||||
enrollmentPlan: 2,
|
enrollmentPlan: 2,
|
||||||
siteMilestones: 3,
|
siteMilestones: 3,
|
||||||
siteEnrollmentPlans: 4,
|
siteEnrollmentPlans: 4,
|
||||||
monitoringStrategies: 5,
|
centerConfirm: 5,
|
||||||
centerConfirm: 6,
|
|
||||||
};
|
};
|
||||||
const currentStepErrors = computed(() => {
|
const currentStepErrors = computed(() => {
|
||||||
const grouped = groupErrorsBySection(
|
const grouped = groupErrorsBySection(
|
||||||
@@ -2933,20 +2750,6 @@ const isSiteMilestoneFieldUpdated = (
|
|||||||
);
|
);
|
||||||
return isPreviewValueUpdated(row[field], baseRow?.[field]);
|
return isPreviewValueUpdated(row[field], baseRow?.[field]);
|
||||||
};
|
};
|
||||||
const isMonitoringStrategyFieldUpdated = (
|
|
||||||
row: MonitoringStrategyDraft,
|
|
||||||
index: number,
|
|
||||||
field: "strategyType" | "detail" | "frequency" | "updatedAt"
|
|
||||||
): boolean => {
|
|
||||||
const baseRow = resolveRowCompareTarget(
|
|
||||||
publishCompareBase.value.monitoringStrategies,
|
|
||||||
row,
|
|
||||||
index,
|
|
||||||
(item) =>
|
|
||||||
`${normalizeIdentityText(item.strategyType)}|${normalizeIdentityText(item.detail)}|${normalizeIdentityText(item.frequency)}`
|
|
||||||
);
|
|
||||||
return isPreviewValueUpdated(row[field], baseRow?.[field]);
|
|
||||||
};
|
|
||||||
const publishedSiteEnrollmentPlanMap = computed(() => {
|
const publishedSiteEnrollmentPlanMap = computed(() => {
|
||||||
const map = new Map<string, SiteEnrollmentPlanDraft>();
|
const map = new Map<string, SiteEnrollmentPlanDraft>();
|
||||||
publishCompareBase.value.siteEnrollmentPlans.forEach((row) => {
|
publishCompareBase.value.siteEnrollmentPlans.forEach((row) => {
|
||||||
@@ -3685,8 +3488,7 @@ const stepHasErrors = (stepIndex: number): boolean => {
|
|||||||
if (path.startsWith("projectMilestones")) return stepIndex === 1;
|
if (path.startsWith("projectMilestones")) return stepIndex === 1;
|
||||||
if (path.startsWith("siteMilestones")) return stepIndex === 3;
|
if (path.startsWith("siteMilestones")) return stepIndex === 3;
|
||||||
if (path.startsWith("siteEnrollmentPlans")) return stepIndex === 4;
|
if (path.startsWith("siteEnrollmentPlans")) return stepIndex === 4;
|
||||||
if (path.startsWith("monitoringStrategies")) return stepIndex === 5;
|
if (path.startsWith("centerConfirm")) return stepIndex === 5;
|
||||||
if (path.startsWith("centerConfirm")) return stepIndex === 6;
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -3753,14 +3555,6 @@ const isStepCompleted = (stepIndex: number): boolean => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (stepIndex === 5) {
|
if (stepIndex === 5) {
|
||||||
return (
|
|
||||||
setupDraft.monitoringStrategies.length > 0 &&
|
|
||||||
setupDraft.monitoringStrategies.every(
|
|
||||||
(item) => (item.strategyType || "").trim().length > 0 && (item.detail || "").trim().length > 0 && (item.frequency || "").trim().length > 0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (stepIndex === 6) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -3976,8 +3770,7 @@ const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => {
|
|||||||
},
|
},
|
||||||
siteMilestones: [],
|
siteMilestones: [],
|
||||||
siteEnrollmentPlans: [],
|
siteEnrollmentPlans: [],
|
||||||
monitoringStrategies: [],
|
centerConfirm: [],
|
||||||
centerConfirm: [],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3996,7 +3789,6 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
|
|||||||
!!data.enrollmentPlan &&
|
!!data.enrollmentPlan &&
|
||||||
Array.isArray(data.siteMilestones) &&
|
Array.isArray(data.siteMilestones) &&
|
||||||
Array.isArray(data.siteEnrollmentPlans) &&
|
Array.isArray(data.siteEnrollmentPlans) &&
|
||||||
Array.isArray(data.monitoringStrategies) &&
|
|
||||||
Array.isArray(data.centerConfirm)
|
Array.isArray(data.centerConfirm)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -4049,7 +3841,6 @@ const applySetupDraft = (draft: SetupConfigDraft) => {
|
|||||||
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
|
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
|
||||||
setupDraft.siteMilestones = clone(draft.siteMilestones);
|
setupDraft.siteMilestones = clone(draft.siteMilestones);
|
||||||
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
||||||
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
|
|
||||||
setupDraft.centerConfirm = clone(draft.centerConfirm);
|
setupDraft.centerConfirm = clone(draft.centerConfirm);
|
||||||
applyProjectInfoDraft(projectInfo);
|
applyProjectInfoDraft(projectInfo);
|
||||||
formBaselineSnapshot.value = serializeFormForCompare();
|
formBaselineSnapshot.value = serializeFormForCompare();
|
||||||
@@ -4190,7 +3981,6 @@ const setupModuleLabels: Array<{ key: keyof SetupConfigDraft; label: string }> =
|
|||||||
{ key: "enrollmentPlan", label: "项目入组计划" },
|
{ key: "enrollmentPlan", label: "项目入组计划" },
|
||||||
{ key: "siteMilestones", label: "中心里程碑" },
|
{ key: "siteMilestones", label: "中心里程碑" },
|
||||||
{ key: "siteEnrollmentPlans", label: "中心入组计划" },
|
{ key: "siteEnrollmentPlans", label: "中心入组计划" },
|
||||||
{ key: "monitoringStrategies", label: "监查计划策略" },
|
|
||||||
{ key: "centerConfirm", label: "中心确认" },
|
{ key: "centerConfirm", label: "中心确认" },
|
||||||
];
|
];
|
||||||
const emptySetupDraft: SetupConfigDraft = {
|
const emptySetupDraft: SetupConfigDraft = {
|
||||||
@@ -4205,7 +3995,6 @@ const emptySetupDraft: SetupConfigDraft = {
|
|||||||
},
|
},
|
||||||
siteMilestones: [],
|
siteMilestones: [],
|
||||||
siteEnrollmentPlans: [],
|
siteEnrollmentPlans: [],
|
||||||
monitoringStrategies: [],
|
|
||||||
centerConfirm: [],
|
centerConfirm: [],
|
||||||
};
|
};
|
||||||
const publishCompareBase = computed<SetupConfigDraft>(() => clone(publishedSetupDraft.value || emptySetupDraft));
|
const publishCompareBase = computed<SetupConfigDraft>(() => clone(publishedSetupDraft.value || emptySetupDraft));
|
||||||
@@ -5465,17 +5254,8 @@ const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const step6Headers = ["策略类型", "策略详情", "监查次数", "是否启用", "更新时间"];
|
const step6Headers = ["中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"];
|
||||||
const step6Rows = configDraft.monitoringStrategies.map((item) => [
|
const step6Rows = configDraft.centerConfirm.map((item) => [
|
||||||
item.strategyType || "-",
|
|
||||||
item.detail || "-",
|
|
||||||
item.frequency || "-",
|
|
||||||
item.enabled ? "是" : "否",
|
|
||||||
item.updatedAt || "-",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const step7Headers = ["中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"];
|
|
||||||
const step7Rows = configDraft.centerConfirm.map((item) => [
|
|
||||||
item.siteId || "-",
|
item.siteId || "-",
|
||||||
item.siteName || "-",
|
item.siteName || "-",
|
||||||
item.confirmer || "-",
|
item.confirmer || "-",
|
||||||
@@ -5528,8 +5308,7 @@ ${buildExcelWorksheetXml("第2步-项目里程碑", metaRows, step2Headers, step
|
|||||||
${buildExcelWorksheetXml("第3步-项目入组计划", metaRows, step3Headers, step3CycleRows)}
|
${buildExcelWorksheetXml("第3步-项目入组计划", metaRows, step3Headers, step3CycleRows)}
|
||||||
${buildExcelWorksheetXml("第4步-中心里程碑", metaRows, step4Headers, step4Rows)}
|
${buildExcelWorksheetXml("第4步-中心里程碑", metaRows, step4Headers, step4Rows)}
|
||||||
${buildExcelWorksheetXml("第5步-中心入组计划", metaRows, step5Headers, step5Rows)}
|
${buildExcelWorksheetXml("第5步-中心入组计划", metaRows, step5Headers, step5Rows)}
|
||||||
${buildExcelWorksheetXml("第6步-监查计划策略", metaRows, step6Headers, step6Rows)}
|
${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Rows)}
|
||||||
${buildExcelWorksheetXml("第7步-中心确认", metaRows, step7Headers, step7Rows)}
|
|
||||||
</Workbook>`;
|
</Workbook>`;
|
||||||
|
|
||||||
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||||
@@ -5782,7 +5561,7 @@ const confirmPublishConfig = async () => {
|
|||||||
|
|
||||||
const enterProject = () => {
|
const enterProject = () => {
|
||||||
if (!project.value) return;
|
if (!project.value) return;
|
||||||
studyStore.setCurrentStudy({ ...project.value, role_in_study: project.value.role_in_study || "PM" } as Study);
|
studyStore.setCurrentStudy(project.value);
|
||||||
router.push("/project/overview");
|
router.push("/project/overview");
|
||||||
};
|
};
|
||||||
const goMembers = () => {
|
const goMembers = () => {
|
||||||
@@ -6093,71 +5872,6 @@ const saveSiteEnrollmentEditor = () => {
|
|||||||
closeIndexedEditor(siteEnrollmentEditor, "中心入组计划已更新");
|
closeIndexedEditor(siteEnrollmentEditor, "中心入组计划已更新");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMonitoringStrategy = () => {
|
|
||||||
if (!canMutateDraft()) return;
|
|
||||||
setupDraft.monitoringStrategies.push({ id: makeId(), strategyType: "", detail: "", frequency: "", updatedAt: nowString(), enabled: true });
|
|
||||||
};
|
|
||||||
const applyMonitoringStrategyTemplate = (templateKey: string) => {
|
|
||||||
if (!canMutateDraft()) return;
|
|
||||||
const template = monitoringStrategyTemplates.find((item) => item.key === templateKey);
|
|
||||||
if (!template) {
|
|
||||||
ElMessage.warning("未找到对应模板");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setupDraft.monitoringStrategies = template.rows.map((item) => ({
|
|
||||||
id: makeId(),
|
|
||||||
strategyType: item.strategyType,
|
|
||||||
detail: item.detail,
|
|
||||||
frequency: item.frequency,
|
|
||||||
updatedAt: nowString(),
|
|
||||||
enabled: true,
|
|
||||||
}));
|
|
||||||
ElMessage.success(`已应用模板:${template.label}`);
|
|
||||||
};
|
|
||||||
const onMonitoringStrategyTemplateSelect = (command: string | number | object) => {
|
|
||||||
applyMonitoringStrategyTemplate(String(command));
|
|
||||||
};
|
|
||||||
const removeMonitoringStrategy = (index: number) => {
|
|
||||||
if (!canMutateDraft()) return;
|
|
||||||
setupDraft.monitoringStrategies.splice(index, 1);
|
|
||||||
};
|
|
||||||
const openMonitoringStrategyEditor = (index: number) => {
|
|
||||||
openIndexedEditor(setupDraft.monitoringStrategies, index, monitoringStrategyEditor, (row) => {
|
|
||||||
monitoringStrategyEditorForm.id = row.id || "";
|
|
||||||
monitoringStrategyEditorForm.strategyType = row.strategyType || "";
|
|
||||||
monitoringStrategyEditorForm.detail = row.detail || "";
|
|
||||||
monitoringStrategyEditorForm.frequency = row.frequency || "";
|
|
||||||
monitoringStrategyEditorForm.updatedAt = row.updatedAt || "";
|
|
||||||
monitoringStrategyEditorForm.enabled = row.enabled ?? true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const saveMonitoringStrategyEditor = () => {
|
|
||||||
if (!canMutateDraft()) return;
|
|
||||||
const index = getEditingIndex(monitoringStrategyEditor, setupDraft.monitoringStrategies.length);
|
|
||||||
if (index < 0) return;
|
|
||||||
if (!(monitoringStrategyEditorForm.strategyType || "").trim()) {
|
|
||||||
ElMessage.warning("请选择监查类型");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(monitoringStrategyEditorForm.detail || "").trim()) {
|
|
||||||
ElMessage.warning("请填写策略详情");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(monitoringStrategyEditorForm.frequency || "").trim()) {
|
|
||||||
ElMessage.warning("请填写监查次数");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setupDraft.monitoringStrategies[index] = {
|
|
||||||
id: monitoringStrategyEditorForm.id || makeId(),
|
|
||||||
strategyType: monitoringStrategyEditorForm.strategyType.trim(),
|
|
||||||
detail: monitoringStrategyEditorForm.detail.trim(),
|
|
||||||
frequency: monitoringStrategyEditorForm.frequency.trim(),
|
|
||||||
updatedAt: nowString(),
|
|
||||||
enabled: monitoringStrategyEditorForm.enabled ?? true,
|
|
||||||
};
|
|
||||||
closeIndexedEditor(monitoringStrategyEditor, "监查策略已更新");
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCenterConfirm = () => {
|
const addCenterConfirm = () => {
|
||||||
if (!canMutateDraft()) return;
|
if (!canMutateDraft()) return;
|
||||||
setupDraft.centerConfirm.push({ id: makeId(), siteId: "", siteName: "", confirmer: "", confirmStatus: "待确认", confirmDate: "", note: "" });
|
setupDraft.centerConfirm.push({ id: makeId(), siteId: "", siteName: "", confirmer: "", confirmStatus: "待确认", confirmDate: "", note: "" });
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const handleLockToggle = async (study: Study) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enterStudy = (row: Study) => {
|
const enterStudy = (row: Study) => {
|
||||||
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
|
studyStore.setCurrentStudy(row);
|
||||||
router.push("/project/overview");
|
router.push("/project/overview");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSiteForm = () => readFileSync(resolve(__dirname, "./SiteForm.vue"), "utf8");
|
||||||
|
const readSiteTypes = () => readFileSync(resolve(__dirname, "../../types/api.ts"), "utf8");
|
||||||
|
|
||||||
|
describe("Admin site form phone field", () => {
|
||||||
|
it("hydrates and submits the phone field", () => {
|
||||||
|
const formSource = readSiteForm();
|
||||||
|
const typeSource = readSiteTypes();
|
||||||
|
|
||||||
|
expect(formSource).toContain("form.phone = props.site.phone || (props.site as any)?.contact_phone || \"\"");
|
||||||
|
expect(formSource).toContain("phone: form.phone");
|
||||||
|
expect(formSource).toContain('v-model="form.phone"');
|
||||||
|
expect(typeSource).toContain("phone?: string | null;");
|
||||||
|
expect(typeSource).toContain("contact_phone?: string | null;");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,30 +101,33 @@ const resetForm = () => {
|
|||||||
form.is_active = true;
|
form.is_active = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hydrateForm = () => {
|
||||||
|
if (!props.site) return;
|
||||||
|
form.name = props.site.name;
|
||||||
|
form.city = props.site.city || "";
|
||||||
|
form.pi_name = props.site.pi_name || "";
|
||||||
|
form.phone = props.site.phone || (props.site as any)?.contact_phone || "";
|
||||||
|
form.contact = props.site.contact || "";
|
||||||
|
const rawSelections = (props.site.contact || "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const optionByLabel: Record<string, string> = {};
|
||||||
|
craOptions.value.forEach((opt) => {
|
||||||
|
optionByLabel[opt.label] = opt.value;
|
||||||
|
});
|
||||||
|
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
|
||||||
|
form.is_active = props.site.is_active;
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(val) => {
|
(visible) => {
|
||||||
if (val) {
|
if (!visible) return;
|
||||||
resetForm();
|
resetForm();
|
||||||
if (props.site) {
|
hydrateForm();
|
||||||
form.name = props.site.name;
|
},
|
||||||
form.city = props.site.city || "";
|
{ immediate: true }
|
||||||
form.pi_name = props.site.pi_name || "";
|
|
||||||
form.phone = (props.site as any)?.contact_phone || (props.site as any)?.phone || "";
|
|
||||||
form.contact = props.site.contact || "";
|
|
||||||
const rawSelections = (props.site.contact || "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const optionByLabel: Record<string, string> = {};
|
|
||||||
craOptions.value.forEach((opt) => {
|
|
||||||
optionByLabel[opt.label] = opt.value;
|
|
||||||
});
|
|
||||||
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
|
|
||||||
form.is_active = props.site.is_active;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
@@ -147,6 +150,7 @@ const onSubmit = async () => {
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
city: form.city,
|
city: form.city,
|
||||||
pi_name: form.pi_name,
|
pi_name: form.pi_name,
|
||||||
|
phone: form.phone,
|
||||||
contact,
|
contact,
|
||||||
is_active: form.is_active,
|
is_active: form.is_active,
|
||||||
});
|
});
|
||||||
@@ -156,6 +160,7 @@ const onSubmit = async () => {
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
city: form.city,
|
city: form.city,
|
||||||
pi_name: form.pi_name,
|
pi_name: form.pi_name,
|
||||||
|
phone: form.phone,
|
||||||
contact,
|
contact,
|
||||||
});
|
});
|
||||||
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const contactLabel = (row: any) => {
|
|||||||
.join("、") || TEXT.common.fallback;
|
.join("、") || TEXT.common.fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
|
const phoneText = (row: any) => row?.phone || TEXT.common.fallback;
|
||||||
|
|
||||||
const normalizeSiteName = (value?: string | null) => String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
const normalizeSiteName = (value?: string | null) => String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
||||||
const isLeadUnitSite = (row: Site): boolean => {
|
const isLeadUnitSite = (row: Site): boolean => {
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog append-to=".layout-main .content-wrapper" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
<el-dialog append-to=".layout-main .content-wrapper" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" autocomplete="off">
|
||||||
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
||||||
<el-input v-model="form.email" :disabled="!!user" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
|
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
||||||
<el-input v-model="form.full_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.common.fields.department" prop="department">
|
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
||||||
<el-input v-model="form.department" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.department" />
|
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="user ? TEXT.modules.adminUsers.passwordOptional : TEXT.modules.adminUsers.passwordInitial" prop="password">
|
|
||||||
<el-input v-model="form.password" type="password" show-password :placeholder="user ? TEXT.modules.adminUsers.passwordOptionalHint : TEXT.modules.adminUsers.passwordInitialHint" />
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
|
<el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
|
||||||
<el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" :disabled="isLastAdmin" />
|
<el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" :disabled="isLastAdmin" />
|
||||||
@@ -52,7 +49,7 @@ const submitting = ref(false);
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
full_name: "",
|
full_name: "",
|
||||||
department: "",
|
clinical_department: "",
|
||||||
password: "",
|
password: "",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
@@ -65,25 +62,13 @@ const rules = reactive<FormRules>({
|
|||||||
{ type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
|
{ type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
|
||||||
],
|
],
|
||||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
||||||
department: [{ required: true, message: requiredMessage(TEXT.common.fields.department), trigger: "blur" }],
|
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
|
||||||
password: [
|
|
||||||
{
|
|
||||||
validator: (_rule, value, callback) => {
|
|
||||||
if (!props.user && !value) {
|
|
||||||
callback(new Error(TEXT.modules.adminUsers.passwordInitialRequired));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
trigger: "blur",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.email = "";
|
form.email = "";
|
||||||
form.full_name = "";
|
form.full_name = "";
|
||||||
form.department = "";
|
form.clinical_department = "";
|
||||||
form.password = "";
|
form.password = "";
|
||||||
form.is_active = true;
|
form.is_active = true;
|
||||||
};
|
};
|
||||||
@@ -103,11 +88,12 @@ watch(
|
|||||||
if (props.user) {
|
if (props.user) {
|
||||||
form.email = props.user.email;
|
form.email = props.user.email;
|
||||||
form.full_name = props.user.full_name;
|
form.full_name = props.user.full_name;
|
||||||
form.department = props.user.department;
|
form.clinical_department = props.user.clinical_department;
|
||||||
form.is_active = props.user.is_active ?? true;
|
form.is_active = props.user.is_active ?? true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
@@ -123,15 +109,14 @@ const onSubmit = async () => {
|
|||||||
await updateUser(props.user.id, {
|
await updateUser(props.user.id, {
|
||||||
is_active: form.is_active,
|
is_active: form.is_active,
|
||||||
full_name: form.full_name,
|
full_name: form.full_name,
|
||||||
department: form.department,
|
clinical_department: form.clinical_department,
|
||||||
password: form.password || undefined,
|
|
||||||
});
|
});
|
||||||
ElMessage.success(TEXT.modules.adminUsers.updateSuccess);
|
ElMessage.success(TEXT.modules.adminUsers.updateSuccess);
|
||||||
} else {
|
} else {
|
||||||
await createUser({
|
await createUser({
|
||||||
email: form.email,
|
email: form.email,
|
||||||
full_name: form.full_name,
|
full_name: form.full_name,
|
||||||
department: form.department,
|
clinical_department: form.clinical_department,
|
||||||
role: defaultRole,
|
role: defaultRole,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readResetForm = () => readFileSync(resolve(__dirname, "./UserResetPassword.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("Admin reset password autofill", () => {
|
||||||
|
it("disables browser autofill for confirm username and manual password", () => {
|
||||||
|
const source = readResetForm();
|
||||||
|
|
||||||
|
expect(source).toContain('autocomplete="off" name="ctms-reset-confirm-username"');
|
||||||
|
expect(source).toContain('autocomplete="new-password"');
|
||||||
|
expect(source).toContain('name="ctms-reset-temp-password"');
|
||||||
|
expect(source).toContain('name="ctms-reset-manual-password"');
|
||||||
|
expect(source).toContain(":prop=\"form.mode === 'auto' ? 'tempPassword' : 'manualPassword'\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="info">{{ TEXT.modules.adminUsers.resetTarget }}<strong>{{ user?.username }}</strong></div>
|
<div class="info">{{ TEXT.modules.adminUsers.resetTarget }}<strong>{{ user?.username }}</strong></div>
|
||||||
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
|
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
|
||||||
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
|
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
|
||||||
<el-input v-model="form.confirmUsername" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
|
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
|
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
|
||||||
<el-radio-group v-model="form.mode">
|
<el-radio-group v-model="form.mode">
|
||||||
@@ -18,12 +18,17 @@
|
|||||||
<el-radio label="manual">{{ TEXT.modules.adminUsers.passwordManual }}</el-radio>
|
<el-radio label="manual">{{ TEXT.modules.adminUsers.passwordManual }}</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword" prop="passwordValue">
|
<el-form-item
|
||||||
|
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
|
||||||
|
:prop="form.mode === 'auto' ? 'tempPassword' : 'manualPassword'"
|
||||||
|
>
|
||||||
<el-input
|
<el-input
|
||||||
v-if="form.mode === 'auto'"
|
v-if="form.mode === 'auto'"
|
||||||
v-model="form.tempPassword"
|
v-model="form.tempPassword"
|
||||||
type="password"
|
type="password"
|
||||||
show-password
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="ctms-reset-temp-password"
|
||||||
readonly
|
readonly
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -35,6 +40,8 @@
|
|||||||
v-model="form.manualPassword"
|
v-model="form.manualPassword"
|
||||||
type="password"
|
type="password"
|
||||||
show-password
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="ctms-reset-manual-password"
|
||||||
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
|
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
|
||||||
/>
|
/>
|
||||||
<div class="hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
|
<div class="hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
|
||||||
@@ -81,7 +88,7 @@ const form = reactive({
|
|||||||
const rules = reactive<FormRules>({
|
const rules = reactive<FormRules>({
|
||||||
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
|
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
|
||||||
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
|
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
|
||||||
passwordValue: [
|
tempPassword: [
|
||||||
{
|
{
|
||||||
validator: (_rule, _value, callback) => {
|
validator: (_rule, _value, callback) => {
|
||||||
if (form.mode === "auto" && !form.tempPassword) {
|
if (form.mode === "auto" && !form.tempPassword) {
|
||||||
@@ -97,6 +104,18 @@ const rules = reactive<FormRules>({
|
|||||||
trigger: "blur",
|
trigger: "blur",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
manualPassword: [
|
||||||
|
{
|
||||||
|
validator: (_rule, _value, callback) => {
|
||||||
|
if (form.mode === "manual" && !form.manualPassword) {
|
||||||
|
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const generatePassword = () => {
|
const generatePassword = () => {
|
||||||
@@ -110,6 +129,10 @@ const generatePassword = () => {
|
|||||||
|
|
||||||
const regenerate = () => generatePassword();
|
const regenerate = () => generatePassword();
|
||||||
|
|
||||||
|
const clearPasswordValidation = () => {
|
||||||
|
formRef.value?.clearValidate(["tempPassword", "manualPassword"]);
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(val) => {
|
(val) => {
|
||||||
@@ -118,10 +141,18 @@ watch(
|
|||||||
form.mode = "auto";
|
form.mode = "auto";
|
||||||
generatePassword();
|
generatePassword();
|
||||||
form.manualPassword = "";
|
form.manualPassword = "";
|
||||||
|
clearPasswordValidation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.mode,
|
||||||
|
() => {
|
||||||
|
clearPasswordValidation();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
|
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readUsersView = () => readFileSync(resolve(__dirname, "./Users.vue"), "utf8");
|
||||||
|
const readUserForm = () => readFileSync(resolve(__dirname, "./UserForm.vue"), "utf8");
|
||||||
|
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
||||||
|
|
||||||
|
describe("Admin users table labels", () => {
|
||||||
|
it("shows clinical_department as 科室 in the account management table", () => {
|
||||||
|
const viewSource = readUsersView();
|
||||||
|
const localeSource = readLocale();
|
||||||
|
|
||||||
|
expect(viewSource).toContain(':label="TEXT.modules.adminUsers.clinicalDepartmentLabel"');
|
||||||
|
expect(viewSource).not.toContain(':label="TEXT.common.fields.clinicalDepartment"');
|
||||||
|
expect(localeSource).toContain('clinicalDepartmentLabel: "科室"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Admin user form autocomplete", () => {
|
||||||
|
it("prevents browser credential autofill when creating a user", () => {
|
||||||
|
const formSource = readUserForm();
|
||||||
|
|
||||||
|
expect(formSource).toContain('autocomplete="off"');
|
||||||
|
expect(formSource).not.toContain("current_password");
|
||||||
|
expect(formSource).not.toContain("confirmPassword");
|
||||||
|
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||||
|
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Admin user edit form defaults", () => {
|
||||||
|
it("hydrates the selected user immediately when the dialog is mounted open", () => {
|
||||||
|
const formSource = readUserForm();
|
||||||
|
|
||||||
|
expect(formSource).toContain("immediate: true");
|
||||||
|
expect(formSource).not.toContain("current_password");
|
||||||
|
expect(formSource).not.toContain("confirmPassword");
|
||||||
|
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||||
|
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<el-table :data="users" stripe v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
<el-table :data="users" stripe v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
||||||
<el-table-column prop="email" :label="TEXT.common.fields.email" show-overflow-tooltip />
|
<el-table-column prop="email" :label="TEXT.common.fields.email" show-overflow-tooltip />
|
||||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" show-overflow-tooltip />
|
<el-table-column prop="full_name" :label="TEXT.common.fields.name" show-overflow-tooltip />
|
||||||
<el-table-column prop="department" :label="TEXT.common.fields.department" show-overflow-tooltip />
|
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||||
<el-table-column :label="TEXT.common.fields.status">
|
<el-table-column :label="TEXT.common.fields.status">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="statusType(scope.row.status)">
|
<el-tag :type="statusType(scope.row.status)">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readWorkbenchView = () => readFileSync(resolve(__dirname, "./MyWorkbench.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("workbench project role context", () => {
|
||||||
|
it("uses the current project role instead of only the global account role", () => {
|
||||||
|
const source = readWorkbenchView();
|
||||||
|
|
||||||
|
expect(source).toContain("const projectRole = computed");
|
||||||
|
expect(source).toContain("isSystemAdmin");
|
||||||
|
expect(source).toContain("getProjectRole");
|
||||||
|
expect(source).toContain("study.currentStudyRole");
|
||||||
|
expect(source).not.toContain('const role = computed(() => auth.user?.role || "")');
|
||||||
|
expect(source).not.toContain("role_in_study || auth.user?.role");
|
||||||
|
expect(source).toContain('projectRole.value === "PM"');
|
||||||
|
expect(source).toContain('projectRole.value === "CRA"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -127,6 +127,7 @@ import { listNotifications } from "../../api/notifications";
|
|||||||
import { Right, Timer, Warning, Money, Collection, RefreshRight, ArrowRight } from "@element-plus/icons-vue";
|
import { Right, Timer, Warning, Money, Collection, RefreshRight, ArrowRight } from "@element-plus/icons-vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { displayEnum } from "../../utils/display";
|
import { displayEnum } from "../../utils/display";
|
||||||
|
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
import type { NotificationItem } from "../../types/notifications";
|
import type { NotificationItem } from "../../types/notifications";
|
||||||
import type { VisitLostItem } from "../../types/visits";
|
import type { VisitLostItem } from "../../types/visits";
|
||||||
@@ -154,23 +155,26 @@ const centerSummaryData = ref<any[]>([]);
|
|||||||
|
|
||||||
const currentStudyId = computed(() => study.currentStudy?.id || "");
|
const currentStudyId = computed(() => study.currentStudy?.id || "");
|
||||||
const todayStr = computed(() => new Date().toISOString().slice(0, 10));
|
const todayStr = computed(() => new Date().toISOString().slice(0, 10));
|
||||||
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, auth.user?.role));
|
const projectRole = computed(() => {
|
||||||
|
if (isSystemAdmin(auth.user)) return "ADMIN";
|
||||||
|
return getProjectRole(study.currentStudy, study.currentStudyRole) || "";
|
||||||
|
});
|
||||||
|
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.value));
|
||||||
const emptyText = TEXT.modules.workbench.quickEmpty;
|
const emptyText = TEXT.modules.workbench.quickEmpty;
|
||||||
const role = computed(() => auth.user?.role || "");
|
|
||||||
const quickActions = computed(() => {
|
const quickActions = computed(() => {
|
||||||
if (!currentStudyId.value) return [];
|
if (!currentStudyId.value) return [];
|
||||||
if (role.value === "CRA")
|
if (projectRole.value === "CRA")
|
||||||
return [
|
return [
|
||||||
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
|
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
|
||||||
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
|
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
|
||||||
];
|
];
|
||||||
if (role.value === "PM")
|
if (projectRole.value === "PM")
|
||||||
return [
|
return [
|
||||||
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
|
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
|
||||||
{ label: TEXT.modules.workbench.contractList, path: "/finance/contracts", icon: Money },
|
{ label: TEXT.modules.workbench.contractList, path: "/finance/contracts", icon: Money },
|
||||||
{ label: TEXT.modules.workbench.medicalConsult, path: "/knowledge/medical-consult", icon: Collection },
|
{ label: TEXT.modules.workbench.medicalConsult, path: "/knowledge/medical-consult", icon: Collection },
|
||||||
];
|
];
|
||||||
if (role.value === "PV") return [{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning }];
|
if (projectRole.value === "PV") return [{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning }];
|
||||||
return [{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer }];
|
return [{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer }];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,13 +237,13 @@ const buildSections = (aes: any[], overdueAes: any[], lost: VisitLostItem[]) =>
|
|||||||
const openAes = aes.filter((a) => a.status !== "CLOSED");
|
const openAes = aes.filter((a) => a.status !== "CLOSED");
|
||||||
const lostVisitItems = lost.map(toLostVisitItem);
|
const lostVisitItems = lost.map(toLostVisitItem);
|
||||||
|
|
||||||
if (role.value === "CRA") {
|
if (projectRole.value === "CRA") {
|
||||||
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
||||||
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
||||||
} else if (role.value === "PM" || role.value === "ADMIN") {
|
} else if (projectRole.value === "PM" || projectRole.value === "ADMIN") {
|
||||||
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
||||||
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
||||||
} else if (role.value === "PV") {
|
} else if (projectRole.value === "PV") {
|
||||||
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
|
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
|
||||||
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
|
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||||
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
||||||
|
|||||||
Reference in New Issue
Block a user