feat: refine subject visits and project workflows

Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support.

Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
This commit is contained in:
Cheng Zhou
2026-05-09 17:10:34 +08:00
parent 74feca4467
commit 917ab7ccf1
41 changed files with 3463 additions and 701 deletions
@@ -0,0 +1,59 @@
"""add template fields to monitoring_visit_issues
Revision ID: 20260509_01
Revises: 20260508_03
Create Date: 2026-05-09 13:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260509_01"
down_revision: Union[str, None] = "20260508_03"
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("monitoring_visit_issues")}
if "severity" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("severity", sa.String(length=64), nullable=True))
if "mark" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("mark", sa.String(length=100), nullable=True))
if "visit_cycle" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("visit_cycle", sa.String(length=100), nullable=True))
if "center_query" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("center_query", sa.Text(), nullable=True))
if "center_latest_reply" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("center_latest_reply", sa.Text(), nullable=True))
if "rectification_completed" not in columns:
op.add_column(
"monitoring_visit_issues",
sa.Column("rectification_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
if "rectification_completed" in columns:
op.drop_column("monitoring_visit_issues", "rectification_completed")
if "center_latest_reply" in columns:
op.drop_column("monitoring_visit_issues", "center_latest_reply")
if "center_query" in columns:
op.drop_column("monitoring_visit_issues", "center_query")
if "visit_cycle" in columns:
op.drop_column("monitoring_visit_issues", "visit_cycle")
if "mark" in columns:
op.drop_column("monitoring_visit_issues", "mark")
if "severity" in columns:
op.drop_column("monitoring_visit_issues", "severity")
@@ -0,0 +1,55 @@
"""add site_id to monitoring_visit_issues
Revision ID: 20260509_02
Revises: 20260509_01
Create Date: 2026-05-09 14:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260509_02"
down_revision: Union[str, None] = "20260509_01"
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("monitoring_visit_issues")}
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
if "site_id" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True))
if "fk_monitoring_visit_issues_site_id" not in fks:
op.create_foreign_key(
"fk_monitoring_visit_issues_site_id",
"monitoring_visit_issues",
"sites",
["site_id"],
["id"],
)
if "ix_monitoring_visit_issues_site_id" not in indexes:
op.create_index("ix_monitoring_visit_issues_site_id", "monitoring_visit_issues", ["site_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
if "ix_monitoring_visit_issues_site_id" in indexes:
op.drop_index("ix_monitoring_visit_issues_site_id", table_name="monitoring_visit_issues")
if "fk_monitoring_visit_issues_site_id" in fks:
op.drop_constraint("fk_monitoring_visit_issues_site_id", "monitoring_visit_issues", type_="foreignkey")
if "site_id" in columns:
op.drop_column("monitoring_visit_issues", "site_id")
@@ -0,0 +1,34 @@
"""add subject actual medication count
Revision ID: 20260509_03
Revises: 20260509_02
Create Date: 2026-05-09 14:58:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260509_03"
down_revision: Union[str, None] = "20260509_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("subjects")}
if "actual_medication_count" not in columns:
op.add_column("subjects", sa.Column("actual_medication_count", sa.Integer(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("subjects")}
if "actual_medication_count" in columns:
op.drop_column("subjects", "actual_medication_count")
+135 -10
View File
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import monitoring_visit_issue as issue_crud from app.crud import monitoring_visit_issue as issue_crud
from app.crud import site as site_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.monitoring_visit_issue import ( from app.schemas.monitoring_visit_issue import (
MonitoringVisitIssueCreate, MonitoringVisitIssueCreate,
@@ -25,9 +26,13 @@ from app.schemas.monitoring_visit_issue import (
router = APIRouter() router = APIRouter()
_HEADER_ALIASES: dict[str, list[str]] = { _HEADER_ALIASES: dict[str, list[str]] = {
"site_name": ["项目/中心", "中心", "中心名称", "site_name", "site"],
"issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"], "issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"],
"open_duration_text": ["开放时长", "open_duration"], "open_duration_text": ["开放时长", "open_duration"],
"category": ["问题分类", "category"], "category": ["问题分类", "category"],
"severity": ["严重程度", "severity"],
"mark": ["标记", "mark"],
"visit_cycle": ["访视周期", "访视", "visit_cycle"],
"subject_code": ["受试者", "受试者编号", "subject_code"], "subject_code": ["受试者", "受试者编号", "subject_code"],
"subject_name": ["受试者姓名", "subject_name"], "subject_name": ["受试者姓名", "subject_name"],
"monitor_item": ["监查项", "monitor_item"], "monitor_item": ["监查项", "monitor_item"],
@@ -40,6 +45,9 @@ _HEADER_ALIASES: dict[str, list[str]] = {
"description": ["问题描述", "description"], "description": ["问题描述", "description"],
"action_taken": ["采取措施", "action_taken"], "action_taken": ["采取措施", "action_taken"],
"follow_up_progress": ["跟进计划及进展", "follow_up_progress"], "follow_up_progress": ["跟进计划及进展", "follow_up_progress"],
"center_query": ["中心质疑", "center_query"],
"center_latest_reply": ["中心最新回复", "中心回复", "center_latest_reply"],
"rectification_completed": ["是否完成整改", "完成整改", "rectification_completed"],
"found_date": ["发现时间", "found_date"], "found_date": ["发现时间", "found_date"],
"due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"], "due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"],
"actual_resolve_date": ["实际解决日期", "actual_resolve_date"], "actual_resolve_date": ["实际解决日期", "actual_resolve_date"],
@@ -48,17 +56,24 @@ _HEADER_ALIASES: dict[str, list[str]] = {
} }
_AUDIT_FIELDS: tuple[str, ...] = ( _AUDIT_FIELDS: tuple[str, ...] = (
"site_id",
"issue_no", "issue_no",
"source", "source",
"monitor_type", "monitor_type",
"monitor_item", "monitor_item",
"category", "category",
"severity",
"mark",
"visit_cycle",
"recommendation", "recommendation",
"subject_name", "subject_name",
"subject_code", "subject_code",
"description", "description",
"action_taken", "action_taken",
"follow_up_progress", "follow_up_progress",
"center_query",
"center_latest_reply",
"rectification_completed",
"found_date", "found_date",
"due_at", "due_at",
"actual_resolve_date", "actual_resolve_date",
@@ -75,6 +90,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
async def _ensure_site_belongs_to_study(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None) -> None:
if site_id is None:
return
site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于当前项目")
def _normalize_status(value: str | None) -> str: def _normalize_status(value: str | None) -> str:
text = (value or "").strip().upper() text = (value or "").strip().upper()
zh = { zh = {
@@ -91,6 +114,21 @@ def _normalize_status(value: str | None) -> str:
return "OPEN" return "OPEN"
def _parse_bool(value: object | None) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if not text:
return None
if text in {"1", "true", "yes", "y", "", "已完成", "完成"}:
return True
if text in {"0", "false", "no", "n", "", "未完成"}:
return False
return None
def _pick(row: dict[str, object], key: str) -> str | None: def _pick(row: dict[str, object], key: str) -> str | None:
for alias in _HEADER_ALIASES[key]: for alias in _HEADER_ALIASES[key]:
if alias not in row: if alias not in row:
@@ -167,9 +205,13 @@ def _to_read(item) -> MonitoringVisitIssueRead:
return MonitoringVisitIssueRead( return MonitoringVisitIssueRead(
id=item.id, id=item.id,
study_id=item.study_id, study_id=item.study_id,
site_id=item.site_id,
issue_no=item.issue_no, issue_no=item.issue_no,
open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text), open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text),
category=item.category, category=item.category,
severity=item.severity,
mark=item.mark,
visit_cycle=item.visit_cycle,
subject_code=item.subject_code, subject_code=item.subject_code,
monitor_item=item.monitor_item, monitor_item=item.monitor_item,
monitor_type=item.monitor_type, monitor_type=item.monitor_type,
@@ -183,6 +225,9 @@ def _to_read(item) -> MonitoringVisitIssueRead:
description=item.description, description=item.description,
action_taken=item.action_taken, action_taken=item.action_taken,
follow_up_progress=item.follow_up_progress, follow_up_progress=item.follow_up_progress,
center_query=item.center_query,
center_latest_reply=item.center_latest_reply,
rectification_completed=bool(item.rectification_completed),
found_date=item.found_date, found_date=item.found_date,
overdue=overdue, overdue=overdue,
due_at=item.due_at, due_at=item.due_at,
@@ -206,6 +251,8 @@ def _format_datetime(value: datetime | None) -> str:
def _to_audit_value(value: object | None): def _to_audit_value(value: object | None):
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, datetime): if isinstance(value, datetime):
parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc) parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return parsed.isoformat() return parsed.isoformat()
@@ -289,22 +336,41 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object
) )
async def list_monitoring_visit_issues( async def list_monitoring_visit_issues(
study_id: uuid.UUID, study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
category: str | None = None, category: str | None = None,
severity: str | None = None,
mark: str | None = None,
visit_cycle: str | None = None,
status_value: str | None = Query(default=None, alias="status"), status_value: str | None = Query(default=None, alias="status"),
overdue: bool | None = None, overdue: bool | None = None,
rectification_completed: bool | None = None,
due_from: date | None = None,
due_to: date | None = None,
created_from: date | None = None,
created_to: date | None = None,
keyword: str | None = None, keyword: str | None = None,
skip: int = 0, skip: int = 0,
limit: int = 500, limit: int = 500,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> list[MonitoringVisitIssueRead]: ) -> list[MonitoringVisitIssueRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_belongs_to_study(db, study_id, site_id)
normalized_status = _normalize_status(status_value) if status_value else None normalized_status = _normalize_status(status_value) if status_value else None
items = await issue_crud.list_issues( items = await issue_crud.list_issues(
db, db,
study_id, study_id,
site_id=site_id,
category=(category or None), category=(category or None),
severity=(severity or None),
mark=(mark or None),
visit_cycle=(visit_cycle or None),
status=normalized_status, status=normalized_status,
overdue=overdue, overdue=overdue,
rectification_completed=rectification_completed,
due_from=due_from,
due_to=due_to,
created_from=created_from,
created_to=created_to,
keyword=(keyword or None), keyword=(keyword or None),
skip=skip, skip=skip,
limit=min(limit, 2000), limit=min(limit, 2000),
@@ -325,6 +391,7 @@ async def create_monitoring_visit_issue(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> MonitoringVisitIssueRead: ) -> MonitoringVisitIssueRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
if payload.issue_no: if payload.issue_no:
existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no) existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no)
@@ -358,20 +425,39 @@ async def create_monitoring_visit_issue(
) )
async def export_monitoring_visit_issues( async def export_monitoring_visit_issues(
study_id: uuid.UUID, study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
category: str | None = None, category: str | None = None,
severity: str | None = None,
mark: str | None = None,
visit_cycle: str | None = None,
status_value: str | None = Query(default=None, alias="status"), status_value: str | None = Query(default=None, alias="status"),
overdue: bool | None = None, overdue: bool | None = None,
rectification_completed: bool | None = None,
due_from: date | None = None,
due_to: date | None = None,
created_from: date | None = None,
created_to: date | None = None,
keyword: str | None = None, keyword: str | None = None,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> StreamingResponse: ) -> StreamingResponse:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_belongs_to_study(db, study_id, site_id)
normalized_status = _normalize_status(status_value) if status_value else None normalized_status = _normalize_status(status_value) if status_value else None
items = await issue_crud.list_issues( items = await issue_crud.list_issues(
db, db,
study_id, study_id,
site_id=site_id,
category=(category or None), category=(category or None),
severity=(severity or None),
mark=(mark or None),
visit_cycle=(visit_cycle or None),
status=normalized_status, status=normalized_status,
overdue=overdue, overdue=overdue,
rectification_completed=rectification_completed,
due_from=due_from,
due_to=due_to,
created_from=created_from,
created_to=created_to,
keyword=(keyword or None), keyword=(keyword or None),
skip=0, skip=0,
limit=50000, limit=50000,
@@ -381,15 +467,23 @@ async def export_monitoring_visit_issues(
ws = wb.active ws = wb.active
ws.title = "监查访视问题" ws.title = "监查访视问题"
headers = [ headers = [
"项目/中心",
"问题编号", "问题编号",
"受试者筛选号",
"访视周期",
"问题分类",
"严重程度",
"建议措施",
"问题描述",
"中心质疑",
"中心最新回复",
"是否完成整改",
"标记",
"状态",
"是否超期",
"问题来源", "问题来源",
"监查类型", "监查类型",
"监查项", "监查项",
"问题分类",
"建议措施",
"受试者",
"受试者缩写号",
"问题描述",
"采取措施", "采取措施",
"跟进计划及进展", "跟进计划及进展",
"发现时间", "发现时间",
@@ -403,20 +497,31 @@ async def export_monitoring_visit_issues(
"创建时间", "创建时间",
] ]
ws.append(headers) ws.append(headers)
site_ids = {item.site_id for item in items if item.site_id}
site_map = await site_crud.get_sites_by_ids(db, site_ids)
for item in items: for item in items:
view = _to_read(item) view = _to_read(item)
site_name = site_map.get(view.site_id).name if view.site_id in site_map else ""
ws.append( ws.append(
[ [
site_name,
view.issue_no or "", view.issue_no or "",
view.subject_code or "",
view.visit_cycle or "",
view.category or "",
view.severity or "",
view.recommendation or "",
view.description or "",
view.center_query or "",
view.center_latest_reply or "",
"" if view.rectification_completed else "",
view.mark or "",
view.progress or "",
"" if view.overdue else "",
view.source or "", view.source or "",
view.monitor_type or "", view.monitor_type or "",
view.monitor_item or "", view.monitor_item or "",
view.category or "",
view.recommendation or "",
view.subject_name or "",
view.subject_code or "",
view.description or "",
view.action_taken or "", view.action_taken or "",
view.follow_up_progress or "", view.follow_up_progress or "",
_format_date(view.found_date), _format_date(view.found_date),
@@ -486,6 +591,8 @@ async def update_monitoring_visit_issue(
if not payload.model_fields_set: if not payload.model_fields_set:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段")
if "site_id" in payload.model_fields_set:
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
before_snapshot = _issue_audit_snapshot(item) before_snapshot = _issue_audit_snapshot(item)
try: try:
@@ -568,6 +675,8 @@ async def import_monitoring_visit_issues(
created_count = 0 created_count = 0
updated_count = 0 updated_count = 0
skipped_rows: list[str] = [] skipped_rows: list[str] = []
sites = await site_crud.list_by_study(db, study_id, limit=5000, include_inactive=True)
site_by_name = {site.name.strip().lower(): site for site in sites if site.name}
for idx, row in enumerate(rows, start=2): for idx, row in enumerate(rows, start=2):
issue_no = _pick(row, "issue_no") issue_no = _pick(row, "issue_no")
@@ -576,6 +685,15 @@ async def import_monitoring_visit_issues(
skipped_rows.append(f"{idx}行:问题编号或问题分类为空") skipped_rows.append(f"{idx}行:问题编号或问题分类为空")
continue continue
site_id = None
site_name = _pick(row, "site_name")
if site_name:
site = site_by_name.get(site_name.strip().lower())
if not site:
skipped_rows.append(f"{idx}行:中心「{site_name}」不存在")
continue
site_id = site.id
status_text = _normalize_status(_pick(row, "status")) status_text = _normalize_status(_pick(row, "status"))
creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None) creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None)
created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at")) created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at"))
@@ -586,7 +704,11 @@ async def import_monitoring_visit_issues(
try: try:
payload = MonitoringVisitIssueCreate( payload = MonitoringVisitIssueCreate(
issue_no=issue_no, issue_no=issue_no,
site_id=site_id,
category=category, category=category,
severity=_pick(row, "severity"),
mark=_pick(row, "mark"),
visit_cycle=_pick(row, "visit_cycle"),
subject_code=_pick(row, "subject_code"), subject_code=_pick(row, "subject_code"),
subject_name=_pick(row, "subject_name"), subject_name=_pick(row, "subject_name"),
monitor_item=_pick(row, "monitor_item"), monitor_item=_pick(row, "monitor_item"),
@@ -598,6 +720,9 @@ async def import_monitoring_visit_issues(
description=_pick(row, "description"), description=_pick(row, "description"),
action_taken=_pick(row, "action_taken"), action_taken=_pick(row, "action_taken"),
follow_up_progress=_pick(row, "follow_up_progress"), follow_up_progress=_pick(row, "follow_up_progress"),
center_query=_pick(row, "center_query"),
center_latest_reply=_pick(row, "center_latest_reply"),
rectification_completed=_parse_bool(_pick(row, "rectification_completed")) or False,
found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")), found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")),
due_at=due_at, due_at=due_at,
actual_resolve_date=_parse_date( actual_resolve_date=_parse_date(
+157 -28
View File
@@ -263,6 +263,13 @@ def _to_date_text(value: date | None) -> str:
return value.isoformat() return value.isoformat()
def _project_snapshot_has_draft_values(snapshot: ProjectPublishSnapshot | None) -> bool:
if not snapshot:
return False
data = snapshot.model_dump(mode="json")
return any(value not in ("", None, []) for value in data.values())
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
return ProjectPublishSnapshot( return ProjectPublishSnapshot(
code=getattr(study, "code", None) or "", code=getattr(study, "code", None) or "",
@@ -288,6 +295,61 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
) )
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
if _project_snapshot_has_draft_values(setup_data.projectInfo):
return setup_data.projectInfo
return _build_project_publish_snapshot(study)
def _parse_optional_snapshot_date(value: str) -> date | None:
text = (value or "").strip()
if not text:
return None
return date.fromisoformat(text)
def _normalize_optional_snapshot_text(value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]:
return [item.model_dump(mode="json") for item in snapshot.visit_schedule]
def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool:
next_values = {
"code": snapshot.code.strip(),
"name": snapshot.name.strip(),
"project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name),
"sponsor": _normalize_optional_snapshot_text(snapshot.sponsor),
"protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no),
"lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit),
"principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator),
"main_pm": _normalize_optional_snapshot_text(snapshot.main_pm),
"research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis),
"research_product": _normalize_optional_snapshot_text(snapshot.research_product),
"control_product": _normalize_optional_snapshot_text(snapshot.control_product),
"indication": _normalize_optional_snapshot_text(snapshot.indication),
"research_population": _normalize_optional_snapshot_text(snapshot.research_population),
"research_design": _normalize_optional_snapshot_text(snapshot.research_design),
"plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date),
"plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date),
"planned_site_count": snapshot.planned_site_count,
"planned_enrollment_count": snapshot.planned_enrollment_count,
"status": snapshot.status or "DRAFT",
"visit_schedule": _snapshot_visit_schedule(snapshot),
}
changed = False
for field, value in next_values.items():
if getattr(study, field, None) != value:
setattr(study, field, value)
changed = True
return changed
def _to_setup_config_read( def _to_setup_config_read(
record, record,
*, *,
@@ -360,6 +422,63 @@ def _ensure_study_timeline_valid(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
async def _validate_setup_project_snapshot(
db: AsyncSession,
*,
study_id: uuid.UUID,
snapshot: ProjectPublishSnapshot,
strict_required: bool = True,
) -> None:
errors: list[dict[str, str]] = []
code = snapshot.code.strip()
name = snapshot.name.strip()
if strict_required and not code:
errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"})
if strict_required and not name:
errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"})
try:
plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date)
plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date)
except ValueError:
errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"})
plan_start = None
plan_end = None
if plan_start and plan_end and plan_start > plan_end:
errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"})
seen_visit_codes: set[str] = set()
for index, item in enumerate(snapshot.visit_schedule):
row_prefix = f"projectInfo.visit_schedule[{index}]"
visit_code = item.visit_code.strip()
has_visit_values = bool(visit_code) or any(
value != 0
for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days)
)
if strict_required and not visit_code:
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"})
if not has_visit_values:
continue
if visit_code:
if len(visit_code) > 50:
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"})
if visit_code in seen_visit_codes:
errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"})
seen_visit_codes.add(visit_code)
if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650:
errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"})
if item.window_before_days < 0 or item.window_before_days > 365:
errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"})
if item.window_after_days < 0 or item.window_after_days > 365:
errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"})
if errors:
_raise_validation_error(errors)
if code:
existing = await study_crud.get_by_code(db, code)
if existing and existing.id != study_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
def _resolve_version_label(record: Any) -> str: def _resolve_version_label(record: Any) -> str:
branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main" branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main"
display_version = int(getattr(record, "display_version", 0) or 0) display_version = int(getattr(record, "display_version", 0) or 0)
@@ -897,18 +1016,23 @@ async def upsert_study_setup_config(
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites} site_lookup = {str(site.id): site.name or "" for site in sites}
setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study)
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False)
setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date)
setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date)
_validate_setup_data( _validate_setup_data(
payload.data, payload.data,
site_lookup, site_lookup,
project_plan_start=getattr(study, "plan_start_date", None), project_plan_start=setup_plan_start,
project_plan_end=getattr(study, "plan_end_date", None), project_plan_end=setup_plan_end,
strict_required=False,
) )
existing = await study_setup_config_crud.get_by_study(db, study_id) existing = await study_setup_config_crud.get_by_study(db, study_id)
old_config = dict(existing.config or {}) if existing else {} old_config = dict(existing.config or {}) if existing else {}
force_draft = bool(payload.force_draft) force_draft = bool(payload.force_draft)
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot: if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json") current_project_snapshot = setup_project_snapshot.model_dump(mode="json")
if current_project_snapshot != dict(existing.published_project_snapshot or {}): if current_project_snapshot != dict(existing.published_project_snapshot or {}):
force_draft = True force_draft = True
record, conflict = await study_setup_config_crud.upsert( record, conflict = await study_setup_config_crud.upsert(
@@ -971,7 +1095,12 @@ async def publish_study_setup_config(
record = await study_setup_config_crud.get_by_study(db, study_id) record = await study_setup_config_crud.get_by_study(db, study_id)
if not record: if not record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
current_project_snapshot_for_publish = _build_project_publish_snapshot(study).model_dump(mode="json") setup_data = StudySetupConfigData.model_validate(record.config or {})
project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study)
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish)
setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date)
setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date)
current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json")
force_create_snapshot = bool( force_create_snapshot = bool(
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish (record.published_project_snapshot or {}) != current_project_snapshot_for_publish
) )
@@ -979,10 +1108,10 @@ async def publish_study_setup_config(
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites} site_lookup = {str(site.id): site.name or "" for site in sites}
_validate_setup_data( _validate_setup_data(
StudySetupConfigData.model_validate(record.config or {}), setup_data,
site_lookup, site_lookup,
project_plan_start=getattr(study, "plan_start_date", None), project_plan_start=setup_plan_start,
project_plan_end=getattr(study, "plan_end_date", None), project_plan_end=setup_plan_end,
) )
published, conflict = await study_setup_config_crud.publish( published, conflict = await study_setup_config_crud.publish(
@@ -1001,6 +1130,7 @@ async def publish_study_setup_config(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败")
try: try:
_apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish)
projection = await apply_setup_projection_on_publish( projection = await apply_setup_projection_on_publish(
db, db,
study_id=study_id, study_id=study_id,
@@ -1017,16 +1147,14 @@ async def publish_study_setup_config(
for item in projection.skipped_items for item in projection.skipped_items
], ],
) )
study_after_publish = await study_crud.get(db, study_id) project_snapshot = project_snapshot_for_publish.model_dump(mode="json")
if study_after_publish: published.published_project_snapshot = project_snapshot
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") await study_setup_config_crud.set_version_project_snapshot(
published.published_project_snapshot = project_snapshot db,
await study_setup_config_crud.set_version_project_snapshot( study_id,
db, version=published.version,
study_id, project_snapshot=project_snapshot,
version=published.version, )
project_snapshot=project_snapshot,
)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
@@ -1362,10 +1490,13 @@ async def merge_study_setup_config_to_main(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
try: try:
merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {})
merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study)
_apply_project_publish_snapshot_to_study(study, merged_project_snapshot)
projection = await apply_setup_projection_on_publish( projection = await apply_setup_projection_on_publish(
db, db,
study_id=study_id, study_id=study_id,
setup_data=StudySetupConfigData.model_validate(merged.published_config or {}), setup_data=merged_setup_data,
operator_id=current_user.id, operator_id=current_user.id,
) )
projection_summary = SetupProjectionSummary( projection_summary = SetupProjectionSummary(
@@ -1375,16 +1506,14 @@ async def merge_study_setup_config_to_main(
warnings=projection.warnings, warnings=projection.warnings,
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items], skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
) )
study_after_publish = await study_crud.get(db, study_id) project_snapshot = merged_project_snapshot.model_dump(mode="json")
if study_after_publish: merged.published_project_snapshot = project_snapshot
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") await study_setup_config_crud.set_version_project_snapshot(
merged.published_project_snapshot = project_snapshot db,
await study_setup_config_crud.set_version_project_snapshot( study_id,
db, version=merged.version,
study_id, project_snapshot=project_snapshot,
version=merged.version, )
project_snapshot=project_snapshot,
)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
+56 -1
View File
@@ -9,7 +9,8 @@ from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import visit as visit_crud from app.crud import visit as visit_crud
from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate from app.schemas.subject import SubjectUpdate
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
router = APIRouter() router = APIRouter()
@@ -110,6 +111,60 @@ async def create_visit(
return visit return visit
@router.post(
"/early-termination",
response_model=VisitRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def create_early_termination(
study_id: uuid.UUID,
subject_id: uuid.UUID,
termination_in: EarlyTerminationCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
reason = termination_in.reason.strip()
if not reason:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止原因不能为空")
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
try:
visit = await visit_crud.create_early_termination_visit(
db,
study_id=study_id,
subject=subject,
termination_date=termination_in.termination_date,
reason=reason,
notes=termination_in.notes,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await subject_crud.update_subject(
db,
subject,
SubjectUpdate(
status="DROPPED",
completion_date=termination_in.termination_date,
drop_reason=reason,
),
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit.id,
action="CREATE_EARLY_TERMINATION",
detail=f"参与者 {subject.subject_no} 已提前终止",
operator_id=current_user.id,
operator_role=current_user.role,
)
return visit
@router.patch( @router.patch(
"/{visit_id}", "/{visit_id}",
response_model=VisitRead, response_model=VisitRead,
+40 -1
View File
@@ -1,5 +1,5 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import date, datetime, time, timedelta, timezone
from typing import Sequence from typing import Sequence
from sqlalchemy import and_, or_, select from sqlalchemy import and_, or_, select
@@ -46,25 +46,55 @@ async def list_issues(
db: AsyncSession, db: AsyncSession,
study_id: uuid.UUID, study_id: uuid.UUID,
*, *,
site_id: uuid.UUID | None = None,
category: str | None = None, category: str | None = None,
severity: str | None = None,
mark: str | None = None,
visit_cycle: str | None = None,
status: str | None = None, status: str | None = None,
overdue: bool | None = None, overdue: bool | None = None,
rectification_completed: bool | None = None,
due_from: date | None = None,
due_to: date | None = None,
created_from: date | None = None,
created_to: date | None = None,
keyword: str | None = None, keyword: str | None = None,
skip: int = 0, skip: int = 0,
limit: int = 500, limit: int = 500,
) -> Sequence[MonitoringVisitIssue]: ) -> Sequence[MonitoringVisitIssue]:
stmt = select(MonitoringVisitIssue).where(MonitoringVisitIssue.study_id == study_id) stmt = select(MonitoringVisitIssue).where(MonitoringVisitIssue.study_id == study_id)
if site_id:
stmt = stmt.where(MonitoringVisitIssue.site_id == site_id)
if category: if category:
stmt = stmt.where(MonitoringVisitIssue.category == category) stmt = stmt.where(MonitoringVisitIssue.category == category)
if severity:
stmt = stmt.where(MonitoringVisitIssue.severity == severity)
if mark:
stmt = stmt.where(MonitoringVisitIssue.mark.ilike(f"%{mark}%"))
if visit_cycle:
stmt = stmt.where(MonitoringVisitIssue.visit_cycle == visit_cycle)
if status: if status:
stmt = stmt.where(MonitoringVisitIssue.status == status) stmt = stmt.where(MonitoringVisitIssue.status == status)
if rectification_completed is not None:
stmt = stmt.where(MonitoringVisitIssue.rectification_completed == rectification_completed)
if due_from:
stmt = stmt.where(MonitoringVisitIssue.due_at >= datetime.combine(due_from, time.min, tzinfo=timezone.utc))
if due_to:
stmt = stmt.where(MonitoringVisitIssue.due_at < datetime.combine(due_to + timedelta(days=1), time.min, tzinfo=timezone.utc))
if created_from:
stmt = stmt.where(MonitoringVisitIssue.created_at >= datetime.combine(created_from, time.min, tzinfo=timezone.utc))
if created_to:
stmt = stmt.where(MonitoringVisitIssue.created_at < datetime.combine(created_to + timedelta(days=1), time.min, tzinfo=timezone.utc))
if keyword: if keyword:
term = f"%{keyword}%" term = f"%{keyword}%"
stmt = stmt.where( stmt = stmt.where(
or_( or_(
MonitoringVisitIssue.issue_no.ilike(term), MonitoringVisitIssue.issue_no.ilike(term),
MonitoringVisitIssue.category.ilike(term), MonitoringVisitIssue.category.ilike(term),
MonitoringVisitIssue.severity.ilike(term),
MonitoringVisitIssue.mark.ilike(term),
MonitoringVisitIssue.visit_cycle.ilike(term),
MonitoringVisitIssue.subject_code.ilike(term), MonitoringVisitIssue.subject_code.ilike(term),
MonitoringVisitIssue.subject_name.ilike(term), MonitoringVisitIssue.subject_name.ilike(term),
MonitoringVisitIssue.monitor_item.ilike(term), MonitoringVisitIssue.monitor_item.ilike(term),
@@ -72,6 +102,8 @@ async def list_issues(
MonitoringVisitIssue.recommendation.ilike(term), MonitoringVisitIssue.recommendation.ilike(term),
MonitoringVisitIssue.action_taken.ilike(term), MonitoringVisitIssue.action_taken.ilike(term),
MonitoringVisitIssue.follow_up_progress.ilike(term), MonitoringVisitIssue.follow_up_progress.ilike(term),
MonitoringVisitIssue.center_query.ilike(term),
MonitoringVisitIssue.center_latest_reply.ilike(term),
MonitoringVisitIssue.responsible_name.ilike(term), MonitoringVisitIssue.responsible_name.ilike(term),
) )
) )
@@ -107,8 +139,12 @@ async def create_issue(
closed_at = datetime.now(timezone.utc) closed_at = datetime.now(timezone.utc)
item = MonitoringVisitIssue( item = MonitoringVisitIssue(
study_id=study_id, study_id=study_id,
site_id=issue_in.site_id,
issue_no=issue_no, issue_no=issue_no,
category=issue_in.category, category=issue_in.category,
severity=issue_in.severity,
mark=issue_in.mark,
visit_cycle=issue_in.visit_cycle,
subject_code=issue_in.subject_code, subject_code=issue_in.subject_code,
monitor_item=issue_in.monitor_item, monitor_item=issue_in.monitor_item,
monitor_type=issue_in.monitor_type, monitor_type=issue_in.monitor_type,
@@ -120,6 +156,9 @@ async def create_issue(
description=issue_in.description, description=issue_in.description,
action_taken=issue_in.action_taken, action_taken=issue_in.action_taken,
follow_up_progress=issue_in.follow_up_progress, follow_up_progress=issue_in.follow_up_progress,
center_query=issue_in.center_query,
center_latest_reply=issue_in.center_latest_reply,
rectification_completed=issue_in.rectification_completed,
found_date=issue_in.found_date, found_date=issue_in.found_date,
due_at=issue_in.due_at, due_at=issue_in.due_at,
actual_resolve_date=issue_in.actual_resolve_date, actual_resolve_date=issue_in.actual_resolve_date,
+6
View File
@@ -21,6 +21,7 @@ from app.models.finance_special import FinanceSpecial
from app.models.kickoff_meeting import KickoffMeeting from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote from app.models.knowledge_note import KnowledgeNote
from app.models.milestone import Milestone from app.models.milestone import Milestone
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.models.site import Site from app.models.site import Site
from app.models.special_expense import SpecialExpense from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics from app.models.startup_ethics import StartupEthics
@@ -328,6 +329,11 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id)) await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id)) await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id)) await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
await db.execute(
update(MonitoringVisitIssue)
.where(MonitoringVisitIssue.site_id == site_id)
.values(site_id=None)
)
await db.execute( await db.execute(
delete(TrainingAuthorization).where( delete(TrainingAuthorization).where(
+6
View File
@@ -37,6 +37,11 @@ def _validate_subject_date_chain(
raise ValueError("完成日期不能早于入组日期") raise ValueError("完成日期不能早于入组日期")
def _validate_actual_medication_count(value: int | None) -> None:
if value is not None and value < 0:
raise ValueError("实际用药次数不能小于0")
def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool: def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool:
return next_baseline_date is not None and previous_baseline_date != next_baseline_date return next_baseline_date is not None and previous_baseline_date != next_baseline_date
@@ -132,6 +137,7 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
enrollment_date=next_enrollment_date, enrollment_date=next_enrollment_date,
completion_date=next_completion_date, completion_date=next_completion_date,
) )
_validate_actual_medication_count(update_data.get("actual_medication_count", subject.actual_medication_count))
if update_data: if update_data:
await db.execute( await db.execute(
sa_update(Subject) sa_update(Subject)
+145
View File
@@ -1,4 +1,5 @@
import uuid import uuid
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
from typing import Sequence from typing import Sequence
@@ -11,6 +12,17 @@ from app.models.visit import Visit
from app.schemas.visit import VisitCreate, VisitUpdate from app.schemas.visit import VisitCreate, VisitUpdate
@dataclass(frozen=True)
class EarlyTerminationVisitChanges:
event_visit_code: str
event_actual_date: date
event_notes: str
visits_to_cancel: list[Visit]
NON_TREATMENT_VISIT_CODES = {"筛选访视", "基线访视", "提前终止"}
def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]: def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]:
order_map: dict[str, int] = {} order_map: dict[str, int] = {}
for index, item in enumerate(visit_schedule or []): for index, item in enumerate(visit_schedule or []):
@@ -23,6 +35,34 @@ def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, in
def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]: def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]:
order_map = _visit_schedule_order_map(visit_schedule) order_map = _visit_schedule_order_map(visit_schedule)
fallback_start = len(order_map) fallback_start = len(order_map)
early_termination = next((visit for visit in visits if (visit.visit_code or "").strip() == "提前终止"), None)
if early_termination and early_termination.actual_date:
last_actual_index = max(
(
order_map.get((visit.visit_code or "").strip(), fallback_start)
for visit in visits
if visit is not early_termination and visit.actual_date is not None
),
default=-1,
)
sorted_visits: list[Visit] = []
inserted = False
for visit in sorted(
[visit for visit in visits if visit is not early_termination],
key=lambda visit: (
order_map.get((visit.visit_code or "").strip(), fallback_start),
visit.planned_date is None,
visit.planned_date or date.max,
visit.visit_code or "",
),
):
sorted_visits.append(visit)
if not inserted and order_map.get((visit.visit_code or "").strip(), fallback_start) == last_actual_index:
sorted_visits.append(early_termination)
inserted = True
if not inserted:
sorted_visits.insert(0, early_termination)
return sorted_visits
return sorted( return sorted(
visits, visits,
key=lambda visit: ( key=lambda visit: (
@@ -58,6 +98,52 @@ def build_visit_schedule_dates(visit_schedule: list[dict] | None, base_date: dat
return rows return rows
def get_visit_window_start_date(visit: Visit) -> date | None:
return visit.window_start or visit.planned_date
def get_last_planned_visit_window_start_date(visits: Sequence[Visit]) -> date | None:
window_start_dates = [
get_visit_window_start_date(visit)
for visit in visits
if get_visit_window_start_date(visit) is not None and (visit.visit_code or "").strip() not in NON_TREATMENT_VISIT_CODES
]
return max(window_start_dates) if window_start_dates else None
def validate_early_termination_date(termination_date: date, visits: Sequence[Visit]) -> None:
last_window_start_date = get_last_planned_visit_window_start_date(visits)
if last_window_start_date is not None and termination_date >= last_window_start_date:
raise ValueError(f"提前终止日期必须早于方案最后一个计划访视窗口开始日({last_window_start_date.isoformat()}")
def build_early_termination_visit_changes(
visits: Sequence[Visit],
*,
termination_date: date,
reason: str,
notes: str | None = None,
) -> EarlyTerminationVisitChanges:
event_notes = reason.strip()
extra_notes = (notes or "").strip()
if extra_notes:
event_notes = f"{event_notes}\n{extra_notes}"
visits_to_cancel = [
visit
for visit in visits
if visit.actual_date is None
and visit.status not in ["DONE", "CANCELLED", "LOST"]
and (visit.visit_code or "").strip() not in NON_TREATMENT_VISIT_CODES
]
return EarlyTerminationVisitChanges(
event_visit_code="提前终止",
event_actual_date=termination_date,
event_notes=event_notes,
visits_to_cancel=visits_to_cancel,
)
async def create_visit( async def create_visit(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -264,3 +350,62 @@ async def create_scheduled_visits(
) )
) )
return created return created
async def create_early_termination_visit(
db: AsyncSession,
*,
study_id: uuid.UUID,
subject: Subject,
termination_date: date,
reason: str,
notes: str | None = None,
) -> Visit:
result = await db.execute(select(Visit).where(Visit.subject_id == subject.id))
existing_visits = list(result.scalars().all())
validate_early_termination_date(termination_date, existing_visits)
changes = build_early_termination_visit_changes(
existing_visits,
termination_date=termination_date,
reason=reason,
notes=notes,
)
existing_event = next(
(visit for visit in existing_visits if visit.visit_code == changes.event_visit_code),
None,
)
if existing_event:
await db.execute(
sa_update(Visit)
.where(Visit.id == existing_event.id)
.values(
actual_date=changes.event_actual_date,
status="DONE",
notes=changes.event_notes,
)
)
event_visit = existing_event
else:
event_visit = Visit(
study_id=study_id,
subject_id=subject.id,
visit_code=changes.event_visit_code,
planned_date=None,
actual_date=changes.event_actual_date,
status="DONE",
window_start=None,
window_end=None,
notes=changes.event_notes,
)
db.add(event_visit)
for visit in changes.visits_to_cancel:
await db.execute(
sa_update(Visit)
.where(Visit.id == visit.id)
.values(status="CANCELLED", notes="提前终止后不再适用")
)
await db.commit()
await db.refresh(event_visit)
return event_visit
+8 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -14,8 +14,12 @@ class MonitoringVisitIssue(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
issue_no: Mapped[str] = mapped_column(String(64), nullable=False) issue_no: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(255), nullable=False) category: Mapped[str] = mapped_column(String(255), nullable=False)
severity: Mapped[str | None] = mapped_column(String(64), nullable=True)
mark: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_cycle: Mapped[str | None] = mapped_column(String(100), nullable=True)
subject_code: Mapped[str | None] = mapped_column(String(64), nullable=True) subject_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
monitor_item: Mapped[str | None] = mapped_column(Text, nullable=True) monitor_item: Mapped[str | None] = mapped_column(Text, nullable=True)
monitor_type: Mapped[str | None] = mapped_column(String(64), nullable=True) monitor_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -27,6 +31,9 @@ class MonitoringVisitIssue(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
action_taken: Mapped[str | None] = mapped_column(Text, nullable=True) action_taken: Mapped[str | None] = mapped_column(Text, nullable=True)
follow_up_progress: Mapped[str | None] = mapped_column(Text, nullable=True) follow_up_progress: Mapped[str | None] = mapped_column(Text, nullable=True)
center_query: Mapped[str | None] = mapped_column(Text, nullable=True)
center_latest_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
rectification_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False)
found_date: Mapped[date | None] = mapped_column(Date, nullable=True) found_date: Mapped[date | None] = mapped_column(Date, nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
actual_resolve_date: Mapped[date | None] = mapped_column(Date, nullable=True) actual_resolve_date: Mapped[date | None] = mapped_column(Date, nullable=True)
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, date from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -22,6 +22,7 @@ class Subject(Base):
enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True) enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
baseline_date: Mapped[date | None] = mapped_column(Date, nullable=True) baseline_date: Mapped[date | None] = mapped_column(Date, nullable=True)
completion_date: Mapped[date | None] = mapped_column(Date, nullable=True) completion_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_medication_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True) drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
@@ -6,7 +6,11 @@ from pydantic import BaseModel, ConfigDict, field_validator
class MonitoringVisitIssueCreate(BaseModel): class MonitoringVisitIssueCreate(BaseModel):
issue_no: str | None = None issue_no: str | None = None
site_id: uuid.UUID | None = None
category: str category: str
severity: str | None = None
mark: str | None = None
visit_cycle: str | None = None
subject_code: str | None = None subject_code: str | None = None
monitor_item: str | None = None monitor_item: str | None = None
monitor_type: str | None = None monitor_type: str | None = None
@@ -18,6 +22,9 @@ class MonitoringVisitIssueCreate(BaseModel):
description: str | None = None description: str | None = None
action_taken: str | None = None action_taken: str | None = None
follow_up_progress: str | None = None follow_up_progress: str | None = None
center_query: str | None = None
center_latest_reply: str | None = None
rectification_completed: bool = False
found_date: date | None = None found_date: date | None = None
due_at: datetime | None = None due_at: datetime | None = None
actual_resolve_date: date | None = None actual_resolve_date: date | None = None
@@ -42,6 +49,9 @@ class MonitoringVisitIssueCreate(BaseModel):
@field_validator( @field_validator(
"subject_code", "subject_code",
"severity",
"mark",
"visit_cycle",
"monitor_item", "monitor_item",
"monitor_type", "monitor_type",
"source", "source",
@@ -51,6 +61,8 @@ class MonitoringVisitIssueCreate(BaseModel):
"description", "description",
"action_taken", "action_taken",
"follow_up_progress", "follow_up_progress",
"center_query",
"center_latest_reply",
"responsible_name", "responsible_name",
mode="before", mode="before",
) )
@@ -80,9 +92,13 @@ class MonitoringVisitIssueCreate(BaseModel):
class MonitoringVisitIssueRead(BaseModel): class MonitoringVisitIssueRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
site_id: uuid.UUID | None
issue_no: str issue_no: str
open_duration: str open_duration: str
category: str category: str
severity: str | None
mark: str | None
visit_cycle: str | None
subject_code: str | None subject_code: str | None
monitor_item: str | None monitor_item: str | None
monitor_type: str | None monitor_type: str | None
@@ -96,6 +112,9 @@ class MonitoringVisitIssueRead(BaseModel):
description: str | None description: str | None
action_taken: str | None action_taken: str | None
follow_up_progress: str | None follow_up_progress: str | None
center_query: str | None
center_latest_reply: str | None
rectification_completed: bool
found_date: date | None found_date: date | None
overdue: bool overdue: bool
due_at: datetime | None due_at: datetime | None
@@ -115,7 +134,11 @@ class MonitoringVisitIssueImportSummary(BaseModel):
class MonitoringVisitIssueUpdate(BaseModel): class MonitoringVisitIssueUpdate(BaseModel):
issue_no: str | None = None issue_no: str | None = None
site_id: uuid.UUID | None = None
category: str | None = None category: str | None = None
severity: str | None = None
mark: str | None = None
visit_cycle: str | None = None
subject_code: str | None = None subject_code: str | None = None
monitor_item: str | None = None monitor_item: str | None = None
monitor_type: str | None = None monitor_type: str | None = None
@@ -127,6 +150,9 @@ class MonitoringVisitIssueUpdate(BaseModel):
description: str | None = None description: str | None = None
action_taken: str | None = None action_taken: str | None = None
follow_up_progress: str | None = None follow_up_progress: str | None = None
center_query: str | None = None
center_latest_reply: str | None = None
rectification_completed: bool | None = None
found_date: date | None = None found_date: date | None = None
due_at: datetime | None = None due_at: datetime | None = None
actual_resolve_date: date | None = None actual_resolve_date: date | None = None
@@ -145,6 +171,9 @@ class MonitoringVisitIssueUpdate(BaseModel):
@field_validator( @field_validator(
"subject_code", "subject_code",
"severity",
"mark",
"visit_cycle",
"monitor_item", "monitor_item",
"monitor_type", "monitor_type",
"source", "source",
@@ -154,6 +183,8 @@ class MonitoringVisitIssueUpdate(BaseModel):
"description", "description",
"action_taken", "action_taken",
"follow_up_progress", "follow_up_progress",
"center_query",
"center_latest_reply",
"responsible_name", "responsible_name",
mode="before", mode="before",
) )
+40 -51
View File
@@ -1,14 +1,9 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
ConfirmStatus = Literal["待确认", "已确认", "退回"]
MilestoneStatus = Literal["未开始", "进行中", "已完成", "延期"]
class ProjectMilestoneItem(BaseModel): class ProjectMilestoneItem(BaseModel):
id: str id: str
name: str = "" name: str = ""
@@ -18,7 +13,7 @@ class ProjectMilestoneItem(BaseModel):
durationDays: int = 1 durationDays: int = 1
owner: str = "" owner: str = ""
remark: str = "" remark: str = ""
status: MilestoneStatus = "未开始" status: str = "未开始"
class EnrollmentPlanItem(BaseModel): class EnrollmentPlanItem(BaseModel):
@@ -35,7 +30,7 @@ class SiteMilestoneItem(BaseModel):
planDate: str = "" planDate: str = ""
owner: str = "" owner: str = ""
remark: str = "" remark: str = ""
status: MilestoneStatus = "未开始" status: str = "未开始"
class SiteEnrollmentPlanItem(BaseModel): class SiteEnrollmentPlanItem(BaseModel):
@@ -63,12 +58,49 @@ class CenterConfirmItem(BaseModel):
siteId: str = "" siteId: str = ""
siteName: str = "" siteName: str = ""
confirmer: str = "" confirmer: str = ""
confirmStatus: ConfirmStatus = "待确认" confirmStatus: str = "待确认"
confirmDate: str = "" confirmDate: str = ""
note: str = "" note: str = ""
class VisitScheduleItem(BaseModel):
visit_code: str = ""
baseline_offset_days: int = 0
window_before_days: int = 0
window_after_days: int = 0
class ProjectPublishSnapshot(BaseModel):
code: str = ""
name: str = ""
project_full_name: str = ""
sponsor: str = ""
protocol_no: str = ""
lead_unit: str = ""
principal_investigator: str = ""
main_pm: str = ""
research_analysis: str = ""
research_product: str = ""
control_product: str = ""
indication: str = ""
research_population: str = ""
research_design: str = ""
plan_start_date: str = ""
plan_end_date: str = ""
planned_site_count: int | None = None
planned_enrollment_count: int | None = None
status: str = ""
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def normalize_visit_schedule(self):
for item in self.visit_schedule:
item.visit_code = item.visit_code.strip()
return self
class StudySetupConfigData(BaseModel): class StudySetupConfigData(BaseModel):
projectInfo: ProjectPublishSnapshot = Field(default_factory=ProjectPublishSnapshot)
projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list) projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list)
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)
@@ -143,49 +175,6 @@ class SetupProjectionSummary(BaseModel):
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
class VisitScheduleItem(BaseModel):
visit_code: str = Field(min_length=1, max_length=50)
baseline_offset_days: int = Field(ge=0, le=3650)
window_before_days: int = Field(ge=0, le=365)
window_after_days: int = Field(ge=0, le=365)
class ProjectPublishSnapshot(BaseModel):
code: str = ""
name: str = ""
project_full_name: str = ""
sponsor: str = ""
protocol_no: str = ""
lead_unit: str = ""
principal_investigator: str = ""
main_pm: str = ""
research_analysis: str = ""
research_product: str = ""
control_product: str = ""
indication: str = ""
research_population: str = ""
research_design: str = ""
plan_start_date: str = ""
plan_end_date: str = ""
planned_site_count: int | None = None
planned_enrollment_count: int | None = None
status: str = ""
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def validate_visit_schedule(self):
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudySetupConfigRead(BaseModel): class StudySetupConfigRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
+2
View File
@@ -20,6 +20,7 @@ class SubjectUpdate(BaseModel):
enrollment_date: Optional[date] = None enrollment_date: Optional[date] = None
baseline_date: Optional[date] = None baseline_date: Optional[date] = None
completion_date: Optional[date] = None completion_date: Optional[date] = None
actual_medication_count: Optional[int] = None
drop_reason: Optional[str] = None drop_reason: Optional[str] = None
@@ -34,6 +35,7 @@ class SubjectRead(BaseModel):
enrollment_date: Optional[date] enrollment_date: Optional[date]
baseline_date: Optional[date] baseline_date: Optional[date]
completion_date: Optional[date] completion_date: Optional[date]
actual_medication_count: Optional[int]
drop_reason: Optional[str] drop_reason: Optional[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+6
View File
@@ -19,6 +19,12 @@ class VisitUpdate(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
class EarlyTerminationCreate(BaseModel):
termination_date: date
reason: str
notes: Optional[str] = None
class VisitRead(BaseModel): class VisitRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
+29 -7
View File
@@ -3,35 +3,57 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from sqlalchemy import inspect
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from app.core.config import PROTECTED_ADMIN_EMAIL # noqa: E402 from app.core.config import PROTECTED_ADMIN_EMAIL # noqa: E402
from app.crud.user import ensure_admin_exists # noqa: E402 from app.crud.user import ensure_admin_exists # noqa: E402
from app.db.session import SessionLocal # noqa: E402 from app.db.base import Base # noqa: E402
from app.db.session import SessionLocal, engine # noqa: E402
def run_migrations() -> None: def run_alembic_command(*args: str) -> None:
subprocess.run( subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"], [sys.executable, "-m", "alembic", *args],
check=True, check=True,
cwd=PROJECT_ROOT, cwd=PROJECT_ROOT,
) )
def _list_user_tables(sync_conn) -> list[str]:
inspector = inspect(sync_conn)
return [table for table in inspector.get_table_names(schema="public") if table != "alembic_version"]
async def initialize_schema() -> None:
async with engine.begin() as conn:
user_tables = await conn.run_sync(_list_user_tables)
if user_tables:
run_alembic_command("upgrade", "head")
return
await conn.run_sync(Base.metadata.create_all)
run_alembic_command("stamp", "head")
async def seed_protected_admin() -> None: async def seed_protected_admin() -> None:
async with SessionLocal() as session: async with SessionLocal() as session:
await ensure_admin_exists(session) await ensure_admin_exists(session)
def main() -> None: async def async_main() -> None:
print("Running Alembic migrations...") print("Initializing database schema...")
run_migrations() await initialize_schema()
print(f"Ensuring protected admin exists: {PROTECTED_ADMIN_EMAIL}") print(f"Ensuring protected admin exists: {PROTECTED_ADMIN_EMAIL}")
asyncio.run(seed_protected_admin()) await seed_protected_admin()
print("Production initialization complete.") print("Production initialization complete.")
def main() -> None:
asyncio.run(async_main())
if __name__ == "__main__": if __name__ == "__main__":
main() main()
@@ -0,0 +1,72 @@
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.crud import monitoring_visit_issue as issue_crud
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate
@pytest.mark.asyncio
async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path):
db_path = tmp_path / "monitoring-issues.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(MonitoringVisitIssue.__table__.create)
study_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
site_id = uuid.UUID("22222222-2222-2222-2222-222222222222")
async with SessionLocal() as session:
created = await issue_crud.create_issue(
session,
study_id,
MonitoringVisitIssueCreate(
issue_no="MV-001",
site_id=site_id,
category="原始记录",
subject_code="SUBJ-001",
status="OPEN",
severity="严重",
mark="SDV",
visit_cycle="V1",
center_query="请补充签名日期",
center_latest_reply="待中心回复",
rectification_completed=False,
due_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
),
created_by=None,
)
assert created.site_id == site_id
assert created.severity == "严重"
assert created.mark == "SDV"
assert created.visit_cycle == "V1"
assert created.center_query == "请补充签名日期"
assert created.center_latest_reply == "待中心回复"
assert created.rectification_completed is False
matched = await issue_crud.list_issues(
session,
study_id,
site_id=site_id,
severity="严重",
mark="SDV",
visit_cycle="V1",
rectification_completed=False,
due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
)
unmatched = await issue_crud.list_issues(
session,
study_id,
site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"),
)
await engine.dispose()
assert [item.issue_no for item in matched] == ["MV-001"]
assert unmatched == []
@@ -0,0 +1,230 @@
from datetime import date
from types import SimpleNamespace
from fastapi import HTTPException
from app.api.v1.studies import _apply_project_publish_snapshot_to_study, _validate_setup_data
from app.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData
def test_setup_config_data_keeps_project_info_draft():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"plan_start_date": "2026-06-01",
"plan_end_date": "2026-12-31",
"planned_enrollment_count": 120,
"status": "ACTIVE",
"visit_schedule": [
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 1,
"window_after_days": 2,
}
],
}
}
)
assert draft.projectInfo.code == "PRJ-002"
assert draft.projectInfo.name == "草稿项目"
assert draft.projectInfo.planned_enrollment_count == 120
assert draft.projectInfo.visit_schedule[0].visit_code == "V1"
def test_draft_schema_accepts_incomplete_project_snapshot_rows():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"visit_schedule": [
{
"visit_code": "",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0,
}
],
},
"projectMilestones": [
{
"id": "row-1",
"name": "",
"planDate": "",
"startDate": "",
"endDate": "",
"durationDays": 1,
"owner": "",
"remark": "",
"status": "",
}
],
"centerConfirm": [
{
"id": "row-1",
"siteId": "",
"siteName": "",
"confirmer": "",
"confirmStatus": "",
"confirmDate": "",
"note": "",
}
],
}
)
assert draft.projectInfo.visit_schedule[0].visit_code == ""
assert draft.projectMilestones[0].status == ""
assert draft.centerConfirm[0].confirmStatus == ""
def test_draft_save_allows_incomplete_setup_steps():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"plan_start_date": "2026-05-01",
"plan_end_date": "2027-05-31",
},
"enrollmentPlan": {
"totalTarget": 180,
"startDate": "",
"endDate": "",
"monthlyGoalNote": "",
"stageBreakdown": "",
},
}
)
_validate_setup_data(
draft,
{},
project_plan_start=date(2026, 5, 1),
project_plan_end=date(2027, 5, 31),
strict_required=False,
)
def test_publish_validation_still_requires_complete_setup_steps():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"plan_start_date": "2026-05-01",
"plan_end_date": "2027-05-31",
},
"enrollmentPlan": {
"totalTarget": 180,
"startDate": "",
"endDate": "",
"monthlyGoalNote": "",
"stageBreakdown": "",
},
}
)
try:
_validate_setup_data(
draft,
{},
project_plan_start=date(2026, 5, 1),
project_plan_end=date(2027, 5, 31),
)
except HTTPException as exc:
assert exc.status_code == 422
else:
raise AssertionError("publish validation should reject incomplete setup steps")
def test_project_info_is_part_of_setup_config_payload():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-VERSION",
"name": "版本内项目信息",
"project_full_name": "版本内项目全称",
"plan_start_date": "2026-06-01",
"plan_end_date": "2026-12-31",
"planned_site_count": 8,
"planned_enrollment_count": 120,
"status": "ACTIVE",
},
"enrollmentPlan": {
"totalTarget": 120,
"startDate": "2026-06-01",
"endDate": "2026-12-31",
"monthlyGoalNote": "",
"stageBreakdown": "",
},
}
)
payload = draft.model_dump(mode="json")
assert payload["projectInfo"]["code"] == "PRJ-VERSION"
assert payload["projectInfo"]["planned_enrollment_count"] == 120
assert payload["enrollmentPlan"]["totalTarget"] == 120
def test_apply_project_publish_snapshot_updates_formal_study_fields():
study = SimpleNamespace(
code="PRJ-001",
name="原项目",
project_full_name=None,
sponsor=None,
protocol_no=None,
lead_unit=None,
principal_investigator=None,
main_pm=None,
research_analysis=None,
research_product=None,
control_product=None,
indication=None,
research_population=None,
research_design=None,
plan_start_date=None,
plan_end_date=None,
planned_site_count=None,
planned_enrollment_count=10,
status="DRAFT",
visit_schedule=[],
)
snapshot = ProjectPublishSnapshot(
code="PRJ-002",
name="发布项目",
project_full_name="发布项目全称",
sponsor="申办方",
plan_start_date="2026-06-01",
plan_end_date="2026-12-31",
planned_site_count=8,
planned_enrollment_count=120,
status="ACTIVE",
visit_schedule=[
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 1,
"window_after_days": 2,
}
],
)
changed = _apply_project_publish_snapshot_to_study(study, snapshot)
assert changed is True
assert study.code == "PRJ-002"
assert study.name == "发布项目"
assert study.project_full_name == "发布项目全称"
assert study.sponsor == "申办方"
assert study.plan_start_date == date(2026, 6, 1)
assert study.plan_end_date == date(2026, 12, 31)
assert study.planned_site_count == 8
assert study.planned_enrollment_count == 120
assert study.status == "ACTIVE"
assert study.visit_schedule[0]["visit_code"] == "V1"
@@ -0,0 +1,44 @@
from datetime import datetime
from types import SimpleNamespace
import uuid
from app.crud.subject import _validate_actual_medication_count
from app.schemas.subject import SubjectRead, SubjectUpdate
def test_subject_update_accepts_actual_medication_count():
payload = SubjectUpdate(actual_medication_count=12)
assert payload.actual_medication_count == 12
def test_subject_read_includes_actual_medication_count():
subject = SimpleNamespace(
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"),
subject_no="S001",
status="ENROLLED",
screening_date=None,
consent_date=None,
enrollment_date=None,
baseline_date=None,
completion_date=None,
actual_medication_count=10,
drop_reason=None,
created_at=datetime(2026, 5, 9, 0, 0, 0),
updated_at=datetime(2026, 5, 9, 0, 0, 0),
)
data = SubjectRead.model_validate(subject)
assert data.actual_medication_count == 10
def test_actual_medication_count_cannot_be_negative():
try:
_validate_actual_medication_count(-1)
except ValueError as exc:
assert "实际用药次数不能小于0" in str(exc)
else:
raise AssertionError("negative actual medication count should be rejected")
+86 -1
View File
@@ -2,7 +2,13 @@ from datetime import date
from types import SimpleNamespace from types import SimpleNamespace
from app.crud.subject import should_generate_visits_after_subject_update from app.crud.subject import should_generate_visits_after_subject_update
from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display from app.crud.visit import (
build_early_termination_visit_changes,
build_visit_schedule_dates,
get_last_planned_visit_window_start_date,
sort_visits_for_display,
validate_early_termination_date,
)
from app.schemas.study import StudyUpdate from app.schemas.study import StudyUpdate
@@ -129,6 +135,30 @@ def test_sort_visits_for_display_does_not_infer_business_order():
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"] assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"]
def test_sort_visits_places_early_termination_after_last_actual_visit():
visits = [
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)),
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)),
SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=None),
SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=None),
SimpleNamespace(visit_code="提前终止", planned_date=None, actual_date=date(2026, 5, 13)),
]
visit_schedule = [
{"visit_code": "筛选访视"},
{"visit_code": "基线访视"},
{"visit_code": "V1"},
{"visit_code": "V2"},
]
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == [
"筛选访视",
"基线访视",
"提前终止",
"V1",
"V2",
]
def test_should_generate_visits_when_baseline_date_is_set_or_changed(): def test_should_generate_visits_when_baseline_date_is_set_or_changed():
assert should_generate_visits_after_subject_update( assert should_generate_visits_after_subject_update(
previous_baseline_date=None, previous_baseline_date=None,
@@ -142,3 +172,58 @@ def test_should_generate_visits_when_baseline_date_is_set_or_changed():
previous_baseline_date=None, previous_baseline_date=None,
next_baseline_date=None, next_baseline_date=None,
) )
def test_build_early_termination_visit_changes_adds_event_and_cancels_future_planned_visits():
visits = [
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"),
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"),
SimpleNamespace(visit_code="V0", planned_date=date(2026, 5, 5), actual_date=None, status="LOST"),
SimpleNamespace(
visit_code="V1",
planned_date=date(2026, 5, 6),
window_start=date(2026, 5, 4),
window_end=date(2026, 5, 8),
actual_date=None,
status="PLANNED",
),
SimpleNamespace(
visit_code="V2",
planned_date=date(2026, 5, 15),
window_start=date(2026, 5, 12),
window_end=date(2026, 5, 18),
actual_date=None,
status="PLANNED",
),
]
changes = build_early_termination_visit_changes(
visits,
termination_date=date(2026, 5, 6),
reason="不良事件退出",
)
assert changes.event_visit_code == "提前终止"
assert changes.event_actual_date == date(2026, 5, 6)
assert changes.event_notes == "不良事件退出"
assert [visit.visit_code for visit in changes.visits_to_cancel] == ["V1", "V2"]
def test_validate_early_termination_date_requires_date_before_last_visit_window_start():
visits = [
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)),
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)),
SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 8), window_start=date(2026, 5, 6)),
SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 15), window_start=date(2026, 5, 12)),
SimpleNamespace(visit_code="提前终止", planned_date=None, window_start=None),
]
assert get_last_planned_visit_window_start_date(visits) == date(2026, 5, 12)
validate_early_termination_date(date(2026, 5, 11), visits)
try:
validate_early_termination_date(date(2026, 5, 12), visits)
except ValueError as exc:
assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc)
else:
raise AssertionError("same-day final visit window start should not be accepted as early termination")
+420 -183
View File
@@ -1,48 +1,95 @@
# CTMS 分支环境安装配置指南 # CTMS 安装步骤向导
本文定义 `dev``main``release` 三类分支对应的推荐部署环境、配置文件、初始化步骤与验证命令。分支本身不会自动切换运行模式,实际模式由后端环境变量 `ENV` 决定。 按目标分支选择对应章节执行:
## 分支与环境映射 - [dev 分支安装](#dev-分支安装)
- [main 分支安装](#main-分支安装)
- [release 分支安装](#release-分支安装)
| 分支 | 分支定位 | 推荐环境 | 后端 `ENV` | 用途 | ## 安装前准备
| --- | --- | --- | --- | --- |
| `dev` | 日常开发与集成 | 开发环境 | `development` | 功能开发、联调、自测、内部集成 |
| `main` | 下一版本候选 | 预发布 / 验收环境 | `production` | 回归测试、验收、发布候选验证 |
| `release` | 当前稳定生产线 | 生产环境 | `production` | 正式生产部署、生产 hotfix |
推广路径保持为: 三个分支都先完成本节。
```text ### 1. 准备安装目录
feature/* -> dev -> main -> release
```bash
mkdir -p /opt/ctms
cd /opt/ctms
``` ```
不要把 `release` 当作日常开发分支使用。`main` 应只接收已经准备进入候选版本范围的变更。 ### 2. 安装 Docker
## 公共前置条件 Linux 可使用 Docker Engine 或 Docker Desktop。安装完成后确认 Docker Compose v2 可用:
所有环境都需要: ```bash
docker --version
- Docker 与 Docker Compose docker compose version
- 可写的 `pg_data/` 数据目录
- 端口 `8888``8000``5432` 未被占用
- 后端镜像可安装 `backend/requirements.txt` 中的依赖
- 前端镜像可执行 `npm ci``npm run build`
当前 compose 服务拓扑:
```text
nginx -> backend -> db
``` ```
对外入口 如果当前用户不能执行 Docker 命令,将用户加入 `docker` 组后重新登录
- 前端:`http://localhost:8888` ```bash
- 后端 API:同域 `/api/v1/*` sudo usermod -aG docker "$USER"
- 健康检查:`http://localhost:8888/health` ```
## dev 分支:开发环境 macOS 安装 Docker Desktop 后执行:
`dev` 默认用于本地开发和内部集成,推荐使用 `ENV=development` ```bash
docker --version
docker compose version
```
### 3. 安装基础工具
Linux
```bash
sudo apt-get update
sudo apt-get install -y git openssl curl lsof
```
macOS
```bash
brew install git openssl curl
```
### 4. 检查端口
```bash
lsof -nP -iTCP:8888 -sTCP:LISTEN
lsof -nP -iTCP:8000 -sTCP:LISTEN
lsof -nP -iTCP:5432 -sTCP:LISTEN
```
如果命令有输出,先停止占用这些端口的服务,或调整 `docker-compose.yaml` 中的端口映射。
### 5. 拉取代码
首次安装:
```bash
git clone https://github.com/chengchengzhou7/CTMS.git
cd CTMS
```
已有仓库:
```bash
cd /opt/ctms/CTMS
git fetch --prune origin
```
### 6. 确认本地配置不会提交
```bash
grep -n "^\\.env$" .gitignore
grep -n "^\\.env\\.\\*$" .gitignore
```
预期能看到 `.env``.env.*` 已被忽略。
## dev 分支安装
### 1. 切换分支 ### 1. 切换分支
@@ -51,68 +98,83 @@ git checkout dev
git pull --rebase origin dev git pull --rebase origin dev
``` ```
### 2. 配置 `.env` ### 2. 写入环境配置
根目录 `.env` 不提交到仓库。开发环境推荐 在仓库根目录创建或覆盖 `.env`
```env ```bash
cat > .env <<'EOF'
COMPOSE_PROJECT_NAME=ctms_dev COMPOSE_PROJECT_NAME=ctms_dev
ENV=development ENV=development
JWT_SECRET_KEY=dev-secret JWT_SECRET_KEY=dev-secret
LOGIN_RSA_KEY_ID=default LOGIN_RSA_KEY_ID=default
LOGIN_RSA_PRIVATE_KEY= LOGIN_RSA_PRIVATE_KEY=
EOF
``` ```
说明: ### 3. 构建镜像并启动
- `ENV=development` 会启用开发行为。
- 未配置 `LOGIN_RSA_PRIVATE_KEY` 时,后端启动后会生成临时 RSA 私钥。
- 后端重启后临时公钥会变化,已有登录页应刷新后重新登录。
- `JWT_SECRET_KEY=dev-secret` 只允许本地开发使用。
### 3. 启动
```bash
docker compose up -d --build backend nginx
```
如果需要首次启动完整栈:
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
### 4. 开发模式行为 ### 4. 检查容器状态
`development` 模式下:
- FastAPI `debug=True`
- 应用启动时会执行 `Base.metadata.create_all`
- 应用启动时会确保默认管理员存在
- 未配置 RSA 私钥时允许临时生成
这些行为只适合开发,不适合生产。
### 5. 验证
```bash ```bash
docker compose ps docker compose ps
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)" ```
确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy`
### 5. 检查后端环境
```bash
docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY)))"
```
预期输出:
```text
ENV=development
JWT_SECRET_IS_DEV=True
LOGIN_RSA_PRIVATE_KEY_SET=False
```
### 6. 检查接口
```bash
curl -i http://127.0.0.1:8888/health curl -i http://127.0.0.1:8888/health
curl -i http://127.0.0.1:8888/api/v1/auth/login-key curl -i http://127.0.0.1:8888/api/v1/auth/login-key
``` ```
预期: 两个接口都应返回 `HTTP/1.1 200 OK`
### 7. 打开系统
```text ```text
ENV=development http://localhost:8888
GET /health -> 200
GET /api/v1/auth/login-key -> 200, key_id=default
``` ```
## main 分支:预发布 / 验收环境 ### 8. 运行验证
`main` 是下一正式版本候选分支,应按生产模式运行,但不直接承载正式生产流量。 后端:
```bash
docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest
```
前端:
```bash
cd frontend
npm install
npm run test:unit
npm run type-check
npm run build
cd ..
```
## main 分支安装
### 1. 切换分支 ### 1. 切换分支
@@ -121,81 +183,143 @@ git checkout main
git pull --rebase origin main git pull --rebase origin main
``` ```
### 2. 配置 `.env` ### 2. 生成密钥
预发布环境应使用 `ENV=production`,并配置独立于生产的密钥: 生成 JWT 密钥:
```env
COMPOSE_PROJECT_NAME=ctms_staging
ENV=production
JWT_SECRET_KEY=<staging-strong-random-secret>
LOGIN_RSA_KEY_ID=staging-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<staging-rsa-private-key-pem-with-\n-escaped-newlines>
```
生成密钥示例:
```bash ```bash
openssl rand -hex 32 openssl rand -hex 32
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
``` ```
要求 生成 RSA 私钥文件
- `JWT_SECRET_KEY` 不得使用 `dev-secret` ```bash
- `LOGIN_RSA_PRIVATE_KEY` 必须固定保存,不能每次部署重新生成。 openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login_rsa_main.pem
- staging 私钥不得复用 production 私钥。 ```
### 3. 初始化数据库 生成可写入 `.env` 的 RSA 私钥单行文本:
生产模式不会自动建表或自动补管理员。首次部署或迁移前执行: ```bash
awk '{printf "%s\\n", $0}' login_rsa_main.pem
```
### 3. 写入环境配置
在仓库根目录创建或覆盖 `.env`,将占位符替换为上一步生成的值:
```bash
cat > .env <<'EOF'
COMPOSE_PROJECT_NAME=ctms_main
ENV=production
JWT_SECRET_KEY=<替换为 JWT 密钥>
LOGIN_RSA_KEY_ID=main-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<替换为 RSA 私钥单行文本>
EOF
```
### 4. 检查 Compose 配置
```bash
docker compose config
```
确认输出中包含:
```text
ENV: production
LOGIN_RSA_KEY_ID: main-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----...'
```
### 5. 初始化数据库
```bash ```bash
docker compose run --rm backend-init docker compose run --rm backend-init
``` ```
该步骤应运行 Alembic migration,并确保固定管理员账号存在。 ### 6. 构建镜像并启动
### 4. 启动
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
### 5. 验证 ### 7. 检查容器状态
```bash ```bash
docker compose config
docker compose ps docker compose ps
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY))" ```
确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy`
### 8. 检查后端环境
```bash
docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)"
```
预期输出:
```text
ENV=production
JWT_SECRET_IS_DEV=False
LOGIN_RSA_PRIVATE_KEY_SET=True
LOGIN_RSA_KEY_ID=main-YYYYMMDD
```
### 9. 检查接口
本机:
```bash
curl -i http://127.0.0.1:8888/health curl -i http://127.0.0.1:8888/health
curl -i http://127.0.0.1:8888/api/v1/auth/login-key curl -i http://127.0.0.1:8888/api/v1/auth/login-key
``` ```
预期 远程域名
```text ```bash
ENV=production curl -i https://<main-domain>/health
JWT_SECRET_KEY == dev-secret -> False curl -i https://<main-domain>/api/v1/auth/login-key
LOGIN_RSA_PRIVATE_KEY_SET -> True
GET /health -> 200
GET /api/v1/auth/login-key -> 200
``` ```
### 6. 验收门禁 接口应返回 `HTTP/1.1 200 OK`
在把 `main` 推进到 `release` 前,至少执行: ### 10. 打开系统
本机:
```text
http://localhost:8888
```
远程访问:
```text
https://<main-domain>
```
远程访问必须使用 HTTPS。
### 11. 运行验证
后端:
```bash ```bash
docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest
cd frontend && npm run test:unit
cd frontend && npm run type-check
cd frontend && npm run build
``` ```
## release 分支:生产环境 前端:
`release` 是正式生产稳定分支。只有正式发布和生产 hotfix 应进入该分支。 ```bash
cd frontend
npm install
npm run test:unit
npm run type-check
npm run build
cd ..
```
## release 分支安装
### 1. 切换分支 ### 1. 切换分支
@@ -204,125 +328,238 @@ git checkout release
git pull --rebase origin release git pull --rebase origin release
``` ```
### 2. 配置 `.env` ### 2. 准备 HTTPS 域名
生产环境必须使用生产专用配置 正式访问地址必须是
```env ```text
COMPOSE_PROJECT_NAME=ctms_prod https://<production-domain>
ENV=production
JWT_SECRET_KEY=<production-strong-random-secret>
LOGIN_RSA_KEY_ID=prod-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<production-rsa-private-key-pem-with-\n-escaped-newlines>
``` ```
要求 如果服务器前面有反向代理,确认代理规则
- `.env` 必须只保存在部署机器或密钥管理系统中。 ```text
- 不得提交 `.env`、私钥、JWT 密钥。 / -> CTMS nginx 8888
- `JWT_SECRET_KEY` 轮换会使既有 token 失效,应安排维护窗口。 /api/* -> CTMS nginx 8888
- `LOGIN_RSA_PRIVATE_KEY` 轮换会影响新登录密钥获取,应同步更新 `LOGIN_RSA_KEY_ID` /health -> CTMS nginx 8888
```
### 3. HTTPS 要求 ### 3. 生成密钥
登录加密依赖浏览器 WebCrypto。生产访问必须使用 HTTPS 生成 JWT 密钥
- `https://正式域名` ```bash
- 本地 `localhost` 是浏览器安全上下文例外,但不能代表生产可用性 openssl rand -hex 32
```
如果生产仍通过 `http://服务器IP:8888` 访问,前端会拒绝执行登录加密。 生成 RSA 私钥文件:
### 4. 初始化与迁移 ```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login_rsa_release.pem
```
首次部署或每次包含 migration 的发布 生成可写入 `.env` 的 RSA 私钥单行文本
```bash
awk '{printf "%s\\n", $0}' login_rsa_release.pem
```
`login_rsa_release.pem` 保存到服务器安全目录或密钥管理系统。
### 4. 写入环境配置
在仓库根目录创建或覆盖 `.env`,将占位符替换为生产值:
```bash
cat > .env <<'EOF'
COMPOSE_PROJECT_NAME=ctms_release
ENV=production
JWT_SECRET_KEY=<替换为生产 JWT 密钥>
LOGIN_RSA_KEY_ID=release-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<替换为生产 RSA 私钥单行文本>
EOF
```
确认 `.env` 没有进入 Git 暂存区:
```bash
git status --short .env
```
预期无输出。
### 5. 检查 Compose 配置
```bash
docker compose config
```
确认输出中包含:
```text
ENV: production
JWT_SECRET_KEY: <不是 dev-secret>
LOGIN_RSA_KEY_ID: release-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----...'
```
### 6. 初始化数据库
```bash ```bash
docker compose run --rm backend-init docker compose run --rm backend-init
``` ```
确认 migration 成功后再启动或滚动重启服务。 确认命令成功后再启动服务。
### 5. 启动 ### 7. 构建镜像并启动
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
### 6. 生产验证 ### 8. 检查容器状态
```bash ```bash
docker compose ps docker compose ps
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY)); print(settings.LOGIN_RSA_KEY_ID)" ```
确认 `ctms_db``ctms_backend``ctms_nginx` 都是 `Up`,且 `ctms_db``healthy`
### 9. 检查后端环境
```bash
docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)"
```
预期输出:
```text
ENV=production
JWT_SECRET_IS_DEV=False
LOGIN_RSA_PRIVATE_KEY_SET=True
LOGIN_RSA_KEY_ID=release-YYYYMMDD
```
### 10. 检查生产接口
```bash
curl -i https://<production-domain>/health curl -i https://<production-domain>/health
curl -i https://<production-domain>/api/v1/auth/login-key curl -i https://<production-domain>/api/v1/auth/login-key
``` ```
预期: 两个接口都应返回 `HTTP/1.1 200 OK`
### 11. 打开系统并登录
```text
https://<production-domain>
```
初始化管理员:
```text
admin@huapont.cn / admin123
```
首次登录后立即修改初始密码。
### 12. 发布后检查
查看容器:
```bash
docker compose ps
```
查看后端日志:
```bash
docker compose logs --tail=200 backend
```
查看 nginx 日志:
```bash
docker compose logs --tail=200 nginx
```
执行 smoke
```bash
BASE_URL=https://<production-domain> EMAIL=<admin-email> PASSWORD=<admin-password> bash docs/setup-config-curl-smoke.sh
```
## 常见安装问题
### 端口被占用
检查:
```bash
lsof -nP -iTCP:8888 -sTCP:LISTEN
lsof -nP -iTCP:8000 -sTCP:LISTEN
lsof -nP -iTCP:5432 -sTCP:LISTEN
```
处理:停止占用进程,或调整 `docker-compose.yaml` 端口映射。
### 容器名冲突
检查:
```bash
docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
```
处理:
```bash
docker compose down
```
如果同一台机器要同时运行多套 CTMS,需为每套环境调整 `docker-compose.yaml` 中的 `container_name` 和端口。
### production 启动失败
检查:
```bash
grep -n "ENV\\|JWT_SECRET_KEY\\|LOGIN_RSA" .env
```
确认:
```text ```text
ENV=production ENV=production
JWT_SECRET_KEY == dev-secret -> False JWT_SECRET_KEY 不是 dev-secret
LOGIN_RSA_PRIVATE_KEY_SET -> True LOGIN_RSA_PRIVATE_KEY 不是空值
GET /health -> 200 LOGIN_RSA_PRIVATE_KEY 包含 -----BEGIN PRIVATE KEY-----
GET /api/v1/auth/login-key -> 200
``` ```
### 7. 多实例约束 ### 远程访问无法登录
当前登录 challenge 默认保存在后端进程内 检查当前浏览器地址。如果不是 `https://`,改用 HTTPS 域名访问
- 单实例、单 worker:可直接使用 ```text
- 多实例或多 worker:必须满足以下至少一项 https://<domain>
- 使用共享缓存保存 challenge,例如 Redis
- 启用粘性会话,确保 `/login-key``/login` 命中同一后端进程
- 将后端限制为单 worker 单副本
如果不满足,上线后可能出现偶发登录失败。
## 环境变量说明
| 变量 | dev | main/staging | release/production | 说明 |
| --- | --- | --- | --- | --- |
| `COMPOSE_PROJECT_NAME` | `ctms_dev` | `ctms_staging` | `ctms_prod` | 防止不同环境容器名、网络名冲突 |
| `ENV` | `development` | `production` | `production` | 后端运行模式 |
| `JWT_SECRET_KEY` | `dev-secret` | 强随机 | 强随机 | JWT 签名密钥 |
| `LOGIN_RSA_KEY_ID` | `default` | `staging-YYYYMMDD` | `prod-YYYYMMDD` | 登录 RSA 密钥版本 |
| `LOGIN_RSA_PRIVATE_KEY` | 空 | staging 私钥 | production 私钥 | RSA 私钥,生产模式必填 |
## 常见问题
### 登录页提示当前浏览器环境不支持安全登录加密
原因:非 HTTPS、非 localhost 的访问环境不满足 WebCrypto 安全上下文要求。
处理:
- 本地使用 `http://localhost:8888`
- staging/production 使用 HTTPS 域名
### production 模式启动失败,提示必须配置 LOGIN_RSA_PRIVATE_KEY
原因:`ENV=production` 时必须提供固定 RSA 私钥。
处理:
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
``` ```
将输出 PEM 写入 `.env``LOGIN_RSA_PRIVATE_KEY`,换行使用 `\n` 转义。 本机 `dev` 可使用:
### 切换分支后环境不符合预期 ```text
http://localhost:8888
分支不会自动修改 `.env`。切换分支后应重新确认:
```bash
docker compose config
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)"
``` ```
必要时修改 `.env` 并重启: ### 切换分支后环境不正确
重新执行对应分支的 `.env` 配置,然后重建:
```bash ```bash
docker compose up -d --build backend nginx docker compose up -d --build backend nginx
``` ```
再检查:
```bash
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)"
```
+32
View File
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiDelete = vi.fn();
vi.mock("./axios", () => ({
apiGet,
apiPost,
apiDelete,
}));
describe("attachments api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses canonical collection URLs with trailing slash", async () => {
const { fetchAttachments, uploadAttachment } = await import("./attachments");
const file = new File(["x"], "x.txt", { type: "text/plain" });
fetchAttachments("study-1", "startup_feasibility", "entity-1");
uploadAttachment("study-1", "startup_feasibility", "entity-1", file);
expect(apiGet).toHaveBeenCalledWith("/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/",
expect.any(FormData),
expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } })
);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { apiGet, apiPost, apiDelete } from "./axios"; import { apiGet, apiPost, apiDelete } from "./axios";
export const fetchAttachments = (studyId: string, entityType: string, entityId: string) => export const fetchAttachments = (studyId: string, entityType: string, entityId: string) =>
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`); apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments/`);
export const uploadAttachment = ( export const uploadAttachment = (
studyId: string, studyId: string,
@@ -12,7 +12,7 @@ export const uploadAttachment = (
) => { ) => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`, formData, { return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments/`, formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
...options, ...options,
}); });
+8 -1
View File
@@ -1,4 +1,4 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import { apiDelete, apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
export const fetchVisits = (studyId: string, subjectId: string) => export const fetchVisits = (studyId: string, subjectId: string) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`); apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`);
@@ -6,6 +6,13 @@ export const fetchVisits = (studyId: string, subjectId: string) =>
export const createVisit = (studyId: string, subjectId: string, payload: Record<string, any>) => export const createVisit = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload); apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload);
export const createEarlyTerminationVisit = (
studyId: string,
subjectId: string,
payload: Record<string, any>,
config?: ApiRequestConfig
) => apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/early-termination`, payload, config);
export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) => export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload); apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
@@ -101,9 +101,6 @@ const onSubmit = async () => {
sort_order: form.sort_order, sort_order: form.sort_order,
is_active: form.is_active, is_active: form.is_active,
}; };
console.log('[FaqCategoryForm] payload:', payload);
console.log('[FaqCategoryForm] isEdit:', isEdit.value);
console.log('[FaqCategoryForm] study.currentStudy:', study.currentStudy);
if (isEdit.value && props.category) { if (isEdit.value && props.category) {
await updateFaqCategory(props.category.id, payload); await updateFaqCategory(props.category.id, payload);
} else { } else {
@@ -45,7 +45,7 @@
</el-table> </el-table>
</div> </div>
<el-dialog append-to=".layout-main .content-wrapper" v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false"> <el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error"> <div v-if="previewError" class="preview-error">
{{ previewError }} {{ previewError }}
</div> </div>
+1
View File
@@ -58,6 +58,7 @@ export interface CenterConfirmDraft {
} }
export interface SetupConfigDraft { export interface SetupConfigDraft {
projectInfo: ProjectPublishSnapshot;
projectMilestones: ProjectMilestoneDraft[]; projectMilestones: ProjectMilestoneDraft[];
enrollmentPlan: EnrollmentPlanDraft; enrollmentPlan: EnrollmentPlanDraft;
siteMilestones: SiteMilestoneDraft[]; siteMilestones: SiteMilestoneDraft[];
+22
View File
@@ -32,6 +32,28 @@ const statusLabelMap: Record<string, string> = {
}; };
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = { const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
projectInfo: {
code: "项目编号",
name: "项目简称",
project_full_name: "项目全称",
sponsor: "申办方",
protocol_no: "方案号",
lead_unit: "组长单位",
principal_investigator: "主要研究者",
main_pm: "主PM",
research_analysis: "研究分期",
research_product: "研究产品",
control_product: "对照产品",
indication: "适应症",
research_population: "研究人群",
research_design: "研究设计",
status: "项目状态",
plan_start_date: "计划开始日期",
plan_end_date: "计划结束日期",
planned_site_count: "计划中心数",
planned_enrollment_count: "计划入组例数",
visit_schedule: "访视计划",
},
projectMilestones: { projectMilestones: {
id: "ID", id: "ID",
name: "里程碑", name: "里程碑",
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { ProjectPublishSnapshot } from "../types/setupConfig";
import { buildProjectDiffRows } from "./setupPublishWorkflow";
import { serializeDiffValue } from "./setupDiffRows";
const emptyProjectSnapshot = (): ProjectPublishSnapshot => ({
code: "",
name: "",
project_full_name: "",
sponsor: "",
protocol_no: "",
lead_unit: "",
principal_investigator: "",
main_pm: "",
research_analysis: "",
research_product: "",
control_product: "",
indication: "",
research_population: "",
research_design: "",
plan_start_date: "",
plan_end_date: "",
planned_site_count: null,
planned_enrollment_count: null,
status: "",
visit_schedule: [],
});
describe("setup publish workflow project diff rows", () => {
it("shows visit schedule changes under the summary module", () => {
const current = emptyProjectSnapshot();
current.visit_schedule = [
{
visit_code: "V1",
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 3,
},
];
const rows = buildProjectDiffRows(current, emptyProjectSnapshot(), serializeDiffValue);
expect(rows).toEqual([
{
moduleLabel: "方案摘要",
path: "访视计划",
changeType: "修改",
localValue: "已配置列表(1项)",
serverValue: "已配置列表(0项)",
},
]);
});
it("does not treat equivalent visit schedule arrays as changed", () => {
const current = emptyProjectSnapshot();
const base = emptyProjectSnapshot();
current.visit_schedule = [
{
visit_code: "V1",
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 3,
},
];
base.visit_schedule = [
{
visit_code: "V1",
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 3,
},
];
expect(buildProjectDiffRows(current, base, serializeDiffValue)).toEqual([]);
});
});
+30 -2
View File
@@ -122,6 +122,34 @@ const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
visit_schedule: "访视计划", visit_schedule: "访视计划",
}; };
const PROJECT_FIELD_MODULE_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
code: "项目信息",
name: "项目信息",
project_full_name: "项目信息",
sponsor: "项目信息",
protocol_no: "项目信息",
lead_unit: "项目信息",
principal_investigator: "项目信息",
main_pm: "项目信息",
research_analysis: "研究信息",
research_product: "研究信息",
control_product: "研究信息",
indication: "研究信息",
research_population: "研究信息",
research_design: "研究信息",
plan_start_date: "执行信息",
plan_end_date: "执行信息",
planned_site_count: "执行信息",
planned_enrollment_count: "执行信息",
status: "执行信息",
visit_schedule: "方案摘要",
};
const isProjectFieldValueEqual = (currentValue: unknown, baseValue: unknown): boolean => {
if (currentValue === baseValue) return true;
return JSON.stringify(currentValue ?? null) === JSON.stringify(baseValue ?? null);
};
export const buildProjectDiffRows = ( export const buildProjectDiffRows = (
currentSnapshot: ProjectPublishSnapshot, currentSnapshot: ProjectPublishSnapshot,
baseSnapshot: ProjectPublishSnapshot | null, baseSnapshot: ProjectPublishSnapshot | null,
@@ -130,9 +158,9 @@ export const buildProjectDiffRows = (
if (!baseSnapshot) return []; if (!baseSnapshot) return [];
const rows: SetupDiffRow[] = []; const rows: SetupDiffRow[] = [];
(Object.keys(PROJECT_FIELD_LABEL_MAP) as Array<keyof ProjectPublishSnapshot>).forEach((key) => { (Object.keys(PROJECT_FIELD_LABEL_MAP) as Array<keyof ProjectPublishSnapshot>).forEach((key) => {
if (currentSnapshot[key] === baseSnapshot[key]) return; if (isProjectFieldValueEqual(currentSnapshot[key], baseSnapshot[key])) return;
rows.push({ rows.push({
moduleLabel: "项目信息", moduleLabel: PROJECT_FIELD_MODULE_LABEL_MAP[key],
path: PROJECT_FIELD_LABEL_MAP[key], path: PROJECT_FIELD_LABEL_MAP[key],
changeType: "修改", changeType: "修改",
localValue: serializeValue(currentSnapshot[key]), localValue: serializeValue(currentSnapshot[key]),
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readLoginView = () => readFileSync(resolve(__dirname, "./Login.vue"), "utf8");
describe("Login protocol agreement", () => {
it("requires protocol agreement before calling login", () => {
const source = readLoginView();
const protocolGuardIndex = source.indexOf("!form.agreeProtocol");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
expect(source).toContain('v-model="form.agreeProtocol"');
expect(source).toContain("我已阅读并同意");
expect(source).toContain("请先阅读并同意用户协议");
expect(protocolGuardIndex).toBeGreaterThan(-1);
expect(loginCallIndex).toBeGreaterThan(-1);
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
});
it("offers remember password through browser credentials without local password storage", () => {
const source = readLoginView();
expect(source).toContain('v-model="form.rememberPassword"');
expect(source).toContain("记住密码");
expect(source).toContain('autocomplete="username"');
expect(source).toContain('name="username"');
expect(source).toContain('name="password"');
expect(source).toContain("tryLoadBrowserCredential");
expect(source).toContain("tryStoreBrowserCredential");
expect(source).toContain("navigator.credentials");
expect(source).toContain("password: true");
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"');
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
});
it("opens a CTMS-specific protocol dialog from the protocol text", () => {
const source = readLoginView();
expect(source).toContain('v-model="protocolDialogVisible"');
expect(source).toContain('@click.stop.prevent="openProtocolDialog"');
expect(source).toContain("protocolSections");
expect(source).toContain("临床试验数据与合规");
expect(source).toContain("项目、中心、受试者、访视、AE/SAE、PD");
expect(source).toContain("权限与审计");
expect(source).toContain("confirmProtocol");
});
});
+273 -2
View File
@@ -42,7 +42,8 @@
type="email" type="email"
placeholder="请输入邮箱" placeholder="请输入邮箱"
size="large" size="large"
autocomplete="email" name="username"
autocomplete="username"
class="login-input" class="login-input"
> >
<template #prefix> <template #prefix>
@@ -62,6 +63,7 @@
placeholder="请输入密码" placeholder="请输入密码"
show-password show-password
size="large" size="large"
name="password"
autocomplete="current-password" autocomplete="current-password"
class="login-input" class="login-input"
> >
@@ -74,6 +76,21 @@
</el-input> </el-input>
</el-form-item> </el-form-item>
<div class="login-options">
<el-checkbox v-model="form.rememberPassword" class="remember-checkbox">
<span class="remember-text">记住密码</span>
</el-checkbox>
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text">
我已阅读并同意
<button type="button" class="protocol-link" @click.stop.prevent="openProtocolDialog">
用户协议
</button>
登录即代表同意当前协议
</span>
</el-checkbox>
</div>
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn"> <el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
</el-button> </el-button>
@@ -85,6 +102,27 @@
</p> </p>
</div> </div>
</div> </div>
<el-dialog
v-model="protocolDialogVisible"
title="用户协议"
width="860px"
class="protocol-dialog"
append-to-body
align-center
>
<div class="protocol-content">
<section v-for="section in protocolSections" :key="section.title" class="protocol-section">
<h3>{{ section.title }}</h3>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
</section>
</div>
<template #footer>
<el-button type="primary" size="large" class="protocol-confirm-btn" @click="confirmProtocol">
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -99,11 +137,17 @@ 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";
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
type StoredPasswordCredential = Credential & { id?: string; password?: string };
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const form = reactive({ const form = reactive({
email: "", email: "",
password: "", password: "",
rememberPassword: false,
agreeProtocol: false,
}); });
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
@@ -115,22 +159,118 @@ const rules: FormRules<typeof form> = {
}; };
const loading = ref(false); const loading = ref(false);
const protocolDialogVisible = ref(false);
onMounted(() => { const protocolSections = [
{
title: "1. 服务条款",
paragraphs: [
"本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。",
"用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。",
],
},
{
title: "2. 账号安全",
paragraphs: [
"用户应妥善保管登录账号、密码和浏览器会话,不得转让、出借、共享账号,不得使用他人账号访问项目数据。因账号保管不当造成的数据泄露、误操作或审计异常,由账号所属用户及其机构承担相应责任。",
"系统支持邮箱密码登录、账号审核、启用停用、项目内角色授权和操作级权限控制。用户发现账号异常、权限错误、设备遗失或疑似未授权访问时,应立即联系管理员处理。",
],
},
{
title: "3. 临床试验数据与合规",
paragraphs: [
"用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、特殊费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。",
"涉及受试者、研究者、中心联系人、医学事件、财务附件、合同文件或其他敏感信息时,用户应遵循最小必要原则,不得上传与当前项目管理无关的数据,不得通过截图、导出、复制或外部工具进行未授权传播。",
],
},
{
title: "4. 权限与审计",
paragraphs: [
"系统按照管理员、PM、CRA、PV、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。",
"系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。",
],
},
{
title: "5. 隐私与数据保护",
paragraphs: [
"平台将按照业务需要处理用户账号信息、项目角色、操作记录以及临床试验管理过程中产生的业务数据,并采取访问控制、登录加密、会话管理和审计追踪等措施保护数据安全。",
"除法律法规、监管要求、机构授权或项目管理必要场景外,平台不会主动向无关第三方披露业务数据。用户应自行确认其录入和上传的数据已取得必要授权或具备合法处理依据。",
],
},
{
title: "6. 免责声明",
paragraphs: [
"因网络故障、浏览器兼容性、用户设备异常、第三方服务中断、不可抗力或用户误操作导致的服务中断、数据延迟、展示异常或操作失败,平台将在合理范围内协助排查,但不承担超出适用法律和合同约定范围的责任。",
"用户继续登录和使用系统,即表示已阅读、理解并同意本协议。若不同意本协议内容,应停止登录并联系管理员或所属机构负责人处理。",
],
},
];
onMounted(async () => {
const logoutReason = consumeLogoutReason(); const logoutReason = consumeLogoutReason();
if (logoutReason === LOGOUT_REASON_TIMEOUT) { if (logoutReason === LOGOUT_REASON_TIMEOUT) {
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录"); ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
} }
form.email = localStorage.getItem("ctms_last_login_email") || ""; form.email = localStorage.getItem("ctms_last_login_email") || "";
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
await tryLoadBrowserCredential();
}); });
const tryLoadBrowserCredential = async () => {
if (!form.rememberPassword || !navigator.credentials?.get) return;
try {
const credential = (await navigator.credentials.get({
password: true,
mediation: "optional",
} as CredentialRequestOptions)) as StoredPasswordCredential | null;
if (!credential?.password) return;
form.email = credential.id || form.email;
form.password = credential.password;
} catch {
// 浏览器可能不支持读取密码凭据,保留用户手动输入流程。
}
};
const tryStoreBrowserCredential = async (email: string, password: string) => {
if (!form.rememberPassword) {
localStorage.removeItem(REMEMBER_PASSWORD_KEY);
return;
}
localStorage.setItem(REMEMBER_PASSWORD_KEY, "true");
const PasswordCredential = (window as typeof window & { PasswordCredential?: PasswordCredentialConstructor })
.PasswordCredential;
if (!navigator.credentials?.store || !PasswordCredential) return;
try {
await navigator.credentials.store(new PasswordCredential({ id: email, password, name: email }));
} catch {
// 浏览器或用户可能拒绝保存凭据,不影响登录主流程。
}
};
const openProtocolDialog = () => {
protocolDialogVisible.value = true;
};
const confirmProtocol = () => {
form.agreeProtocol = true;
protocolDialogVisible.value = false;
};
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
const valid = await formRef.value.validate(); const valid = await formRef.value.validate();
if (!valid) return; if (!valid) return;
if (!form.agreeProtocol) {
ElMessage.warning("请先阅读并同意用户协议");
return;
}
loading.value = true; loading.value = true;
try { try {
await auth.login(form.email, form.password); await auth.login(form.email, form.password);
await tryStoreBrowserCredential(form.email, form.password);
const studyStore = useStudyStore(); const studyStore = useStudyStore();
if (!studyStore.currentStudy) { if (!studyStore.currentStudy) {
if (auth.user?.role === "ADMIN") { if (auth.user?.role === "ADMIN") {
@@ -309,6 +449,129 @@ const onSubmit = async () => {
transition: background-color 5000s ease-in-out 0s; transition: background-color 5000s ease-in-out 0s;
} }
.login-options {
display: flex;
flex-direction: column;
gap: 12px;
margin: -4px 0 26px;
}
.remember-checkbox,
.protocol-checkbox {
display: flex;
align-items: flex-start;
width: 100%;
min-height: 24px;
}
.remember-checkbox :deep(.el-checkbox__input),
.protocol-checkbox :deep(.el-checkbox__input) {
margin-top: 2px;
}
.remember-checkbox :deep(.el-checkbox__label),
.protocol-checkbox :deep(.el-checkbox__label) {
display: block;
padding-left: 10px;
line-height: 20px;
white-space: normal;
}
.remember-text {
color: #52657b;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.protocol-text {
color: #475569;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.protocol-link {
appearance: none;
padding: 0;
border: 0;
background: transparent;
color: #4da3ff;
cursor: pointer;
font: inherit;
font-weight: 800;
}
.protocol-link:hover {
color: #2f8fff;
text-decoration: underline;
}
.protocol-content {
max-height: 58vh;
overflow-y: auto;
padding: 2px 8px 2px 0;
}
.protocol-section {
margin-bottom: 26px;
}
.protocol-section:last-child {
margin-bottom: 0;
}
.protocol-section h3 {
margin: 0 0 14px;
color: #2f3946;
font-size: 18px;
font-weight: 800;
line-height: 1.4;
}
.protocol-section p {
margin: 0 0 10px;
color: #5f6b7a;
font-size: 15px;
font-weight: 600;
line-height: 1.8;
}
.protocol-section p:last-child {
margin-bottom: 0;
}
.protocol-confirm-btn {
min-width: 112px;
height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
background: linear-gradient(135deg, #3f5d75, #4a6d87);
border: none;
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
}
:global(.protocol-dialog .el-dialog__header) {
padding: 24px 28px 12px;
margin-right: 0;
}
:global(.protocol-dialog .el-dialog__title) {
color: #2f3946;
font-size: 20px;
font-weight: 800;
}
:global(.protocol-dialog .el-dialog__body) {
padding: 22px 36px 10px;
}
:global(.protocol-dialog .el-dialog__footer) {
padding: 18px 28px 24px;
}
.login-btn { .login-btn {
width: 100%; width: 100%;
height: 48px; height: 48px;
@@ -359,5 +622,13 @@ const onSubmit = async () => {
.sys-title { .sys-title {
font-size: 22px; font-size: 22px;
} }
:global(.protocol-dialog) {
width: calc(100vw - 32px) !important;
}
:global(.protocol-dialog .el-dialog__body) {
padding: 18px 22px 8px;
}
} }
</style> </style>
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readProjectDetail = () => readFileSync(resolve(__dirname, "./ProjectDetail.vue"), "utf8");
describe("ProjectDetail setup draft publish workflow", () => {
it("marks the draft server-synced after a successful server draft save", () => {
const source = readProjectDetail();
const functionIndex = source.indexOf("const persistSetupDraft = async");
const successIndex = source.indexOf("applySetupResponseMeta(data);", functionIndex);
const syncedIndex = source.indexOf("markSetupServerSynced();", successIndex);
expect(functionIndex).toBeGreaterThan(-1);
expect(successIndex).toBeGreaterThan(-1);
expect(syncedIndex).toBeGreaterThan(successIndex);
});
it("reconciles stale dirty flags before opening the publish dialog", () => {
const source = readProjectDetail();
const functionIndex = source.indexOf("const publishConfigNow = async");
const reconcileIndex = source.indexOf("reconcileDraftSyncStateBeforePublish();", functionIndex);
const unsavedGuardIndex = source.indexOf("if (hasSetupDraftUnsavedChanges.value)", functionIndex);
expect(functionIndex).toBeGreaterThan(-1);
expect(reconcileIndex).toBeGreaterThan(functionIndex);
expect(unsavedGuardIndex).toBeGreaterThan(reconcileIndex);
});
it("uses an empty project snapshot as the first publish diff baseline", () => {
const source = readProjectDetail();
const computedIndex = source.indexOf("const projectPublishCompareBase = computed");
const emptyBaselineIndex = source.indexOf("return isFirstPublish.value ? emptyProjectInfoDraft() : null;", computedIndex);
expect(computedIndex).toBeGreaterThan(-1);
expect(emptyBaselineIndex).toBeGreaterThan(computedIndex);
});
it("clears the full setup draft including project info", () => {
const source = readProjectDetail();
const functionIndex = source.indexOf("const handleClearDraft = async");
const confirmTextIndex = source.indexOf("确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。", functionIndex);
const applyServerDataIndex = source.indexOf("applySetupDraft(clone(data.data));", functionIndex);
const clearProjectDraftIndex = source.indexOf("clearLocalProjectDraft();", applyServerDataIndex);
expect(functionIndex).toBeGreaterThan(-1);
expect(confirmTextIndex).toBeGreaterThan(functionIndex);
expect(applyServerDataIndex).toBeGreaterThan(confirmTextIndex);
expect(clearProjectDraftIndex).toBeGreaterThan(applyServerDataIndex);
expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1);
});
it("keeps early termination out of regular visit schedule options", () => {
const source = readProjectDetail();
expect(source).toContain('const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"];');
expect(source).toContain("提前终止不作为常规计划访视配置");
});
});
+159 -212
View File
@@ -1696,7 +1696,7 @@
<el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" /> <el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" />
<el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" /> <el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" />
</el-option-group> </el-option-group>
<el-option-group label="特殊访视"> <el-option-group label="计划特殊访视">
<el-option <el-option
v-for="option in specialVisitOptions" v-for="option in specialVisitOptions"
:key="option" :key="option"
@@ -1725,6 +1725,12 @@
class="visit-schedule-remove" class="visit-schedule-remove"
@click="removeVisitScheduleRow(index)" @click="removeVisitScheduleRow(index)"
/> />
</div>
</div>
<div class="visit-special-rule">
<div class="visit-special-rule-title">特殊访视规则</div>
<div class="visit-special-rule-body">
提前终止不作为常规计划访视配置受试者发生提前终止时在访视页通过提前终止单独录入系统会自动关闭后续未完成计划访视
</div> </div>
</div> </div>
</el-form> </el-form>
@@ -2044,7 +2050,6 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "elem
import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFilled, ArrowDown, Close, List, EditPen, Connection, FolderOpened, Clock, RefreshLeft, DocumentCopy, Download, Delete } from "@element-plus/icons-vue"; import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFilled, ArrowDown, Close, List, EditPen, Connection, FolderOpened, Clock, RefreshLeft, DocumentCopy, Download, Delete } from "@element-plus/icons-vue";
import { import {
fetchStudyDetail, fetchStudyDetail,
updateStudy,
} from "../../api/studies"; } from "../../api/studies";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
@@ -2240,7 +2245,6 @@ const draftReady = ref(false);
const suppressDraftWatch = ref(false); const suppressDraftWatch = ref(false);
const autoSaveTimer = ref<number | undefined>(undefined); const autoSaveTimer = ref<number | undefined>(undefined);
const setupDirtySinceLastPersist = ref(false); const setupDirtySinceLastPersist = ref(false);
const projectDirtySinceLastPersist = ref(false);
const suppressEnrollmentFieldSync = ref(false); const suppressEnrollmentFieldSync = ref(false);
const enrollmentPlanCycle = ref<EnrollmentCycle>("month"); const enrollmentPlanCycle = ref<EnrollmentCycle>("month");
const enrollmentTargetsByCycle = ref<Record<EnrollmentCycle, Record<string, number>>>({ const enrollmentTargetsByCycle = ref<Record<EnrollmentCycle, Record<string, number>>>({
@@ -2358,9 +2362,7 @@ const publishedProjectSnapshot = ref<ProjectPublishSnapshot | null>(null);
const projectPublishCompareFallback = ref<ProjectPublishSnapshot | null>(null); const projectPublishCompareFallback = ref<ProjectPublishSnapshot | null>(null);
const projectSnapshotAtLoad = ref<ProjectPublishSnapshot | null>(null); const projectSnapshotAtLoad = ref<ProjectPublishSnapshot | null>(null);
const setupServerSnapshot = ref(""); const setupServerSnapshot = ref("");
const projectServerSnapshot = ref("");
const setupLocalDraftSavedAt = ref(""); const setupLocalDraftSavedAt = ref("");
const projectLocalDraftSavedAt = ref("");
const setupSaveMeta = ref<{ updatedAt: string; savedBy: string | null; serverSynced: boolean }>({ const setupSaveMeta = ref<{ updatedAt: string; savedBy: string | null; serverSynced: boolean }>({
updatedAt: "", updatedAt: "",
savedBy: null, savedBy: null,
@@ -2400,7 +2402,7 @@ const form = ref({
const formBaselineSnapshot = ref(""); const formBaselineSnapshot = ref("");
const regularVisitOptionValue = "__REGULAR_VISIT__"; const regularVisitOptionValue = "__REGULAR_VISIT__";
const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__"; const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__";
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"]; const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"];
const getNextRegularVisitCode = (excludeIndex?: number): string => { const getNextRegularVisitCode = (excludeIndex?: number): string => {
const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => { const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
@@ -2538,7 +2540,31 @@ const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): stri
const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days}`; const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days}`;
const emptyProjectInfoDraft = (): ProjectPublishSnapshot => ({
code: "",
name: "",
project_full_name: "",
sponsor: "",
protocol_no: "",
lead_unit: "",
principal_investigator: "",
main_pm: "",
research_analysis: "",
research_product: "",
control_product: "",
indication: "",
research_population: "",
research_design: "",
plan_start_date: "",
plan_end_date: "",
planned_site_count: null,
planned_enrollment_count: null,
status: "",
visit_schedule: [],
});
const setupDraft = reactive<SetupConfigDraft>({ const setupDraft = reactive<SetupConfigDraft>({
projectInfo: emptyProjectInfoDraft(),
projectMilestones: [], projectMilestones: [],
enrollmentPlan: { enrollmentPlan: {
totalTarget: 0, totalTarget: 0,
@@ -2628,10 +2654,10 @@ const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps
const setupPublishedVersionText = computed(() => { const setupPublishedVersionText = computed(() => {
return setupPublishedVersion.value || "v0"; return setupPublishedVersion.value || "v0";
}); });
const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value || projectDirtySinceLastPersist.value); const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value);
const draftSyncStatus = computed<DraftSyncStatus>(() => { const draftSyncStatus = computed<DraftSyncStatus>(() => {
const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value); const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value);
if (formDirty || setupDirtySinceLastPersist.value || projectDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) { if (formDirty || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
return "DIRTY_UNSAVED"; return "DIRTY_UNSAVED";
} }
return setupSaveMeta.value.serverSynced ? "SYNCED" : "LOCAL_ONLY"; return setupSaveMeta.value.serverSynced ? "SYNCED" : "LOCAL_ONLY";
@@ -2702,7 +2728,14 @@ const serializeFormForCompare = (): string =>
status: form.value.status || "", status: form.value.status || "",
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule), visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
}); });
const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft)); const setupDraftForPersistence = (): SetupConfigDraft => ({
...clone(setupDraft),
projectInfo: buildProjectPublishSnapshot(),
});
const syncProjectInfoIntoSetupDraft = () => {
setupDraft.projectInfo = buildProjectPublishSnapshot();
};
const serializeSetupForCompare = (): string => JSON.stringify(setupDraftForPersistence());
const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({ const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({
code: study?.code || "", code: study?.code || "",
name: study?.name || "", name: study?.name || "",
@@ -2750,8 +2783,6 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
}); });
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot()); const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value)); const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
const hasProjectDiffFromServer = (): boolean =>
Boolean(projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value);
const hasSetupDiffFromServer = (): boolean => const hasSetupDiffFromServer = (): boolean =>
Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value); Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value);
const clearPendingAutoSave = () => { const clearPendingAutoSave = () => {
@@ -2759,24 +2790,30 @@ const clearPendingAutoSave = () => {
window.clearTimeout(autoSaveTimer.value); window.clearTimeout(autoSaveTimer.value);
autoSaveTimer.value = undefined; autoSaveTimer.value = undefined;
}; };
const refreshProjectDirtyState = (): boolean => {
projectDirtySinceLastPersist.value = hasProjectDiffFromServer();
return projectDirtySinceLastPersist.value;
};
const refreshSetupDirtyState = (): boolean => { const refreshSetupDirtyState = (): boolean => {
setupDirtySinceLastPersist.value = hasSetupDiffFromServer(); setupDirtySinceLastPersist.value = hasSetupDiffFromServer();
return setupDirtySinceLastPersist.value; return setupDirtySinceLastPersist.value;
}; };
const markServerSyncedIfNoLocalChanges = () => { const markServerSyncedIfNoLocalChanges = () => {
if (projectDirtySinceLastPersist.value || setupDirtySinceLastPersist.value || autoSaveTimer.value) return; if (setupDirtySinceLastPersist.value || autoSaveTimer.value) return;
if (setupSaveMeta.value.serverSynced) return; if (setupSaveMeta.value.serverSynced) return;
setupSaveMeta.value = { setupSaveMeta.value = {
...setupSaveMeta.value, ...setupSaveMeta.value,
serverSynced: true, serverSynced: true,
}; };
}; };
const reconcileDraftSyncStateBeforePublish = () => {
const changed = refreshSetupDirtyState();
if (changed) return;
clearPendingAutoSave();
markSetupSynced();
if (setupSaveMeta.value.serverSynced) {
clearLocalSetupDraft();
clearLocalProjectDraft();
}
};
const hasUnsavedChanges = computed( const hasUnsavedChanges = computed(
() => hasProjectPendingChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value) () => hasFormUnsavedChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
); );
const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)); const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value));
const hasLeaveGuardChanges = computed( const hasLeaveGuardChanges = computed(
@@ -2999,6 +3036,13 @@ const clearSetupValidationErrors = () => {
const markSetupSynced = () => { const markSetupSynced = () => {
setupDirtySinceLastPersist.value = false; setupDirtySinceLastPersist.value = false;
}; };
const markSetupServerSynced = () => {
clearPendingAutoSave();
setupServerSnapshot.value = serializeSetupForCompare();
markSetupSynced();
clearLocalSetupDraft();
clearLocalProjectDraft();
};
const updateLastServerSaveMeta = (updatedAt?: string | null, savedBy?: string | null) => { const updateLastServerSaveMeta = (updatedAt?: string | null, savedBy?: string | null) => {
lastServerSaveMeta.value = { lastServerSaveMeta.value = {
updatedAt: formatDisplayTime(updatedAt) || lastServerSaveMeta.value.updatedAt || "-", updatedAt: formatDisplayTime(updatedAt) || lastServerSaveMeta.value.updatedAt || "-",
@@ -3918,6 +3962,7 @@ watch(
const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => { const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => {
return { return {
projectInfo: buildProjectPublishSnapshot(),
projectMilestones: [], projectMilestones: [],
enrollmentPlan: { enrollmentPlan: {
totalTarget: 0, totalTarget: 0,
@@ -3943,6 +3988,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const data = value as Record<string, unknown>; const data = value as Record<string, unknown>;
return ( return (
(!Object.prototype.hasOwnProperty.call(data, "projectInfo") || isProjectPublishSnapshotShape(data.projectInfo)) &&
Array.isArray(data.projectMilestones) && Array.isArray(data.projectMilestones) &&
!!data.enrollmentPlan && !!data.enrollmentPlan &&
Array.isArray(data.siteMilestones) && Array.isArray(data.siteMilestones) &&
@@ -3963,14 +4009,43 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS
); );
}; };
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
suppressEnrollmentFieldSync.value = true;
form.value.code = snapshot.code || "";
form.value.name = snapshot.name || "";
form.value.project_full_name = snapshot.project_full_name || "";
form.value.sponsor = snapshot.sponsor || "";
form.value.protocol_no = snapshot.protocol_no || "";
form.value.lead_unit = snapshot.lead_unit || "";
form.value.principal_investigator = snapshot.principal_investigator || "";
form.value.main_pm = snapshot.main_pm || "";
form.value.research_analysis = snapshot.research_analysis || "";
form.value.research_product = snapshot.research_product || "";
form.value.control_product = snapshot.control_product || "";
form.value.indication = snapshot.indication || "";
form.value.research_population = snapshot.research_population || "";
form.value.research_design = snapshot.research_design || "";
form.value.plan_start_date = snapshot.plan_start_date || "";
form.value.plan_end_date = snapshot.plan_end_date || "";
form.value.planned_site_count = snapshot.planned_site_count ?? null;
form.value.planned_enrollment_count = snapshot.planned_enrollment_count ?? null;
form.value.status = snapshot.status || "DRAFT";
form.value.visit_schedule = normalizeVisitSchedule(snapshot.visit_schedule);
suppressEnrollmentFieldSync.value = false;
};
const applySetupDraft = (draft: SetupConfigDraft) => { const applySetupDraft = (draft: SetupConfigDraft) => {
suppressDraftWatch.value = true; suppressDraftWatch.value = true;
const projectInfo = isProjectPublishSnapshotShape(draft.projectInfo) ? clone(draft.projectInfo) : buildProjectPublishSnapshot();
setupDraft.projectInfo = projectInfo;
setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones)); setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones));
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.monitoringStrategies = clone(draft.monitoringStrategies);
setupDraft.centerConfirm = clone(draft.centerConfirm); setupDraft.centerConfirm = clone(draft.centerConfirm);
applyProjectInfoDraft(projectInfo);
formBaselineSnapshot.value = serializeFormForCompare();
suppressDraftWatch.value = false; suppressDraftWatch.value = false;
}; };
@@ -3995,7 +4070,7 @@ const persistSetupDraftToLocal = () => {
storageKey.value, storageKey.value,
JSON.stringify({ JSON.stringify({
version: DRAFT_VERSION, version: DRAFT_VERSION,
data: clone(setupDraft), data: setupDraftForPersistence(),
savedAt, savedAt,
savedAtMs: nowMs(), savedAtMs: nowMs(),
ttlMs: LOCAL_DRAFT_TTL_MS, ttlMs: LOCAL_DRAFT_TTL_MS,
@@ -4020,7 +4095,7 @@ const cacheSetupDraftToLocal = () => {
storageKey.value, storageKey.value,
JSON.stringify({ JSON.stringify({
version: DRAFT_VERSION, version: DRAFT_VERSION,
data: clone(setupDraft), data: setupDraftForPersistence(),
savedAt, savedAt,
savedAtMs: nowMs(), savedAtMs: nowMs(),
ttlMs: LOCAL_DRAFT_TTL_MS, ttlMs: LOCAL_DRAFT_TTL_MS,
@@ -4033,78 +4108,9 @@ const cacheSetupDraftToLocal = () => {
} }
}; };
const loadLocalProjectDraft = (): LocalDraftLoadResult<Record<string, unknown>> | null => {
try {
const raw = localStorage.getItem(projectDraftStorageKey.value);
if (!raw) return null;
return parseLocalDraftEnvelope(raw, (candidate) => {
if (!candidate || typeof candidate !== "object") return null;
return clone(candidate as Record<string, unknown>);
});
} catch {
return null;
}
};
const applyProjectDraft = (draft: Record<string, unknown>) => {
const keys = Object.keys(form.value) as Array<keyof typeof form.value>;
keys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(draft, key)) {
(form.value[key] as any) = draft[key as string] as any;
}
});
};
const persistProjectDraftToLocal = () => {
try {
const savedAt = nowString();
localStorage.setItem(
projectDraftStorageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(form.value),
savedAt,
savedAtMs: nowMs(),
ttlMs: LOCAL_DRAFT_TTL_MS,
})
);
projectLocalDraftSavedAt.value = savedAt;
projectDirtySinceLastPersist.value = Boolean(
projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value
);
setupSaveMeta.value = {
updatedAt: savedAt,
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
} catch {
// ignore local storage errors
}
};
const cacheProjectDraftToLocal = () => {
try {
const savedAt = nowString();
localStorage.setItem(
projectDraftStorageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(form.value),
savedAt,
savedAtMs: nowMs(),
ttlMs: LOCAL_DRAFT_TTL_MS,
})
);
projectLocalDraftSavedAt.value = savedAt;
} catch {
// ignore local storage errors
}
};
const clearLocalProjectDraft = () => { const clearLocalProjectDraft = () => {
try { try {
localStorage.removeItem(projectDraftStorageKey.value); localStorage.removeItem(projectDraftStorageKey.value);
projectLocalDraftSavedAt.value = "";
} catch { } catch {
// ignore local storage errors // ignore local storage errors
} }
@@ -4154,7 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by), savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true, serverSynced: true,
}; };
return clone(data.data); return {
...clone(data.data),
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
};
} catch { } catch {
return null; return null;
} }
@@ -4177,6 +4186,7 @@ const setupModuleLabels: Array<{ key: keyof SetupConfigDraft; label: string }> =
{ key: "centerConfirm", label: "中心确认" }, { key: "centerConfirm", label: "中心确认" },
]; ];
const emptySetupDraft: SetupConfigDraft = { const emptySetupDraft: SetupConfigDraft = {
projectInfo: emptyProjectInfoDraft(),
projectMilestones: [], projectMilestones: [],
enrollmentPlan: { enrollmentPlan: {
totalTarget: 0, totalTarget: 0,
@@ -4199,7 +4209,8 @@ const hasBranchPublishDiff = computed(() => {
}); });
const projectPublishCompareBase = computed<ProjectPublishSnapshot | null>(() => { const projectPublishCompareBase = computed<ProjectPublishSnapshot | null>(() => {
const base = resolveProjectPublishCompareBase(publishedProjectSnapshot.value, projectPublishCompareFallback.value); const base = resolveProjectPublishCompareBase(publishedProjectSnapshot.value, projectPublishCompareFallback.value);
return base ? clone(base) : null; if (base) return clone(base);
return isFirstPublish.value ? emptyProjectInfoDraft() : null;
}); });
const hasProjectPublishDiff = computed(() => { const hasProjectPublishDiff = computed(() => {
return hasProjectSnapshotDiff(buildProjectPublishSnapshot(), projectPublishCompareBase.value); return hasProjectSnapshotDiff(buildProjectPublishSnapshot(), projectPublishCompareBase.value);
@@ -4278,7 +4289,7 @@ const refreshSetupDraftFromServer = async (options?: { silent?: boolean }) => {
if (!latest) return false; if (!latest) return false;
applySetupDraft(latest); applySetupDraft(latest);
clearSetupValidationErrors(); clearSetupValidationErrors();
markSetupSynced(); markSetupServerSynced();
if (!options?.silent) { if (!options?.silent) {
ElMessage.info("已刷新服务端最新配置"); ElMessage.info("已刷新服务端最新配置");
} }
@@ -4292,26 +4303,20 @@ const persistSetupDraft = async (
if (!project.value) return { serverSynced: false, retryable: false }; if (!project.value) return { serverSynced: false, retryable: false };
const payload: StudySetupConfigUpsertPayload = { const payload: StudySetupConfigUpsertPayload = {
expected_version: setupRevision.value, expected_version: setupRevision.value,
data: clone(setupDraft), data: setupDraftForPersistence(),
force_draft: Boolean(options?.forceDraft), force_draft: Boolean(options?.forceDraft),
}; };
try { try {
const { data } = await upsertSetupConfig(project.value.id, payload); const { data } = await upsertSetupConfig(project.value.id, payload);
applySetupResponseMeta(data); applySetupResponseMeta(data);
clearSetupValidationErrors(); clearSetupValidationErrors();
markSetupSynced(); markSetupServerSynced();
setupServerSnapshot.value = serializeSetupForCompare();
clearLocalSetupDraft();
setupSaveMeta.value = { setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at), updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by), savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true, serverSynced: true,
}; };
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value);
autoSaveTimer.value = undefined;
}
return { serverSynced: true, retryable: false }; return { serverSynced: true, retryable: false };
} catch (e: any) { } catch (e: any) {
if (handleValidationFailure(e, "配置保存失败,请修正后重试")) { if (handleValidationFailure(e, "配置保存失败,请修正后重试")) {
@@ -4582,37 +4587,9 @@ const loadProject = async () => {
project.value = data as Study; project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value); syncFormFromProjectWithoutSetupLink(project.value);
projectSnapshotAtLoad.value = buildProjectPublishSnapshot(); projectSnapshotAtLoad.value = buildProjectPublishSnapshot();
projectServerSnapshot.value = serializeFormForCompare(); clearLocalProjectDraft();
const serverProjectSnapshot = serializeFormForCompare();
const localProjectDraft = loadLocalProjectDraft();
if (localProjectDraft) {
const shouldRestore = await confirmRestoreLocalDraft("恢复项目基础信息草稿", localProjectDraft.savedAt, {
expired: localProjectDraft.expired,
});
if (shouldRestore) {
applyProjectDraft(localProjectDraft.data);
const localSnapshot = serializeFormForCompare();
if (localSnapshot !== serverProjectSnapshot) {
projectDirtySinceLastPersist.value = true;
projectLocalDraftSavedAt.value = localProjectDraft.savedAt;
setupSaveMeta.value = {
updatedAt: localProjectDraft.savedAt || nowString(),
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
} else {
clearLocalProjectDraft();
projectDirtySinceLastPersist.value = false;
}
} else {
clearLocalProjectDraft();
projectDirtySinceLastPersist.value = false;
}
} else {
projectDirtySinceLastPersist.value = false;
}
formBaselineSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare();
if (setupSaveMeta.value.serverSynced && !projectDirtySinceLastPersist.value) { if (setupSaveMeta.value.serverSynced) {
setupDirtySinceLastPersist.value = false; setupDirtySinceLastPersist.value = false;
if (autoSaveTimer.value) { if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value); window.clearTimeout(autoSaveTimer.value);
@@ -4638,7 +4615,6 @@ const refreshProjectDisplayAfterPublish = async () => {
const { data } = await fetchStudyDetail(project.value.id); const { data } = await fetchStudyDetail(project.value.id);
project.value = data as Study; project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value); syncFormFromProjectWithoutSetupLink(project.value);
projectServerSnapshot.value = serializeFormForCompare();
formBaselineSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare();
if (studyStore.currentStudy?.id === project.value.id) { if (studyStore.currentStudy?.id === project.value.id) {
studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study); studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study);
@@ -4693,14 +4669,7 @@ const startStep1SectionEdit = (section: Exclude<Step1EditSection, "all">) => {
}; };
const cancelEdit = () => { const cancelEdit = () => {
syncFormFromProjectWithoutSetupLink(project.value); applyProjectInfoDraft(setupDraft.projectInfo);
if (projectDirtySinceLastPersist.value) {
const localProjectDraft = loadLocalProjectDraft();
if (localProjectDraft) {
applyProjectDraft(localProjectDraft.data);
formBaselineSnapshot.value = serializeFormForCompare();
}
}
isEditing.value = false; isEditing.value = false;
step1EditSection.value = "all"; step1EditSection.value = "all";
visitScheduleDialogVisible.value = false; visitScheduleDialogVisible.value = false;
@@ -4753,13 +4722,16 @@ const saveVisitScheduleDialogEdit = async () => {
if (!canEditSetup.value) return; if (!canEditSetup.value) return;
drawerConfirmLoading.value = true; drawerConfirmLoading.value = true;
try { try {
const projectChanged = refreshProjectDirtyState(); syncProjectInfoIntoSetupDraft();
if (projectChanged) { const setupChanged = refreshSetupDirtyState();
persistProjectDraftToLocal(); if (setupChanged) {
persistSetupDraftToLocal();
} else { } else {
clearLocalProjectDraft(); clearLocalSetupDraft();
clearPendingAutoSave();
markServerSyncedIfNoLocalChanges(); markServerSyncedIfNoLocalChanges();
} }
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare();
visitScheduleDialogVisible.value = false; visitScheduleDialogVisible.value = false;
isEditing.value = false; isEditing.value = false;
@@ -4780,13 +4752,16 @@ const handleDrawerConfirm = async () => {
if (formRef.value) { if (formRef.value) {
await formRef.value.validate(); await formRef.value.validate();
} }
const projectChanged = refreshProjectDirtyState(); syncProjectInfoIntoSetupDraft();
if (projectChanged) { const setupChanged = refreshSetupDirtyState();
persistProjectDraftToLocal(); if (setupChanged) {
persistSetupDraftToLocal();
} else { } else {
clearLocalProjectDraft(); clearLocalSetupDraft();
clearPendingAutoSave();
markServerSyncedIfNoLocalChanges(); markServerSyncedIfNoLocalChanges();
} }
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare();
saved = true; saved = true;
} else { } else {
@@ -4808,62 +4783,10 @@ const handleDrawerConfirm = async () => {
} }
}; };
const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promise<boolean> => {
if (!project.value) return false;
if (formRef.value) {
await formRef.value.validate();
}
submitting.value = true;
try {
const { data } = await updateStudy(project.value.id, {
code: form.value.code.trim(),
name: form.value.name.trim(),
project_full_name: form.value.project_full_name?.trim() || null,
sponsor: form.value.sponsor?.trim() || null,
protocol_no: form.value.protocol_no?.trim() || null,
lead_unit: form.value.lead_unit?.trim() || null,
principal_investigator: form.value.principal_investigator?.trim() || null,
main_pm: form.value.main_pm?.trim() || null,
research_analysis: form.value.research_analysis?.trim() || null,
research_product: form.value.research_product?.trim() || null,
control_product: form.value.control_product?.trim() || null,
indication: form.value.indication?.trim() || null,
research_population: form.value.research_population?.trim() || null,
research_design: form.value.research_design?.trim() || null,
plan_start_date: form.value.plan_start_date || null,
plan_end_date: form.value.plan_end_date || null,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
phase: form.value.phase?.trim() || null,
status: form.value.status,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
projectServerSnapshot.value = serializeFormForCompare();
projectDirtySinceLastPersist.value = false;
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare();
if (closeAfterSave) {
isEditing.value = false;
step1EditSection.value = "all";
}
if (showSuccessMessage) {
ElMessage.success("项目信息已保存");
}
return true;
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, TEXT.common.messages.saveFailed));
return false;
} finally {
submitting.value = false;
}
};
const captureProjectCompareFallbackBeforeSave = (): ProjectPublishSnapshot | null => { const captureProjectCompareFallbackBeforeSave = (): ProjectPublishSnapshot | null => {
if ( if (
!shouldCaptureProjectCompareFallback({ !shouldCaptureProjectCompareFallback({
hasProjectPendingChanges: hasProjectPendingChanges.value, hasProjectPendingChanges: hasFormUnsavedChanges.value,
hasPublishedProjectSnapshot: Boolean(publishedProjectSnapshot.value), hasPublishedProjectSnapshot: Boolean(publishedProjectSnapshot.value),
isFirstPublish: isFirstPublish.value, isFirstPublish: isFirstPublish.value,
}) })
@@ -4880,11 +4803,11 @@ const applyCapturedProjectCompareFallback = (snapshot: ProjectPublishSnapshot |
const saveConfigNow = async (): Promise<boolean> => { const saveConfigNow = async (): Promise<boolean> => {
if (!canSaveDraftAction.value) return false; if (!canSaveDraftAction.value) return false;
const projectSnapshotBeforeSave = captureProjectCompareFallbackBeforeSave(); const projectSnapshotBeforeSave = captureProjectCompareFallbackBeforeSave();
if (hasProjectPendingChanges.value) { if (hasFormUnsavedChanges.value) {
const basicSaved = await saveEdit(false, false); syncProjectInfoIntoSetupDraft();
if (!basicSaved) return false; setupDirtySinceLastPersist.value = true;
} }
const result = await persistSetupDraftWithRetry(false, { forceDraft: hasProjectPendingChanges.value }); const result = await persistSetupDraftWithRetry(false, { forceDraft: true });
if (!result.serverSynced) return false; if (!result.serverSynced) return false;
applyCapturedProjectCompareFallback(projectSnapshotBeforeSave); applyCapturedProjectCompareFallback(projectSnapshotBeforeSave);
formBaselineSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare();
@@ -4894,9 +4817,13 @@ const saveConfigNow = async (): Promise<boolean> => {
const doPublishConfigNow = async (): Promise<boolean> => { const doPublishConfigNow = async (): Promise<boolean> => {
if (!project.value || !canPublishAction.value) return false; if (!project.value || !canPublishAction.value) return false;
try { try {
if (hasProjectPendingChanges.value) { if (hasFormUnsavedChanges.value) {
const basicSaved = await saveEdit(false, false); syncProjectInfoIntoSetupDraft();
if (!basicSaved) return false; setupDirtySinceLastPersist.value = true;
}
if (setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
const draftSaved = await persistSetupDraftWithRetry(false, { forceDraft: true });
if (!draftSaved.serverSynced) return false;
} }
const { data } = await publishConfig(project.value.id, { expected_version: setupRevision.value }); const { data } = await publishConfig(project.value.id, { expected_version: setupRevision.value });
applySetupResponseMeta(data); applySetupResponseMeta(data);
@@ -4950,6 +4877,7 @@ const doPublishConfigNow = async (): Promise<boolean> => {
const publishConfigNow = async () => { const publishConfigNow = async () => {
if (!project.value || !canManageSetup.value) return; if (!project.value || !canManageSetup.value) return;
reconcileDraftSyncStateBeforePublish();
if (hasSetupDraftUnsavedChanges.value) { if (hasSetupDraftUnsavedChanges.value) {
ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”"); ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”");
return; return;
@@ -5734,7 +5662,7 @@ const handleClearDraft = async () => {
if (!project.value || !canManageSetup.value) return; if (!project.value || !canManageSetup.value) return;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"确认清空当前立项配置草稿(第2-7步)?该操作不会影响已发布版本,也不会影响第1步项目信息主数据。", "确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。",
"清空草稿确认", "清空草稿确认",
{ {
type: "warning", type: "warning",
@@ -5751,10 +5679,11 @@ const handleClearDraft = async () => {
expected_version: setupRevision.value, expected_version: setupRevision.value,
}); });
applySetupResponseMeta(data); applySetupResponseMeta(data);
applySetupDraft(createDefaultSetupDraft(siteOptions.value)); applySetupDraft(clone(data.data));
clearSetupValidationErrors(); clearSetupValidationErrors();
markSetupSynced(); markSetupSynced();
clearLocalSetupDraft(); clearLocalSetupDraft();
clearLocalProjectDraft();
setupViewMode.value = "draft"; setupViewMode.value = "draft";
setupSaveMeta.value = { setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at), updatedAt: formatDisplayTime(data.updated_at),
@@ -5764,10 +5693,7 @@ const handleClearDraft = async () => {
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
await refreshSetupDraftFromServer({ silent: true }); await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory(); await loadSetupVersionHistory();
if (activeStep.value === 0) { ElMessage.success(`草稿已清空(当前发布仍为 ${setupPublishedVersionText.value}`);
activeStep.value = 1;
}
ElMessage.success(`草稿已清空(第2-7步已重置,当前发布仍为 ${setupPublishedVersionText.value}`);
} catch (e: any) { } catch (e: any) {
if (e?.response?.status === 409) { if (e?.response?.status === 409) {
ElMessage.warning("清空失败:版本冲突,正在刷新最新配置"); ElMessage.warning("清空失败:版本冲突,正在刷新最新配置");
@@ -8407,6 +8333,27 @@ onBeforeUnmount(() => {
margin-top: 0; margin-top: 0;
} }
.visit-special-rule {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid #dce6f2;
border-radius: 8px;
background: #f8fbff;
}
.visit-special-rule-title {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.visit-special-rule-body {
margin-top: 6px;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.visit-schedule-dialog :deep(.el-dialog) { .visit-schedule-dialog :deep(.el-dialog) {
border-radius: 10px; border-radius: 10px;
} }
+9 -9
View File
@@ -11,7 +11,7 @@
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3> <h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.site" prop="center_id"> <el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly"> <el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option <el-option
@@ -24,7 +24,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.direction" prop="direction"> <el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly"> <el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" /> <el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
@@ -32,7 +32,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.status" prop="status"> <el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
@@ -47,32 +47,32 @@
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3> <h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date"> <el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" /> <el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date"> <el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" /> <el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity"> <el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" /> <el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no"> <el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" /> <el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier"> <el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" /> <el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no"> <el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" /> <el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item> </el-form-item>
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,10 @@ describe("route view overlays are mounted only when visible", () => {
'v-model="viewDialogVisible"', 'v-model="viewDialogVisible"',
], ],
}, },
{
file: "../components/attachments/AttachmentList.vue",
snippets: ['<el-dialog v-if="previewVisible"', 'append-to-body', 'v-model="previewVisible"'],
},
{ {
file: "./admin/ProjectDetail.vue", file: "./admin/ProjectDetail.vue",
snippets: [ snippets: [
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSubjectDetail = () => readFileSync(resolve(__dirname, "./SubjectDetail.vue"), "utf8");
describe("SubjectDetail medication adherence", () => {
it("adds a medication adherence tab with editable actual count", () => {
const source = readSubjectDetail();
expect(source).toContain('name="medicationAdherence"');
expect(source).toContain("actual_medication_count");
expect(source).toContain("保存用药次数");
expect(source).toContain("实际用药次数为试验期间实际药物暴露次数");
expect(source).toContain("75%~125% 范围内(包含边界值)");
});
it("uses baseline and termination visit actual dates for adherence calculation", () => {
const source = readSubjectDetail();
expect(source).toContain("const medicationStartDate = computed(() => detail.baseline_date || \"\");");
expect(source).toContain('new Set(["终止治疗", "提前终止"])');
expect(source).toContain("return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;");
});
it("marks adherence outside 75 to 125 percent as abnormal", () => {
const source = readSubjectDetail();
expect(source).toContain("if (rate < 75 || rate > 125) return \"danger\";");
expect(source).not.toContain("if (rate < 80 || rate > 120) return \"danger\";");
});
it("provides a dedicated early termination workflow", () => {
const source = readSubjectDetail();
expect(source).toContain("openEarlyTerminationDialog");
expect(source).toContain("createEarlyTerminationVisit");
expect(source).toContain("return \"不适用\";");
expect(source).toContain("getVisitStatusLabel");
expect(source).toContain("ElMessage.success(TEXT.common.messages.saveSuccess);");
expect(source).toContain("提前终止已保存,刷新数据失败,请手动刷新页面");
expect(source).toContain("提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗");
expect(source).toContain("validateEarlyTerminationDateInput");
expect(source).toContain("提前终止日期必须早于最后计划访视窗口期开始日");
expect(source).toContain("getVisitPlannedDateLabel");
expect(source).toContain("getVisitWindowRangeLabel");
expect(source).toContain("");
expect(source).toContain("row.window_start === row.planned_date && row.window_end === row.planned_date");
expect(source).toContain("return \"不适用\";");
});
});
+312 -5
View File
@@ -88,9 +88,15 @@
<el-card class="unified-shell subject-shell subject-tabs-shell"> <el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action"> <div class="subject-tabs-action">
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick"> <el-button type="primary" :loading="currentTabAction.loading" :disabled="isReadOnly" @click="currentTabAction.onClick">
{{ currentTabAction.label }} {{ currentTabAction.label }}
</el-button> </el-button>
<el-button v-if="activeTab === 'visits'" type="warning" plain :disabled="isReadOnly" @click="openEarlyTerminationDialog">
提前终止
</el-button>
<el-button v-if="adherenceEditing && activeTab === 'medicationAdherence'" :disabled="isReadOnly" @click="adherenceEditing = false">
{{ TEXT.common.actions.cancel }}
</el-button>
</div> </div>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
@@ -131,7 +137,14 @@
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed"> <el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip /> <el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip> <el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template> <template #default="scope">
<div class="visit-planned-cell">
<span>{{ getVisitPlannedDateLabel(scope.row) }}</span>
<span v-if="getVisitWindowRangeLabel(scope.row)" class="visit-window-range">
{{ getVisitWindowRangeLabel(scope.row) }}
</span>
</div>
</template>
</el-table-column> </el-table-column>
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" show-overflow-tooltip> <el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
@@ -151,7 +164,7 @@
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))" :type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
effect="light" effect="light"
> >
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }} {{ getVisitStatusLabel(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -205,6 +218,43 @@
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="用药依从性" name="medicationAdherence">
<div class="adherence-panel">
<el-descriptions :column="2" border>
<el-descriptions-item label="开始用药日期">
{{ displayDate(medicationStartDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束用药日期">
{{ displayDate(medicationEndDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束日期来源">
{{ medicationEndVisitLabel }}
</el-descriptions-item>
<el-descriptions-item label="理论用药次数">
{{ expectedMedicationCountText }}
</el-descriptions-item>
<el-descriptions-item label="实际用药次数">
<el-input-number
v-if="adherenceEditing"
v-model="adherenceForm.actual_medication_count"
:min="0"
:controls="false"
size="small"
class="adherence-count-input"
:disabled="isReadOnly"
/>
<span v-else>{{ actualMedicationCountText }}</span>
</el-descriptions-item>
<el-descriptions-item label="用药依从性">
<el-tag :type="adherenceRateTagType" effect="light">{{ medicationAdherenceRateText }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="adherence-hint">
用药依从性 = 实际用药次数 ÷ 理论用药次数 × 100%实际用药次数为试验期间实际药物暴露次数理论用药次数 =结束用药日期 - 开始用药日期 + 1× 1/试验期间的用药依从性原则上要求在 75%~125% 范围内包含边界值
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed"> <el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip> <el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
@@ -347,6 +397,35 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog
v-if="earlyTerminationDialogVisible"
append-to=".layout-main .content-wrapper"
v-model="earlyTerminationDialogVisible"
title="提前终止"
width="560px"
>
<el-form :model="earlyTerminationForm" label-width="110px">
<el-form-item label="提前终止日期" required>
<el-date-picker v-model="earlyTerminationForm.termination_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="提前终止原因" required>
<el-input v-model="earlyTerminationForm.reason" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="earlyTerminationForm.notes" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<div class="early-termination-hint">
提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗日期进入最后访视窗口期后应录入正常终止治疗访视
</div>
<template #footer>
<el-button @click="earlyTerminationDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="earlyTerminationSaving" :disabled="isReadOnly" @click="saveEarlyTermination">
{{ TEXT.common.actions.save }}
</el-button>
</template>
</el-dialog>
<el-dialog v-if="aeDialogVisible" append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px"> <el-dialog v-if="aeDialogVisible" append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
<el-form :model="aeForm" label-width="100px"> <el-form :model="aeForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.title" required> <el-form-item :label="TEXT.common.fields.title" required>
@@ -426,7 +505,7 @@ import { useStudyStore } from "../../store/study";
import { getSubject, updateSubject } from "../../api/subjects"; import { getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories"; import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits"; import { fetchVisits, createEarlyTerminationVisit, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes"; import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds"; import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
import { displayDate, displayEnum, displayText } from "../../utils/display"; import { displayDate, displayEnum, displayText } from "../../utils/display";
@@ -439,7 +518,7 @@ const subjectId = route.params.subjectId as string;
const studyId = study.currentStudy?.id || ""; const studyId = study.currentStudy?.id || "";
const resolveTabFromQuery = () => { const resolveTabFromQuery = () => {
const tab = typeof route.query.tab === "string" ? route.query.tab : ""; const tab = typeof route.query.tab === "string" ? route.query.tab : "";
return ["history", "visits", "ae", "pd"].includes(tab) ? tab : "history"; return ["history", "visits", "medicationAdherence", "ae", "pd"].includes(tab) ? tab : "history";
}; };
const loading = ref(false); const loading = ref(false);
@@ -452,6 +531,7 @@ const detail = reactive<any>({
enrollment_date: "", enrollment_date: "",
baseline_date: "", baseline_date: "",
completion_date: "", completion_date: "",
actual_medication_count: null as number | null,
drop_reason: "", drop_reason: "",
}); });
@@ -483,6 +563,8 @@ const historyForm = reactive({
const visitItems = ref<any[]>([]); const visitItems = ref<any[]>([]);
const loadingVisits = ref(false); const loadingVisits = ref(false);
const visitDialogVisible = ref(false); const visitDialogVisible = ref(false);
const earlyTerminationDialogVisible = ref(false);
const earlyTerminationSaving = ref(false);
const visitEditingId = ref<string | null>(null); const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null); const visitEditingRowId = ref<string | null>(null);
const visitRowDraft = reactive({ const visitRowDraft = reactive({
@@ -497,7 +579,17 @@ const visitForm = reactive<any>({
actual_date: "", actual_date: "",
notes: "", notes: "",
}); });
const earlyTerminationForm = reactive({
termination_date: "",
reason: "",
notes: "",
});
const isFirstVisit = computed(() => visitForm.visit_code === "V1"); const isFirstVisit = computed(() => visitForm.visit_code === "V1");
const adherenceEditing = ref(false);
const adherenceSaving = ref(false);
const adherenceForm = reactive({
actual_medication_count: null as number | null,
});
const aeItems = ref<any[]>([]); const aeItems = ref<any[]>([]);
const loadingAes = ref(false); const loadingAes = ref(false);
@@ -572,6 +664,14 @@ const normalizeSeriousness = (value?: string | null) => {
return "I"; return "I";
}; };
const getErrorMessage = (error: any, fallback: string) => {
const detail = error?.response?.data?.detail;
if (typeof error?.response?.data?.message === "string") return error.response.data.message;
if (typeof detail === "string") return detail;
if (typeof detail?.message === "string") return detail.message;
return fallback;
};
const loadSites = async () => { const loadSites = async () => {
if (!studyId) return; if (!studyId) return;
try { try {
@@ -763,6 +863,17 @@ const openVisitDialog = (row?: any) => {
visitDialogVisible.value = true; visitDialogVisible.value = true;
}; };
const openEarlyTerminationDialog = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
earlyTerminationForm.termination_date = "";
earlyTerminationForm.reason = "";
earlyTerminationForm.notes = "";
earlyTerminationDialogVisible.value = true;
};
const startVisitRowEdit = (row: any) => { const startVisitRowEdit = (row: any) => {
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
@@ -803,6 +914,84 @@ const parseDate = (value?: string | null) => {
return date; return date;
}; };
const parseDateAtNoon = (value?: string | null) => {
if (!value) return null;
const date = new Date(`${value}T12:00:00`);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitWindowStartDateText = (row: any) => row?.window_start || row?.planned_date || "";
const getVisitPlannedDateLabel = (row: any) => {
if (String(row?.visit_code || "").trim() === "提前终止") return "不适用";
return displayDate(row?.planned_date);
};
const getVisitWindowRangeLabel = (row: any) => {
if (String(row?.visit_code || "").trim() === "提前终止") return "";
if (!row?.window_start || !row?.window_end) return "";
if (row.window_start === row.planned_date && row.window_end === row.planned_date) return "";
return `${displayDate(row.window_start)} ${displayDate(row.window_end)}`;
};
const lastPlannedVisitWindowStartText = computed(() => {
const excludedCodes = new Set(["筛选访视", "基线访视", "提前终止"]);
const candidates = visitItems.value
.filter((item) => !excludedCodes.has(String(item?.visit_code || "").trim()))
.map((item) => getVisitWindowStartDateText(item))
.filter(Boolean)
.sort();
return candidates[candidates.length - 1] || "";
});
const validateEarlyTerminationDateInput = () => {
const boundary = lastPlannedVisitWindowStartText.value;
if (!boundary || !earlyTerminationForm.termination_date) return true;
if (earlyTerminationForm.termination_date >= boundary) {
ElMessage.error(`提前终止日期必须早于最后计划访视窗口期开始日(${boundary}`);
return false;
}
return true;
};
const formatCount = (value: number | null | undefined) => (value === null || value === undefined ? TEXT.common.fallback : String(value));
const medicationStartDate = computed(() => detail.baseline_date || "");
const medicationEndVisit = computed(() => {
const targetLabels = new Set(["终止治疗", "提前终止"]);
return (
visitItems.value.find((item) => targetLabels.has(String(item?.visit_code || "").trim()) && item?.actual_date) || null
);
});
const medicationEndDate = computed(() => medicationEndVisit.value?.actual_date || "");
const medicationEndVisitLabel = computed(() => medicationEndVisit.value?.visit_code || "未记录终止治疗/提前终止实际日期");
const expectedMedicationCount = computed(() => {
const start = parseDateAtNoon(medicationStartDate.value);
const end = parseDateAtNoon(medicationEndDate.value);
if (!start || !end || end < start) return null;
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
});
const expectedMedicationCountText = computed(() => formatCount(expectedMedicationCount.value));
const actualMedicationCount = computed(() => detail.actual_medication_count ?? null);
const actualMedicationCountText = computed(() => formatCount(actualMedicationCount.value));
const medicationAdherenceRate = computed(() => {
const expected = expectedMedicationCount.value;
const actual = actualMedicationCount.value;
if (!expected || actual === null || actual === undefined) return null;
return (actual / expected) * 100;
});
const medicationAdherenceRateText = computed(() => {
const rate = medicationAdherenceRate.value;
if (rate === null) return "无法计算";
return `${rate.toFixed(1)}%`;
});
const adherenceRateTagType = computed(() => {
const rate = medicationAdherenceRate.value;
if (rate === null) return "info";
if (rate < 75 || rate > 125) return "danger";
return "success";
});
const getVisitStatus = (row: any, actualDateOverride?: string) => { const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED"; if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date); const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
@@ -840,6 +1029,14 @@ const getVisitStatusTagType = (status: string) => {
} }
}; };
const getVisitStatusLabel = (row: any, actualDateOverride?: string) => {
const status = getVisitStatus(row, actualDateOverride);
if (status === "CANCELLED" && String(row?.notes || "").includes("提前终止")) {
return "不适用";
}
return displayEnum(TEXT.enums.visitStatus, status);
};
const getAeTypeKey = (row: any) => { const getAeTypeKey = (row: any) => {
if (row?.is_susar) return "susar"; if (row?.is_susar) return "susar";
if (row?.is_sae) return "sae"; if (row?.is_sae) return "sae";
@@ -896,6 +1093,46 @@ const saveVisit = async () => {
} }
}; };
const saveEarlyTermination = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
if (!earlyTerminationForm.termination_date) {
ElMessage.error("请填写提前终止日期");
return;
}
if (!earlyTerminationForm.reason.trim()) {
ElMessage.error("请填写提前终止原因");
return;
}
if (!validateEarlyTerminationDateInput()) return;
earlyTerminationSaving.value = true;
try {
await createEarlyTerminationVisit(studyId, subjectId, {
termination_date: earlyTerminationForm.termination_date,
reason: earlyTerminationForm.reason.trim(),
notes: earlyTerminationForm.notes || null,
}, {
suppressErrorMessage: true,
});
earlyTerminationDialogVisible.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(getErrorMessage(e, TEXT.common.messages.saveFailed));
earlyTerminationSaving.value = false;
return;
}
try {
await Promise.all([loadSubject(), loadVisits()]);
} catch {
ElMessage.warning("提前终止已保存,刷新数据失败,请手动刷新页面");
} finally {
earlyTerminationSaving.value = false;
}
};
const removeVisit = async (row: any) => { const removeVisit = async (row: any) => {
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
@@ -1032,6 +1269,11 @@ const currentTabAction = computed(() => {
switch (activeTab.value) { switch (activeTab.value) {
case "visits": case "visits":
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() }; return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
case "medicationAdherence":
if (adherenceEditing.value) {
return { label: "保存用药次数", loading: adherenceSaving.value, onClick: () => saveMedicationAdherence() };
}
return { label: "编辑", loading: false, onClick: () => startMedicationAdherenceEdit() };
case "ae": case "ae":
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() }; return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
case "pd": case "pd":
@@ -1042,6 +1284,36 @@ const currentTabAction = computed(() => {
} }
}); });
const startMedicationAdherenceEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
adherenceEditing.value = true;
};
const saveMedicationAdherence = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
adherenceSaving.value = true;
try {
await updateSubject(studyId, subjectId, {
actual_medication_count: adherenceForm.actual_medication_count ?? null,
});
adherenceEditing.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
await loadSubject();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
adherenceSaving.value = false;
}
};
const savePd = async () => { const savePd = async () => {
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
@@ -1168,12 +1440,47 @@ onMounted(async () => {
z-index: 2; z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
} }
.subject-overview-descriptions :deep(.el-descriptions__table) { .subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed; table-layout: fixed;
} }
.visit-planned-cell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.visit-window-range {
color: #8a97ab;
font-size: 12px;
}
.adherence-panel {
padding: 16px 0 0;
}
.adherence-count-input {
width: 160px;
}
.adherence-hint {
margin-top: 12px;
color: #6b778c;
font-size: 13px;
}
.early-termination-hint {
margin-top: 10px;
color: #6b778c;
font-size: 13px;
line-height: 1.6;
}
.subject-overview-descriptions :deep(.el-descriptions__label) { .subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px; width: 140px;
min-width: 140px; min-width: 140px;
+11
View File
@@ -42,6 +42,16 @@ http {
add_header Cache-Control "public, max-age=86400" always; add_header Cache-Control "public, max-age=86400" always;
} }
location = /index.html {
try_files /index.html =404;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location /api/ { location /api/ {
proxy_pass http://backend; proxy_pass http://backend;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -60,6 +70,7 @@ http {
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
} }
} }
} }