diff --git a/README.md b/README.md
index 62f45e77..ed77cecb 100644
--- a/README.md
+++ b/README.md
@@ -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 中,清除后需重新登录/选择项目。
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
diff --git a/backend/alembic/versions/20260508_01_replace_global_visit_window.py b/backend/alembic/versions/20260508_01_replace_global_visit_window.py
new file mode 100644
index 00000000..08f775c4
--- /dev/null
+++ b/backend/alembic/versions/20260508_01_replace_global_visit_window.py
@@ -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")
diff --git a/backend/alembic/versions/20260508_02_remove_summary_objective_notes.py b/backend/alembic/versions/20260508_02_remove_summary_objective_notes.py
new file mode 100644
index 00000000..8a602aa4
--- /dev/null
+++ b/backend/alembic/versions/20260508_02_remove_summary_objective_notes.py
@@ -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))
diff --git a/backend/alembic/versions/20260508_03_add_subject_baseline_date.py b/backend/alembic/versions/20260508_03_add_subject_baseline_date.py
new file mode 100644
index 00000000..d3a6d2a8
--- /dev/null
+++ b/backend/alembic/versions/20260508_03_add_subject_baseline_date.py
@@ -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")
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index ed3eb31b..14ee5ff5 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -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)
diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py
index 2c54569a..0875004f 100644
--- a/backend/app/api/v1/studies.py
+++ b/backend/app/api/v1/studies.py
@@ -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)
diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py
index 7370b693..8c33e466 100644
--- a/backend/app/api/v1/subjects.py
+++ b/backend/app/api/v1/subjects.py
@@ -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}"
diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py
index 29bf8da0..abd695e6 100644
--- a/backend/app/api/v1/visits.py
+++ b/backend/app/api/v1/visits.py
@@ -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,
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index e0eaeb53..ad8ee134 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -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
diff --git a/backend/app/core/login_crypto.py b/backend/app/core/login_crypto.py
new file mode 100644
index 00000000..722433d5
--- /dev/null
+++ b/backend/app/core/login_crypto.py
@@ -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
diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py
index 275add11..7568c9fe 100644
--- a/backend/app/crud/study.py
+++ b/backend/app/crud/study.py
@@ -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)
diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py
index 1f734832..0e29168b 100644
--- a/backend/app/crud/subject.py
+++ b/backend/app/crud/subject.py
@@ -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))
diff --git a/backend/app/crud/visit.py b/backend/app/crud/visit.py
index ab73c6db..7d7e418f 100644
--- a/backend/app/crud/visit.py
+++ b/backend/app/crud/visit.py
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
index fd415f37..03cfb705 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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="临床试验项目管理系统后端接口文档",
diff --git a/backend/app/models/study.py b/backend/app/models/study.py
index cafdfd6e..d65fc86b 100644
--- a/backend/app/models/study.py
+++ b/backend/app/models/study.py
@@ -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())
diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py
index 2c118b04..c14acc76 100644
--- a/backend/app/models/subject.py
+++ b/backend/app/models/subject.py
@@ -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())
diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py
index 87305f9a..59a032d6 100644
--- a/backend/app/schemas/study.py
+++ b/backend/app/schemas/study.py
@@ -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
diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py
index 0183a4ba..5d456d72 100644
--- a/backend/app/schemas/study_setup_config.py
+++ b/backend/app/schemas/study_setup_config.py
@@ -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):
diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py
index 11449ba4..fcaa32d9 100644
--- a/backend/app/schemas/subject.py
+++ b/backend/app/schemas/subject.py
@@ -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
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index bb9e5b8f..c343e08e 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -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
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7e3e6a59..e58d9030 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/scripts/smoke_setup_config.py b/backend/scripts/smoke_setup_config.py
index 857def60..0254d1ec 100644
--- a/backend/scripts/smoke_setup_config.py
+++ b/backend/scripts/smoke_setup_config.py
@@ -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"]
diff --git a/backend/tests/test_login_crypto.py b/backend/tests/test_login_crypto.py
new file mode 100644
index 00000000..eda18df9
--- /dev/null
+++ b/backend/tests/test_login_crypto.py
@@ -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()
diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py
index 6603a54e..f8fc2cb1 100644
--- a/backend/tests/test_registration.py
+++ b/backend/tests/test_registration.py
@@ -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
diff --git a/backend/tests/test_visit_schedule.py b/backend/tests/test_visit_schedule.py
new file mode 100644
index 00000000..c16c0f14
--- /dev/null
+++ b/backend/tests/test_visit_schedule.py
@@ -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,
+ )
diff --git a/database/init.sql b/database/init.sql
index 80d6ab26..42e8a715 100644
--- a/database/init.sql
+++ b/database/init.sql
@@ -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;
diff --git a/docker-compose.yaml b/docker-compose.yaml
index e5d43329..44fb55d6 100755
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 9b6e816c..0c7cb5c4 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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 联调集合
diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md
new file mode 100644
index 00000000..ef5326d9
--- /dev/null
+++ b/docs/guides/branch-environment-installation.md
@@ -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=
+LOGIN_RSA_KEY_ID=staging-YYYYMMDD
+LOGIN_RSA_PRIVATE_KEY=
+```
+
+生成密钥示例:
+
+```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=
+LOGIN_RSA_KEY_ID=prod-YYYYMMDD
+LOGIN_RSA_PRIVATE_KEY=
+```
+
+要求:
+
+- `.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:///health
+curl -i https:///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
+```
diff --git a/docs/guides/setup-config-api.md b/docs/guides/setup-config-api.md
index 64b1cffe..5ec66047 100644
--- a/docs/guides/setup-config-api.md
+++ b/docs/guides/setup-config-api.md
@@ -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",
diff --git a/docs/plans/2026-03-31-business-table-width-unification.md b/docs/plans/2026-03-31-business-table-width-unification.md
new file mode 100644
index 00000000..61011946
--- /dev/null
+++ b/docs/plans/2026-03-31-business-table-width-unification.md
@@ -0,0 +1,122 @@
+# Business Table Width Unification Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 统一用户实际浏览的业务列表/详情表格列宽,使表格横向铺满容器、避免横向滚动,并保持操作列紧凑。
+
+**Architecture:** 仅处理业务浏览类表格页面,不改管理配置矩阵和计划编辑类表格。通过为目标表格统一启用固定布局、移除大部分硬编码列宽、保留少量操作列宽度,并为长文本列补充溢出提示来实现一致体验。
+
+**Tech Stack:** Vue 3, Element Plus, Vite, scoped CSS
+
+---
+
+### Task 1: 锁定业务表格范围
+
+**Files:**
+- Modify: `frontend/src/views/admin/Users.vue`
+- Modify: `frontend/src/views/admin/AdminUserApproval.vue`
+- Modify: `frontend/src/views/admin/AuditLogs.vue`
+- Modify: `frontend/src/views/admin/Projects.vue`
+- Modify: `frontend/src/views/ia/FinanceContracts.vue`
+- Modify: `frontend/src/views/ia/FinanceSpecial.vue`
+- Modify: `frontend/src/views/documents/DocumentList.vue`
+- Modify: `frontend/src/views/ia/SubjectManagement.vue`
+- Modify: `frontend/src/views/ia/RiskIssueSae.vue`
+- Modify: `frontend/src/views/ia/RiskIssuePd.vue`
+- Modify: `frontend/src/views/ia/RiskIssueMonitoringVisits.vue`
+- Modify: `frontend/src/views/ia/ProjectMilestones.vue`
+- Modify: `frontend/src/views/ia/StartupFeasibilityEthics.vue`
+- Modify: `frontend/src/views/ia/StartupMeetingAuth.vue`
+- Modify: `frontend/src/views/ia/KnowledgeNotes.vue`
+- Modify: `frontend/src/views/ia/DrugShipments.vue`
+- Modify: `frontend/src/views/ia/MaterialEquipment.vue`
+- Modify: `frontend/src/views/subjects/SubjectDetail.vue`
+- Modify: `frontend/src/views/fees/ContractFeeDetail.vue`
+- Modify: `frontend/src/views/documents/DocumentDetail.vue`
+- Modify: `frontend/src/views/startup/KickoffDetail.vue`
+- Modify: `frontend/src/components/attachments/AttachmentList.vue`
+- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
+- Modify: `frontend/src/components/FaqList.vue`
+- Modify: `frontend/src/views/workbench/components/CenterSummary.vue`
+
+**Step 1: 仅保留业务浏览页**
+
+明确不修改 `frontend/src/views/admin/ProjectDetail.vue` 及其各类配置矩阵。
+
+**Step 2: 统一策略**
+
+- 业务表格统一启用 `table-layout="fixed"`
+- 取消大多数 `width` / `min-width`
+- 操作列保留较小固定宽度
+- 长文本列补 `show-overflow-tooltip`
+
+### Task 2: 改列表页表格
+
+**Files:**
+- Modify: `frontend/src/views/admin/Users.vue`
+- Modify: `frontend/src/views/admin/AdminUserApproval.vue`
+- Modify: `frontend/src/views/admin/AuditLogs.vue`
+- Modify: `frontend/src/views/admin/Projects.vue`
+- Modify: `frontend/src/views/ia/FinanceContracts.vue`
+- Modify: `frontend/src/views/ia/FinanceSpecial.vue`
+- Modify: `frontend/src/views/documents/DocumentList.vue`
+- Modify: `frontend/src/views/ia/SubjectManagement.vue`
+- Modify: `frontend/src/views/ia/RiskIssueSae.vue`
+- Modify: `frontend/src/views/ia/RiskIssuePd.vue`
+- Modify: `frontend/src/views/ia/RiskIssueMonitoringVisits.vue`
+- Modify: `frontend/src/views/ia/ProjectMilestones.vue`
+- Modify: `frontend/src/views/ia/StartupFeasibilityEthics.vue`
+- Modify: `frontend/src/views/ia/StartupMeetingAuth.vue`
+- Modify: `frontend/src/views/ia/KnowledgeNotes.vue`
+- Modify: `frontend/src/views/ia/DrugShipments.vue`
+- Modify: `frontend/src/views/ia/MaterialEquipment.vue`
+
+**Step 1: 为目标表格添加固定布局**
+
+在 `el-table` 上显式加入 `table-layout="fixed"`,必要时补 `fit`。
+
+**Step 2: 收缩操作列,释放内容列**
+
+操作列保留 `width="110"` 或 `width="120"`;其余列尽量移除 `width` / `min-width`。
+
+**Step 3: 为易溢出文本补 tooltip**
+
+在标题、备注、描述、对象、变更明细等列使用 `show-overflow-tooltip`。
+
+### Task 3: 改详情页子表和通用表格组件
+
+**Files:**
+- Modify: `frontend/src/views/subjects/SubjectDetail.vue`
+- Modify: `frontend/src/views/fees/ContractFeeDetail.vue`
+- Modify: `frontend/src/views/documents/DocumentDetail.vue`
+- Modify: `frontend/src/views/startup/KickoffDetail.vue`
+- Modify: `frontend/src/components/attachments/AttachmentList.vue`
+- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
+- Modify: `frontend/src/components/FaqList.vue`
+- Modify: `frontend/src/views/workbench/components/CenterSummary.vue`
+
+**Step 1: 对齐详情子表策略**
+
+详情子表同样改成固定布局,压缩状态/操作列宽度,文本列使用 tooltip。
+
+**Step 2: 通用组件同步**
+
+附件表、FAQ 表和中心摘要表也改成同一列宽语言,避免跨页面体验不一致。
+
+### Task 4: 验证
+
+**Files:**
+- Test: `frontend`
+
+**Step 1: 运行构建**
+
+Run: `npm run build`
+
+**Expected:** 构建成功,无 TypeScript / Vue 模板错误。
+
+**Step 2: 人工复查目标**
+
+确认业务列表/详情表格:
+- 横向铺满容器
+- 默认不出现横向滚动条
+- 操作列不挤压主体信息
diff --git a/docs/postman/setup-config.postman_collection.json b/docs/postman/setup-config.postman_collection.json
index 5b7e5f97..07f9e509 100644
--- a/docs/postman/setup-config.postman_collection.json
+++ b/docs/postman/setup-config.postman_collection.json
@@ -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",
diff --git a/docs/setup-config-curl-smoke.sh b/docs/setup-config-curl-smoke.sh
index 1184c1e2..bf85e409 100755
--- a/docs/setup-config-curl-smoke.sh
+++ b/docs/setup-config-curl-smoke.sh
@@ -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"
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
index 937ff9eb..01970c38 100644
--- a/frontend/src/api/auth.ts
+++ b/frontend/src/api/auth.ts
@@ -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> =>
apiPost("/api/v1/auth/login", payload);
+export const getLoginKey = (): Promise> =>
+ apiGet("/api/v1/auth/login-key");
+
export const fetchMe = (): Promise> => apiGet("/api/v1/auth/me");
export const register = (payload: RegisterRequest): Promise> =>
diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts
index 9c10975f..63a8f10f 100644
--- a/frontend/src/api/authClient.ts
+++ b/frontend/src/api/authClient.ts
@@ -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> =>
authClient.post(
"/api/v1/auth/extend",
@@ -27,7 +40,10 @@ export const extendToken = (token: string): Promise> =>
+export const getLoginKey = (): Promise> =>
+ authClient.get("/api/v1/auth/login-key");
+
+export const unlockSession = (payload: EncryptedPasswordRequest): Promise> =>
authClient.post("/api/v1/auth/unlock", payload);
export default authClient;
diff --git a/frontend/src/components/FaqList.vue b/frontend/src/components/FaqList.vue
index 05e533b7..25f8331f 100644
--- a/frontend/src/components/FaqList.vue
+++ b/frontend/src/components/FaqList.vue
@@ -1,33 +1,33 @@
-
-
+
+
{{ scope.row.question }}
-
+
{{ categoryMap[scope.row.category_id] || scope.row.category_id }}
-
+
{{ scope.row.is_active ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}
-
+
{{ displayDateTime(scope.row.updated_at) }}
-
+
{{ statusLabel(scope.row.status) }}
-
+
{{ TEXT.common.actions.delete }}
diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue
index f14cfa25..45772d5d 100644
--- a/frontend/src/components/attachments/AttachmentList.vue
+++ b/frontend/src/components/attachments/AttachmentList.vue
@@ -10,22 +10,22 @@
@uploaded="load"
/>
-
-
-
+
+
+
{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}
-
+
{{ uploaderLabel(scope.row) }}
-
+
{{ displayDateTime(scope.row.uploaded_at) }}
-
+
{{ TEXT.common.labels.preview }}
diff --git a/frontend/src/components/fees/FeeAttachmentPanel.vue b/frontend/src/components/fees/FeeAttachmentPanel.vue
index a86520a7..228208cc 100644
--- a/frontend/src/components/fees/FeeAttachmentPanel.vue
+++ b/frontend/src/components/fees/FeeAttachmentPanel.vue
@@ -28,12 +28,12 @@
-
-
-
+
+
+
{{ formatFileSize(scope.row.size || scope.row.file_size) }}
-
+
{{ uploaderLabel(scope.row) }}
diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts
index 10122f57..47329ef2 100644
--- a/frontend/src/locales/zh-CN.ts
+++ b/frontend/src/locales/zh-CN.ts
@@ -121,11 +121,12 @@ export const TEXT = {
screeningDate: "筛选日期",
consentDate: "知情日期",
enrollmentDate: "入组日期",
+ baselineDate: "基线/治疗日期",
completionDate: "完成日期",
dropReason: "退出原因",
recordDate: "日期",
content: "内容",
- visitCode: "访视编号",
+ visitCode: "访视",
plannedDate: "计划访视",
actualDate: "实际访视",
windowStart: "窗口开始",
diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts
index 0157e415..a55c17c5 100644
--- a/frontend/src/session/sessionManager.ts
+++ b/frontend/src/session/sessionManager.ts
@@ -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 {
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
index 18a5979e..6520edf6 100644
--- a/frontend/src/store/auth.ts
+++ b/frontend/src/store/auth.ts
@@ -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 被请求拦截
diff --git a/frontend/src/store/session.test.ts b/frontend/src/store/session.test.ts
new file mode 100644
index 00000000..1e8c7a70
--- /dev/null
+++ b/frontend/src/store/session.test.ts
@@ -0,0 +1,28 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+
+describe("session store", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-31T12:00:00.000Z"));
+ setActivePinia(createPinia());
+ });
+
+ it("resets activity timestamps when unlocking a previously stale session", async () => {
+ const { useSessionStore } = await import("./session");
+ const session = useSessionStore();
+ const staleTs = Date.now() - 2 * 60 * 60 * 1000 - 1_000;
+
+ session.recordUserActivity(staleTs);
+ session.recordNetworkActivity(staleTs);
+ session.setLastExtendAt(staleTs);
+ session.lock("token_invalid", "user@example.com", staleTs + 10_000);
+
+ session.unlock();
+
+ expect(session.locked).toBe(false);
+ expect(session.lastUserActiveAt).toBe(Date.now());
+ expect(session.lastNetworkActiveAt).toBe(Date.now());
+ expect(session.lastExtendAt).toBe(0);
+ });
+});
diff --git a/frontend/src/store/session.ts b/frontend/src/store/session.ts
index e904fb02..da5df69b 100644
--- a/frontend/src/store/session.ts
+++ b/frontend/src/store/session.ts
@@ -29,11 +29,15 @@ export const useSessionStore = defineStore("session", () => {
};
const unlock = () => {
+ const now = Date.now();
locked.value = false;
lockReason.value = null;
lockEmail.value = "";
lockAutoLogoutDeadlineAt.value = 0;
unlockAttempts.value = 0;
+ lastUserActiveAt.value = now;
+ lastNetworkActiveAt.value = now;
+ lastExtendAt.value = 0;
};
const incrementUnlockAttempt = () => {
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index ae8ca944..0cf85169 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -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;
diff --git a/frontend/src/types/setupConfig.ts b/frontend/src/types/setupConfig.ts
index 700a3996..c03b2748 100644
--- a/frontend/src/types/setupConfig.ts
+++ b/frontend/src/types/setupConfig.ts
@@ -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 {
diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts
index f5e16640..dbdf55c6 100644
--- a/frontend/src/utils/auth.ts
+++ b/frontend/src/utils/auth.ts
@@ -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);
-};
diff --git a/frontend/src/utils/loginCrypto.test.ts b/frontend/src/utils/loginCrypto.test.ts
new file mode 100644
index 00000000..860de348
--- /dev/null
+++ b/frontend/src/utils/loginCrypto.test.ts
@@ -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();
+ });
+});
diff --git a/frontend/src/utils/loginCrypto.ts b/frontend/src/utils/loginCrypto.ts
new file mode 100644
index 00000000..cca17679
--- /dev/null
+++ b/frontend/src/utils/loginCrypto.ts
@@ -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 => {
+ 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));
+};
diff --git a/frontend/src/utils/setupPublishWorkflow.ts b/frontend/src/utils/setupPublishWorkflow.ts
index 95175736..7aff9934 100644
--- a/frontend/src/utils/setupPublishWorkflow.ts
+++ b/frontend/src/utils/setupPublishWorkflow.ts
@@ -118,13 +118,8 @@ const PROJECT_FIELD_LABEL_MAP: Record = {
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 = (
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index 7191cd5b..9ec09681 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -74,12 +74,6 @@
-
-
- 记住此设备
-
-
-
登 录
@@ -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();
const form = reactive({
email: "",
password: "",
- remember: false,
});
const rules: FormRules = {
@@ -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;
diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue
index 37cb7097..b5fcaef4 100644
--- a/frontend/src/views/Register.vue
+++ b/frontend/src/views/Register.vue
@@ -204,7 +204,10 @@
- 我同意 服务条款 和 隐私政策
+ 我同意
+
+ 和
+
{{ errors.agreeTerms }}
@@ -280,9 +283,9 @@
@@ -936,6 +939,16 @@ const handleSubmit = async () => {
transition: opacity 0.2s ease;
}
+.link-button,
+.footer-link-button {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ line-height: inherit;
+ cursor: pointer;
+}
+
.link:hover {
opacity: 0.8;
}
diff --git a/frontend/src/views/admin/AdminUserApproval.vue b/frontend/src/views/admin/AdminUserApproval.vue
index 4a55bd72..7b16a2e4 100644
--- a/frontend/src/views/admin/AdminUserApproval.vue
+++ b/frontend/src/views/admin/AdminUserApproval.vue
@@ -14,22 +14,22 @@
-
-
-
-
+
+
+
+
{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}
-
-
+
+
{{ displayDateTime(scope.row.created_at) }}
-
+
{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}
-
+
-
-
+
+
{{ displayDateTime(scope.row.timestamp) }}
-
-
+
+
{{ scope.row.eventLabel }}
-
-
+
+
{{ scope.row.targetTypeLabel }}
@@ -82,7 +82,7 @@
{{ TEXT.common.fallback }}
-
+
{{ line }}
@@ -94,7 +94,7 @@
{{ TEXT.audit.emptyValue }}
-
+
{{ scope.row.resultLabel }}
diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue
index 85491aba..7671737b 100644
--- a/frontend/src/views/admin/ProjectDetail.vue
+++ b/frontend/src/views/admin/ProjectDetail.vue
@@ -471,55 +471,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ item.label }}
-
- {{ item.value || "-" }}
+
+
+
访视计划
+
已更新
-
+
+ 编辑
+
+
+
+
+
+ 访视
+ 基线后天数
+ 访视窗
+
+
+ {{ row.visit_code || "-" }}
+ {{ row.baseline_offset_days }} 天
+ {{ formatVisitWindow(row) }}
+
+
暂无访视计划
@@ -1661,6 +1644,100 @@
+
+
+
+ 添加访视
+
+
+
+ 访视
+ 基线后天数
+ 窗口前(天)
+ 窗口后(天)
+
+
+
+
+ 访视
+
+
+
+
+
+
+
+
+
+
+
+ 基线后天数
+
+
+
+ 窗口前
+
+
+
+ 窗口后
+
+
+
+
+
+
+
+
+
+
+
("basic");
const step1EditSection = ref("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(null);
+const visitScheduleDragOverIndex = ref(null);
+type VisitScheduleInsertPosition = "before" | "after";
+const visitScheduleInsertPosition = ref("before");
const drawerCloseTimer = ref(undefined);
const submitting = ref(false);
const formRef = ref();
@@ -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({
projectMilestones: [],
@@ -2354,9 +2572,6 @@ const canEditSetup = computed(
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude) =>
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(() => {
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 | null | undefined): ProjectPublishSnapshot => ({
@@ -2512,13 +2722,8 @@ const buildProjectPublishSnapshotFromStudy = (study: Partial | 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(() => [
},
]);
-const summaryInfoItems = computed(() => [
- { 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(() => normalizeVisitSchedule(currentProjectPublishSnapshot.value.visit_schedule));
const rules = ref({
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) => {
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;
}
diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue
index 5f4d5481..e2aef726 100644
--- a/frontend/src/views/admin/Projects.vue
+++ b/frontend/src/views/admin/Projects.vue
@@ -8,51 +8,53 @@
-
-
+
+
{{ scope.row.name }}
-
+
{{ scope.row.code || "-" }}
-
+
{{ statusLabel(scope.row.status) }}
-
+
已锁定
未锁定
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -239,6 +241,13 @@ onMounted(() => {
padding: 0;
}
+.action-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ white-space: nowrap;
+}
+
.actions-only-bar {
display: flex;
justify-content: flex-end;
diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue
index 6b85373f..6c8377a9 100644
--- a/frontend/src/views/admin/Users.vue
+++ b/frontend/src/views/admin/Users.vue
@@ -3,71 +3,73 @@
-
-
-
-
-
+
+
+
+
+
{{ statusLabel(scope.row.status) }}
-
+
{{ displayDateTime(scope.row.created_at) }}
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -327,6 +329,13 @@ onMounted(() => {
flex: 1;
}
+.action-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ white-space: nowrap;
+}
+
.user-table-section {
padding: 0;
}
diff --git a/frontend/src/views/documents/DocumentDetail.vue b/frontend/src/views/documents/DocumentDetail.vue
index d4b340fa..6e34a5fc 100644
--- a/frontend/src/views/documents/DocumentDetail.vue
+++ b/frontend/src/views/documents/DocumentDetail.vue
@@ -50,34 +50,34 @@
-
-
+
+
V{{ row.version_no }}
-
+
{{ formatDateOnly(row.effective_at || row.created_at) }}
-
-
+
+
{{ displayCreator(row.created_by) }}
-
+
{{ displayParentVersion(row.parent_version_id) }}
-
+
{{ formatDate(row.effective_at || row.created_at) }}
-
+
{{ TEXT.modules.fileVersionManagement.actions.download }}
@@ -108,18 +108,18 @@
-
-
+
+
{{ displayEnum(TEXT.enums.distributionTargetType, row.target_type) }}
-
+
{{ displayTarget(row) }}
-
+
{{ formatDate(row.created_at) }}
diff --git a/frontend/src/views/documents/DocumentList.vue b/frontend/src/views/documents/DocumentList.vue
index 0861b70d..144a91ef 100644
--- a/frontend/src/views/documents/DocumentList.vue
+++ b/frontend/src/views/documents/DocumentList.vue
@@ -3,24 +3,24 @@
-
+
-
-
+
+
-
-
+
+
-
-
+
+
{{ TEXT.common.actions.reset }}
-
+
-
-
+
+
{{ TEXT.modules.feeContracts.paymentSeq }}{{ scope.row.seq }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}
-
+
{{ formatAmountWan(scope.row.amount) }}
-
+
{{ TEXT.modules.feeContracts.isPaid }}
@@ -72,7 +72,7 @@
{{ TEXT.common.fallback }}
-
+
{{ TEXT.modules.feeContracts.isVerified }}
@@ -81,7 +81,7 @@
{{ TEXT.common.fallback }}
-
+
{{ scope.row.remark || TEXT.common.fallback }}
diff --git a/frontend/src/views/fees/ContractFees.vue b/frontend/src/views/fees/ContractFees.vue
index f8ee862a..3d963e13 100644
--- a/frontend/src/views/fees/ContractFees.vue
+++ b/frontend/src/views/fees/ContractFees.vue
@@ -56,7 +56,7 @@
-
+
@@ -68,17 +68,17 @@
:value="site.id"
/>
-
-
+
+
-
-
+
+
{{ TEXT.common.actions.reset }}
-
+