feat: harden auth and study workflows

- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
This commit is contained in:
Cheng Zhou
2026-05-08 22:13:12 +08:00
parent a7bbcaa5dc
commit 74feca4467
47 changed files with 2423 additions and 534 deletions
+5 -1
View File
@@ -8,6 +8,9 @@
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
- 生产环境必须配置 `LOGIN_RSA_PRIVATE_KEY`;登录密码传输使用 RSA-OAEP 包裹 AES-GCM 密钥,数据库仍只保存 bcrypt 哈希
- 浏览器端加密依赖 WebCrypto 安全上下文;生产访问必须使用 HTTPS,本地 `localhost` 例外
- 当前 challenge 缓存在后端进程内,默认最多保留 1000 个;多实例或多 worker 部署需改为共享缓存或启用粘性会话
- 验证方式:
- `docker compose config`
- `curl -i http://127.0.0.1:8888/`
@@ -34,6 +37,7 @@
## 仓库治理文档
- 分支治理规范:`docs/branch-governance.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md`
## 本地配置
@@ -57,6 +61,6 @@
> ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。
## 注意
- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。
- 登录使用邮箱 + 密码,前端会先获取登录公钥并加密提交;未审核/已拒绝/已停用账号无法登录。
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
@@ -0,0 +1,43 @@
"""replace global visit window with per-visit schedule
Revision ID: 20260508_01
Revises: 20260331_01
Create Date: 2026-05-08 11:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260508_01"
down_revision: Union[str, None] = "20260331_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"studies",
sa.Column(
"visit_schedule",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
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")
def downgrade() -> 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))
op.drop_column("studies", "visit_schedule")
@@ -0,0 +1,47 @@
"""remove summary and objective note fields
Revision ID: 20260508_02
Revises: 20260508_01
Create Date: 2026-05-08 15:55:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260508_02"
down_revision: Union[str, None] = "20260508_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"""
UPDATE study_setup_configs
SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note'
WHERE published_project_snapshot IS NOT NULL
"""
)
)
bind.execute(
sa.text(
"""
UPDATE study_setup_config_versions
SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note'
WHERE published_project_snapshot IS NOT NULL
"""
)
)
op.drop_column("studies", "objective_note")
op.drop_column("studies", "summary_note")
def downgrade() -> None:
op.add_column("studies", sa.Column("summary_note", sa.Text(), nullable=True))
op.add_column("studies", sa.Column("objective_note", sa.Text(), nullable=True))
@@ -0,0 +1,26 @@
"""add subject baseline date
Revision ID: 20260508_03
Revises: 20260508_02
Create Date: 2026-05-08 16:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260508_03"
down_revision: Union[str, None] = "20260508_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("subjects", sa.Column("baseline_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("subjects", "baseline_date")
+64 -30
View File
@@ -1,12 +1,13 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
from app.core.config import settings
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud
@@ -16,8 +17,16 @@ from fastapi.responses import FileResponse
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
key_id: str = Field(min_length=1)
challenge: str = Field(min_length=16)
ciphertext: str = Field(min_length=1)
class LoginKeyResponse(BaseModel):
key_id: str
public_key: str
challenge: str
expires_at: datetime
class ExtendResponse(BaseModel):
@@ -25,9 +34,8 @@ class ExtendResponse(BaseModel):
expiresAt: datetime
class UnlockRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
class UnlockRequest(LoginRequest):
pass
class UnlockResponse(BaseModel):
@@ -40,6 +48,42 @@ AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avata
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
def issue_user_token(db_user) -> Token:
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
return Token(access_token=access_token, token_type="bearer")
async def authenticate_encrypted_password(payload: LoginRequest, db: AsyncSession):
decrypted = decrypt_login_payload(
key_id=payload.key_id,
challenge=payload.challenge,
ciphertext=payload.ciphertext,
)
if not decrypted:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证登录凭据",
)
db_user = await user_crud.get_by_email(db, decrypted.email)
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在",
)
if not verify_password(decrypted.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="密码错误",
)
return db_user
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
payload: UserRegisterRequest,
@@ -54,35 +98,29 @@ async def register(
return {"message": "注册成功,等待管理员审核"}
@router.get("/login-key", response_model=LoginKeyResponse)
async def get_login_key() -> LoginKeyResponse:
challenge = create_login_challenge()
return LoginKeyResponse(
key_id=settings.LOGIN_RSA_KEY_ID,
public_key=get_public_key_pem(),
challenge=challenge.value,
expires_at=challenge.expires_at,
)
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在",
)
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="密码错误",
)
db_user = await authenticate_encrypted_password(payload, db)
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号未审核或不可用",
)
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
return Token(access_token=access_token, token_type="bearer")
return issue_user_token(db_user)
@router.get("/me", response_model=UserRead)
@@ -133,11 +171,7 @@ async def unlock_session(
payload: UnlockRequest,
db: AsyncSession = Depends(get_db_session),
) -> UnlockResponse:
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在")
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
db_user = await authenticate_encrypted_password(payload, db)
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start = datetime.now(timezone.utc)
+1 -28
View File
@@ -283,13 +283,8 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
planned_site_count=getattr(study, "planned_site_count", None),
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
summary_note=getattr(study, "summary_note", None) or "",
objective_note=getattr(study, "objective_note", None) or "",
status=getattr(study, "status", None) or "",
visit_interval_days=getattr(study, "visit_interval_days", None),
visit_total=getattr(study, "visit_total", None),
visit_window_start_offset=getattr(study, "visit_window_start_offset", None),
visit_window_end_offset=getattr(study, "visit_window_end_offset", None),
visit_schedule=getattr(study, "visit_schedule", None) or [],
)
@@ -360,17 +355,9 @@ def _ensure_study_timeline_valid(
*,
plan_start_date: date | None,
plan_end_date: date | None,
visit_window_start_offset: int | None,
visit_window_end_offset: int | None,
) -> None:
if plan_start_date and plan_end_date and plan_start_date > plan_end_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
if (
visit_window_start_offset is not None
and visit_window_end_offset is not None
and visit_window_start_offset > visit_window_end_offset
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
def _resolve_version_label(record: Any) -> str:
@@ -649,8 +636,6 @@ async def create_study(
_ensure_study_timeline_valid(
plan_start_date=study_in.plan_start_date,
plan_end_date=study_in.plan_end_date,
visit_window_start_offset=study_in.visit_window_start_offset,
visit_window_end_offset=study_in.visit_window_end_offset,
)
study_in.code = study_in.code.strip()
if not study_in.code:
@@ -746,21 +731,9 @@ async def update_study(
next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date
next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date
next_window_start = (
study_in.visit_window_start_offset
if "visit_window_start_offset" in study_in.model_fields_set
else study.visit_window_start_offset
)
next_window_end = (
study_in.visit_window_end_offset
if "visit_window_end_offset" in study_in.model_fields_set
else study.visit_window_end_offset
)
_ensure_study_timeline_valid(
plan_start_date=next_plan_start,
plan_end_date=next_plan_end,
visit_window_start_offset=next_window_start,
visit_window_end_offset=next_window_end,
)
updated = await study_crud.update(db, study, study_in)
+3 -3
View File
@@ -173,9 +173,9 @@ async def update_subject(
await _ensure_subject_active(db, subject)
old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled
if subject_in.status and subject_in.status == "ENROLLED" and (subject_in.enrollment_date or updated.enrollment_date):
await subject_crud.generate_default_visits(db, updated)
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
if updated.baseline_date:
await subject_crud.sync_visits_from_baseline(db, updated)
detail = None
if subject_in.status and subject_in.status != old_status:
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}"
+2 -5
View File
@@ -90,15 +90,12 @@ async def create_visit(
if next_visit_code == "V1" and visit_in.planned_date:
study = await study_crud.get(db, study_id)
if study:
await visit_crud.create_followup_visits(
await visit_crud.create_scheduled_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,
visit_schedule=study.visit_schedule,
)
await audit_crud.log_action(
db,
+6 -1
View File
@@ -1,5 +1,5 @@
from functools import lru_cache
from typing import Literal
from typing import Literal, Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -19,6 +19,11 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
LOGIN_RSA_KEY_ID: str = "default"
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
@lru_cache
+138
View File
@@ -0,0 +1,138 @@
import base64
import json
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from pydantic import BaseModel, EmailStr, Field, ValidationError
from app.core.config import settings
class DecryptedLoginPayload(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=72)
challenge: str = Field(min_length=16)
class EncryptedLoginEnvelope(BaseModel):
encrypted_key: str = Field(min_length=1)
iv: str = Field(min_length=1)
data: str = Field(min_length=1)
@dataclass(frozen=True)
class LoginChallenge:
value: str
expires_at: datetime
_private_key: RSAPrivateKey | None = None
_public_key_pem: str | None = None
_challenges: dict[str, LoginChallenge] = {}
def _normalize_pem(value: str) -> str:
return value.replace("\\n", "\n").strip()
def _load_or_create_private_key() -> RSAPrivateKey:
configured_key = settings.LOGIN_RSA_PRIVATE_KEY
if configured_key:
key = serialization.load_pem_private_key(_normalize_pem(configured_key).encode("utf-8"), password=None)
if not isinstance(key, RSAPrivateKey):
raise ValueError("LOGIN_RSA_PRIVATE_KEY 必须是 RSA 私钥")
return key
if settings.ENV == "production":
raise ValueError("生产环境必须配置 LOGIN_RSA_PRIVATE_KEY")
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def get_private_key() -> RSAPrivateKey:
global _private_key
if _private_key is None:
_private_key = _load_or_create_private_key()
return _private_key
def validate_login_crypto_configuration() -> None:
if settings.ENV == "production" and not settings.LOGIN_RSA_PRIVATE_KEY:
raise ValueError("生产环境必须配置 LOGIN_RSA_PRIVATE_KEY")
def get_public_key_pem() -> str:
global _public_key_pem
if _public_key_pem is not None:
return _public_key_pem
if settings.LOGIN_RSA_PUBLIC_KEY:
if not settings.LOGIN_RSA_PRIVATE_KEY:
raise ValueError("配置 LOGIN_RSA_PUBLIC_KEY 时必须同时配置 LOGIN_RSA_PRIVATE_KEY")
_public_key_pem = _normalize_pem(settings.LOGIN_RSA_PUBLIC_KEY)
return _public_key_pem
public_key: RSAPublicKey = get_private_key().public_key()
_public_key_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
return _public_key_pem
def create_login_challenge() -> LoginChallenge:
prune_expired_challenges()
while len(_challenges) >= settings.LOGIN_CHALLENGE_MAX_ACTIVE:
oldest = next(iter(_challenges))
_challenges.pop(oldest, None)
value = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.LOGIN_CHALLENGE_TTL_SECONDS)
challenge = LoginChallenge(value=value, expires_at=expires_at)
_challenges[value] = challenge
return challenge
def prune_expired_challenges() -> None:
now = datetime.now(timezone.utc)
expired = [value for value, challenge in _challenges.items() if challenge.expires_at <= now]
for value in expired:
_challenges.pop(value, None)
def consume_login_challenge(value: str) -> bool:
prune_expired_challenges()
challenge = _challenges.pop(value, None)
if not challenge:
return False
return challenge.expires_at > datetime.now(timezone.utc)
def decrypt_login_payload(*, key_id: str, challenge: str, ciphertext: str) -> DecryptedLoginPayload | None:
if key_id != settings.LOGIN_RSA_KEY_ID:
return None
if not consume_login_challenge(challenge):
return None
try:
envelope_data = json.loads(base64.b64decode(ciphertext, validate=True).decode("utf-8"))
envelope = EncryptedLoginEnvelope.model_validate(envelope_data)
encrypted_key = base64.b64decode(envelope.encrypted_key, validate=True)
iv = base64.b64decode(envelope.iv, validate=True)
encrypted_payload = base64.b64decode(envelope.data, validate=True)
aes_key = get_private_key().decrypt(
encrypted_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
decrypted = AESGCM(aes_key).decrypt(iv, encrypted_payload, None)
payload: Any = json.loads(decrypted.decode("utf-8"))
parsed = DecryptedLoginPayload.model_validate(payload)
except (ValueError, TypeError, json.JSONDecodeError, ValidationError):
return None
if parsed.challenge != challenge:
return None
return parsed
+1 -6
View File
@@ -30,14 +30,9 @@ async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UU
planned_enrollment_count=study_in.planned_enrollment_count,
enrollment_monthly_goal_note=study_in.enrollment_monthly_goal_note,
enrollment_stage_breakdown=study_in.enrollment_stage_breakdown,
summary_note=study_in.summary_note,
objective_note=study_in.objective_note,
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,
visit_schedule=[item.model_dump() for item in study_in.visit_schedule],
created_by=created_by,
)
db.add(study)
+26 -44
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import date, timedelta
from datetime import date
from typing import Sequence
from sqlalchemy import delete as sa_delete, select, update as sa_update
@@ -37,6 +37,10 @@ def _validate_subject_date_chain(
raise ValueError("完成日期不能早于入组日期")
def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool:
return next_baseline_date is not None and previous_baseline_date != next_baseline_date
async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID) -> None:
result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none()
@@ -51,7 +55,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
_validate_subject_date_chain(
screening_date=subject_in.screening_date,
consent_date=subject_in.consent_date,
enrollment_date=None,
enrollment_date=subject_in.enrollment_date,
completion_date=None,
)
subject = Subject(
@@ -61,7 +65,8 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
status="SCREENING",
screening_date=subject_in.screening_date,
consent_date=subject_in.consent_date,
enrollment_date=None,
enrollment_date=subject_in.enrollment_date,
baseline_date=subject_in.baseline_date,
completion_date=None,
drop_reason=None,
)
@@ -69,15 +74,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
await db.commit()
await db.refresh(subject)
# initial visit: Screening (V0)
await visit_crud.create_visit(
db,
study_id=study_id,
visit_in=None,
subject=subject,
visit_code="V0",
planned_date=subject.screening_date,
)
await sync_visits_from_baseline(db, subject)
return subject
@@ -103,49 +100,26 @@ async def list_subjects(
return result.scalars().all()
async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
# Baseline + Follow-up visits based on enrollment_date
if not subject.enrollment_date:
return
async def sync_visits_from_baseline(db: AsyncSession, subject: Subject) -> None:
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
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
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="V1",
planned_date=baseline_date,
window_start=window_start,
window_end=window_end,
)
await visit_crud.create_followup_visits(
await visit_crud.create_scheduled_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,
base_date=subject.baseline_date,
visit_schedule=study.visit_schedule,
)
async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
await sync_visits_from_baseline(db, subject)
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
update_data = subject_in.model_dump(exclude_unset=True)
next_screening_date = subject.screening_date
@@ -169,6 +143,14 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
return subject
def should_generate_visits_after_subject_update(
*,
previous_baseline_date: date | None,
next_baseline_date: date | None,
) -> bool:
return _should_sync_visits(previous_baseline_date, next_baseline_date)
async def delete_subject(db: AsyncSession, subject: Subject) -> None:
subject_id = subject.id
await db.execute(sa_delete(Visit).where(Visit.subject_id == subject_id))
+99 -24
View File
@@ -5,11 +5,59 @@ from typing import Sequence
from sqlalchemy import and_, func, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study import Study
from app.models.subject import Subject
from app.models.visit import Visit
from app.schemas.visit import VisitCreate, VisitUpdate
def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]:
order_map: dict[str, int] = {}
for index, item in enumerate(visit_schedule or []):
code = str(item.get("visit_code") or "").strip()
if code and code not in order_map:
order_map[code] = index
return order_map
def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]:
order_map = _visit_schedule_order_map(visit_schedule)
fallback_start = len(order_map)
return sorted(
visits,
key=lambda visit: (
order_map.get((visit.visit_code or "").strip(), fallback_start),
visit.planned_date is None,
visit.planned_date or date.max,
visit.visit_code or "",
),
)
def build_visit_schedule_dates(visit_schedule: list[dict] | None, base_date: date | None) -> list[dict]:
if not visit_schedule:
return []
rows: list[dict] = []
for item in visit_schedule:
code = str(item.get("visit_code") or "").strip()
if not code:
continue
baseline_offset_days = int(item.get("baseline_offset_days", 0))
window_before_days = int(item.get("window_before_days", 0))
window_after_days = int(item.get("window_after_days", 0))
planned_date = base_date + timedelta(days=baseline_offset_days) if base_date else None
rows.append(
{
"visit_code": code,
"baseline_offset_days": baseline_offset_days,
"planned_date": planned_date,
"window_start": planned_date - timedelta(days=window_before_days) if planned_date else None,
"window_end": planned_date + timedelta(days=window_after_days) if planned_date else None,
}
)
return rows
async def create_visit(
db: AsyncSession,
*,
@@ -20,14 +68,16 @@ async def create_visit(
planned_date,
window_start: date | None = None,
window_end: date | None = None,
actual_date: date | None = None,
status: str = "PLANNED",
) -> Visit:
visit = Visit(
study_id=study_id,
subject_id=subject.id,
visit_code=visit_code,
planned_date=planned_date,
actual_date=None,
status="PLANNED",
actual_date=actual_date,
status=status,
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,
@@ -39,8 +89,13 @@ async def create_visit(
async def list_visits(db: AsyncSession, subject_id: uuid.UUID) -> Sequence[Visit]:
result = await db.execute(select(Visit).where(Visit.subject_id == subject_id).order_by(Visit.planned_date))
return result.scalars().all()
result = await db.execute(select(Visit).where(Visit.subject_id == subject_id))
visits = result.scalars().all()
if not visits:
return []
study_result = await db.execute(select(Study.visit_schedule).where(Study.id == visits[0].study_id))
visit_schedule = study_result.scalar_one_or_none() or []
return sort_visits_for_display(visits, visit_schedule)
async def mark_overdue_as_lost(db: AsyncSession, subject_id: uuid.UUID) -> None:
@@ -150,32 +205,50 @@ async def list_lost_visits(
return result.all()
async def create_followup_visits(
async def create_scheduled_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,
base_date: date | None,
visit_schedule: list[dict] | None,
) -> Sequence[Visit]:
if not visit_total or not visit_interval_days or visit_total < 2:
if not visit_schedule:
return []
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id))
existing_codes = {row[0] for row in result.all()}
result = await db.execute(select(Visit).where(Visit.subject_id == subject.id))
existing_by_code = {visit.visit_code: visit for visit in result.scalars().all()}
created: list[Visit] = []
for index in range(2, visit_total + 1):
code = f"V{index}"
if code in existing_codes:
for item in build_visit_schedule_dates(visit_schedule, base_date):
code = item["visit_code"]
is_baseline_visit = item["baseline_offset_days"] == 0 and base_date is not None
existing = existing_by_code.get(code)
if existing:
if is_baseline_visit:
await db.execute(
sa_update(Visit)
.where(Visit.id == existing.id)
.values(
planned_date=item["planned_date"],
window_start=item["window_start"],
window_end=item["window_end"],
actual_date=base_date,
status="DONE",
)
)
await db.commit()
elif existing.status == "PLANNED" and existing.actual_date is None:
await db.execute(
sa_update(Visit)
.where(Visit.id == existing.id)
.values(
planned_date=item["planned_date"],
window_start=item["window_start"],
window_end=item["window_end"],
)
)
await db.commit()
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,
@@ -183,9 +256,11 @@ async def create_followup_visits(
visit_in=None,
subject=subject,
visit_code=code,
planned_date=planned_date,
window_start=window_start,
window_end=window_end,
planned_date=item["planned_date"],
window_start=item["window_start"],
window_end=item["window_end"],
actual_date=base_date if is_baseline_visit else None,
status="DONE" if is_baseline_visit else "PLANNED",
)
)
return created
+2
View File
@@ -12,6 +12,7 @@ from sqlalchemy import text
from app.api.v1.router import api_router
from app.core.config import settings
from app.core.exceptions import register_exception_handlers
from app.core.login_crypto import validate_login_crypto_configuration
from app.crud.user import ensure_admin_exists
from app.db.base import Base
from app.db.session import SessionLocal, engine
@@ -76,6 +77,7 @@ async def _ensure_legacy_primary_keys(conn) -> None:
def create_app() -> FastAPI:
validate_login_crypto_configuration()
app = FastAPI(
title="CTMS 后端 API",
description="临床试验项目管理系统后端接口文档",
+2 -7
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -32,14 +32,9 @@ class Study(Base):
planned_enrollment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
enrollment_monthly_goal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
enrollment_stage_breakdown: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_note: Mapped[str | None] = mapped_column(Text, nullable=True)
objective_note: Mapped[str | None] = mapped_column(Text, nullable=True)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
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)
visit_schedule: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
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())
+1
View File
@@ -20,6 +20,7 @@ class Subject(Base):
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)
baseline_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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+43 -20
View File
@@ -2,12 +2,36 @@ import uuid
from datetime import date, datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
class StudyCreate(BaseModel):
class VisitScheduleItem(BaseModel):
visit_code: str = Field(min_length=1, max_length=50)
baseline_offset_days: int = Field(ge=0, le=3650)
window_before_days: int = Field(ge=0, le=365)
window_after_days: int = Field(ge=0, le=365)
class VisitScheduleMixin(BaseModel):
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def validate_visit_schedule(self):
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudyCreate(VisitScheduleMixin):
name: str = Field(min_length=1)
code: str = Field(min_length=1)
project_full_name: Optional[str] = None
@@ -28,14 +52,8 @@ class StudyCreate(BaseModel):
planned_enrollment_count: Optional[int] = None
enrollment_monthly_goal_note: Optional[str] = None
enrollment_stage_breakdown: Optional[str] = None
summary_note: Optional[str] = None
objective_note: 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):
@@ -59,15 +77,25 @@ class StudyUpdate(BaseModel):
planned_enrollment_count: Optional[int] = None
enrollment_monthly_goal_note: Optional[str] = None
enrollment_stage_breakdown: Optional[str] = None
summary_note: Optional[str] = None
objective_note: Optional[str] = None
phase: Optional[str] = None
status: Optional[StudyStatus] = None
is_locked: Optional[bool] = 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
visit_schedule: Optional[list[VisitScheduleItem]] = None
@model_validator(mode="after")
def validate_visit_schedule(self):
if self.visit_schedule is None:
return self
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudyRead(BaseModel):
@@ -92,15 +120,10 @@ class StudyRead(BaseModel):
planned_enrollment_count: Optional[int]
enrollment_monthly_goal_note: Optional[str]
enrollment_stage_breakdown: Optional[str]
summary_note: Optional[str]
objective_note: Optional[str]
phase: Optional[str]
status: StudyStatus
is_locked: bool
visit_interval_days: Optional[int]
visit_total: Optional[int]
visit_window_start_offset: Optional[int]
visit_window_end_offset: Optional[int]
visit_schedule: list[VisitScheduleItem]
created_by: Optional[uuid.UUID]
created_at: datetime
+22 -7
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
ConfirmStatus = Literal["待确认", "已确认", "退回"]
@@ -143,6 +143,13 @@ class SetupProjectionSummary(BaseModel):
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
class VisitScheduleItem(BaseModel):
visit_code: str = Field(min_length=1, max_length=50)
baseline_offset_days: int = Field(ge=0, le=3650)
window_before_days: int = Field(ge=0, le=365)
window_after_days: int = Field(ge=0, le=365)
class ProjectPublishSnapshot(BaseModel):
code: str = ""
name: str = ""
@@ -162,13 +169,21 @@ class ProjectPublishSnapshot(BaseModel):
plan_end_date: str = ""
planned_site_count: int | None = None
planned_enrollment_count: int | None = None
summary_note: str = ""
objective_note: str = ""
status: str = ""
visit_interval_days: int | None = None
visit_total: int | None = None
visit_window_start_offset: int | None = None
visit_window_end_offset: int | None = None
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def validate_visit_schedule(self):
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudySetupConfigRead(BaseModel):
+4
View File
@@ -10,12 +10,15 @@ class SubjectCreate(BaseModel):
subject_no: str
screening_date: Optional[date] = None
consent_date: Optional[date] = None
enrollment_date: Optional[date] = None
baseline_date: Optional[date] = None
class SubjectUpdate(BaseModel):
status: Optional[str] = None
consent_date: Optional[date] = None
enrollment_date: Optional[date] = None
baseline_date: Optional[date] = None
completion_date: Optional[date] = None
drop_reason: Optional[str] = None
@@ -29,6 +32,7 @@ class SubjectRead(BaseModel):
screening_date: Optional[date]
consent_date: Optional[date]
enrollment_date: Optional[date]
baseline_date: Optional[date]
completion_date: Optional[date]
drop_reason: Optional[str]
created_at: datetime
+3 -3
View File
@@ -23,7 +23,7 @@ class UserDisplay(BaseModel):
class _PasswordValidator(BaseModel):
password: Optional[str] = Field(default=None, min_length=8)
password: Optional[str] = Field(default=None, min_length=8, max_length=72)
@field_validator("password")
@classmethod
@@ -36,7 +36,7 @@ class _PasswordValidator(BaseModel):
class UserRegisterRequest(_PasswordValidator):
password: str = Field(min_length=8)
password: str = Field(min_length=8, max_length=72)
email: EmailStr
full_name: str = Field(min_length=1)
role: RegisterRole
@@ -44,7 +44,7 @@ class UserRegisterRequest(_PasswordValidator):
class UserCreate(_PasswordValidator):
password: str = Field(min_length=8)
password: str = Field(min_length=8, max_length=72)
email: EmailStr
full_name: str = Field(min_length=1)
role: UserRole
+1
View File
@@ -2,6 +2,7 @@ fastapi==0.104.1
uvicorn[standard]==0.24.0.post1
sqlalchemy==2.0.23
asyncpg==0.29.0
aiosqlite==0.20.0
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
+48 -4
View File
@@ -1,4 +1,5 @@
import asyncio
import base64
import json
import os
import sys
@@ -6,6 +7,9 @@ import urllib.error
import urllib.request
import asyncpg
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
BASE = os.getenv("BASE_URL", "http://localhost:8000")
@@ -66,13 +70,53 @@ def db_fetch(sql: str, *args) -> list[dict]:
return asyncio.run(_db_fetch(sql, *args))
def main() -> int:
print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}")
status, login = request_json(
def encrypted_login(email: str, password: str) -> tuple[int, dict]:
status, login_key = request_json("/api/v1/auth/login-key")
if status != 200:
return status, login_key
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
plaintext = json.dumps(
{
"email": email,
"password": password,
"challenge": login_key["challenge"],
},
separators=(",", ":"),
).encode("utf-8")
aes_key = AESGCM.generate_key(bit_length=256)
iv = os.urandom(12)
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
encrypted_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return request_json(
"/api/v1/auth/login",
method="POST",
payload={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
payload={
"key_id": login_key["key_id"],
"challenge": login_key["challenge"],
"ciphertext": base64.b64encode(
json.dumps(
{
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
"data": base64.b64encode(encrypted_data).decode("ascii"),
},
separators=(",", ":"),
).encode("utf-8")
).decode("ascii"),
},
)
def main() -> int:
print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}")
status, login = encrypted_login(ADMIN_EMAIL, ADMIN_PASSWORD)
assert_or_exit(status == 200, f"登录失败 status={status} body={login}")
token = login["access_token"]
+39
View File
@@ -0,0 +1,39 @@
import pytest
from app.core import login_crypto
from app.core.config import settings
from app.main import create_app
def reset_login_crypto_state():
login_crypto._private_key = None
login_crypto._public_key_pem = None
login_crypto._challenges.clear()
def test_production_requires_configured_login_private_key(monkeypatch):
reset_login_crypto_state()
monkeypatch.setattr(settings, "ENV", "production")
monkeypatch.setattr(settings, "LOGIN_RSA_PRIVATE_KEY", None)
with pytest.raises(ValueError, match="生产环境必须配置 LOGIN_RSA_PRIVATE_KEY"):
create_app()
reset_login_crypto_state()
def test_login_challenge_cache_has_max_active_limit(monkeypatch):
reset_login_crypto_state()
monkeypatch.setattr(settings, "ENV", "test")
monkeypatch.setattr(settings, "LOGIN_CHALLENGE_MAX_ACTIVE", 2)
first = login_crypto.create_login_challenge()
second = login_crypto.create_login_challenge()
third = login_crypto.create_login_challenge()
assert len(login_crypto._challenges) == 2
assert not login_crypto.consume_login_challenge(first.value)
assert login_crypto.consume_login_challenge(second.value)
assert login_crypto.consume_login_challenge(third.value)
reset_login_crypto_state()
+127 -2
View File
@@ -1,7 +1,15 @@
import pytest
import pytest_asyncio
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from httpx import AsyncClient
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.ext.compiler import compiles
import base64
import json
import os
from app.main import create_app
from app.core.deps import get_db_session
@@ -12,6 +20,55 @@ from app.models.user import User, UserRole, UserStatus
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@compiles(UUID, "sqlite")
def compile_uuid_for_sqlite(_type, _compiler, **_kw):
return "CHAR(32)"
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
key_resp = await client.get("/api/v1/auth/login-key")
assert key_resp.status_code == 200
login_key = key_resp.json()
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
plaintext = json.dumps(
{
"email": email,
"password": password,
"challenge": login_key["challenge"],
},
separators=(",", ":"),
).encode("utf-8")
aes_key = AESGCM.generate_key(bit_length=256)
iv = os.urandom(12)
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
encrypted_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return {
"key_id": login_key["key_id"],
"challenge": login_key["challenge"],
"ciphertext": base64.b64encode(
json.dumps(
{
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
"data": base64.b64encode(encrypted_data).decode("ascii"),
},
separators=(",", ":"),
).encode("utf-8")
).decode("ascii"),
}
async def encrypted_login(client: AsyncClient, email: str, password: str):
return await client.post("/api/v1/auth/login", json=await encrypted_auth_payload(client, email, password))
@pytest_asyncio.fixture
async def client_and_db():
engine = create_async_engine(TEST_DATABASE_URL, future=True)
@@ -87,7 +144,7 @@ async def test_login_blocked_before_approval(client_and_db):
"department": "Safety",
}
await client.post("/api/v1/auth/register", json=payload)
resp = await client.post("/api/v1/auth/login", json={"email": payload["email"], "password": payload["password"]})
resp = await encrypted_login(client, payload["email"], payload["password"])
assert resp.status_code == 401
assert "账号未审核" in resp.json().get("detail", "")
@@ -107,7 +164,7 @@ async def test_admin_can_approve_user(client_and_db):
user = await user_crud.get_by_email(session, payload["email"])
user_id = user.id
admin_login = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"})
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
token = admin_login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
@@ -131,3 +188,71 @@ async def test_admin_role_cannot_register(client_and_db):
}
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code in (400, 422)
@pytest.mark.asyncio
async def test_plaintext_login_is_rejected(client_and_db):
client, _ = client_and_db
resp = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_login_challenge_cannot_be_reused(client_and_db):
client, _ = client_and_db
key_resp = await client.get("/api/v1/auth/login-key")
login_key = key_resp.json()
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
plaintext = json.dumps(
{
"email": "admin@test.com",
"password": "admin123",
"challenge": login_key["challenge"],
},
separators=(",", ":"),
).encode("utf-8")
aes_key = AESGCM.generate_key(bit_length=256)
iv = os.urandom(12)
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
encrypted_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)
)
ciphertext = base64.b64encode(
json.dumps(
{
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
"data": base64.b64encode(encrypted_data).decode("ascii"),
},
separators=(",", ":"),
).encode("utf-8")
).decode("ascii")
payload = {
"key_id": login_key["key_id"],
"challenge": login_key["challenge"],
"ciphertext": ciphertext,
}
first = await client.post("/api/v1/auth/login", json=payload)
second = await client.post("/api/v1/auth/login", json=payload)
assert first.status_code == 200
assert second.status_code == 401
@pytest.mark.asyncio
async def test_unlock_requires_encrypted_password(client_and_db):
client, _ = client_and_db
plaintext = await client.post("/api/v1/auth/unlock", json={"email": "admin@test.com", "password": "admin123"})
encrypted = await client.post(
"/api/v1/auth/unlock",
json=await encrypted_auth_payload(client, "admin@test.com", "admin123"),
)
assert plaintext.status_code == 422
assert encrypted.status_code == 200
+144
View File
@@ -0,0 +1,144 @@
from datetime import date
from types import SimpleNamespace
from app.crud.subject import should_generate_visits_after_subject_update
from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display
from app.schemas.study import StudyUpdate
def test_build_visit_schedule_dates_uses_per_visit_windows():
baseline_date = date(2026, 5, 8)
visits = build_visit_schedule_dates(
[
{
"visit_code": "基线访视",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0,
},
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 2,
"window_after_days": 2,
},
{
"visit_code": "V2",
"baseline_offset_days": 15,
"window_before_days": 2,
"window_after_days": 2,
},
{
"visit_code": "V3",
"baseline_offset_days": 23,
"window_before_days": 3,
"window_after_days": 3,
},
],
baseline_date,
)
assert [
(visit["visit_code"], visit["baseline_offset_days"], visit["planned_date"], visit["window_start"], visit["window_end"])
for visit in visits
] == [
("基线访视", 0, date(2026, 5, 8), date(2026, 5, 8), date(2026, 5, 8)),
("V1", 7, date(2026, 5, 15), date(2026, 5, 13), date(2026, 5, 17)),
("V2", 15, date(2026, 5, 23), date(2026, 5, 21), date(2026, 5, 25)),
("V3", 23, date(2026, 5, 31), date(2026, 5, 28), date(2026, 6, 3)),
]
def test_build_visit_schedule_dates_keeps_visit_structure_without_baseline_date():
visits = build_visit_schedule_dates(
[
{
"visit_code": "基线访视",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0,
},
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 2,
"window_after_days": 2,
},
],
None,
)
assert [
(visit["visit_code"], visit["planned_date"], visit["window_start"], visit["window_end"])
for visit in visits
] == [
("基线访视", None, None, None),
("V1", None, None, None),
]
def test_study_update_allows_multiple_visits_with_same_baseline_offset():
payload = StudyUpdate(
visit_schedule=[
{
"visit_code": "筛选访视",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0,
},
{
"visit_code": "基线访视",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0,
},
]
)
assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"]
def test_sort_visits_for_display_follows_configured_visit_order_without_dates():
visits = [
SimpleNamespace(visit_code="V1", planned_date=None),
SimpleNamespace(visit_code="V2", planned_date=None),
SimpleNamespace(visit_code="基线访视", planned_date=None),
]
visit_schedule = [
{"visit_code": "基线访视"},
{"visit_code": "V1"},
{"visit_code": "V2"},
]
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["基线访视", "V1", "V2"]
def test_sort_visits_for_display_does_not_infer_business_order():
visits = [
SimpleNamespace(visit_code="基线访视", planned_date=None),
SimpleNamespace(visit_code="V1", planned_date=None),
SimpleNamespace(visit_code="V2", planned_date=None),
]
visit_schedule = [
{"visit_code": "V1"},
{"visit_code": "V2"},
{"visit_code": "基线访视"},
]
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"]
def test_should_generate_visits_when_baseline_date_is_set_or_changed():
assert should_generate_visits_after_subject_update(
previous_baseline_date=None,
next_baseline_date=date(2026, 5, 8),
)
assert should_generate_visits_after_subject_update(
previous_baseline_date=date(2026, 5, 8),
next_baseline_date=date(2026, 5, 9),
)
assert not should_generate_visits_after_subject_update(
previous_baseline_date=None,
next_baseline_date=None,
)
+15 -17
View File
@@ -46,10 +46,7 @@ CREATE TABLE IF NOT EXISTS public.studies (
phase character varying(50),
status character varying(20) NOT NULL DEFAULT 'DRAFT',
is_locked boolean NOT NULL DEFAULT false,
visit_interval_days integer,
visit_total integer,
visit_window_start_offset integer,
visit_window_end_offset integer,
visit_schedule jsonb NOT NULL DEFAULT '[]'::jsonb,
created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uq_studies_code UNIQUE (code),
@@ -112,6 +109,7 @@ CREATE TABLE IF NOT EXISTS public.subjects (
screening_date date,
consent_date date,
enrollment_date date,
baseline_date date,
completion_date date,
drop_reason text,
created_at timestamp with time zone NOT NULL DEFAULT now(),
@@ -591,7 +589,7 @@ ON CONFLICT (email) DO UPDATE SET
updated_at = EXCLUDED.updated_at;
INSERT INTO public.studies (
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
id, code, name, sponsor, protocol_no, phase, status, visit_schedule, created_by, created_at
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'DEMO-CTMS',
@@ -600,10 +598,12 @@ INSERT INTO public.studies (
'DP-001',
'Phase II',
'ACTIVE',
7,
3,
-2,
2,
'[
{"visit_code": "基线访视", "baseline_offset_days": 0, "window_before_days": 0, "window_after_days": 0},
{"visit_code": "V1", "baseline_offset_days": 7, "window_before_days": 2, "window_after_days": 2},
{"visit_code": "V2", "baseline_offset_days": 15, "window_before_days": 2, "window_after_days": 2},
{"visit_code": "V3", "baseline_offset_days": 23, "window_before_days": 3, "window_after_days": 3}
]'::jsonb,
(SELECT id FROM public.users WHERE email = 'admin@example.com'),
'2025-01-06 08:00:00+00'
) ON CONFLICT (code) DO UPDATE SET
@@ -612,10 +612,7 @@ 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,
visit_schedule = EXCLUDED.visit_schedule,
created_by = EXCLUDED.created_by;
INSERT INTO public.sites (
@@ -753,17 +750,18 @@ INSERT INTO public.training_authorizations (
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.subjects (
id, study_id, site_id, subject_no, status, screening_date, consent_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, baseline_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-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')
('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', '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, 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', '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,
baseline_date = EXCLUDED.baseline_date,
completion_date = EXCLUDED.completion_date,
drop_reason = EXCLUDED.drop_reason,
updated_at = EXCLUDED.updated_at;
+8
View File
@@ -30,6 +30,10 @@ services:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
ENV: ${ENV:-development}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
depends_on:
db:
condition: service_healthy
@@ -43,6 +47,10 @@ services:
command: python scripts/init_production.py
environment:
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
ENV: ${ENV:-development}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
depends_on:
db:
condition: service_healthy
+1
View File
@@ -5,6 +5,7 @@ CTMS 文档按用途分为三类:当前操作手册、审计与治理记录、
## 当前常用
- [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁
- [`guides/branch-environment-installation.md`](guides/branch-environment-installation.md): `dev``main``release` 分支环境安装配置
- [`guides/setup-config-api.md`](guides/setup-config-api.md): 立项配置接口、联调与冒烟说明
- [`setup-config-curl-smoke.sh`](setup-config-curl-smoke.sh): 立项配置 curl 冒烟脚本
- [`postman/setup-config.postman_collection.json`](postman/setup-config.postman_collection.json): Postman 联调集合
@@ -0,0 +1,328 @@
# CTMS 分支环境安装配置指南
本文定义 `dev``main``release` 三类分支对应的推荐部署环境、配置文件、初始化步骤与验证命令。分支本身不会自动切换运行模式,实际模式由后端环境变量 `ENV` 决定。
## 分支与环境映射
| 分支 | 分支定位 | 推荐环境 | 后端 `ENV` | 用途 |
| --- | --- | --- | --- | --- |
| `dev` | 日常开发与集成 | 开发环境 | `development` | 功能开发、联调、自测、内部集成 |
| `main` | 下一版本候选 | 预发布 / 验收环境 | `production` | 回归测试、验收、发布候选验证 |
| `release` | 当前稳定生产线 | 生产环境 | `production` | 正式生产部署、生产 hotfix |
推广路径保持为:
```text
feature/* -> dev -> main -> release
```
不要把 `release` 当作日常开发分支使用。`main` 应只接收已经准备进入候选版本范围的变更。
## 公共前置条件
所有环境都需要:
- Docker 与 Docker Compose
- 可写的 `pg_data/` 数据目录
- 端口 `8888``8000``5432` 未被占用
- 后端镜像可安装 `backend/requirements.txt` 中的依赖
- 前端镜像可执行 `npm ci``npm run build`
当前 compose 服务拓扑:
```text
nginx -> backend -> db
```
对外入口:
- 前端:`http://localhost:8888`
- 后端 API:同域 `/api/v1/*`
- 健康检查:`http://localhost:8888/health`
## dev 分支:开发环境
`dev` 默认用于本地开发和内部集成,推荐使用 `ENV=development`
### 1. 切换分支
```bash
git checkout dev
git pull --rebase origin dev
```
### 2. 配置 `.env`
根目录 `.env` 不提交到仓库。开发环境推荐:
```env
COMPOSE_PROJECT_NAME=ctms_dev
ENV=development
JWT_SECRET_KEY=dev-secret
LOGIN_RSA_KEY_ID=default
LOGIN_RSA_PRIVATE_KEY=
```
说明:
- `ENV=development` 会启用开发行为。
- 未配置 `LOGIN_RSA_PRIVATE_KEY` 时,后端启动后会生成临时 RSA 私钥。
- 后端重启后临时公钥会变化,已有登录页应刷新后重新登录。
- `JWT_SECRET_KEY=dev-secret` 只允许本地开发使用。
### 3. 启动
```bash
docker compose up -d --build backend nginx
```
如果需要首次启动完整栈:
```bash
docker compose up -d --build
```
### 4. 开发模式行为
`development` 模式下:
- FastAPI `debug=True`
- 应用启动时会执行 `Base.metadata.create_all`
- 应用启动时会确保默认管理员存在
- 未配置 RSA 私钥时允许临时生成
这些行为只适合开发,不适合生产。
### 5. 验证
```bash
docker compose ps
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)"
curl -i http://127.0.0.1:8888/health
curl -i http://127.0.0.1:8888/api/v1/auth/login-key
```
预期:
```text
ENV=development
GET /health -> 200
GET /api/v1/auth/login-key -> 200, key_id=default
```
## main 分支:预发布 / 验收环境
`main` 是下一正式版本候选分支,应按生产模式运行,但不直接承载正式生产流量。
### 1. 切换分支
```bash
git checkout main
git pull --rebase origin main
```
### 2. 配置 `.env`
预发布环境应使用 `ENV=production`,并配置独立于生产的密钥:
```env
COMPOSE_PROJECT_NAME=ctms_staging
ENV=production
JWT_SECRET_KEY=<staging-strong-random-secret>
LOGIN_RSA_KEY_ID=staging-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<staging-rsa-private-key-pem-with-\n-escaped-newlines>
```
生成密钥示例:
```bash
openssl rand -hex 32
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
```
要求:
- `JWT_SECRET_KEY` 不得使用 `dev-secret`
- `LOGIN_RSA_PRIVATE_KEY` 必须固定保存,不能每次部署重新生成。
- staging 私钥不得复用 production 私钥。
### 3. 初始化数据库
生产模式不会自动建表或自动补管理员。首次部署或迁移前执行:
```bash
docker compose run --rm backend-init
```
该步骤应运行 Alembic migration,并确保固定管理员账号存在。
### 4. 启动
```bash
docker compose up -d --build
```
### 5. 验证
```bash
docker compose config
docker compose ps
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY))"
curl -i http://127.0.0.1:8888/health
curl -i http://127.0.0.1:8888/api/v1/auth/login-key
```
预期:
```text
ENV=production
JWT_SECRET_KEY == dev-secret -> False
LOGIN_RSA_PRIVATE_KEY_SET -> True
GET /health -> 200
GET /api/v1/auth/login-key -> 200
```
### 6. 验收门禁
在把 `main` 推进到 `release` 前,至少执行:
```bash
docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest
cd frontend && npm run test:unit
cd frontend && npm run type-check
cd frontend && npm run build
```
## release 分支:生产环境
`release` 是正式生产稳定分支。只有正式发布和生产 hotfix 应进入该分支。
### 1. 切换分支
```bash
git checkout release
git pull --rebase origin release
```
### 2. 配置 `.env`
生产环境必须使用生产专用配置:
```env
COMPOSE_PROJECT_NAME=ctms_prod
ENV=production
JWT_SECRET_KEY=<production-strong-random-secret>
LOGIN_RSA_KEY_ID=prod-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<production-rsa-private-key-pem-with-\n-escaped-newlines>
```
要求:
- `.env` 必须只保存在部署机器或密钥管理系统中。
- 不得提交 `.env`、私钥、JWT 密钥。
- `JWT_SECRET_KEY` 轮换会使既有 token 失效,应安排维护窗口。
- `LOGIN_RSA_PRIVATE_KEY` 轮换会影响新登录密钥获取,应同步更新 `LOGIN_RSA_KEY_ID`
### 3. HTTPS 要求
登录加密依赖浏览器 WebCrypto。生产访问必须使用 HTTPS
- `https://正式域名`
- 本地 `localhost` 是浏览器安全上下文例外,但不能代表生产可用性
如果生产仍通过 `http://服务器IP:8888` 访问,前端会拒绝执行登录加密。
### 4. 初始化与迁移
首次部署或每次包含 migration 的发布:
```bash
docker compose run --rm backend-init
```
确认 migration 成功后再启动或滚动重启服务。
### 5. 启动
```bash
docker compose up -d --build
```
### 6. 生产验证
```bash
docker compose ps
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY)); print(settings.LOGIN_RSA_KEY_ID)"
curl -i https://<production-domain>/health
curl -i https://<production-domain>/api/v1/auth/login-key
```
预期:
```text
ENV=production
JWT_SECRET_KEY == dev-secret -> False
LOGIN_RSA_PRIVATE_KEY_SET -> True
GET /health -> 200
GET /api/v1/auth/login-key -> 200
```
### 7. 多实例约束
当前登录 challenge 默认保存在后端进程内:
- 单实例、单 worker:可直接使用
- 多实例或多 worker:必须满足以下至少一项
- 使用共享缓存保存 challenge,例如 Redis
- 启用粘性会话,确保 `/login-key``/login` 命中同一后端进程
- 将后端限制为单 worker 单副本
如果不满足,上线后可能出现偶发登录失败。
## 环境变量说明
| 变量 | dev | main/staging | release/production | 说明 |
| --- | --- | --- | --- | --- |
| `COMPOSE_PROJECT_NAME` | `ctms_dev` | `ctms_staging` | `ctms_prod` | 防止不同环境容器名、网络名冲突 |
| `ENV` | `development` | `production` | `production` | 后端运行模式 |
| `JWT_SECRET_KEY` | `dev-secret` | 强随机 | 强随机 | JWT 签名密钥 |
| `LOGIN_RSA_KEY_ID` | `default` | `staging-YYYYMMDD` | `prod-YYYYMMDD` | 登录 RSA 密钥版本 |
| `LOGIN_RSA_PRIVATE_KEY` | 空 | staging 私钥 | production 私钥 | RSA 私钥,生产模式必填 |
## 常见问题
### 登录页提示当前浏览器环境不支持安全登录加密
原因:非 HTTPS、非 localhost 的访问环境不满足 WebCrypto 安全上下文要求。
处理:
- 本地使用 `http://localhost:8888`
- staging/production 使用 HTTPS 域名
### production 模式启动失败,提示必须配置 LOGIN_RSA_PRIVATE_KEY
原因:`ENV=production` 时必须提供固定 RSA 私钥。
处理:
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
```
将输出 PEM 写入 `.env``LOGIN_RSA_PRIVATE_KEY`,换行使用 `\n` 转义。
### 切换分支后环境不符合预期
分支不会自动修改 `.env`。切换分支后应重新确认:
```bash
docker compose config
docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)"
```
必要时修改 `.env` 并重启:
```bash
docker compose up -d --build backend nginx
```
+14 -6
View File
@@ -104,13 +104,21 @@
"plan_end_date": "2026-12-31",
"planned_site_count": 12,
"planned_enrollment_count": 120,
"summary_note": "",
"objective_note": "",
"status": "DRAFT",
"visit_interval_days": null,
"visit_total": null,
"visit_window_start_offset": null,
"visit_window_end_offset": null
"visit_schedule": [
{
"visit_code": "基线访视",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0
},
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 2,
"window_after_days": 2
}
]
},
"saved_by": "11111111-1111-1111-1111-111111111111",
"saved_by_name": "System Admin",
@@ -6,7 +6,35 @@
},
"item": [
{
"name": "1. 登录(获取 Token",
"name": "1. 获取登录公钥",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/auth/login-key",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "login-key"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('status is 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"pm.collectionVariables.set('login_key_id', json.key_id || '');",
"pm.collectionVariables.set('login_challenge', json.challenge || '');",
"pm.collectionVariables.set('login_public_key', json.public_key || '');",
"pm.collectionVariables.set('login_ciphertext', '<请生成 AES-GCM 密文,并用 login_public_key 通过 RSA-OAEP-SHA256 加密 AES key 后填写外层 Base64 envelope>');"
]
}
}
]
},
{
"name": "2. 加密登录(获取 Token",
"request": {
"method": "POST",
"header": [
@@ -14,7 +42,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{email}}\",\n \"password\": \"{{password}}\"\n}"
"raw": "{\n \"key_id\": \"{{login_key_id}}\",\n \"challenge\": \"{{login_challenge}}\",\n \"ciphertext\": \"{{login_ciphertext}}\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/auth/login",
+59 -4
View File
@@ -7,10 +7,65 @@ PASSWORD="${PASSWORD:-admin123}"
STUDY_ID="${STUDY_ID:-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}"
echo "[1/6] login: $EMAIL"
TOKEN=$(curl -sS -X POST "$BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \
python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))')
TOKEN=$(BASE_URL="$BASE_URL" EMAIL="$EMAIL" PASSWORD="$PASSWORD" python3 - <<'PY'
import base64
import json
import os
import urllib.request
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
base_url = os.environ["BASE_URL"]
email = os.environ["EMAIL"]
password = os.environ["PASSWORD"]
with urllib.request.urlopen(f"{base_url}/api/v1/auth/login-key", timeout=20) as resp:
login_key = json.loads(resp.read().decode())
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
plaintext = json.dumps(
{"email": email, "password": password, "challenge": login_key["challenge"]},
separators=(",", ":"),
).encode("utf-8")
aes_key = AESGCM.generate_key(bit_length=256)
iv = os.urandom(12)
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
encrypted_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
payload = json.dumps(
{
"key_id": login_key["key_id"],
"challenge": login_key["challenge"],
"ciphertext": base64.b64encode(
json.dumps(
{
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
"data": base64.b64encode(encrypted_data).decode("ascii"),
},
separators=(",", ":"),
).encode("utf-8")
).decode("ascii"),
}
).encode()
req = urllib.request.Request(
f"{base_url}/api/v1/auth/login",
method="POST",
headers={"Content-Type": "application/json"},
data=payload,
)
with urllib.request.urlopen(req, timeout=20) as resp:
print(json.loads(resp.read().decode()).get("access_token", ""))
PY
)
if [[ -z "$TOKEN" ]]; then
echo "login failed: access_token empty"
+4 -1
View File
@@ -1,10 +1,13 @@
import type { AxiosResponse } from "axios";
import api, { apiGet, apiPatch, apiPost } from "./axios";
import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api";
import type { UserMeResponse, LoginRequest, LoginResponse, LoginKeyResponse, RegisterRequest } from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/login", payload);
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
+17 -1
View File
@@ -16,6 +16,19 @@ export type UnlockResponse = {
expiresAt: string;
};
export type LoginKeyResponse = {
key_id: string;
public_key: string;
challenge: string;
expires_at: string;
};
export type EncryptedPasswordRequest = {
key_id: string;
challenge: string;
ciphertext: string;
};
export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse>> =>
authClient.post<ExtendResponse>(
"/api/v1/auth/extend",
@@ -27,7 +40,10 @@ export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse
}
);
export const unlockSession = (payload: { email: string; password: string }): Promise<AxiosResponse<UnlockResponse>> =>
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
authClient.get<LoginKeyResponse>("/api/v1/auth/login-key");
export const unlockSession = (payload: EncryptedPasswordRequest): Promise<AxiosResponse<UnlockResponse>> =>
authClient.post<UnlockResponse>("/api/v1/auth/unlock", payload);
export default authClient;
+2 -1
View File
@@ -121,11 +121,12 @@ export const TEXT = {
screeningDate: "筛选日期",
consentDate: "知情日期",
enrollmentDate: "入组日期",
baselineDate: "基线/治疗日期",
completionDate: "完成日期",
dropReason: "退出原因",
recordDate: "日期",
content: "内容",
visitCode: "访视编号",
visitCode: "访视",
plannedDate: "计划访视",
actualDate: "实际访视",
windowStart: "窗口开始",
+13 -2
View File
@@ -2,7 +2,8 @@ import router from "../router";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { getToken, setToken, clearToken } from "../utils/auth";
import { extendToken, unlockSession } from "../api/authClient";
import { extendToken, getLoginKey, unlockSession } from "../api/authClient";
import { encryptLoginPayload } from "../utils/loginCrypto";
import { parseJwtExp } from "./jwt";
export const IDLE_TIMEOUT_MINUTES = 30;
@@ -292,7 +293,17 @@ export const startTokenKeepAlive = () => {
export const unlockWithPassword = async (email: string, password: string) => {
const session = useSessionStore();
const auth = useAuthStore();
const { data } = await unlockSession({ email, password });
const { data: loginKey } = await getLoginKey();
const ciphertext = await encryptLoginPayload(loginKey.public_key, {
email,
password,
challenge: loginKey.challenge,
});
const { data } = await unlockSession({
key_id: loginKey.key_id,
challenge: loginKey.challenge,
ciphertext,
});
updateToken(data.accessToken);
session.unlock();
try {
+13 -2
View File
@@ -1,7 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { login as apiLogin, fetchMe } from "../api/auth";
import { getLoginKey, login as apiLogin, fetchMe } from "../api/auth";
import { setToken, clearToken, getToken } from "../utils/auth";
import { encryptLoginPayload } from "../utils/loginCrypto";
import type { UserInfo } from "../types/api";
import { useStudyStore } from "./study";
import { useSessionStore } from "./session";
@@ -16,7 +17,17 @@ export const useAuthStore = defineStore("auth", () => {
const login = async (email: string, password: string) => {
loading.value = true;
try {
const { data } = await apiLogin({ email, password });
const { data: loginKey } = await getLoginKey();
const ciphertext = await encryptLoginPayload(loginKey.public_key, {
email,
password,
challenge: loginKey.challenge,
});
const { data } = await apiLogin({
key_id: loginKey.key_id,
challenge: loginKey.challenge,
ciphertext,
});
token.value = data.access_token;
setToken(data.access_token);
// 避免锁屏态下 fetchMe 被请求拦截
+18 -8
View File
@@ -15,8 +15,9 @@ export interface ApiError {
}
export interface LoginRequest {
email: string;
password: string;
key_id: string;
challenge: string;
ciphertext: string;
}
export interface LoginResponse {
@@ -24,6 +25,13 @@ export interface LoginResponse {
token_type: string;
}
export interface LoginKeyResponse {
key_id: string;
public_key: string;
challenge: string;
expires_at: string;
}
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
@@ -79,20 +87,22 @@ export interface Study {
planned_enrollment_count?: number | null;
enrollment_monthly_goal_note?: string | null;
enrollment_stage_breakdown?: string | null;
summary_note?: string | null;
objective_note?: string | null;
phase?: string | null;
status: string;
is_locked?: boolean;
visit_interval_days?: number | null;
visit_total?: number | null;
visit_window_start_offset?: number | null;
visit_window_end_offset?: number | null;
visit_schedule?: VisitScheduleItem[];
created_by?: string | null;
created_at?: string;
role_in_study?: string | null;
}
export interface VisitScheduleItem {
visit_code: string;
baseline_offset_days: number;
window_before_days: number;
window_after_days: number;
}
export interface StudyMember {
id: string;
study_id: string;
+8 -6
View File
@@ -66,6 +66,13 @@ export interface SetupConfigDraft {
centerConfirm: CenterConfirmDraft[];
}
export interface VisitScheduleItem {
visit_code: string;
baseline_offset_days: number;
window_before_days: number;
window_after_days: number;
}
export interface ProjectPublishSnapshot {
code: string;
name: string;
@@ -85,13 +92,8 @@ export interface ProjectPublishSnapshot {
plan_end_date: string;
planned_site_count: number | null;
planned_enrollment_count: number | null;
summary_note: string;
objective_note: string;
status: string;
visit_interval_days: number | null;
visit_total: number | null;
visit_window_start_offset: number | null;
visit_window_end_offset: number | null;
visit_schedule: VisitScheduleItem[];
}
export interface StudySetupConfigResponse {
-28
View File
@@ -1,5 +1,4 @@
const TOKEN_KEY = "ctms_token";
const CREDENTIAL_KEY = "ctms_credential";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
@@ -10,30 +9,3 @@ export const setToken = (token: string): void => {
export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
export const setCachedCredential = (email: string, password: string) => {
try {
const payload = btoa(JSON.stringify({ email, password }));
localStorage.setItem(CREDENTIAL_KEY, payload);
} catch {
/* ignore */
}
};
export const getCachedCredential = (): { email: string; password: string } | null => {
const value = localStorage.getItem(CREDENTIAL_KEY);
if (!value) return null;
try {
const parsed = JSON.parse(atob(value));
if (parsed?.email && parsed?.password) {
return { email: parsed.email, password: parsed.password };
}
} catch {
/* ignore */
}
return null;
};
export const clearCachedCredential = (): void => {
localStorage.removeItem(CREDENTIAL_KEY);
};
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { encryptLoginPayload } from "./loginCrypto";
describe("encryptLoginPayload", () => {
it("fails with a clear error outside a secure browser context", async () => {
vi.stubGlobal("isSecureContext", false);
await expect(
encryptLoginPayload("unused", {
email: "admin@test.com",
password: "admin123",
challenge: "challenge-value-with-enough-length",
})
).rejects.toThrow("当前浏览器环境不支持安全登录加密");
vi.unstubAllGlobals();
});
});
+56
View File
@@ -0,0 +1,56 @@
const pemToArrayBuffer = (pem: string): ArrayBuffer => {
const base64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
.replace(/-----END PUBLIC KEY-----/g, "")
.replace(/\s/g, "");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
};
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
};
const assertSecureCryptoAvailable = () => {
if (!window.isSecureContext || !crypto?.subtle) {
throw new Error("当前浏览器环境不支持安全登录加密,请使用 HTTPS 或 localhost 访问系统");
}
};
export const encryptLoginPayload = async (
publicKeyPem: string,
payload: { email: string; password: string; challenge: string }
): Promise<string> => {
assertSecureCryptoAvailable();
const publicKey = await crypto.subtle.importKey(
"spki",
pemToArrayBuffer(publicKeyPem),
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["encrypt"]
);
const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const encryptedData = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
const rawAesKey = await crypto.subtle.exportKey("raw", aesKey);
const encryptedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, rawAesKey);
const envelope = {
encrypted_key: arrayBufferToBase64(encryptedKey),
iv: arrayBufferToBase64(iv.buffer),
data: arrayBufferToBase64(encryptedData),
};
return btoa(JSON.stringify(envelope));
};
+1 -6
View File
@@ -118,13 +118,8 @@ const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
plan_end_date: "计划结束日期",
planned_site_count: "计划中心数",
planned_enrollment_count: "计划入组数",
summary_note: "方案摘要说明",
objective_note: "研究目标摘要",
status: "项目状态",
visit_interval_days: "访视间隔(天)",
visit_total: "访视总数",
visit_window_start_offset: "窗口期起始偏移",
visit_window_end_offset: "窗口期结束偏移",
visit_schedule: "访视计划",
};
export const buildProjectDiffRows = (
+1 -35
View File
@@ -74,12 +74,6 @@
</el-input>
</el-form-item>
<div class="remember-row">
<el-checkbox id="remember" v-model="form.remember" class="remember-checkbox">
<span class="remember-text">记住此设备</span>
</el-checkbox>
</div>
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
</el-button>
@@ -100,7 +94,6 @@ import { useRouter } from "vue-router";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
import { TEXT, requiredMessage } from "../locales";
import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionManager";
@@ -111,7 +104,6 @@ const formRef = ref<FormInstance>();
const form = reactive({
email: "",
password: "",
remember: false,
});
const rules: FormRules<typeof form> = {
@@ -129,12 +121,7 @@ onMounted(() => {
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
}
const cached = getCachedCredential();
if (cached) {
form.email = cached.email;
form.password = cached.password;
form.remember = true;
}
form.email = localStorage.getItem("ctms_last_login_email") || "";
});
const onSubmit = async () => {
@@ -144,11 +131,6 @@ const onSubmit = async () => {
loading.value = true;
try {
await auth.login(form.email, form.password);
if (form.remember) {
setCachedCredential(form.email, form.password);
} else {
clearCachedCredential();
}
const studyStore = useStudyStore();
if (!studyStore.currentStudy) {
if (auth.user?.role === "ADMIN") {
@@ -327,22 +309,6 @@ const onSubmit = async () => {
transition: background-color 5000s ease-in-out 0s;
}
.remember-row {
display: flex;
align-items: center;
justify-content: flex-start;
margin: -6px 0 18px;
}
.remember-checkbox :deep(.el-checkbox__label) {
padding-left: 8px;
}
.remember-text {
color: #64748b;
font-size: 13px;
}
.login-btn {
width: 100%;
height: 48px;
+555 -103
View File
@@ -471,55 +471,12 @@
</el-tab-pane>
<el-tab-pane v-if="showStep1SummaryTab" label="方案摘要" name="summary">
<el-form v-if="canEditStep1Summary" label-width="120px" class="detail-form" label-position="top">
<el-row :gutter="24">
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="方案摘要说明">
<el-input v-model="form.summary_note" type="textarea" :rows="3" placeholder="用于展示项目信息概览说明" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="研究目标摘要">
<el-input v-model="form.objective_note" type="textarea" :rows="3" placeholder="用于展示研究目标摘要" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-else class="info-grid">
<div
v-for="item in summaryInfoItems"
:key="item.label"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<div class="info-group summary-display-group">
<div class="info-group-title-row">
<div class="info-group-title">访视计划</div>
<div class="info-group-title-actions">
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
v-if="isProjectSnapshotFieldUpdated('visit_schedule') && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
@@ -527,7 +484,33 @@
>
已更新
</el-tag>
</span>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('summary')"
>
编辑
</el-button>
</div>
</div>
<div class="visit-schedule-display">
<div class="visit-schedule-display-head">
<span>访视</span>
<span>基线后天数</span>
<span>访视窗</span>
</div>
<div
v-for="(row, index) in summaryVisitScheduleRows"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-display-row"
>
<span class="visit-schedule-display-code">{{ row.visit_code || "-" }}</span>
<span>{{ row.baseline_offset_days }} </span>
<span>{{ formatVisitWindow(row) }}</span>
</div>
<div v-if="!summaryVisitScheduleRows.length" class="visit-schedule-display-empty">暂无访视计划</div>
</div>
</div>
</el-tab-pane>
@@ -1661,6 +1644,100 @@
</template>
</el-dialog>
<el-dialog
v-model="visitScheduleDialogVisible"
title="编辑访视计划"
width="min(860px, calc(100vw - 32px))"
:close-on-click-modal="false"
:show-close="false"
class="visit-schedule-dialog"
>
<el-form label-width="120px" class="detail-form visit-schedule-dialog-form" label-position="top">
<div class="summary-section-bar">
<el-button plain size="small" @click="addVisitScheduleRow">添加访视</el-button>
</div>
<div class="visit-schedule-editor">
<div class="visit-schedule-head">
<span>访视</span>
<span>基线后天数</span>
<span>窗口前</span>
<span>窗口后</span>
<span />
</div>
<div
v-for="(row, index) in form.visit_schedule"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-row"
:class="{
'is-dragging': visitScheduleDragIndex === index,
'insert-before': isVisitScheduleInsertTarget(index, 'before'),
'insert-after': isVisitScheduleInsertTarget(index, 'after'),
}"
draggable="true"
aria-label="拖拽调整访视顺序"
@dragstart="handleVisitScheduleDragStart(index, $event)"
@dragover.prevent="handleVisitScheduleDragOver(index, $event)"
@drop.prevent="handleVisitScheduleDrop(index)"
@dragenter.prevent="handleVisitScheduleDragOver(index, $event)"
@dragend="handleVisitScheduleDragEnd"
>
<div class="visit-field visit-code-field">
<span class="visit-field-label">访视</span>
<el-select
v-model="row.visit_code"
filterable
allow-create
default-first-option
placeholder="选择或输入访视"
class="w-full"
@change="handleVisitScheduleCodeChange(index, $event)"
>
<el-option-group label="常规访视">
<el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" />
<el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" />
</el-option-group>
<el-option-group label="特殊访视">
<el-option
v-for="option in specialVisitOptions"
:key="option"
:label="option"
:value="option"
/>
</el-option-group>
</el-select>
</div>
<div class="visit-field">
<span class="visit-field-label">基线后天数</span>
<el-input-number v-model="row.baseline_offset_days" :min="0" :max="3650" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口前</span>
<el-input-number v-model="row.window_before_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口后</span>
<el-input-number v-model="row.window_after_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<el-button
:icon="Delete"
text
type="danger"
class="visit-schedule-remove"
@click="removeVisitScheduleRow(index)"
/>
</div>
</div>
</el-form>
<template #footer>
<div class="visit-schedule-dialog-footer">
<el-button :disabled="drawerConfirmLoading || submitting" @click="cancelVisitScheduleDialogEdit">取消</el-button>
<el-button type="primary" :loading="drawerConfirmLoading || submitting" :disabled="!canEditSetup" @click="saveVisitScheduleDialogEdit">
保存
</el-button>
</div>
</template>
</el-dialog>
<el-drawer
v-if="projectMilestoneEditorVisible"
v-model="projectMilestoneEditorVisible"
@@ -1984,6 +2061,7 @@ import type {
StudySetupConfigResponse,
StudySetupConfigVersionItem,
StudySetupConfigUpsertPayload,
VisitScheduleItem,
} from "../../types/setupConfig";
import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study";
@@ -2145,10 +2223,16 @@ const infoTab = ref<"basic" | "summary">("basic");
const step1EditSection = ref<Step1EditSection>("all");
const isEditing = ref(false);
const drawerClosing = ref(false);
const visitScheduleDialogVisible = ref(false);
const drawerVisible = computed(() => {
if (visitScheduleDialogVisible.value) return false;
if (activeStep.value === 2 || activeStep.value === 4) return false;
return isEditing.value || drawerClosing.value;
});
const visitScheduleDragIndex = ref<number | null>(null);
const visitScheduleDragOverIndex = ref<number | null>(null);
type VisitScheduleInsertPosition = "before" | "after";
const visitScheduleInsertPosition = ref<VisitScheduleInsertPosition>("before");
const drawerCloseTimer = ref<number | undefined>(undefined);
const submitting = ref(false);
const formRef = ref<FormInstance>();
@@ -2311,14 +2395,148 @@ const form = ref({
phase: "",
status: "DRAFT",
scope: "国内",
summary_note: "",
objective_note: "",
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,
visit_schedule: [] as VisitScheduleItem[],
});
const formBaselineSnapshot = ref("");
const regularVisitOptionValue = "__REGULAR_VISIT__";
const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__";
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"];
const getNextRegularVisitCode = (excludeIndex?: number): string => {
const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `V${maxRegularIndex + 1}`;
};
const getNextSafetyFollowUpVisitCode = (excludeIndex?: number): string => {
const maxSafetyFollowUpIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^安全性随访V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `安全性随访V${maxSafetyFollowUpIndex + 1}`;
};
const hasDuplicateVisitCode = (visitCode: string, currentIndex: number): boolean => {
const normalizedCode = visitCode.trim();
if (!normalizedCode) return false;
return form.value.visit_schedule.some(
(row, rowIndex) => rowIndex !== currentIndex && (row.visit_code || "").trim() === normalizedCode
);
};
const normalizeVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): VisitScheduleItem[] =>
(rows || []).map((row) => ({
visit_code: (row.visit_code || "").trim(),
baseline_offset_days: Number(row.baseline_offset_days || 0),
window_before_days: Number(row.window_before_days || 0),
window_after_days: Number(row.window_after_days || 0),
}));
const addVisitScheduleRow = () => {
form.value.visit_schedule.push({
visit_code: getNextRegularVisitCode(),
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 0,
});
};
const removeVisitScheduleRow = (index: number) => {
form.value.visit_schedule.splice(index, 1);
};
const handleVisitScheduleCodeChange = (index: number, value: string) => {
const row = form.value.visit_schedule[index];
if (!row) return;
let nextVisitCode = String(value || "").trim();
if (nextVisitCode === regularVisitOptionValue) {
nextVisitCode = getNextRegularVisitCode(index);
} else if (nextVisitCode === safetyFollowUpVisitOptionValue) {
nextVisitCode = getNextSafetyFollowUpVisitCode(index);
}
if (hasDuplicateVisitCode(nextVisitCode, index)) {
row.visit_code = "";
ElMessage.warning(`访视已存在:${nextVisitCode}`);
return;
}
row.visit_code = nextVisitCode;
};
const handleVisitScheduleDragStart = (index: number, event: DragEvent) => {
visitScheduleDragIndex.value = index;
visitScheduleDragOverIndex.value = index;
visitScheduleInsertPosition.value = "before";
event.dataTransfer?.setData("text/plain", String(index));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
};
const handleVisitScheduleDragOver = (index: number, event: DragEvent) => {
if (visitScheduleDragIndex.value === null) return;
const target = event.currentTarget as HTMLElement | null;
if (target) {
const rect = target.getBoundingClientRect();
visitScheduleInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
}
visitScheduleDragOverIndex.value = index;
};
const handleVisitScheduleDrop = (targetIndex: number) => {
const sourceIndex = visitScheduleDragIndex.value;
if (sourceIndex === null) {
handleVisitScheduleDragEnd();
return;
}
let insertIndex = visitScheduleInsertPosition.value === "after" ? targetIndex + 1 : targetIndex;
if (sourceIndex === targetIndex || sourceIndex + 1 === insertIndex) {
handleVisitScheduleDragEnd();
return;
}
const nextRows = [...form.value.visit_schedule];
const [movedRow] = nextRows.splice(sourceIndex, 1);
if (!movedRow) {
handleVisitScheduleDragEnd();
return;
}
if (sourceIndex < insertIndex) {
insertIndex -= 1;
}
nextRows.splice(insertIndex, 0, movedRow);
form.value.visit_schedule = nextRows;
handleVisitScheduleDragEnd();
};
const handleVisitScheduleDragEnd = () => {
visitScheduleDragIndex.value = null;
visitScheduleDragOverIndex.value = null;
visitScheduleInsertPosition.value = "before";
};
const isVisitScheduleInsertTarget = (index: number, position: VisitScheduleInsertPosition) =>
visitScheduleDragIndex.value !== null &&
visitScheduleDragIndex.value !== index &&
visitScheduleDragOverIndex.value === index &&
visitScheduleInsertPosition.value === position;
const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): string => {
const normalized = normalizeVisitSchedule(rows);
if (!normalized.length) return "-";
return normalized
.map(
(row) =>
`${row.visit_code || "-"}:基线+${row.baseline_offset_days}天,窗口-${row.window_before_days}/+${row.window_after_days}`
)
.join("");
};
const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days}`;
const setupDraft = reactive<SetupConfigDraft>({
projectMilestones: [],
@@ -2354,9 +2572,6 @@ const canEditSetup = computed(
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === group);
const canEditStep1Summary = computed(
() => activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === "summary")
);
const isStep1BasicScopedEdit = computed(
() => activeStep.value === 0 && isEditing.value && ["project", "research", "execution"].includes(step1EditSection.value)
);
@@ -2370,7 +2585,7 @@ const stepActionMode = computed<StepActionMode>(() => {
case 2:
return "edit";
case 0:
return infoTab.value === "summary" ? "edit" : "none";
return "none";
case 1:
return "project-milestone";
case 3:
@@ -2483,14 +2698,9 @@ const serializeFormForCompare = (): string =>
plan_start_date: "",
plan_end_date: "",
planned_enrollment_count: null,
summary_note: form.value.summary_note || "",
objective_note: form.value.objective_note || "",
phase: form.value.phase || "",
status: form.value.status || "",
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft));
const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({
@@ -2512,13 +2722,8 @@ const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | und
plan_end_date: study?.plan_end_date || "",
planned_site_count: study?.planned_site_count ?? null,
planned_enrollment_count: study?.planned_enrollment_count ?? null,
summary_note: study?.summary_note || "",
objective_note: study?.objective_note || "",
status: study?.status || "",
visit_interval_days: study?.visit_interval_days ?? null,
visit_total: study?.visit_total ?? null,
visit_window_start_offset: study?.visit_window_start_offset ?? null,
visit_window_end_offset: study?.visit_window_end_offset ?? null,
visit_schedule: normalizeVisitSchedule(study?.visit_schedule),
});
const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
buildProjectPublishSnapshotFromStudy({
@@ -2540,13 +2745,8 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
plan_end_date: form.value.plan_end_date,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note,
objective_note: form.value.objective_note,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
@@ -2774,14 +2974,7 @@ const executionInfoItems = computed<DisplayInfoItem[]>(() => [
},
]);
const summaryInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "访视总数", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_total), updated: isProjectSnapshotFieldUpdated("visit_total") },
{ label: "访视间隔(天)", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_interval_days), updated: isProjectSnapshotFieldUpdated("visit_interval_days") },
{ label: "窗口期起始偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_start_offset), updated: isProjectSnapshotFieldUpdated("visit_window_start_offset") },
{ label: "窗口期结束偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_end_offset), updated: isProjectSnapshotFieldUpdated("visit_window_end_offset") },
{ label: "方案摘要说明", value: currentProjectPublishSnapshot.value.summary_note || "-", updated: isProjectSnapshotFieldUpdated("summary_note") },
{ label: "研究目标摘要", value: currentProjectPublishSnapshot.value.objective_note || "-", updated: isProjectSnapshotFieldUpdated("objective_note") },
]);
const summaryVisitScheduleRows = computed<VisitScheduleItem[]>(() => normalizeVisitSchedule(currentProjectPublishSnapshot.value.visit_schedule));
const rules = ref<FormRules>({
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
@@ -4283,12 +4476,7 @@ const syncForm = (data: Study | null) => {
form.value.phase = data?.phase || "";
form.value.status = data?.status || "DRAFT";
form.value.scope = "国内";
form.value.summary_note = data?.summary_note || "";
form.value.objective_note = data?.objective_note || "";
form.value.visit_interval_days = data?.visit_interval_days ?? null;
form.value.visit_total = data?.visit_total ?? null;
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
form.value.visit_schedule = normalizeVisitSchedule(data?.visit_schedule);
formBaselineSnapshot.value = serializeFormForCompare();
};
@@ -4498,6 +4686,9 @@ const startStep1SectionEdit = (section: Exclude<Step1EditSection, "all">) => {
startEdit();
if (isEditing.value) {
step1EditSection.value = section;
if (section === "summary") {
visitScheduleDialogVisible.value = true;
}
}
};
@@ -4512,12 +4703,17 @@ const cancelEdit = () => {
}
isEditing.value = false;
step1EditSection.value = "all";
visitScheduleDialogVisible.value = false;
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
};
const closeEditDrawer = () => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
cancelEdit();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
cancelEdit();
@@ -4528,6 +4724,15 @@ const closeEditDrawer = () => {
const closeEditDrawerAfterSave = (onClosed?: () => void) => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
onClosed?.();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
isEditing.value = false;
@@ -4540,6 +4745,32 @@ const closeEditDrawerAfterSave = (onClosed?: () => void) => {
}, 220);
};
const cancelVisitScheduleDialogEdit = () => {
cancelEdit();
};
const saveVisitScheduleDialogEdit = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
try {
const projectChanged = refreshProjectDirtyState();
if (projectChanged) {
persistProjectDraftToLocal();
} else {
clearLocalProjectDraft();
markServerSyncedIfNoLocalChanges();
}
formBaselineSnapshot.value = serializeFormForCompare();
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
ElMessage.success("访视计划已保存");
} finally {
drawerConfirmLoading.value = false;
}
};
const handleDrawerConfirm = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
@@ -4603,14 +4834,9 @@ const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promi
plan_end_date: form.value.plan_end_date || null,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note?.trim() || null,
objective_note: form.value.objective_note?.trim() || null,
phase: form.value.phase?.trim() || null,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
@@ -5225,12 +5451,7 @@ const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
["项目状态", projectSnapshot?.status ? statusLabel(projectSnapshot.status) : "-"],
["计划中心数", projectSnapshot?.planned_site_count ?? "-"],
["计划入组数", projectSnapshot?.planned_enrollment_count ?? "-"],
["访视总数", projectSnapshot?.visit_total ?? "-"],
["访视间隔(天)", projectSnapshot?.visit_interval_days ?? "-"],
["窗口期起始偏移", projectSnapshot?.visit_window_start_offset ?? "-"],
["窗口期结束偏移", projectSnapshot?.visit_window_end_offset ?? "-"],
["方案摘要说明", projectSnapshot?.summary_note || "-"],
["研究目标摘要", projectSnapshot?.objective_note || "-"],
["访视计划", formatVisitSchedule(projectSnapshot?.visit_schedule)],
];
const step2Headers = ["里程碑", "计划日期", "开始日期", "结束日期", "耗时(天)", "负责人", "状态", "备注"];
@@ -6962,6 +7183,7 @@ onBeforeUnmount(() => {
}
.setup-section {
position: relative;
padding: 8px 12px 12px;
border-bottom: 1px solid #edf2f8;
background: #ffffff;
@@ -7559,6 +7781,12 @@ onBeforeUnmount(() => {
gap: 8px;
}
.info-group-title-actions {
display: flex;
align-items: center;
gap: 10px;
}
.info-group-edit-btn {
min-width: 64px;
height: 28px;
@@ -7674,6 +7902,7 @@ onBeforeUnmount(() => {
.setup-info-tabs {
margin-top: -8px;
position: relative;
}
.setup-section :deep(.el-tabs__item) {
@@ -8017,6 +8246,202 @@ onBeforeUnmount(() => {
margin-bottom: 8px;
}
.summary-section-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin: 10px 0 8px;
}
.summary-display-group {
padding-top: 16px;
}
.visit-schedule-display {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-display-head,
.visit-schedule-display-row {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(220px, 1fr);
gap: 16px;
align-items: center;
}
.visit-schedule-display-head {
min-height: 40px;
padding: 0 14px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-display-row {
min-height: 42px;
padding: 0 14px;
border-bottom: 1px solid #edf2f7;
color: #334155;
font-size: 13px;
}
.visit-schedule-display-row:last-of-type {
border-bottom: none;
}
.visit-schedule-display-code {
color: #0f172a;
font-weight: 700;
}
.visit-schedule-display-empty {
padding: 18px 14px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.visit-schedule-editor {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-head,
.visit-schedule-row {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1fr) minmax(150px, 0.75fr) minmax(150px, 0.75fr) 48px;
gap: 12px;
align-items: center;
}
.visit-schedule-head {
min-height: 40px;
padding: 0 12px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-row {
position: relative;
padding: 9px 12px;
border-bottom: 1px solid #edf2f7;
cursor: grab;
transition: background-color 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.visit-schedule-row:active {
cursor: grabbing;
}
.visit-schedule-row:last-child {
border-bottom: none;
}
.visit-schedule-row.is-dragging {
background: #f8fbff;
opacity: 0.62;
}
.visit-schedule-row.insert-before::before,
.visit-schedule-row.insert-after::after {
position: absolute;
left: 12px;
right: 12px;
z-index: 2;
height: 3px;
border-radius: 999px;
background: #2f80ed;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.14);
content: "";
}
.visit-schedule-row.insert-before::before {
top: -2px;
}
.visit-schedule-row.insert-after::after {
bottom: -2px;
}
.visit-schedule-row.insert-before,
.visit-schedule-row.insert-after {
background: #f8fbff;
}
.visit-code-field {
min-width: 0;
}
.visit-field {
min-width: 0;
}
.visit-field-label {
display: none;
margin-bottom: 5px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.visit-schedule-remove {
justify-self: center;
width: 32px;
height: 32px;
}
.visit-schedule-add {
margin-top: 0;
}
.visit-schedule-dialog :deep(.el-dialog) {
border-radius: 10px;
}
.visit-schedule-dialog :deep(.el-dialog__header) {
padding: 18px 20px 12px;
margin: 0;
border-bottom: 1px solid #e6edf7;
}
.visit-schedule-dialog :deep(.el-dialog__title) {
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.visit-schedule-dialog :deep(.el-dialog__body) {
padding: 14px 20px 18px;
}
.visit-schedule-dialog :deep(.el-dialog__footer) {
padding: 12px 20px;
border-top: 1px solid #e6edf7;
}
.visit-schedule-dialog-form .summary-section-bar {
margin-top: 0;
}
.visit-schedule-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
background: #f7faff;
color: #2f4672;
@@ -8040,6 +8465,33 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr;
}
.visit-schedule-head {
display: none;
}
.visit-schedule-display-head {
display: none;
}
.visit-schedule-display-row {
grid-template-columns: 1fr;
gap: 4px;
padding: 10px 14px;
}
.visit-schedule-row {
grid-template-columns: 1fr;
gap: 10px;
}
.visit-field-label {
display: block;
}
.visit-schedule-remove {
justify-self: flex-end;
}
.flow-label {
font-size: 14px;
}
+100 -38
View File
@@ -26,7 +26,7 @@
</div>
<el-card class="unified-shell subject-shell" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions class="subject-overview-descriptions" :column="2" border>
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ 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">
@@ -59,6 +59,16 @@
/>
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.baselineDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.baseline_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.baseline_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
@@ -76,14 +86,14 @@
</el-descriptions>
</el-card>
<el-card class="unified-shell subject-shell">
<el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick">
{{ currentTabAction.label }}
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
{{ TEXT.common.actions.newHistory }}
</el-button>
</div>
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
@@ -118,11 +128,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
{{ TEXT.common.actions.newVisit }}
</el-button>
</div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
@@ -201,11 +206,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
{{ TEXT.common.actions.newAe }}
</el-button>
</div>
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
@@ -259,11 +259,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openPdDialog()">
{{ TEXT.modules.subjectDetail.newPd }}
</el-button>
</div>
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" show-overflow-tooltip />
@@ -455,6 +450,7 @@ const detail = reactive<any>({
screening_date: "",
consent_date: "",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -464,6 +460,7 @@ const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
@@ -615,6 +612,7 @@ const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.baseline_date = detail.baseline_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
@@ -636,13 +634,15 @@ const saveSubjectEdit = async () => {
status: subjectForm.status || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
baseline_date: subjectForm.baseline_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();
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -720,15 +720,7 @@ const loadVisits = async () => {
try {
const { data } = (await fetchVisits(studyId, subjectId)) as any;
const items = Array.isArray(data) ? data : data.items || [];
visitItems.value = items.sort((a: any, b: any) => {
const getNum = (code: any) => {
const text = String(code || "");
if (!text.startsWith("V")) return Number.POSITIVE_INFINITY;
const num = Number(text.slice(1));
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
return getNum(a?.visit_code) - getNum(b?.visit_code);
});
visitItems.value = items;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -1036,6 +1028,20 @@ const openPdDialog = (row?: any) => {
pdDialogVisible.value = true;
};
const currentTabAction = computed(() => {
switch (activeTab.value) {
case "visits":
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
case "ae":
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
case "pd":
return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() };
case "history":
default:
return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() };
}
});
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -1123,6 +1129,68 @@ onMounted(async () => {
padding: 0;
}
.subject-tabs-shell {
margin-top: 12px;
border-top: 8px solid #f3f6fb;
}
.subject-tabs-shell {
position: relative;
}
.subject-tabs-shell :deep(.el-tabs__header) {
margin: 0;
padding: 0 160px 0 0;
background: #ffffff;
border-top: 1px solid #e5edf6;
border-bottom: 1px solid #e5edf6;
}
.subject-tabs-shell :deep(.el-tabs__nav-wrap) {
padding-left: 0;
}
.subject-tabs-shell :deep(.el-tabs__item) {
height: 48px;
line-height: 48px;
font-weight: 700;
}
.subject-tabs-shell :deep(.el-tabs__content) {
padding-top: 0;
background: #ffffff;
}
.subject-tabs-action {
position: absolute;
top: 8px;
right: 0;
z-index: 2;
display: flex;
align-items: center;
}
.subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed;
}
.subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px;
min-width: 140px;
color: #344258;
font-weight: 700;
}
.subject-overview-descriptions :deep(.el-descriptions__content) {
width: calc(50% - 140px);
min-width: 0;
}
.subject-overview-descriptions :deep(.el-descriptions__cell) {
height: 42px;
vertical-align: middle;
}
.page-title {
margin: 0;
font-size: 22px;
@@ -1140,12 +1208,6 @@ onMounted(async () => {
gap: 8px;
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.subject-detail-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
+265 -89
View File
@@ -1,86 +1,120 @@
<template>
<div class="page">
<div class="page-header unified-action-bar">
<div>
<h1 class="page-title">{{ isEdit ? TEXT.modules.subjectForm.titleEdit : TEXT.modules.subjectForm.titleNew }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectForm.subtitle }}</p>
</div>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
<div class="page subject-form-page">
<el-card class="unified-shell subject-form-shell">
<el-form :model="form" label-position="top" class="subject-form">
<div class="form-layout">
<section class="form-section form-section-primary">
<div class="section-heading">
<span class="section-kicker">Subject</span>
<h2>参与者标识</h2>
</div>
<div class="field-grid">
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required class="field-span-2">
<el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.site" required class="field-span-2">
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select" size="large">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
</el-select>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status" class="field-span-2">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" size="large">
<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>
</el-form-item>
</div>
</section>
<el-card class="unified-shell">
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
<el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker
v-model="form.screening_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isEdit || isReadOnly"
/>
</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"
:disabled="isReadOnly"
/>
</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" :disabled="isReadOnly">
<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>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-model="form.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
<el-date-picker
v-model="form.completion_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
<el-input
v-model="form.drop_reason"
type="textarea"
:rows="3"
:placeholder="TEXT.common.placeholders.input"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Timeline</span>
<h2>关键日期</h2>
</div>
<div class="field-grid date-grid">
<el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker
v-model="form.screening_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isEdit || isReadOnly"
size="large"
/>
</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"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-model="form.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.baselineDate">
<el-date-picker
v-model="form.baseline_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
<el-date-picker
v-model="form.completion_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
</div>
</section>
<section v-if="isEdit" class="form-section field-span-all">
<div class="section-heading compact-heading">
<span class="section-kicker">Status</span>
<h2>退出信息</h2>
</div>
<el-form-item :label="TEXT.common.fields.dropReason">
<el-input
v-model="form.drop_reason"
type="textarea"
:rows="3"
:placeholder="TEXT.common.placeholders.input"
:disabled="isReadOnly"
/>
</el-form-item>
</section>
</div>
<div class="form-actions">
<el-button class="form-cancel-btn" @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" class="form-submit-btn" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</div>
</el-form>
</el-card>
</div>
@@ -120,6 +154,7 @@ const form = reactive({
consent_date: "",
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -145,6 +180,7 @@ const load = async () => {
consent_date: data.consent_date || "",
status: data.status || "SCREENING",
enrollment_date: data.enrollment_date || "",
baseline_date: data.baseline_date || "",
completion_date: data.completion_date || "",
drop_reason: data.drop_reason || "",
});
@@ -166,6 +202,7 @@ const submit = async () => {
status: form.status || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
completion_date: form.completion_date || null,
drop_reason: form.drop_reason || null,
};
@@ -178,6 +215,8 @@ const submit = async () => {
site_id: form.site_id,
screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
};
const { data } = await createSubject(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
@@ -199,27 +238,164 @@ onMounted(async () => {
</script>
<style scoped>
.page {
.subject-form-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
.subject-form-shell {
overflow: hidden;
border: 1px solid #e5edf6;
border-radius: 18px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
}
.subject-form-shell :deep(.el-card__body) {
padding: 0;
}
.subject-form {
background: #ffffff;
}
.form-layout {
display: grid;
grid-template-columns: minmax(320px, 0.78fr) minmax(420px, 1.22fr);
gap: 0;
}
.form-section {
padding: 26px 28px 22px;
border-bottom: 1px solid #edf2f7;
}
.form-section-primary {
border-right: 1px solid #edf2f7;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 72%);
}
.field-span-all {
grid-column: 1 / -1;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-direction: column;
gap: 4px;
margin-bottom: 18px;
}
.page-title {
.compact-heading {
margin-bottom: 12px;
}
.section-kicker {
color: #3b82f6;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.section-heading h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #102033;
font-size: 17px;
font-weight: 800;
}
.page-subtitle {
margin: 6px 0 0;
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 18px;
}
.date-grid {
align-items: start;
}
.field-span-2 {
grid-column: 1 / -1;
}
.subject-form :deep(.el-form-item) {
margin-bottom: 0;
}
.subject-form :deep(.el-form-item__label) {
margin-bottom: 7px;
color: #53657f;
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 750;
line-height: 1.2;
}
.subject-form :deep(.el-input__wrapper),
.subject-form :deep(.el-select__wrapper) {
min-height: 44px;
border-radius: 12px;
box-shadow: 0 0 0 1px #dfe7f1 inset;
transition: box-shadow 0.16s ease, background-color 0.16s ease;
}
.subject-form :deep(.el-input__wrapper:hover),
.subject-form :deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px #b8c8dc inset;
}
.subject-form :deep(.el-input__wrapper.is-focus),
.subject-form :deep(.el-select__wrapper.is-focused) {
box-shadow: 0 0 0 1px #3b82f6 inset, 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.subject-form :deep(.el-date-editor),
.subject-form :deep(.el-select),
.subject-form :deep(.el-input) {
width: 100%;
}
.form-actions {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 28px;
border-top: 1px solid #e5edf6;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
}
.form-submit-btn,
.form-cancel-btn {
min-width: 88px;
height: 40px;
border-radius: 12px;
font-weight: 800;
}
.form-submit-btn {
background: #415b76;
border-color: #415b76;
}
@media (max-width: 960px) {
.form-layout {
grid-template-columns: 1fr;
}
.form-section-primary {
border-right: none;
}
.field-grid {
grid-template-columns: 1fr;
}
.form-actions {
padding: 14px 18px;
}
}
</style>