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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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="临床试验项目管理系统后端接口文档",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }>> =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -121,11 +121,12 @@ export const TEXT = {
|
||||
screeningDate: "筛选日期",
|
||||
consentDate: "知情日期",
|
||||
enrollmentDate: "入组日期",
|
||||
baselineDate: "基线/治疗日期",
|
||||
completionDate: "完成日期",
|
||||
dropReason: "退出原因",
|
||||
recordDate: "日期",
|
||||
content: "内容",
|
||||
visitCode: "访视编号",
|
||||
visitCode: "访视",
|
||||
plannedDate: "计划访视",
|
||||
actualDate: "实际访视",
|
||||
windowStart: "窗口开始",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 被请求拦截
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user