项目概览、参与者管理初步优化(未接入真实数据)
This commit is contained in:
@@ -9,8 +9,8 @@
|
||||
|
||||
## 角色权限概要(前端操作级提示,后端仍最终裁决)
|
||||
- ADMIN:全权限
|
||||
- PM:里程碑/任务维护、受试者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护
|
||||
- CRA:任务/受试者/AE/Data Query 创建,受试者状态更新,Finance 创建/提交,其他只读
|
||||
- PM:里程碑/任务维护、参与者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护
|
||||
- CRA:任务/参与者/AE/Data Query 创建,参与者状态更新,Finance 创建/提交,其他只读
|
||||
- PV:AE 创建/关闭,Issue 创建/关闭,其余只读
|
||||
- IMP:IMP 交易/产品/批次维护,其余只读
|
||||
- 普通成员(无项目角色):仅浏览
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -34,7 +34,7 @@ async def create_history(
|
||||
) -> SubjectHistoryRead:
|
||||
await _ensure_study_exists(db, study_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)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
|
||||
@@ -42,7 +42,7 @@ async def create_subject(
|
||||
entity_type="subject",
|
||||
entity_id=subject.id,
|
||||
action="CREATE_SUBJECT",
|
||||
detail=f"受试者 {subject.subject_no} 已创建",
|
||||
detail=f"参与者 {subject.subject_no} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -81,7 +81,7 @@ async def get_subject(
|
||||
await _ensure_study_exists(db, study_id)
|
||||
subject = await subject_crud.get_subject(db, subject_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
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ async def update_subject(
|
||||
await _ensure_study_exists(db, study_id)
|
||||
subject = await subject_crud.get_subject(db, subject_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
|
||||
updated = await subject_crud.update_subject(db, subject, subject_in)
|
||||
# auto-generate visits when enrolled
|
||||
@@ -108,14 +108,14 @@ async def update_subject(
|
||||
await subject_crud.generate_default_visits(db, updated)
|
||||
detail = None
|
||||
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(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="subject",
|
||||
entity_id=subject_id,
|
||||
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
|
||||
detail=detail or "受试者已更新",
|
||||
detail=detail or "参与者已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -136,7 +136,7 @@ async def delete_subject(
|
||||
await _ensure_study_exists(db, study_id)
|
||||
subject = await subject_crud.get_subject(db, subject_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 audit_crud.log_action(
|
||||
db,
|
||||
@@ -144,7 +144,7 @@ async def delete_subject(
|
||||
entity_type="subject",
|
||||
entity_id=subject_id,
|
||||
action="DELETE_SUBJECT",
|
||||
detail=f"受试者 {subject_id} 已删除",
|
||||
detail=f"参与者 {subject_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
@@ -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.crud import audit as audit_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.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):
|
||||
subject = await subject_crud.get_subject(db, subject_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
|
||||
|
||||
|
||||
@@ -49,16 +50,28 @@ async def create_visit(
|
||||
) -> VisitRead:
|
||||
subject = await _ensure_subject(db, study_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(
|
||||
db,
|
||||
study_id=study_id,
|
||||
visit_in=visit_in,
|
||||
subject=subject,
|
||||
visit_code=visit_in.visit_code,
|
||||
visit_name=visit_in.visit_name,
|
||||
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(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
@@ -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))
|
||||
subj = result.scalar_one_or_none()
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise ValueError("受试者不属于当前项目")
|
||||
raise ValueError("参与者不属于当前项目")
|
||||
|
||||
|
||||
async def create_ae(
|
||||
|
||||
@@ -16,6 +16,10 @@ async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UU
|
||||
protocol_no=study_in.protocol_no,
|
||||
phase=study_in.phase,
|
||||
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,
|
||||
)
|
||||
db.add(study)
|
||||
|
||||
+33
-12
@@ -6,6 +6,8 @@ from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.subject import Subject
|
||||
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,
|
||||
status="SCREENING",
|
||||
screening_date=subject_in.screening_date,
|
||||
consent_date=subject_in.consent_date,
|
||||
enrollment_date=None,
|
||||
completion_date=None,
|
||||
drop_reason=None,
|
||||
@@ -41,7 +44,6 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
|
||||
visit_in=None,
|
||||
subject=subject,
|
||||
visit_code="V0",
|
||||
visit_name="Screening",
|
||||
planned_date=subject.screening_date,
|
||||
)
|
||||
return subject
|
||||
@@ -74,25 +76,44 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
|
||||
# Baseline + Follow-up visits based on enrollment_date
|
||||
if not subject.enrollment_date:
|
||||
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
|
||||
follow1 = baseline_date + timedelta(days=30)
|
||||
follow2 = baseline_date + timedelta(days=60)
|
||||
visits_data = [
|
||||
("V1", "Baseline", baseline_date),
|
||||
("FU1", "Follow-up 1", follow1),
|
||||
("FU2", "Follow-up 2", follow2),
|
||||
]
|
||||
for code, name, plan_date in visits_data:
|
||||
|
||||
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id))
|
||||
existing_codes = {row[0] for row in result.all()}
|
||||
if "V1" not in existing_codes:
|
||||
window_start = baseline_date + timedelta(days=window_start_offset) if window_start_offset is not None else None
|
||||
window_end = baseline_date + timedelta(days=window_end_offset) if window_end_offset is not None else None
|
||||
await visit_crud.create_visit(
|
||||
db,
|
||||
study_id=subject.study_id,
|
||||
visit_in=None,
|
||||
subject=subject,
|
||||
visit_code=code,
|
||||
visit_name=name,
|
||||
planned_date=plan_date,
|
||||
visit_code="V1",
|
||||
planned_date=baseline_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:
|
||||
update_data = subject_in.model_dump(exclude_unset=True)
|
||||
|
||||
@@ -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))
|
||||
subject = result.scalar_one_or_none()
|
||||
if not subject or subject.study_id != study_id:
|
||||
raise ValueError("受试者不属于当前项目")
|
||||
raise ValueError("参与者不属于当前项目")
|
||||
|
||||
|
||||
async def create_history(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
@@ -16,19 +17,19 @@ async def create_visit(
|
||||
visit_in: VisitCreate | None,
|
||||
subject: Subject,
|
||||
visit_code: str,
|
||||
visit_name: str,
|
||||
planned_date,
|
||||
window_start: date | None = None,
|
||||
window_end: date | None = None,
|
||||
) -> Visit:
|
||||
visit = Visit(
|
||||
study_id=study_id,
|
||||
subject_id=subject.id,
|
||||
visit_code=visit_code,
|
||||
visit_name=visit_name,
|
||||
planned_date=planned_date,
|
||||
actual_date=None,
|
||||
status="PLANNED",
|
||||
window_start=visit_in.window_start if visit_in else None,
|
||||
window_end=visit_in.window_end 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=window_end if window_end is not None else (visit_in.window_end if visit_in else None),
|
||||
notes=None,
|
||||
)
|
||||
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:
|
||||
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:
|
||||
await db.execute(
|
||||
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:
|
||||
await db.delete(visit)
|
||||
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
@@ -75,7 +75,7 @@ def create_app() -> FastAPI:
|
||||
{"name": "attachments", "description": "通用附件"},
|
||||
{"name": "audit-logs", "description": "审计日志"},
|
||||
{"name": "dashboard", "description": "项目总览与统计"},
|
||||
{"name": "subjects", "description": "受试者"},
|
||||
{"name": "subjects", "description": "参与者"},
|
||||
{"name": "visits", "description": "访视"},
|
||||
{"name": "aes", "description": "不良事件"},
|
||||
{"name": "finance", "description": "费用管理"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
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.orm import Mapped, mapped_column
|
||||
|
||||
@@ -18,5 +18,9 @@ class Study(Base):
|
||||
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
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_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -18,6 +18,7 @@ class Subject(Base):
|
||||
subject_no: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="SCREENING")
|
||||
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)
|
||||
completion_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -15,7 +15,6 @@ class Visit(Base):
|
||||
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)
|
||||
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)
|
||||
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="PLANNED")
|
||||
|
||||
@@ -14,6 +14,10 @@ class StudyCreate(BaseModel):
|
||||
protocol_no: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
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):
|
||||
@@ -23,6 +27,10 @@ class StudyUpdate(BaseModel):
|
||||
protocol_no: Optional[str] = None
|
||||
phase: Optional[str] = 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):
|
||||
@@ -33,6 +41,10 @@ class StudyRead(BaseModel):
|
||||
protocol_no: Optional[str]
|
||||
phase: Optional[str]
|
||||
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_at: datetime
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ class SubjectCreate(BaseModel):
|
||||
site_id: uuid.UUID
|
||||
subject_no: str
|
||||
screening_date: Optional[date] = None
|
||||
consent_date: Optional[date] = None
|
||||
|
||||
|
||||
class SubjectUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
consent_date: Optional[date] = None
|
||||
enrollment_date: Optional[date] = None
|
||||
completion_date: Optional[date] = None
|
||||
drop_reason: Optional[str] = None
|
||||
@@ -25,6 +27,7 @@ class SubjectRead(BaseModel):
|
||||
subject_no: str
|
||||
status: str
|
||||
screening_date: Optional[date]
|
||||
consent_date: Optional[date]
|
||||
enrollment_date: Optional[date]
|
||||
completion_date: Optional[date]
|
||||
drop_reason: Optional[str]
|
||||
|
||||
@@ -8,7 +8,6 @@ from pydantic import BaseModel, ConfigDict
|
||||
class VisitCreate(BaseModel):
|
||||
subject_id: uuid.UUID
|
||||
visit_code: str
|
||||
visit_name: str
|
||||
planned_date: Optional[date] = None
|
||||
window_start: Optional[date] = None
|
||||
window_end: Optional[date] = None
|
||||
@@ -25,7 +24,6 @@ class VisitRead(BaseModel):
|
||||
study_id: uuid.UUID
|
||||
subject_id: uuid.UUID
|
||||
visit_code: str
|
||||
visit_name: str
|
||||
planned_date: Optional[date]
|
||||
actual_date: Optional[date]
|
||||
status: str
|
||||
|
||||
@@ -2,6 +2,7 @@ fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0.post1
|
||||
sqlalchemy==2.0.23
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
+24
-14
@@ -38,6 +38,10 @@ CREATE TABLE IF NOT EXISTS public.studies (
|
||||
protocol_no character varying(100),
|
||||
phase character varying(50),
|
||||
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_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_studies_code UNIQUE (code),
|
||||
@@ -93,6 +97,7 @@ CREATE TABLE IF NOT EXISTS public.subjects (
|
||||
subject_no character varying(50) NOT NULL,
|
||||
status character varying(20) NOT NULL DEFAULT 'SCREENING',
|
||||
screening_date date,
|
||||
consent_date date,
|
||||
enrollment_date date,
|
||||
completion_date date,
|
||||
drop_reason text,
|
||||
@@ -122,7 +127,6 @@ CREATE TABLE IF NOT EXISTS public.visits (
|
||||
study_id uuid NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
visit_code character varying(50) NOT NULL,
|
||||
visit_name character varying(200) NOT NULL,
|
||||
planned_date date,
|
||||
actual_date date,
|
||||
status character varying(20) NOT NULL DEFAULT 'PLANNED',
|
||||
@@ -453,7 +457,7 @@ ON CONFLICT (email) DO UPDATE SET
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
|
||||
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 (
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'DEMO-CTMS',
|
||||
@@ -462,6 +466,10 @@ INSERT INTO public.studies (
|
||||
'DP-001',
|
||||
'Phase II',
|
||||
'ACTIVE',
|
||||
7,
|
||||
3,
|
||||
-2,
|
||||
2,
|
||||
(SELECT id FROM public.users WHERE email = 'admin@example.com'),
|
||||
'2025-01-06 08:00:00+00'
|
||||
) ON CONFLICT (code) DO UPDATE SET
|
||||
@@ -470,6 +478,10 @@ INSERT INTO public.studies (
|
||||
protocol_no = EXCLUDED.protocol_no,
|
||||
phase = EXCLUDED.phase,
|
||||
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;
|
||||
|
||||
INSERT INTO public.sites (
|
||||
@@ -564,15 +576,16 @@ INSERT INTO public.training_authorizations (
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
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
|
||||
('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'),
|
||||
('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'),
|
||||
('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')
|
||||
('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', '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-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
|
||||
site_id = EXCLUDED.site_id,
|
||||
status = EXCLUDED.status,
|
||||
screening_date = EXCLUDED.screening_date,
|
||||
consent_date = EXCLUDED.consent_date,
|
||||
enrollment_date = EXCLUDED.enrollment_date,
|
||||
completion_date = EXCLUDED.completion_date,
|
||||
drop_reason = EXCLUDED.drop_reason,
|
||||
@@ -587,20 +600,17 @@ INSERT INTO public.subject_histories (
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
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
|
||||
('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-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-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')
|
||||
('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', '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'), '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')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
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
|
||||
) 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-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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { apiGet } from "./axios";
|
||||
|
||||
export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`);
|
||||
@@ -84,7 +84,7 @@ export const TEXT = {
|
||||
case: "例",
|
||||
},
|
||||
fields: {
|
||||
subjectNo: "受试者编号",
|
||||
subjectNo: "参与者编号",
|
||||
site: "分中心",
|
||||
status: "状态",
|
||||
keyword: "关键词",
|
||||
@@ -109,17 +109,21 @@ export const TEXT = {
|
||||
category: "分类",
|
||||
question: "问题",
|
||||
screeningDate: "筛选日期",
|
||||
consentDate: "知情日期",
|
||||
enrollmentDate: "入组日期",
|
||||
completionDate: "完成日期",
|
||||
dropReason: "退出原因",
|
||||
recordDate: "日期",
|
||||
content: "内容",
|
||||
visitCode: "访视编号",
|
||||
visitName: "访视名称",
|
||||
plannedDate: "计划日期",
|
||||
actualDate: "实际日期",
|
||||
plannedDate: "计划访视",
|
||||
actualDate: "实际访视",
|
||||
windowStart: "窗口开始",
|
||||
windowEnd: "窗口结束",
|
||||
visitTotal: "访视总次数",
|
||||
visitIntervalDays: "访视间隔(天)",
|
||||
visitWindowStartOffset: "窗口开始偏移(天)",
|
||||
visitWindowEndOffset: "窗口结束偏移(天)",
|
||||
notes: "备注",
|
||||
event: "事件",
|
||||
onsetDate: "发生日期",
|
||||
@@ -204,7 +208,7 @@ export const TEXT = {
|
||||
},
|
||||
eventDict: {
|
||||
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: "费用记录" },
|
||||
ADMIN_RESET_PASSWORD: { label: "重置用户密码", actionText: "重置了用户密码", targetLabel: "用户账号" },
|
||||
ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" },
|
||||
@@ -236,7 +240,7 @@ export const TEXT = {
|
||||
drugShipments: "运输/流向",
|
||||
startupFeasibilityEthics: "立项与伦理",
|
||||
startupMeetingAuth: "启动与授权",
|
||||
subjects: "受试者管理",
|
||||
subjects: "参与者管理",
|
||||
monitoring: "监查",
|
||||
audit: "稽查",
|
||||
knowledge: "知识库",
|
||||
@@ -367,18 +371,18 @@ export const TEXT = {
|
||||
trainingDetailSubtitle: "查看人员培训与授权信息",
|
||||
},
|
||||
subjectManagement: {
|
||||
title: "受试者管理",
|
||||
subtitle: "受试者、病史、访视、AE 综合管理",
|
||||
subjectLabel: "受试者",
|
||||
empty: "暂无受试者记录",
|
||||
title: "参与者管理",
|
||||
subtitle: "参与者、病史、访视、AE 综合管理",
|
||||
subjectLabel: "参与者",
|
||||
empty: "暂无参与者记录",
|
||||
},
|
||||
subjectForm: {
|
||||
titleEdit: "编辑受试者",
|
||||
titleNew: "新增受试者",
|
||||
subtitle: "维护受试者基本信息",
|
||||
titleEdit: "编辑参与者",
|
||||
titleNew: "新增参与者",
|
||||
subtitle: "维护参与者基本信息",
|
||||
},
|
||||
subjectDetail: {
|
||||
title: "受试者详情",
|
||||
title: "参与者详情",
|
||||
subtitle: "查看病史、访视与 AE 信息",
|
||||
tabs: {
|
||||
history: "病史",
|
||||
@@ -527,6 +531,7 @@ export const TEXT = {
|
||||
createdAt: "创建时间",
|
||||
newTitle: "新建项目",
|
||||
editTitle: "编辑项目",
|
||||
visitTemplate: "访视模板",
|
||||
sponsorPlaceholder: "申办方(可选)",
|
||||
protocolPlaceholder: "方案号(可选)",
|
||||
phasePlaceholder: "研究分期(可选)",
|
||||
@@ -644,9 +649,9 @@ export const TEXT = {
|
||||
loadFailed: "工作台数据加载失败",
|
||||
},
|
||||
permissions: {
|
||||
subjectEnroll: "仅 PM/CRA 可更新受试者状态",
|
||||
subjectComplete: "仅 PM/CRA 可更新受试者状态",
|
||||
subjectDrop: "仅 PM/CRA 可更新受试者状态",
|
||||
subjectEnroll: "仅 PM/CRA 可更新参与者状态",
|
||||
subjectComplete: "仅 PM/CRA 可更新参与者状态",
|
||||
subjectDrop: "仅 PM/CRA 可更新参与者状态",
|
||||
aeClose: "仅 PM/PV/ADMIN 可关闭 AE",
|
||||
faqEdit: "仅 PM/ADMIN 可维护 FAQ",
|
||||
faqCreate: "仅项目成员可新建 FAQ",
|
||||
@@ -699,14 +704,10 @@ export const TEXT = {
|
||||
PLANNED: "计划中",
|
||||
DONE: "已完成",
|
||||
MISSED: "未完成",
|
||||
OVERDUE: "超窗",
|
||||
LOST: "失访",
|
||||
CANCELLED: "已取消",
|
||||
},
|
||||
visitName: {
|
||||
Screening: "筛选访视",
|
||||
Baseline: "基线访视",
|
||||
"Follow-up 1": "随访 1",
|
||||
"Follow-up 2": "随访 2",
|
||||
},
|
||||
aeSeriousness: {
|
||||
SERIOUS: "严重",
|
||||
NON_SERIOUS: "一般",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
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 "./styles/main.css";
|
||||
|
||||
@@ -12,7 +15,8 @@ const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
|
||||
@@ -59,6 +59,10 @@ export interface Study {
|
||||
protocol_no?: string | null;
|
||||
phase?: string | null;
|
||||
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_at?: string;
|
||||
role_in_study?: string | null;
|
||||
|
||||
@@ -64,5 +64,5 @@ export const displayDateTime = (value?: string | number | Date | null) => {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
||||
date.getMinutes()
|
||||
)}`;
|
||||
)}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,19 @@
|
||||
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
|
||||
</el-select>
|
||||
</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>
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -62,6 +75,10 @@ const form = reactive({
|
||||
protocol_no: "",
|
||||
phase: "",
|
||||
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>({
|
||||
@@ -77,6 +94,10 @@ const resetForm = () => {
|
||||
form.protocol_no = "";
|
||||
form.phase = "";
|
||||
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(
|
||||
@@ -91,6 +112,10 @@ watch(
|
||||
form.protocol_no = props.project.protocol_no || "";
|
||||
form.phase = props.project.phase || "";
|
||||
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,
|
||||
phase: form.phase,
|
||||
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);
|
||||
} else {
|
||||
@@ -118,6 +147,10 @@ const onSubmit = async () => {
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,326 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 },
|
||||
],
|
||||
};
|
||||
@@ -6,8 +6,14 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<template v-if="subjectEditing">
|
||||
<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>
|
||||
|
||||
@@ -15,11 +21,50 @@
|
||||
<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.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.enrollmentDate">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.completionDate">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">{{ detail.drop_reason || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.consentDate">
|
||||
<el-date-picker
|
||||
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-card>
|
||||
|
||||
@@ -50,24 +95,53 @@
|
||||
</div>
|
||||
<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_name" :label="TEXT.common.fields.visitName" min-width="140">
|
||||
<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">
|
||||
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" min-width="180">
|
||||
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" width="140">
|
||||
<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">
|
||||
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" min-width="180">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openVisitDialog(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>
|
||||
<el-date-picker
|
||||
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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -122,9 +196,6 @@
|
||||
<el-form-item :label="TEXT.common.fields.visitCode" required>
|
||||
<el-input v-model="visitForm.visit_code" />
|
||||
</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-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
@@ -139,13 +210,6 @@
|
||||
<el-form-item :label="TEXT.common.fields.actualDate">
|
||||
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</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-input v-model="visitForm.notes" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
@@ -201,12 +265,12 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getSubject } from "../../api/subjects";
|
||||
import { getSubject, updateSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
|
||||
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
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 { TEXT } from "../../locales";
|
||||
|
||||
@@ -222,11 +286,22 @@ const detail = reactive<any>({
|
||||
site_id: "",
|
||||
status: "",
|
||||
screening_date: "",
|
||||
consent_date: "",
|
||||
enrollment_date: "",
|
||||
completion_date: "",
|
||||
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 activeTab = ref("history");
|
||||
|
||||
@@ -243,14 +318,17 @@ const visitItems = ref<any[]>([]);
|
||||
const loadingVisits = ref(false);
|
||||
const visitDialogVisible = ref(false);
|
||||
const visitEditingId = ref<string | null>(null);
|
||||
const visitEditingRowId = ref<string | null>(null);
|
||||
const visitRowDraft = reactive({
|
||||
actual_date: "",
|
||||
notes: "",
|
||||
});
|
||||
const visitForm = reactive<any>({
|
||||
visit_code: "",
|
||||
visit_name: "",
|
||||
planned_date: "",
|
||||
window_start: "",
|
||||
window_end: "",
|
||||
actual_date: "",
|
||||
status: "PLANNED",
|
||||
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 () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingHistory.value = true;
|
||||
@@ -364,28 +477,93 @@ const openVisitDialog = (row?: any) => {
|
||||
visitEditingId.value = row?.id || null;
|
||||
if (row) {
|
||||
visitForm.actual_date = row.actual_date || "";
|
||||
visitForm.status = row.status || "PLANNED";
|
||||
visitForm.notes = row.notes || "";
|
||||
} else {
|
||||
visitForm.visit_code = "";
|
||||
visitForm.visit_name = "";
|
||||
visitForm.planned_date = "";
|
||||
visitForm.window_start = "";
|
||||
visitForm.window_end = "";
|
||||
visitForm.actual_date = "";
|
||||
visitForm.status = "PLANNED";
|
||||
visitForm.notes = "";
|
||||
}
|
||||
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 () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
if (visitEditingId.value) {
|
||||
const payload = {
|
||||
actual_date: visitForm.actual_date || null,
|
||||
status: visitForm.status || null,
|
||||
notes: visitForm.notes || null,
|
||||
};
|
||||
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
|
||||
@@ -393,7 +571,6 @@ const saveVisit = async () => {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
visit_code: visitForm.visit_code,
|
||||
visit_name: visitForm.visit_name,
|
||||
planned_date: visitForm.planned_date || null,
|
||||
window_start: visitForm.window_start || 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");
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</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-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
||||
@@ -76,6 +84,7 @@ const form = reactive({
|
||||
subject_no: "",
|
||||
site_id: "",
|
||||
screening_date: "",
|
||||
consent_date: "",
|
||||
status: "SCREENING",
|
||||
enrollment_date: "",
|
||||
completion_date: "",
|
||||
@@ -100,6 +109,7 @@ const load = async () => {
|
||||
subject_no: data.subject_no || "",
|
||||
site_id: data.site_id || "",
|
||||
screening_date: data.screening_date || "",
|
||||
consent_date: data.consent_date || "",
|
||||
status: data.status || "SCREENING",
|
||||
enrollment_date: data.enrollment_date || "",
|
||||
completion_date: data.completion_date || "",
|
||||
@@ -117,6 +127,7 @@ const submit = async () => {
|
||||
if (isEdit.value && subjectId.value) {
|
||||
const payload = {
|
||||
status: form.status || null,
|
||||
consent_date: form.consent_date || null,
|
||||
enrollment_date: form.enrollment_date || null,
|
||||
completion_date: form.completion_date || null,
|
||||
drop_reason: form.drop_reason || null,
|
||||
@@ -129,6 +140,7 @@ const submit = async () => {
|
||||
subject_no: form.subject_no,
|
||||
site_id: form.site_id,
|
||||
screening_date: form.screening_date || null,
|
||||
consent_date: form.consent_date || null,
|
||||
};
|
||||
const { data } = await createSubject(studyId.value, payload);
|
||||
ElMessage.success(TEXT.common.messages.createSuccess);
|
||||
|
||||
Reference in New Issue
Block a user