项目概览、参与者管理初步优化(未接入真实数据)

This commit is contained in:
Cheng Zhou
2026-01-13 10:10:28 +08:00
parent 1338fa7e2b
commit b3cd8e03f2
37 changed files with 1785 additions and 118 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
## 角色权限概要(前端操作级提示,后端仍最终裁决) ## 角色权限概要(前端操作级提示,后端仍最终裁决)
- ADMIN:全权限 - ADMIN:全权限
- PM:里程碑/任务维护、受试者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护 - PM:里程碑/任务维护、参与者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护
- CRA:任务/受试者/AE/Data Query 创建,受试者状态更新,Finance 创建/提交,其他只读 - CRA:任务/参与者/AE/Data Query 创建,参与者状态更新,Finance 创建/提交,其他只读
- PVAE 创建/关闭,Issue 创建/关闭,其余只读 - PVAE 创建/关闭,Issue 创建/关闭,其余只读
- IMP:IMP 交易/产品/批次维护,其余只读 - IMP:IMP 交易/产品/批次维护,其余只读
- 普通成员(无项目角色):仅浏览 - 普通成员(无项目角色):仅浏览
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@db:5432/ctms
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import asyncio
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.core.config import settings
from app.db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_database_url() -> str:
url = os.getenv("DATABASE_URL", settings.DATABASE_URL)
if url and url.startswith("postgresql+asyncpg://"):
return url
return url
def run_migrations_offline() -> None:
url = _get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = _get_database_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
@@ -0,0 +1,22 @@
"""drop visit_name from visits"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000003"
down_revision = "20240501_000002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("visits", "visit_name")
def downgrade() -> None:
op.add_column(
"visits",
sa.Column("visit_name", sa.String(length=200), nullable=False, server_default=""),
)
@@ -0,0 +1,25 @@
"""add visit template fields to studies"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000004"
down_revision = "20240501_000003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("studies", sa.Column("visit_interval_days", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_total", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_window_start_offset", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_window_end_offset", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("studies", "visit_window_end_offset")
op.drop_column("studies", "visit_window_start_offset")
op.drop_column("studies", "visit_total")
op.drop_column("studies", "visit_interval_days")
@@ -0,0 +1,19 @@
"""add consent_date to subjects"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000005"
down_revision = "20240501_000004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("subjects", sa.Column("consent_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("subjects", "consent_date")
+1 -1
View File
@@ -34,7 +34,7 @@ async def create_history(
) -> SubjectHistoryRead: ) -> SubjectHistoryRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
if history_in.subject_id != subject_id: if history_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="受试者不匹配") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id) history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+7 -7
View File
@@ -42,7 +42,7 @@ async def create_subject(
entity_type="subject", entity_type="subject",
entity_id=subject.id, entity_id=subject.id,
action="CREATE_SUBJECT", action="CREATE_SUBJECT",
detail=f"受试{subject.subject_no} 已创建", detail=f"参与{subject.subject_no} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -81,7 +81,7 @@ async def get_subject(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
return subject return subject
@@ -100,7 +100,7 @@ async def update_subject(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
old_status = subject.status old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in) updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled # auto-generate visits when enrolled
@@ -108,14 +108,14 @@ async def update_subject(
await subject_crud.generate_default_visits(db, updated) await subject_crud.generate_default_visits(db, updated)
detail = None detail = None
if subject_in.status and subject_in.status != old_status: if subject_in.status and subject_in.status != old_status:
detail = f"受试{updated.subject_no} 状态 {old_status} -> {subject_in.status}" detail = f"参与{updated.subject_no} 状态 {old_status} -> {subject_in.status}"
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
entity_type="subject", entity_type="subject",
entity_id=subject_id, entity_id=subject_id,
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT", action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
detail=detail or "受试者已更新", detail=detail or "参与者已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
@@ -136,7 +136,7 @@ async def delete_subject(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
await subject_crud.delete_subject(db, subject) await subject_crud.delete_subject(db, subject)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -144,7 +144,7 @@ async def delete_subject(
entity_type="subject", entity_type="subject",
entity_id=subject_id, entity_id=subject_id,
action="DELETE_SUBJECT", action="DELETE_SUBJECT",
detail=f"受试{subject_id} 已删除", detail=f"参与{subject_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+16 -3
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud from app.crud import audit as audit_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 visit as visit_crud from app.crud import visit as visit_crud
from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate
@@ -15,7 +16,7 @@ router = APIRouter()
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID): async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID):
subject = await subject_crud.get_subject(db, subject_id) subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="受试者不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
return subject return subject
@@ -49,16 +50,28 @@ async def create_visit(
) -> VisitRead: ) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id) subject = await _ensure_subject(db, study_id, subject_id)
if visit_in.subject_id != subject_id: if visit_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="受试者不匹配") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
visit = await visit_crud.create_visit( visit = await visit_crud.create_visit(
db, db,
study_id=study_id, study_id=study_id,
visit_in=visit_in, visit_in=visit_in,
subject=subject, subject=subject,
visit_code=visit_in.visit_code, visit_code=visit_in.visit_code,
visit_name=visit_in.visit_name,
planned_date=visit_in.planned_date, planned_date=visit_in.planned_date,
) )
if visit_in.visit_code == "V1" and visit_in.planned_date:
study = await study_crud.get(db, study_id)
if study:
await visit_crud.create_followup_visits(
db,
study_id=study_id,
subject=subject,
base_date=visit_in.planned_date,
visit_total=study.visit_total,
visit_interval_days=study.visit_interval_days,
window_start_offset=study.visit_window_start_offset,
window_end_offset=study.visit_window_end_offset,
)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=study_id, study_id=study_id,
+1 -1
View File
@@ -28,7 +28,7 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id)) result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none() subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id: if not subj or subj.study_id != study_id:
raise ValueError("受试者不属于当前项目") raise ValueError("参与者不属于当前项目")
async def create_ae( async def create_ae(
+4
View File
@@ -16,6 +16,10 @@ async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UU
protocol_no=study_in.protocol_no, protocol_no=study_in.protocol_no,
phase=study_in.phase, phase=study_in.phase,
status=study_in.status, status=study_in.status,
visit_interval_days=study_in.visit_interval_days,
visit_total=study_in.visit_total,
visit_window_start_offset=study_in.visit_window_start_offset,
visit_window_end_offset=study_in.visit_window_end_offset,
created_by=created_by, created_by=created_by,
) )
db.add(study) db.add(study)
+33 -12
View File
@@ -6,6 +6,8 @@ from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import visit as visit_crud from app.crud import visit as visit_crud
from app.models.study import Study
from app.models.visit import Visit
from app.models.site import Site from app.models.site import Site
from app.models.subject import Subject from app.models.subject import Subject
from app.schemas.subject import SubjectCreate, SubjectUpdate from app.schemas.subject import SubjectCreate, SubjectUpdate
@@ -26,6 +28,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
subject_no=subject_in.subject_no, subject_no=subject_in.subject_no,
status="SCREENING", status="SCREENING",
screening_date=subject_in.screening_date, screening_date=subject_in.screening_date,
consent_date=subject_in.consent_date,
enrollment_date=None, enrollment_date=None,
completion_date=None, completion_date=None,
drop_reason=None, drop_reason=None,
@@ -41,7 +44,6 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
visit_in=None, visit_in=None,
subject=subject, subject=subject,
visit_code="V0", visit_code="V0",
visit_name="Screening",
planned_date=subject.screening_date, planned_date=subject.screening_date,
) )
return subject return subject
@@ -74,25 +76,44 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
# Baseline + Follow-up visits based on enrollment_date # Baseline + Follow-up visits based on enrollment_date
if not subject.enrollment_date: if not subject.enrollment_date:
return return
result = await db.execute(select(Study).where(Study.id == subject.study_id))
study = result.scalar_one_or_none()
if not study:
return
visit_total = study.visit_total or 3
visit_interval_days = study.visit_interval_days or 30
window_start_offset = study.visit_window_start_offset
window_end_offset = study.visit_window_end_offset
baseline_date = subject.enrollment_date baseline_date = subject.enrollment_date
follow1 = baseline_date + timedelta(days=30)
follow2 = baseline_date + timedelta(days=60) result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id))
visits_data = [ existing_codes = {row[0] for row in result.all()}
("V1", "Baseline", baseline_date), if "V1" not in existing_codes:
("FU1", "Follow-up 1", follow1), window_start = baseline_date + timedelta(days=window_start_offset) if window_start_offset is not None else None
("FU2", "Follow-up 2", follow2), window_end = baseline_date + timedelta(days=window_end_offset) if window_end_offset is not None else None
]
for code, name, plan_date in visits_data:
await visit_crud.create_visit( await visit_crud.create_visit(
db, db,
study_id=subject.study_id, study_id=subject.study_id,
visit_in=None, visit_in=None,
subject=subject, subject=subject,
visit_code=code, visit_code="V1",
visit_name=name, planned_date=baseline_date,
planned_date=plan_date, window_start=window_start,
window_end=window_end,
) )
await visit_crud.create_followup_visits(
db,
study_id=subject.study_id,
subject=subject,
base_date=baseline_date,
visit_total=visit_total,
visit_interval_days=visit_interval_days,
window_start_offset=window_start_offset,
window_end_offset=window_end_offset,
)
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject: async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
update_data = subject_in.model_dump(exclude_unset=True) update_data = subject_in.model_dump(exclude_unset=True)
+1 -1
View File
@@ -13,7 +13,7 @@ async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uui
result = await db.execute(select(Subject).where(Subject.id == subject_id)) result = await db.execute(select(Subject).where(Subject.id == subject_id))
subject = result.scalar_one_or_none() subject = result.scalar_one_or_none()
if not subject or subject.study_id != study_id: if not subject or subject.study_id != study_id:
raise ValueError("受试者不属于当前项目") raise ValueError("参与者不属于当前项目")
async def create_history( async def create_history(
+52 -4
View File
@@ -1,4 +1,5 @@
import uuid import uuid
from datetime import date, timedelta
from typing import Sequence from typing import Sequence
from sqlalchemy import select, update as sa_update from sqlalchemy import select, update as sa_update
@@ -16,19 +17,19 @@ async def create_visit(
visit_in: VisitCreate | None, visit_in: VisitCreate | None,
subject: Subject, subject: Subject,
visit_code: str, visit_code: str,
visit_name: str,
planned_date, planned_date,
window_start: date | None = None,
window_end: date | None = None,
) -> Visit: ) -> Visit:
visit = Visit( visit = Visit(
study_id=study_id, study_id=study_id,
subject_id=subject.id, subject_id=subject.id,
visit_code=visit_code, visit_code=visit_code,
visit_name=visit_name,
planned_date=planned_date, planned_date=planned_date,
actual_date=None, actual_date=None,
status="PLANNED", status="PLANNED",
window_start=visit_in.window_start if visit_in else None, window_start=window_start if window_start is not None else (visit_in.window_start if visit_in else None),
window_end=visit_in.window_end if visit_in else None, window_end=window_end if window_end is not None else (visit_in.window_end if visit_in else None),
notes=None, notes=None,
) )
db.add(visit) db.add(visit)
@@ -49,6 +50,12 @@ async def get_visit(db: AsyncSession, visit_id: uuid.UUID) -> Visit | None:
async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit: async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit:
update_data = visit_in.model_dump(exclude_unset=True) update_data = visit_in.model_dump(exclude_unset=True)
if "actual_date" in update_data and update_data["actual_date"] is not None and "status" not in update_data:
actual_date = update_data["actual_date"]
if (visit.window_start and actual_date < visit.window_start) or (visit.window_end and actual_date > visit.window_end):
update_data["status"] = "OVERDUE"
else:
update_data["status"] = "DONE"
if update_data: if update_data:
await db.execute( await db.execute(
sa_update(Visit) sa_update(Visit)
@@ -63,3 +70,44 @@ async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) ->
async def delete_visit(db: AsyncSession, visit: Visit) -> None: async def delete_visit(db: AsyncSession, visit: Visit) -> None:
await db.delete(visit) await db.delete(visit)
await db.commit() await db.commit()
async def create_followup_visits(
db: AsyncSession,
*,
study_id: uuid.UUID,
subject: Subject,
base_date: date,
visit_total: int | None,
visit_interval_days: int | None,
window_start_offset: int | None,
window_end_offset: int | None,
) -> Sequence[Visit]:
if not visit_total or not visit_interval_days or visit_total < 2:
return []
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id))
existing_codes = {row[0] for row in result.all()}
created: list[Visit] = []
for index in range(2, visit_total + 1):
code = f"V{index}"
if code in existing_codes:
continue
planned_date = base_date + timedelta(days=visit_interval_days * (index - 1))
window_start = (
planned_date + timedelta(days=window_start_offset) if window_start_offset is not None else None
)
window_end = planned_date + timedelta(days=window_end_offset) if window_end_offset is not None else None
created.append(
await create_visit(
db,
study_id=study_id,
visit_in=None,
subject=subject,
visit_code=code,
planned_date=planned_date,
window_start=window_start,
window_end=window_end,
)
)
return created
+1 -1
View File
@@ -75,7 +75,7 @@ def create_app() -> FastAPI:
{"name": "attachments", "description": "通用附件"}, {"name": "attachments", "description": "通用附件"},
{"name": "audit-logs", "description": "审计日志"}, {"name": "audit-logs", "description": "审计日志"},
{"name": "dashboard", "description": "项目总览与统计"}, {"name": "dashboard", "description": "项目总览与统计"},
{"name": "subjects", "description": "受试"}, {"name": "subjects", "description": "参与"},
{"name": "visits", "description": "访视"}, {"name": "visits", "description": "访视"},
{"name": "aes", "description": "不良事件"}, {"name": "aes", "description": "不良事件"},
{"name": "finance", "description": "费用管理"}, {"name": "finance", "description": "费用管理"},
+5 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy import DateTime, ForeignKey, Integer, String, 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
@@ -18,5 +18,9 @@ class Study(Base):
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True) protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True) phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT") status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True)
visit_window_end_offset: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+1
View File
@@ -18,6 +18,7 @@ class Subject(Base):
subject_no: Mapped[str] = mapped_column(String(50), nullable=False) subject_no: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="SCREENING") status: Mapped[str] = mapped_column(String(20), nullable=False, default="SCREENING")
screening_date: Mapped[date | None] = mapped_column(Date, nullable=True) screening_date: Mapped[date | None] = mapped_column(Date, nullable=True)
consent_date: Mapped[date | None] = mapped_column(Date, nullable=True)
enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True) enrollment_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)
drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True) drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
-1
View File
@@ -15,7 +15,6 @@ class Visit(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False) study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False) subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
visit_code: Mapped[str] = mapped_column(String(50), nullable=False) visit_code: Mapped[str] = mapped_column(String(50), nullable=False)
visit_name: Mapped[str] = mapped_column(String(200), nullable=False)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True) planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True) actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="PLANNED") status: Mapped[str] = mapped_column(String(20), nullable=False, default="PLANNED")
+12
View File
@@ -14,6 +14,10 @@ class StudyCreate(BaseModel):
protocol_no: Optional[str] = None protocol_no: Optional[str] = None
phase: Optional[str] = None phase: Optional[str] = None
status: StudyStatus = "DRAFT" status: StudyStatus = "DRAFT"
visit_interval_days: Optional[int] = None
visit_total: Optional[int] = None
visit_window_start_offset: Optional[int] = None
visit_window_end_offset: Optional[int] = None
class StudyUpdate(BaseModel): class StudyUpdate(BaseModel):
@@ -23,6 +27,10 @@ class StudyUpdate(BaseModel):
protocol_no: Optional[str] = None protocol_no: Optional[str] = None
phase: Optional[str] = None phase: Optional[str] = None
status: Optional[StudyStatus] = None status: Optional[StudyStatus] = None
visit_interval_days: Optional[int] = None
visit_total: Optional[int] = None
visit_window_start_offset: Optional[int] = None
visit_window_end_offset: Optional[int] = None
class StudyRead(BaseModel): class StudyRead(BaseModel):
@@ -33,6 +41,10 @@ class StudyRead(BaseModel):
protocol_no: Optional[str] protocol_no: Optional[str]
phase: Optional[str] phase: Optional[str]
status: StudyStatus status: StudyStatus
visit_interval_days: Optional[int]
visit_total: Optional[int]
visit_window_start_offset: Optional[int]
visit_window_end_offset: Optional[int]
created_by: Optional[uuid.UUID] created_by: Optional[uuid.UUID]
created_at: datetime created_at: datetime
+3
View File
@@ -9,10 +9,12 @@ class SubjectCreate(BaseModel):
site_id: uuid.UUID site_id: uuid.UUID
subject_no: str subject_no: str
screening_date: Optional[date] = None screening_date: Optional[date] = None
consent_date: Optional[date] = None
class SubjectUpdate(BaseModel): class SubjectUpdate(BaseModel):
status: Optional[str] = None status: Optional[str] = None
consent_date: Optional[date] = None
enrollment_date: Optional[date] = None enrollment_date: Optional[date] = None
completion_date: Optional[date] = None completion_date: Optional[date] = None
drop_reason: Optional[str] = None drop_reason: Optional[str] = None
@@ -25,6 +27,7 @@ class SubjectRead(BaseModel):
subject_no: str subject_no: str
status: str status: str
screening_date: Optional[date] screening_date: Optional[date]
consent_date: Optional[date]
enrollment_date: Optional[date] enrollment_date: Optional[date]
completion_date: Optional[date] completion_date: Optional[date]
drop_reason: Optional[str] drop_reason: Optional[str]
-2
View File
@@ -8,7 +8,6 @@ from pydantic import BaseModel, ConfigDict
class VisitCreate(BaseModel): class VisitCreate(BaseModel):
subject_id: uuid.UUID subject_id: uuid.UUID
visit_code: str visit_code: str
visit_name: str
planned_date: Optional[date] = None planned_date: Optional[date] = None
window_start: Optional[date] = None window_start: Optional[date] = None
window_end: Optional[date] = None window_end: Optional[date] = None
@@ -25,7 +24,6 @@ class VisitRead(BaseModel):
study_id: uuid.UUID study_id: uuid.UUID
subject_id: uuid.UUID subject_id: uuid.UUID
visit_code: str visit_code: str
visit_name: str
planned_date: Optional[date] planned_date: Optional[date]
actual_date: Optional[date] actual_date: Optional[date]
status: str status: str
+1
View File
@@ -2,6 +2,7 @@ fastapi==0.104.1
uvicorn[standard]==0.24.0.post1 uvicorn[standard]==0.24.0.post1
sqlalchemy==2.0.23 sqlalchemy==2.0.23
asyncpg==0.29.0 asyncpg==0.29.0
alembic==1.13.1
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
+24 -14
View File
@@ -38,6 +38,10 @@ CREATE TABLE IF NOT EXISTS public.studies (
protocol_no character varying(100), protocol_no character varying(100),
phase character varying(50), phase character varying(50),
status character varying(20) NOT NULL DEFAULT 'DRAFT', status character varying(20) NOT NULL DEFAULT 'DRAFT',
visit_interval_days integer,
visit_total integer,
visit_window_start_offset integer,
visit_window_end_offset integer,
created_by uuid, created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(), created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uq_studies_code UNIQUE (code), CONSTRAINT uq_studies_code UNIQUE (code),
@@ -93,6 +97,7 @@ CREATE TABLE IF NOT EXISTS public.subjects (
subject_no character varying(50) NOT NULL, subject_no character varying(50) NOT NULL,
status character varying(20) NOT NULL DEFAULT 'SCREENING', status character varying(20) NOT NULL DEFAULT 'SCREENING',
screening_date date, screening_date date,
consent_date date,
enrollment_date date, enrollment_date date,
completion_date date, completion_date date,
drop_reason text, drop_reason text,
@@ -122,7 +127,6 @@ CREATE TABLE IF NOT EXISTS public.visits (
study_id uuid NOT NULL, study_id uuid NOT NULL,
subject_id uuid NOT NULL, subject_id uuid NOT NULL,
visit_code character varying(50) NOT NULL, visit_code character varying(50) NOT NULL,
visit_name character varying(200) NOT NULL,
planned_date date, planned_date date,
actual_date date, actual_date date,
status character varying(20) NOT NULL DEFAULT 'PLANNED', status character varying(20) NOT NULL DEFAULT 'PLANNED',
@@ -453,7 +457,7 @@ ON CONFLICT (email) DO UPDATE SET
updated_at = EXCLUDED.updated_at; updated_at = EXCLUDED.updated_at;
INSERT INTO public.studies ( INSERT INTO public.studies (
id, code, name, sponsor, protocol_no, phase, status, created_by, created_at id, code, name, sponsor, protocol_no, phase, status, visit_interval_days, visit_total, visit_window_start_offset, visit_window_end_offset, created_by, created_at
) VALUES ( ) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'DEMO-CTMS', 'DEMO-CTMS',
@@ -462,6 +466,10 @@ INSERT INTO public.studies (
'DP-001', 'DP-001',
'Phase II', 'Phase II',
'ACTIVE', 'ACTIVE',
7,
3,
-2,
2,
(SELECT id FROM public.users WHERE email = 'admin@example.com'), (SELECT id FROM public.users WHERE email = 'admin@example.com'),
'2025-01-06 08:00:00+00' '2025-01-06 08:00:00+00'
) ON CONFLICT (code) DO UPDATE SET ) ON CONFLICT (code) DO UPDATE SET
@@ -470,6 +478,10 @@ INSERT INTO public.studies (
protocol_no = EXCLUDED.protocol_no, protocol_no = EXCLUDED.protocol_no,
phase = EXCLUDED.phase, phase = EXCLUDED.phase,
status = EXCLUDED.status, status = EXCLUDED.status,
visit_interval_days = EXCLUDED.visit_interval_days,
visit_total = EXCLUDED.visit_total,
visit_window_start_offset = EXCLUDED.visit_window_start_offset,
visit_window_end_offset = EXCLUDED.visit_window_end_offset,
created_by = EXCLUDED.created_by; created_by = EXCLUDED.created_by;
INSERT INTO public.sites ( INSERT INTO public.sites (
@@ -564,15 +576,16 @@ INSERT INTO public.training_authorizations (
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO public.subjects ( INSERT INTO public.subjects (
id, study_id, site_id, subject_no, status, screening_date, enrollment_date, completion_date, drop_reason, created_at, updated_at id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, completion_date, drop_reason, created_at, updated_at
) VALUES ) VALUES
('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-06', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'),
('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'), ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', '2025-01-13', NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'),
('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00') ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-21', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00')
ON CONFLICT (study_id, subject_no) DO UPDATE SET ON CONFLICT (study_id, subject_no) DO UPDATE SET
site_id = EXCLUDED.site_id, site_id = EXCLUDED.site_id,
status = EXCLUDED.status, status = EXCLUDED.status,
screening_date = EXCLUDED.screening_date, screening_date = EXCLUDED.screening_date,
consent_date = EXCLUDED.consent_date,
enrollment_date = EXCLUDED.enrollment_date, enrollment_date = EXCLUDED.enrollment_date,
completion_date = EXCLUDED.completion_date, completion_date = EXCLUDED.completion_date,
drop_reason = EXCLUDED.drop_reason, drop_reason = EXCLUDED.drop_reason,
@@ -587,20 +600,17 @@ INSERT INTO public.subject_histories (
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO public.visits ( INSERT INTO public.visits (
id, study_id, subject_id, visit_code, visit_name, planned_date, actual_date, status, window_start, window_end, notes, created_at, updated_at id, study_id, subject_id, visit_code, planned_date, actual_date, status, window_start, window_end, notes, created_at, updated_at
) VALUES ) VALUES
('44444444-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V1', '基线访视', '2025-01-09', '2025-01-10', 'DONE', '2025-01-07', '2025-01-12', '按期完成', '2025-01-10 12:00:00+00', '2025-01-10 12:00:00+00'), ('44444444-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V1', '2025-01-09', '2025-01-10', 'DONE', '2025-01-07', '2025-01-12', '按期完成', '2025-01-10 12:00:00+00', '2025-01-10 12:00:00+00'),
('44444444-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V2', '随访访视', '2025-02-09', NULL, 'PLANNED', '2025-02-07', '2025-02-12', NULL, '2025-01-20 12:00:00+00', '2025-01-20 12:00:00+00'), ('44444444-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'FU1', '2025-02-09', NULL, 'PLANNED', '2025-02-07', '2025-02-12', NULL, '2025-01-20 12:00:00+00', '2025-01-20 12:00:00+00'),
('44444444-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', 'V1', '筛选访视', '2025-01-15', NULL, 'PLANNED', '2025-01-13', '2025-01-18', NULL, '2025-01-15 12:00:00+00', '2025-01-15 12:00:00+00'), ('44444444-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'FU2', '2025-03-11', NULL, 'PLANNED', '2025-03-09', '2025-03-14', NULL, '2025-02-01 12:00:00+00', '2025-02-01 12:00:00+00')
('44444444-aaaa-bbbb-cccc-444444444444', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', 'V2', '基线访视', '2025-01-22', NULL, 'PLANNED', '2025-01-20', '2025-01-25', NULL, '2025-01-22 12:00:00+00', '2025-01-22 12:00:00+00'),
('44444444-aaaa-bbbb-cccc-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', 'V1', '筛选访视', '2024-12-22', '2024-12-22', 'DONE', '2024-12-20', '2024-12-25', '完成筛选', '2024-12-22 12:00:00+00', '2024-12-22 12:00:00+00'),
('44444444-aaaa-bbbb-cccc-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', 'V2', '随访访视', '2025-01-22', '2025-01-23', 'DONE', '2025-01-20', '2025-01-25', '随访完成', '2025-01-23 12:00:00+00', '2025-01-23 12:00:00+00')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO public.adverse_events ( INSERT INTO public.adverse_events (
id, study_id, site_id, subject_id, visit_id, term, onset_date, resolution_date, seriousness, severity, causality, action_taken, outcome, reported_to_sponsor, report_due_date, status, description, created_by, created_at, updated_at id, study_id, site_id, subject_id, visit_id, term, onset_date, resolution_date, seriousness, severity, causality, action_taken, outcome, reported_to_sponsor, report_due_date, status, description, created_by, created_at, updated_at
) VALUES ) VALUES
('66666666-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', '44444444-aaaa-bbbb-cccc-111111111111', '轻微头痛', '2025-01-10', '2025-01-11', 'NON_SERIOUS', 'MILD', '可能相关', '休息观察', '恢复', true, '2025-01-20', 'CLOSED', '受试者轻微头痛,次日缓解。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-10 13:00:00+00', '2025-01-11 09:00:00+00'), ('66666666-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', '44444444-aaaa-bbbb-cccc-111111111111', '轻微头痛', '2025-01-10', '2025-01-11', 'NON_SERIOUS', 'MILD', '可能相关', '休息观察', '恢复', true, '2025-01-20', 'CLOSED', '参与者轻微头痛,次日缓解。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-10 13:00:00+00', '2025-01-11 09:00:00+00'),
('66666666-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-3333-4444-5555-666666666666', NULL, '血糖升高', '2025-01-18', NULL, 'SERIOUS', 'SEVERE', '相关', '调整用药', '未恢复', false, '2025-01-28', 'NEW', '筛选期血糖异常。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-18 13:00:00+00', '2025-01-18 13:00:00+00'), ('66666666-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-3333-4444-5555-666666666666', NULL, '血糖升高', '2025-01-18', NULL, 'SERIOUS', 'SEVERE', '相关', '调整用药', '未恢复', false, '2025-01-28', 'NEW', '筛选期血糖异常。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-18 13:00:00+00', '2025-01-18 13:00:00+00'),
('66666666-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', NULL, '轻度皮疹', '2025-02-10', NULL, 'NON_SERIOUS', 'MODERATE', '待评估', NULL, '未恢复', false, '2025-02-20', 'FOLLOW_UP', '出现皮疹,持续观察。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-10 13:00:00+00', '2025-02-10 13:00:00+00') ('66666666-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', NULL, '轻度皮疹', '2025-02-10', NULL, 'NON_SERIOUS', 'MODERATE', '待评估', NULL, '未恢复', false, '2025-02-20', 'FOLLOW_UP', '出现皮疹,持续观察。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-10 13:00:00+00', '2025-02-10 13:00:00+00')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
+3
View File
@@ -0,0 +1,3 @@
import { apiGet } from "./axios";
export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`);
+24 -23
View File
@@ -84,7 +84,7 @@ export const TEXT = {
case: "例", case: "例",
}, },
fields: { fields: {
subjectNo: "受试者编号", subjectNo: "参与者编号",
site: "分中心", site: "分中心",
status: "状态", status: "状态",
keyword: "关键词", keyword: "关键词",
@@ -109,17 +109,21 @@ export const TEXT = {
category: "分类", category: "分类",
question: "问题", question: "问题",
screeningDate: "筛选日期", screeningDate: "筛选日期",
consentDate: "知情日期",
enrollmentDate: "入组日期", enrollmentDate: "入组日期",
completionDate: "完成日期", completionDate: "完成日期",
dropReason: "退出原因", dropReason: "退出原因",
recordDate: "日期", recordDate: "日期",
content: "内容", content: "内容",
visitCode: "访视编号", visitCode: "访视编号",
visitName: "访视名称", plannedDate: "计划访视",
plannedDate: "计划日期", actualDate: "实际访视",
actualDate: "实际日期",
windowStart: "窗口开始", windowStart: "窗口开始",
windowEnd: "窗口结束", windowEnd: "窗口结束",
visitTotal: "访视总次数",
visitIntervalDays: "访视间隔(天)",
visitWindowStartOffset: "窗口开始偏移(天)",
visitWindowEndOffset: "窗口结束偏移(天)",
notes: "备注", notes: "备注",
event: "事件", event: "事件",
onsetDate: "发生日期", onsetDate: "发生日期",
@@ -204,7 +208,7 @@ export const TEXT = {
}, },
eventDict: { eventDict: {
AE_CLOSED: { label: "关闭 AE(不良事件)", actionText: "关闭了 AE", targetLabel: "不良事件" }, AE_CLOSED: { label: "关闭 AE(不良事件)", actionText: "关闭了 AE", targetLabel: "不良事件" },
SUBJECT_STATUS_CHANGED: { label: "受试者状态变更", actionText: "变更了受试者状态", targetLabel: "受试者" }, SUBJECT_STATUS_CHANGED: { label: "参与者状态变更", actionText: "变更了参与者状态", targetLabel: "参与者" },
FINANCE_STATUS_CHANGED: { label: "费用状态变更", actionText: "变更了费用状态", targetLabel: "费用记录" }, FINANCE_STATUS_CHANGED: { label: "费用状态变更", actionText: "变更了费用状态", targetLabel: "费用记录" },
ADMIN_RESET_PASSWORD: { label: "重置用户密码", actionText: "重置了用户密码", targetLabel: "用户账号" }, ADMIN_RESET_PASSWORD: { label: "重置用户密码", actionText: "重置了用户密码", targetLabel: "用户账号" },
ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" }, ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" },
@@ -236,7 +240,7 @@ export const TEXT = {
drugShipments: "运输/流向", drugShipments: "运输/流向",
startupFeasibilityEthics: "立项与伦理", startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权", startupMeetingAuth: "启动与授权",
subjects: "受试者管理", subjects: "参与者管理",
monitoring: "监查", monitoring: "监查",
audit: "稽查", audit: "稽查",
knowledge: "知识库", knowledge: "知识库",
@@ -367,18 +371,18 @@ export const TEXT = {
trainingDetailSubtitle: "查看人员培训与授权信息", trainingDetailSubtitle: "查看人员培训与授权信息",
}, },
subjectManagement: { subjectManagement: {
title: "受试者管理", title: "参与者管理",
subtitle: "受试者、病史、访视、AE 综合管理", subtitle: "参与者、病史、访视、AE 综合管理",
subjectLabel: "受试者", subjectLabel: "参与者",
empty: "暂无受试者记录", empty: "暂无参与者记录",
}, },
subjectForm: { subjectForm: {
titleEdit: "编辑受试者", titleEdit: "编辑参与者",
titleNew: "新增受试者", titleNew: "新增参与者",
subtitle: "维护受试者基本信息", subtitle: "维护参与者基本信息",
}, },
subjectDetail: { subjectDetail: {
title: "受试者详情", title: "参与者详情",
subtitle: "查看病史、访视与 AE 信息", subtitle: "查看病史、访视与 AE 信息",
tabs: { tabs: {
history: "病史", history: "病史",
@@ -527,6 +531,7 @@ export const TEXT = {
createdAt: "创建时间", createdAt: "创建时间",
newTitle: "新建项目", newTitle: "新建项目",
editTitle: "编辑项目", editTitle: "编辑项目",
visitTemplate: "访视模板",
sponsorPlaceholder: "申办方(可选)", sponsorPlaceholder: "申办方(可选)",
protocolPlaceholder: "方案号(可选)", protocolPlaceholder: "方案号(可选)",
phasePlaceholder: "研究分期(可选)", phasePlaceholder: "研究分期(可选)",
@@ -644,9 +649,9 @@ export const TEXT = {
loadFailed: "工作台数据加载失败", loadFailed: "工作台数据加载失败",
}, },
permissions: { permissions: {
subjectEnroll: "仅 PM/CRA 可更新受试者状态", subjectEnroll: "仅 PM/CRA 可更新参与者状态",
subjectComplete: "仅 PM/CRA 可更新受试者状态", subjectComplete: "仅 PM/CRA 可更新参与者状态",
subjectDrop: "仅 PM/CRA 可更新受试者状态", subjectDrop: "仅 PM/CRA 可更新参与者状态",
aeClose: "仅 PM/PV/ADMIN 可关闭 AE", aeClose: "仅 PM/PV/ADMIN 可关闭 AE",
faqEdit: "仅 PM/ADMIN 可维护 FAQ", faqEdit: "仅 PM/ADMIN 可维护 FAQ",
faqCreate: "仅项目成员可新建 FAQ", faqCreate: "仅项目成员可新建 FAQ",
@@ -699,14 +704,10 @@ export const TEXT = {
PLANNED: "计划中", PLANNED: "计划中",
DONE: "已完成", DONE: "已完成",
MISSED: "未完成", MISSED: "未完成",
OVERDUE: "超窗",
LOST: "失访",
CANCELLED: "已取消", CANCELLED: "已取消",
}, },
visitName: {
Screening: "筛选访视",
Baseline: "基线访视",
"Follow-up 1": "随访 1",
"Follow-up 2": "随访 2",
},
aeSeriousness: { aeSeriousness: {
SERIOUS: "严重", SERIOUS: "严重",
NON_SERIOUS: "一般", NON_SERIOUS: "一般",
+5 -1
View File
@@ -1,6 +1,9 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import ElementPlus from "element-plus"; import ElementPlus from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import "element-plus/dist/index.css"; import "element-plus/dist/index.css";
import "./styles/main.css"; import "./styles/main.css";
@@ -12,7 +15,8 @@ const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
app.use(ElementPlus); dayjs.locale("zh-cn");
app.use(ElementPlus, { locale: zhCn });
// 初始化项目上下文 // 初始化项目上下文
const studyStore = useStudyStore(); const studyStore = useStudyStore();
+4
View File
@@ -59,6 +59,10 @@ export interface Study {
protocol_no?: string | null; protocol_no?: string | null;
phase?: string | null; phase?: string | null;
status: string; status: string;
visit_interval_days?: number | null;
visit_total?: number | null;
visit_window_start_offset?: number | null;
visit_window_end_offset?: number | null;
created_by?: string | null; created_by?: string | null;
created_at?: string; created_at?: string;
role_in_study?: string | null; role_in_study?: string | null;
+1 -1
View File
@@ -64,5 +64,5 @@ export const displayDateTime = (value?: string | number | Date | null) => {
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad( return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes() date.getMinutes()
)}`; )}:${pad(date.getSeconds())}`;
}; };
+33
View File
@@ -23,6 +23,19 @@
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" /> <el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-divider content-position="left">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button> <el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
@@ -62,6 +75,10 @@ const form = reactive({
protocol_no: "", protocol_no: "",
phase: "", phase: "",
status: "DRAFT", status: "DRAFT",
visit_interval_days: null as number | null,
visit_total: null as number | null,
visit_window_start_offset: null as number | null,
visit_window_end_offset: null as number | null,
}); });
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
@@ -77,6 +94,10 @@ const resetForm = () => {
form.protocol_no = ""; form.protocol_no = "";
form.phase = ""; form.phase = "";
form.status = "DRAFT"; form.status = "DRAFT";
form.visit_interval_days = null;
form.visit_total = null;
form.visit_window_start_offset = null;
form.visit_window_end_offset = null;
}; };
watch( watch(
@@ -91,6 +112,10 @@ watch(
form.protocol_no = props.project.protocol_no || ""; form.protocol_no = props.project.protocol_no || "";
form.phase = props.project.phase || ""; form.phase = props.project.phase || "";
form.status = props.project.status || "DRAFT"; form.status = props.project.status || "DRAFT";
form.visit_interval_days = props.project.visit_interval_days ?? null;
form.visit_total = props.project.visit_total ?? null;
form.visit_window_start_offset = props.project.visit_window_start_offset ?? null;
form.visit_window_end_offset = props.project.visit_window_end_offset ?? null;
} }
} }
} }
@@ -108,6 +133,10 @@ const onSubmit = async () => {
protocol_no: form.protocol_no, protocol_no: form.protocol_no,
phase: form.phase, phase: form.phase,
status: form.status, status: form.status,
visit_interval_days: form.visit_interval_days,
visit_total: form.visit_total,
visit_window_start_offset: form.visit_window_start_offset,
visit_window_end_offset: form.visit_window_end_offset,
}); });
ElMessage.success(TEXT.modules.adminProjects.updateSuccess); ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
} else { } else {
@@ -118,6 +147,10 @@ const onSubmit = async () => {
protocol_no: form.protocol_no, protocol_no: form.protocol_no,
phase: form.phase, phase: form.phase,
status: form.status, status: form.status,
visit_interval_days: form.visit_interval_days,
visit_total: form.visit_total,
visit_window_start_offset: form.visit_window_start_offset,
visit_window_end_offset: form.visit_window_end_offset,
}); });
ElMessage.success(TEXT.modules.adminProjects.createSuccess); ElMessage.success(TEXT.modules.adminProjects.createSuccess);
} }
+321 -2
View File
@@ -1,7 +1,326 @@
<template> <template>
<StudyHome /> <div class="page">
<div v-if="study.currentStudy" class="page-body">
<div class="page-header">
<div>
<h1 class="page-title">项目概览</h1>
<p class="page-subtitle">多中心项目整体进度与入组情况</p>
<p class="study-meta">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
<span class="study-code">{{ study.currentStudy.code }}</span>
</p>
</div>
<div class="header-meta">
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
<div class="updated-at">更新{{ nowLabel }}</div>
<el-button size="small" @click="loadOverview">刷新</el-button>
</div>
</div>
<el-card>
<template #header>
<div class="card-header">
<div>
<div class="card-title">中心整体进度</div>
</div>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
</div>
</div>
</template>
<StateLoading v-if="loading" :rows="6" />
<StateEmpty
v-else-if="centers.length === 0"
title="暂无中心进度"
description="当前项目未配置中心或暂无进度数据"
/>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</el-card>
<el-card>
<template #header>
<div class="card-header">
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">
{{ enrollmentSummary }}
</div>
</div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
</template>
<EnrollmentBarChart
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</el-card>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import StudyHome from "../StudyHome.vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { displayDateTime } from "../../utils/display";
import { fetchProjectOverview } from "../../api/overview";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
import { overviewMock } from "./project-overview/overview.mock";
const study = useStudyStore();
const loading = ref(false);
const usingDemo = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true";
const centers = computed(() => overview.value?.centers || []);
const nowLabel = ref(displayDateTime(new Date()));
let clockTimer: number | undefined;
const startClock = () => {
if (clockTimer) return;
clockTimer = window.setInterval(() => {
nowLabel.value = displayDateTime(new Date());
}, 1000);
};
const stopClock = () => {
if (clockTimer) {
window.clearInterval(clockTimer);
clockTimer = undefined;
}
};
const enrollmentSummary = computed(() => {
const summary = overview.value?.summary;
if (!summary) return "目标与实际入组情况概览";
return `已入组 ${summary.total_actual} / 目标 ${summary.total_target}`;
});
const chartItems = computed<EnrollmentBarItem[]>(() => {
if (!overview.value) return [];
if (chartMode.value === "center") {
return overview.value.centers.map((center, index) => ({
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual: center.enrollment_actual,
target: center.enrollment_target,
}));
}
return overview.value.months.map((month) => ({
key: month.month,
label: month.month,
actual: month.count,
}));
});
const chartEmptyText = computed(() =>
chartMode.value === "center" ? "暂无中心入组数据" : "暂无月度入组数据"
);
const loadOverview = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
if (preferApi) {
const { data } = await fetchProjectOverview(studyId);
overview.value = adaptProjectOverview(data);
usingDemo.value = false;
return;
}
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
} catch {
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
} finally {
loading.value = false;
}
};
const reset = () => {
overview.value = null;
usingDemo.value = false;
};
onMounted(() => {
loadOverview();
startClock();
});
onUnmounted(() => {
stopClock();
});
watch(
() => study.currentStudy?.id,
() => {
reset();
loadOverview();
}
);
</script> </script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--ctms-text-main);
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.study-meta {
margin: 6px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.study-divider {
opacity: 0.3;
}
.header-meta {
display: flex;
align-items: center;
gap: 10px;
color: var(--ctms-text-secondary);
font-size: 12px;
}
.demo-tag {
border-color: var(--ctms-border-color);
color: var(--ctms-text-secondary);
}
.updated-at {
white-space: nowrap;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.card-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.progress-legend {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 999px;
border: 2px solid var(--ctms-border-color);
background-color: #ffffff;
}
.legend-dot.completed {
background-color: var(--ctms-success);
border-color: var(--ctms-success);
}
.legend-dot.active {
border-color: var(--ctms-primary);
box-shadow: 0 0 0 2px rgba(63, 93, 117, 0.18);
}
.legend-dot.pending {
background-color: #f8fafc;
}
.legend-dot.blocked {
border-color: var(--ctms-danger);
background-color: rgba(194, 75, 75, 0.12);
}
.progress-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mode-switch :deep(.el-radio-button__inner) {
padding: 4px 12px;
}
@media (max-width: 1024px) {
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="center-row">
<div class="center-meta">
<div class="center-name">{{ centerName }}</div>
<div class="center-enrollment">
<span class="enrollment-label">入组</span>
<span class="enrollment-value">{{ enrollmentLabel }}</span>
</div>
</div>
<div class="center-timeline">
<div v-for="(stage, index) in stages" :key="stage.key" class="timeline-segment">
<StageNode :label="stage.label" :status="stage.status" :completed-at="stage.completedAt" />
<div
v-if="index < stages.length - 1"
class="stage-connector"
:class="connectorClass(stage.status)"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import StageNode from "./StageNode.vue";
import { STAGE_ORDER, type CenterOverview, type StageStatus } from "./overview.adapter";
import { TEXT } from "../../../locales";
const props = defineProps<{
center: CenterOverview;
}>();
const centerName = computed(() => props.center.center_name || TEXT.common.fallback);
const enrollmentLabel = computed(() => {
const actual = props.center.enrollment_actual ?? 0;
const target = props.center.enrollment_target ?? 0;
if (!target) return `${actual}`;
return `${actual} / ${target}`;
});
const stages = computed(() =>
STAGE_ORDER.map((stage) => ({
...stage,
status: props.center[stage.key] || "NOT_STARTED",
completedAt: props.center[stage.completedKey],
}))
);
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
</script>
<style scoped>
.center-row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 16px;
padding: 14px 0;
border-bottom: 1px dashed var(--ctms-border-color);
}
.center-row:last-child {
border-bottom: none;
}
.center-meta {
display: flex;
flex-direction: column;
gap: 6px;
}
.center-name {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.center-enrollment {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.enrollment-label {
padding: 2px 8px;
border-radius: 999px;
background-color: var(--ctms-bg-muted);
color: var(--ctms-text-secondary);
font-weight: 600;
}
.center-timeline {
display: flex;
align-items: flex-start;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
width: 100%;
min-width: 0;
}
.timeline-segment {
display: flex;
align-items: flex-start;
gap: 8px;
flex: 1 1 0;
min-width: 0;
}
.timeline-segment:last-child {
flex: 0 0 auto;
}
.stage-connector {
flex: 1 1 0;
min-width: 48px;
width: auto;
position: relative;
height: 2px;
background-color: var(--ctms-border-color);
border-radius: 999px;
margin-top: 6px;
}
.stage-connector::after {
content: "";
position: absolute;
right: -2px;
top: 50%;
width: 6px;
height: 6px;
border-top: 2px solid var(--ctms-border-color);
border-right: 2px solid var(--ctms-border-color);
transform: translateY(-50%) rotate(45deg);
}
.connector-completed {
background-color: var(--ctms-success);
}
.connector-in_progress {
background-color: var(--ctms-primary);
}
.connector-blocked {
background-color: var(--ctms-danger);
}
.connector-completed::after {
border-top-color: var(--ctms-success);
border-right-color: var(--ctms-success);
}
.connector-in_progress::after {
border-top-color: var(--ctms-primary);
border-right-color: var(--ctms-primary);
}
.connector-blocked::after {
border-top-color: var(--ctms-danger);
border-right-color: var(--ctms-danger);
}
@media (max-width: 1024px) {
.center-row {
grid-template-columns: 1fr;
}
.center-meta {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
</style>
@@ -0,0 +1,304 @@
<template>
<div class="enrollment-chart">
<StateLoading v-if="loading" :rows="5" />
<StateEmpty v-else-if="items.length === 0" :description="emptyText" />
<div v-else class="chart-body">
<div class="chart-scroll">
<div class="chart-plot">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
</linearGradient>
<filter :id="shadowId" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#3f5d75" flood-opacity="0.18" />
</filter>
</defs>
<rect
class="chart-frame"
x="0.5"
:y="frameTop"
:width="chartWidth - 1"
:height="frameHeight"
rx="12"
/>
<g class="chart-axis">
<line
class="axis-line"
:x1="axisLeft"
:y1="axisTop"
:x2="axisLeft"
:y2="axisBottom"
/>
<line
class="axis-line"
:x1="axisLeft"
:y1="axisBottom"
:x2="axisRight"
:y2="axisBottom"
/>
<g v-for="tick in yTicks" :key="tick.value" class="axis-tick">
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label">
{{ formatNumber(tick.value) }}
</text>
<line
class="tick-line"
:class="{ minor: tick.minor }"
:x1="axisLeft - 8"
:y1="tickY(tick.value)"
:x2="axisLeft"
:y2="tickY(tick.value)"
/>
</g>
</g>
<g class="chart-bars">
<g v-for="(item, index) in items" :key="item.key" class="bar-group">
<title>{{ item.label }}</title>
<rect
v-if="showTarget"
class="bar-target"
:x="barLeft(index)"
:y="barTop(item.target || 0)"
:width="barWidth"
:height="barHeight(item.target || 0)"
rx="8"
:fill="`url(#${gradientId})`"
/>
<rect
class="bar-actual"
:x="barLeft(index)"
:y="barTop(item.actual)"
:width="barWidth"
:height="barHeight(item.actual)"
rx="8"
:filter="`url(#${shadowId})`"
/>
<text :x="barCenter(index)" :y="valueY(item)" class="bar-value">
<tspan class="bar-value-actual">{{ formatNumber(item.actual) }}</tspan>
<tspan v-if="showTarget" class="bar-value-divider" dx="4">/</tspan>
<tspan v-if="showTarget" class="bar-value-target" dx="4">{{ formatNumber(item.target || 0) }}</tspan>
</text>
<text :x="barCenter(index)" :y="labelY" class="bar-label">
{{ truncateLabel(item.label) }}
</text>
</g>
</g>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import StateLoading from "../../../components/StateLoading.vue";
import StateEmpty from "../../../components/StateEmpty.vue";
export type ChartMode = "center" | "month";
export interface EnrollmentBarItem {
key: string;
label: string;
actual: number;
target?: number;
}
const props = withDefaults(
defineProps<{
mode: ChartMode;
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
}
);
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 300;
const axisPadding = {
top: 24,
right: 24,
bottom: 52,
left: 72,
};
const gradientId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const shadowId = `enroll-shadow-${Math.random().toString(36).slice(2, 8)}`;
const maxValue = computed(() => {
if (!props.items.length) return 1;
return props.items.reduce((max, item) => {
const candidate = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return Math.max(max, candidate);
}, 1);
});
const yTicks = computed(() => {
const max = maxValue.value;
const majorStep = Math.max(1, Math.ceil(max / 4));
const top = majorStep * 4;
const ticks: Array<{ value: number; minor: boolean }> = [];
for (let i = 0; i <= 4; i += 1) {
const value = top - i * majorStep;
ticks.push({ value, minor: false });
if (i < 4) {
ticks.push({ value: value - Math.round(majorStep / 2), minor: true });
}
}
return ticks.filter((tick) => tick.value >= 0).sort((a, b) => b.value - a.value);
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 18;
const valueGap = 8;
const valuePadding = 8;
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(48, bandWidth.value * 0.6));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
};
const minValueY = computed(() => {
if (!props.items.length) return axisTop;
return Math.min(
...props.items.map((item) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
})
);
});
const extraTop = computed(() => Math.max(0, valuePadding - minValueY.value));
const frameTop = computed(() => 0.5 - extraTop.value);
const frameHeight = computed(() => chartHeight - 1 + extraTop.value);
const truncateLabel = (label: string) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
</script>
<style scoped>
.enrollment-chart {
width: 100%;
}
.chart-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-scroll {
overflow: hidden;
padding-bottom: 8px;
}
.chart-plot {
border-radius: 12px;
padding: 0;
background-color: transparent;
position: relative;
aspect-ratio: 16 / 5;
min-height: 220px;
}
.chart-svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.chart-frame {
fill: #ffffff;
stroke: #000000;
stroke-width: 1px;
}
.axis-line,
.tick-line {
stroke: #000000;
stroke-width: 1px;
}
.tick-label {
font-size: 11px;
fill: #000000;
dominant-baseline: middle;
text-anchor: end;
}
.bar-target {
stroke: #e2e8f0;
stroke-width: 1px;
}
.bar-actual {
fill: var(--ctms-primary);
}
.bar-value {
font-size: 12px;
fill: var(--ctms-text-main);
font-weight: 600;
text-anchor: middle;
}
.bar-value-actual {
fill: var(--ctms-text-main);
font-weight: 600;
}
.bar-value-divider,
.bar-value-target {
fill: var(--ctms-text-disabled);
font-weight: 600;
}
.bar-label {
font-size: 12px;
fill: var(--ctms-text-regular);
text-anchor: middle;
dominant-baseline: hanging;
}
.chart-footnote {
font-size: 12px;
color: var(--ctms-text-secondary);
text-align: right;
}
@media (max-width: 768px) {
.bar-label {
font-size: 11px;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="stage-node" :class="statusClass" :title="tooltip">
<div class="stage-dot"></div>
<div class="stage-label">{{ label }}</div>
<div v-if="completionLabel" class="stage-date">{{ completionLabel }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { StageStatus } from "./overview.adapter";
import { displayDate, displayFallback } from "../../../utils/display";
const props = defineProps<{
label: string;
status: StageStatus;
completedAt?: string;
}>();
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusLabel = computed(() => statusLabelMap[props.status] || "未开始");
const completionLabel = computed(() => {
if (props.status !== "COMPLETED") return "";
const label = displayDate(props.completedAt);
return label === displayFallback ? "" : label;
});
const tooltip = computed(() => {
if (!completionLabel.value) return `${props.label} · ${statusLabel.value}`;
return `${props.label} · ${statusLabel.value} · ${completionLabel.value}`;
});
const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
</script>
<style scoped>
.stage-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 64px;
}
.stage-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--ctms-border-color);
background-color: #ffffff;
transition: var(--ctms-transition);
}
.stage-label {
font-size: 12px;
color: var(--ctms-text-secondary);
text-align: center;
white-space: nowrap;
}
.stage-date {
font-size: 11px;
color: var(--ctms-text-disabled);
text-align: center;
white-space: nowrap;
}
.stage-completed .stage-dot {
background-color: var(--ctms-success);
border-color: var(--ctms-success);
}
.stage-in_progress .stage-dot {
border-color: var(--ctms-primary);
box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.18);
background-color: #ffffff;
}
.stage-not_started .stage-dot {
border-color: var(--ctms-border-color);
background-color: #f8fafc;
}
.stage-blocked .stage-dot {
border-color: var(--ctms-danger);
background-color: rgba(194, 75, 75, 0.12);
}
.stage-completed .stage-label {
color: var(--ctms-text-main);
font-weight: 600;
}
.stage-in_progress .stage-label {
color: var(--ctms-primary);
font-weight: 600;
}
.stage-blocked .stage-label {
color: var(--ctms-danger);
font-weight: 600;
}
</style>
@@ -0,0 +1,160 @@
export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED";
export type StageKey =
| "ethics_status"
| "startup_status"
| "enrollment_status"
| "inspection_status"
| "closeout_status";
export type StageCompletionKey =
| "ethics_completed_at"
| "startup_completed_at"
| "enrollment_completed_at"
| "inspection_completed_at"
| "closeout_completed_at";
export interface CenterOverview {
center_id: string;
center_name: string;
ethics_status: StageStatus;
startup_status: StageStatus;
enrollment_status: StageStatus;
inspection_status: StageStatus;
closeout_status: StageStatus;
enrollment_target: number;
enrollment_actual: number;
ethics_completed_at?: string;
startup_completed_at?: string;
enrollment_completed_at?: string;
inspection_completed_at?: string;
closeout_completed_at?: string;
}
export interface EnrollmentByMonth {
month: string;
count: number;
}
export interface ProjectOverviewResponse {
study_id?: string;
updated_at?: string;
centers?: CenterOverview[];
enrollment_by_month?: EnrollmentByMonth[];
}
export interface ProjectOverviewViewModel {
study_id: string;
updated_at: string;
centers: CenterOverview[];
months: EnrollmentByMonth[];
summary: {
total_target: number;
total_actual: number;
};
}
export const STAGE_ORDER: Array<{
key: StageKey;
label: string;
completedKey: StageCompletionKey;
}> = [
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
{ key: "inspection_status", label: "稽查", completedKey: "inspection_completed_at" },
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
];
const STATUS_ALIAS: Record<string, StageStatus> = {
NOT_STARTED: "NOT_STARTED",
PENDING: "NOT_STARTED",
TODO: "NOT_STARTED",
IN_PROGRESS: "IN_PROGRESS",
ACTIVE: "IN_PROGRESS",
ONGOING: "IN_PROGRESS",
COMPLETED: "COMPLETED",
DONE: "COMPLETED",
FINISHED: "COMPLETED",
BLOCKED: "BLOCKED",
DELAYED: "BLOCKED",
HOLD: "BLOCKED",
};
const toString = (value: unknown) => (typeof value === "string" ? value : "");
const toNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num) || num < 0) return 0;
return num;
};
const normalizeStatus = (value: unknown): StageStatus => {
if (typeof value !== "string") return "NOT_STARTED";
return STATUS_ALIAS[value.toUpperCase()] || "NOT_STARTED";
};
export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel => {
const payload = raw && typeof raw === "object" ? (raw as ProjectOverviewResponse) : {};
const centersRaw = Array.isArray(payload.centers) ? payload.centers : [];
const centers = centersRaw.map((center) => {
const enrollmentStatus = normalizeStatus((center as any).enrollment_status);
const enrollmentActualRaw = toNumber((center as any).enrollment_actual);
const hasEnrollmentStage = enrollmentStatus === "IN_PROGRESS" || enrollmentStatus === "COMPLETED";
return {
center_id: toString((center as any).center_id || (center as any).id),
center_name: toString((center as any).center_name || (center as any).name),
ethics_status: normalizeStatus((center as any).ethics_status),
startup_status: normalizeStatus((center as any).startup_status),
enrollment_status: enrollmentStatus,
inspection_status: normalizeStatus((center as any).inspection_status),
closeout_status: normalizeStatus((center as any).closeout_status),
enrollment_target: toNumber((center as any).enrollment_target),
enrollment_actual: hasEnrollmentStage ? enrollmentActualRaw : 0,
ethics_completed_at: toString((center as any).ethics_completed_at),
startup_completed_at: toString((center as any).startup_completed_at),
enrollment_completed_at: toString((center as any).enrollment_completed_at),
inspection_completed_at: toString((center as any).inspection_completed_at),
closeout_completed_at: toString((center as any).closeout_completed_at),
};
});
let monthsRaw = Array.isArray(payload.enrollment_by_month) ? payload.enrollment_by_month : [];
if (monthsRaw.length === 0) {
const merged: Record<string, number> = {};
centersRaw.forEach((center: any) => {
const list = Array.isArray(center.enrollment_by_month) ? center.enrollment_by_month : [];
list.forEach((item: any) => {
const month = toString(item.month);
if (!month) return;
merged[month] = (merged[month] || 0) + toNumber(item.count);
});
});
monthsRaw = Object.entries(merged).map(([month, count]) => ({ month, count }));
}
const months = monthsRaw
.map((item) => ({
month: toString((item as any).month),
count: toNumber((item as any).count),
}))
.filter((item) => item.month)
.sort((a, b) => a.month.localeCompare(b.month));
const summary = centers.reduce(
(acc, center) => {
acc.total_target += center.enrollment_target;
acc.total_actual += center.enrollment_actual;
return acc;
},
{ total_target: 0, total_actual: 0 }
);
return {
study_id: toString((payload as any).study_id),
updated_at: toString((payload as any).updated_at),
centers,
months,
summary,
};
};
@@ -0,0 +1,80 @@
import type { ProjectOverviewResponse } from "./overview.adapter";
// demo / overview only
export const overviewMock: ProjectOverviewResponse = {
study_id: "demo-overview",
updated_at: "2025-06-18",
centers: [
{
center_id: "center-001",
center_name: "中心01",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-12",
startup_status: "COMPLETED",
startup_completed_at: "2025-02-03",
enrollment_status: "IN_PROGRESS",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 80,
enrollment_actual: 52,
},
{
center_id: "center-002",
center_name: "中心02",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-02-08",
startup_status: "IN_PROGRESS",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 60,
enrollment_actual: 0,
},
{
center_id: "center-003",
center_name: "中心03",
ethics_status: "COMPLETED",
ethics_completed_at: "2024-12-28",
startup_status: "COMPLETED",
startup_completed_at: "2025-01-20",
enrollment_status: "COMPLETED",
enrollment_completed_at: "2025-04-05",
inspection_status: "IN_PROGRESS",
closeout_status: "NOT_STARTED",
enrollment_target: 70,
enrollment_actual: 70,
},
{
center_id: "center-004",
center_name: "中心04",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-15",
startup_status: "BLOCKED",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 50,
enrollment_actual: 0,
},
{
center_id: "center-005",
center_name: "中心05",
ethics_status: "IN_PROGRESS",
startup_status: "NOT_STARTED",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 40,
enrollment_actual: 0,
},
],
enrollment_by_month: [
{ month: "2025-01", count: 12 },
{ month: "2025-02", count: 18 },
{ month: "2025-03", count: 24 },
{ month: "2025-04", count: 28 },
{ month: "2025-05", count: 32 },
{ month: "2025-06", count: 30 },
],
};
+217 -41
View File
@@ -6,8 +6,14 @@
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p> <p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button> <template v-if="subjectEditing">
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button> <el-button type="primary" :loading="subjectSaving" @click="saveSubjectEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</template>
</div> </div>
</div> </div>
@@ -15,11 +21,50 @@
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.status">
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select>
<span v-else>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.consentDate">
<el-descriptions-item :label="TEXT.common.fields.completionDate">{{ displayDate(detail.completion_date) }}</el-descriptions-item> <el-date-picker
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">{{ detail.drop_reason || TEXT.common.fallback }}</el-descriptions-item> v-if="subjectEditing"
v-model="subjectForm.consent_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.consent_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.completion_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.completion_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">
<el-input v-if="subjectEditing" v-model="subjectForm.drop_reason" type="textarea" :rows="2" />
<span v-else>{{ detail.drop_reason || TEXT.common.fallback }}</span>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -50,24 +95,53 @@
</div> </div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%"> <el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" /> <el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
<el-table-column prop="visit_name" :label="TEXT.common.fields.visitName" min-width="140"> <el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" min-width="180">
<template #default="scope">
{{ displayText(scope.row.visit_name, TEXT.enums.visitName) }}
</template>
</el-table-column>
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" width="140">
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template> <template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" width="140"> <el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" min-width="180">
<template #default="scope">{{ displayDate(scope.row.actual_date) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.visitStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button> <el-date-picker
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button> v-if="visitEditingRowId === scope.row.id"
v-model="visitRowDraft.actual_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(scope.row.actual_date) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120">
<template #default="scope">
<el-tag
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
effect="light"
>
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="notes" :label="TEXT.common.fields.notes" min-width="200">
<template #default="scope">
<el-input
v-if="visitEditingRowId === scope.row.id"
v-model="visitRowDraft.notes"
size="small"
type="textarea"
:rows="1"
/>
<span v-else>{{ scope.row.notes || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
<template #default="scope">
<template v-if="visitEditingRowId === scope.row.id">
<el-button link type="primary" size="small" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button>
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -122,9 +196,6 @@
<el-form-item :label="TEXT.common.fields.visitCode" required> <el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="visitForm.visit_code" /> <el-input v-model="visitForm.visit_code" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.visitName" required>
<el-input v-model="visitForm.visit_name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.plannedDate"> <el-form-item :label="TEXT.common.fields.plannedDate">
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
@@ -139,13 +210,6 @@
<el-form-item :label="TEXT.common.fields.actualDate"> <el-form-item :label="TEXT.common.fields.actualDate">
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.status">
<el-select v-model="visitForm.status">
<el-option :label="TEXT.enums.visitStatus.PLANNED" value="PLANNED" />
<el-option :label="TEXT.enums.visitStatus.DONE" value="DONE" />
<el-option :label="TEXT.enums.visitStatus.MISSED" value="MISSED" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark"> <el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="visitForm.notes" type="textarea" :rows="3" /> <el-input v-model="visitForm.notes" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
@@ -201,12 +265,12 @@ import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { getSubject } 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, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes"; import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { displayDate, displayEnum, displayText } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -222,11 +286,22 @@ const detail = reactive<any>({
site_id: "", site_id: "",
status: "", status: "",
screening_date: "", screening_date: "",
consent_date: "",
enrollment_date: "", enrollment_date: "",
completion_date: "", completion_date: "",
drop_reason: "", drop_reason: "",
}); });
const subjectEditing = ref(false);
const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
});
const siteMap = ref<Record<string, string>>({}); const siteMap = ref<Record<string, string>>({});
const activeTab = ref("history"); const activeTab = ref("history");
@@ -243,14 +318,17 @@ const visitItems = ref<any[]>([]);
const loadingVisits = ref(false); const loadingVisits = ref(false);
const visitDialogVisible = ref(false); const visitDialogVisible = ref(false);
const visitEditingId = ref<string | null>(null); const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null);
const visitRowDraft = reactive({
actual_date: "",
notes: "",
});
const visitForm = reactive<any>({ const visitForm = reactive<any>({
visit_code: "", visit_code: "",
visit_name: "",
planned_date: "", planned_date: "",
window_start: "", window_start: "",
window_end: "", window_end: "",
actual_date: "", actual_date: "",
status: "PLANNED",
notes: "", notes: "",
}); });
@@ -295,6 +373,41 @@ const loadSubject = async () => {
} }
}; };
const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
};
const cancelSubjectEdit = () => {
subjectEditing.value = false;
};
const saveSubjectEdit = async () => {
if (!studyId || !subjectId) return;
subjectSaving.value = true;
try {
const payload = {
status: subjectForm.status || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
completion_date: subjectForm.completion_date || null,
drop_reason: subjectForm.drop_reason || null,
};
await updateSubject(studyId, subjectId, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
subjectEditing.value = false;
loadSubject();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
subjectSaving.value = false;
}
};
const loadHistories = async () => { const loadHistories = async () => {
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
loadingHistory.value = true; loadingHistory.value = true;
@@ -364,28 +477,93 @@ const openVisitDialog = (row?: any) => {
visitEditingId.value = row?.id || null; visitEditingId.value = row?.id || null;
if (row) { if (row) {
visitForm.actual_date = row.actual_date || ""; visitForm.actual_date = row.actual_date || "";
visitForm.status = row.status || "PLANNED";
visitForm.notes = row.notes || ""; visitForm.notes = row.notes || "";
} else { } else {
visitForm.visit_code = ""; visitForm.visit_code = "";
visitForm.visit_name = "";
visitForm.planned_date = ""; visitForm.planned_date = "";
visitForm.window_start = ""; visitForm.window_start = "";
visitForm.window_end = ""; visitForm.window_end = "";
visitForm.actual_date = ""; visitForm.actual_date = "";
visitForm.status = "PLANNED";
visitForm.notes = ""; visitForm.notes = "";
} }
visitDialogVisible.value = true; visitDialogVisible.value = true;
}; };
const startVisitRowEdit = (row: any) => {
visitEditingRowId.value = row?.id || null;
visitRowDraft.actual_date = row?.actual_date || "";
visitRowDraft.notes = row?.notes || "";
};
const cancelVisitRow = () => {
visitEditingRowId.value = null;
};
const saveVisitRow = async (row: any) => {
if (!studyId || !subjectId || !row?.id) return;
try {
const payload = {
actual_date: visitRowDraft.actual_date || null,
notes: visitRowDraft.notes || null,
};
await updateVisit(studyId, subjectId, row.id, payload);
visitEditingRowId.value = null;
loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const parseDate = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
const windowStart = parseDate(row?.window_start);
const windowEnd = parseDate(row?.window_end);
const plannedDate = parseDate(row?.planned_date);
if (actualDate) {
if ((windowStart && actualDate < windowStart) || (windowEnd && actualDate > windowEnd)) {
return "OVERDUE";
}
return "DONE";
}
const today = new Date();
const deadline = windowEnd || plannedDate;
if (deadline && today > deadline) {
return "LOST";
}
return "PLANNED";
};
const getVisitStatusTagType = (status: string) => {
switch (status) {
case "DONE":
return "success";
case "OVERDUE":
return "warning";
case "LOST":
return "danger";
case "CANCELLED":
return "info";
default:
return "info";
}
};
const saveVisit = async () => { const saveVisit = async () => {
if (!studyId || !subjectId) return; if (!studyId || !subjectId) return;
try { try {
if (visitEditingId.value) { if (visitEditingId.value) {
const payload = { const payload = {
actual_date: visitForm.actual_date || null, actual_date: visitForm.actual_date || null,
status: visitForm.status || null,
notes: visitForm.notes || null, notes: visitForm.notes || null,
}; };
await updateVisit(studyId, subjectId, visitEditingId.value, payload); await updateVisit(studyId, subjectId, visitEditingId.value, payload);
@@ -393,7 +571,6 @@ const saveVisit = async () => {
const payload = { const payload = {
subject_id: subjectId, subject_id: subjectId,
visit_code: visitForm.visit_code, visit_code: visitForm.visit_code,
visit_name: visitForm.visit_name,
planned_date: visitForm.planned_date || null, planned_date: visitForm.planned_date || null,
window_start: visitForm.window_start || null, window_start: visitForm.window_start || null,
window_end: visitForm.window_end || null, window_end: visitForm.window_end || null,
@@ -484,7 +661,6 @@ const removeAe = async (row: any) => {
} }
}; };
const goEdit = () => router.push(`/subjects/${subjectId}/edit`);
const goBack = () => router.push("/subjects"); const goBack = () => router.push("/subjects");
onMounted(async () => { onMounted(async () => {
@@ -27,6 +27,14 @@
:disabled="isEdit" :disabled="isEdit"
/> />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.consentDate">
<el-date-picker
v-model="form.consent_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status"> <el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" /> <el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
@@ -76,6 +84,7 @@ const form = reactive({
subject_no: "", subject_no: "",
site_id: "", site_id: "",
screening_date: "", screening_date: "",
consent_date: "",
status: "SCREENING", status: "SCREENING",
enrollment_date: "", enrollment_date: "",
completion_date: "", completion_date: "",
@@ -100,6 +109,7 @@ const load = async () => {
subject_no: data.subject_no || "", subject_no: data.subject_no || "",
site_id: data.site_id || "", site_id: data.site_id || "",
screening_date: data.screening_date || "", screening_date: data.screening_date || "",
consent_date: data.consent_date || "",
status: data.status || "SCREENING", status: data.status || "SCREENING",
enrollment_date: data.enrollment_date || "", enrollment_date: data.enrollment_date || "",
completion_date: data.completion_date || "", completion_date: data.completion_date || "",
@@ -117,6 +127,7 @@ const submit = async () => {
if (isEdit.value && subjectId.value) { if (isEdit.value && subjectId.value) {
const payload = { const payload = {
status: form.status || null, status: form.status || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null, enrollment_date: form.enrollment_date || null,
completion_date: form.completion_date || null, completion_date: form.completion_date || null,
drop_reason: form.drop_reason || null, drop_reason: form.drop_reason || null,
@@ -129,6 +140,7 @@ const submit = async () => {
subject_no: form.subject_no, subject_no: form.subject_no,
site_id: form.site_id, site_id: form.site_id,
screening_date: form.screening_date || null, screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
}; };
const { data } = await createSubject(studyId.value, payload); const { data } = await createSubject(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess); ElMessage.success(TEXT.common.messages.createSuccess);