Commit 98876adf authored by 375242562@qq.com's avatar 375242562@qq.com

feat: 数据同步、批量匹配、Dashboard 等核心功能

后端新增:
- 数据同步模块: SyncSource/ConnectionProfile 模型, sync_adapters/sync_service
  支持 MySQL/PostgreSQL/Oracle/MSSQL, Oracle thick mode, SID/ServiceName
  3步向导式同步任务配置, 表发现(list-tables), 增量同步
- 批量匹配模块: BatchMatchingJob 模型, 按试验筛选, 进度追踪, 取消支持
  操作日志 trial_title 字段, 分页接口
- Dashboard 统计接口: 患者/试验/匹配/批量任务/通知 聚合数据

前端新增:
- DashboardPage: KPI卡片, 匹配状态分布, 最近批量记录, 系统概况
- DataSyncPage: 连接管理(支持Oracle SID/ServiceName), 3步向导同步任务
- 批量自动匹配: 必须选择试验项目, 操作日志分页(每页10条)
- AI匹配页面: Tab分离手动/批量匹配, 批量为默认
- 侧边栏新增数据概览入口, 首页跳转至 /dashboard
parent 21eca8c8
...@@ -6,7 +6,38 @@ ...@@ -6,7 +6,38 @@
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(uv run:*)", "Bash(uv run:*)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"Bash(python:*)",
"Bash(.venv/Scripts/python.exe:*)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(uv pip:*)",
"Bash(uv add:*)",
"Bash(ping:*)",
"Bash(taskkill:*)",
"Bash(uv lock:*)",
"Bash(dir:*)",
"Bash(npx tsc:*)",
"Bash(netstat:*)",
"Bash(NO_PROXY=localhost,127.0.0.1 uv run:*)",
"Bash(tasklist:*)",
"Bash(cmd:*)",
"Bash(kill 24908:*)",
"Bash(npm run:*)",
"Bash(npx vite:*)",
"Bash(node:*)",
"Bash(while read pid)",
"Bash(do echo \"killing $pid\")",
"Bash(done)",
"Bash(powershell:*)",
"Bash(xargs -I{} taskkill //F //PID {})",
"Bash(cmd.exe:*)",
"Bash(wmic process:*)",
"Bash(ss:*)",
"Bash(ps:*)",
"Bash(kill:*)",
"Bash(taskkill.exe:*)",
"Bash(powershell.exe:*)"
] ]
} }
} }
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus
from app.models.trial import Trial
from app.schemas.batch_matching import BatchJobResponse
from app.services.batch_matching_service import run_batch_matching
router = APIRouter(prefix="/matching/batch", tags=["batch-matching"])
def _job_to_response(job: BatchMatchingJob, trial_title: str | None = None) -> BatchJobResponse:
pct = (job.completed_pairs / job.total_pairs) if job.total_pairs > 0 else 0.0
return BatchJobResponse(
id=job.id,
status=job.status.value,
total_pairs=job.total_pairs,
completed_pairs=job.completed_pairs,
failed_pairs=job.failed_pairs,
triggered_by=job.triggered_by,
trial_id=job.trial_id,
trial_title=trial_title,
cancel_requested=job.cancel_requested,
started_at=job.started_at.isoformat() if job.started_at else None,
completed_at=job.completed_at.isoformat() if job.completed_at else None,
error_log=job.error_log,
created_at=job.created_at.isoformat(),
progress_pct=pct,
)
@router.post("/run", response_model=BatchJobResponse)
async def trigger_batch_run(
background_tasks: BackgroundTasks,
trial_id: str = Query(..., description="指定要匹配的临床试验 ID"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Trigger a batch matching job for a specific trial. Only one job can run at a time."""
running = await db.execute(
select(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.running)
)
if running.scalar_one_or_none():
raise HTTPException(status_code=409, detail="已有批量匹配任务正在运行,请等待完成后再试")
job = BatchMatchingJob(
id=str(uuid.uuid4()),
triggered_by=current_user.username,
trial_id=trial_id,
created_at=datetime.utcnow(),
)
db.add(job)
await db.commit()
await db.refresh(job)
background_tasks.add_task(run_batch_matching, job.id)
trial = await db.get(Trial, trial_id)
return _job_to_response(job, trial.title if trial else None)
@router.get("/jobs", response_model=list[BatchJobResponse])
async def list_batch_jobs(
skip: int = Query(0, ge=0),
limit: int = Query(10, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(BatchMatchingJob)
.order_by(BatchMatchingJob.created_at.desc())
.offset(skip).limit(limit)
)
jobs = result.scalars().all()
# Batch-fetch trials to avoid N+1
trial_ids = {j.trial_id for j in jobs if j.trial_id}
trials_map: dict[str, str] = {}
for tid in trial_ids:
t = await db.get(Trial, tid)
if t:
trials_map[tid] = t.title
return [_job_to_response(j, trials_map.get(j.trial_id or "")) for j in jobs]
@router.get("/jobs/{job_id}", response_model=BatchJobResponse)
async def get_batch_job(
job_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
job = await db.get(BatchMatchingJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
trial = await db.get(Trial, job.trial_id) if job.trial_id else None
return _job_to_response(job, trial.title if trial else None)
@router.post("/jobs/{job_id}/cancel", response_model=BatchJobResponse)
async def cancel_batch_job(
job_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Request cancellation of a running batch job."""
job = await db.get(BatchMatchingJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.status not in (BatchJobStatus.running, BatchJobStatus.pending):
raise HTTPException(status_code=400, detail="只有运行中或待启动的任务才能停止")
job.cancel_requested = True
await db.commit()
await db.refresh(job)
trial = await db.get(Trial, job.trial_id) if job.trial_id else None
return _job_to_response(job, trial.title if trial else None)
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.connection_profile import ConnectionProfile
from app.models.sync_source import SyncSource
router = APIRouter(prefix="/connection-profiles", tags=["connection-profiles"])
class ConnectionProfileCreate(BaseModel):
name: str
description: Optional[str] = None
connection_type: str = "http" # "http" | "database"
base_url: Optional[str] = None
auth_config: dict = {}
is_active: bool = True
class ConnectionProfileResponse(BaseModel):
id: str
name: str
description: Optional[str]
connection_type: str
base_url: Optional[str]
auth_config: dict
is_active: bool
created_at: str
model_config = {"from_attributes": True}
def _to_response(p: ConnectionProfile) -> ConnectionProfileResponse:
return ConnectionProfileResponse(
id=p.id,
name=p.name,
description=p.description,
connection_type=p.connection_type,
base_url=p.base_url,
auth_config=p.auth_config or {},
is_active=p.is_active,
created_at=p.created_at.isoformat(),
)
@router.get("", response_model=list[ConnectionProfileResponse])
async def list_profiles(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(ConnectionProfile).order_by(ConnectionProfile.created_at.desc())
)
return [_to_response(p) for p in result.scalars().all()]
@router.post("", response_model=ConnectionProfileResponse, status_code=201)
async def create_profile(
data: ConnectionProfileCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
profile = ConnectionProfile(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
connection_type=data.connection_type,
base_url=data.base_url or None,
auth_config=data.auth_config,
is_active=data.is_active,
created_at=datetime.utcnow(),
)
db.add(profile)
await db.commit()
await db.refresh(profile)
return _to_response(profile)
@router.put("/{profile_id}", response_model=ConnectionProfileResponse)
async def update_profile(
profile_id: str,
data: ConnectionProfileCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
profile = await db.get(ConnectionProfile, profile_id)
if not profile:
raise HTTPException(status_code=404, detail="连接档案不存在")
profile.name = data.name
profile.description = data.description
profile.connection_type = data.connection_type
profile.base_url = data.base_url or None
profile.auth_config = data.auth_config
profile.is_active = data.is_active
await db.commit()
await db.refresh(profile)
return _to_response(profile)
@router.delete("/{profile_id}", status_code=204)
async def delete_profile(
profile_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
profile = await db.get(ConnectionProfile, profile_id)
if not profile:
raise HTTPException(status_code=404, detail="连接档案不存在")
# Check if any sync source references this profile
ref = await db.execute(
select(SyncSource).where(SyncSource.connection_profile_id == profile_id)
)
if ref.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该连接档案已被同步任务引用,无法删除")
await db.delete(profile)
await db.commit()
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.patient import Patient
from app.models.trial import Trial, TrialStatus
from app.models.matching import MatchingResult, MatchStatus
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus
from app.models.notification import Notification
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/stats")
async def get_dashboard_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# ── 患者 ──
patient_total = await db.scalar(select(func.count()).select_from(Patient))
# ── 试验 ──
trial_total = await db.scalar(select(func.count()).select_from(Trial))
trial_recruiting = await db.scalar(
select(func.count()).select_from(Trial).where(Trial.status == TrialStatus.recruiting)
)
# ── 匹配 ──
match_total = await db.scalar(select(func.count()).select_from(MatchingResult))
match_eligible = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.eligible)
)
match_ineligible = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.ineligible)
)
match_pending = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.pending_review)
)
match_needs_info = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.needs_more_info)
)
# ── 批量任务 ──
batch_total = await db.scalar(select(func.count()).select_from(BatchMatchingJob))
batch_completed = await db.scalar(
select(func.count()).select_from(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.completed)
)
batch_failed = await db.scalar(
select(func.count()).select_from(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.failed)
)
batch_running = await db.scalar(
select(func.count()).select_from(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.running)
)
# ── 通知 ──
notif_unread = await db.scalar(
select(func.count()).select_from(Notification).where(Notification.is_read == False)
)
# ── 最近 5 条批量任务(附 trial 名称)──
recent_jobs_result = await db.execute(
select(BatchMatchingJob)
.order_by(BatchMatchingJob.created_at.desc())
.limit(5)
)
recent_jobs = recent_jobs_result.scalars().all()
recent_jobs_list = []
for j in recent_jobs:
trial_title = None
if j.trial_id:
t = await db.get(Trial, j.trial_id)
trial_title = t.title if t else None
pct = (j.completed_pairs / j.total_pairs) if j.total_pairs > 0 else 0.0
recent_jobs_list.append({
"id": j.id,
"status": j.status.value,
"trial_title": trial_title,
"total_pairs": j.total_pairs,
"completed_pairs": j.completed_pairs,
"failed_pairs": j.failed_pairs,
"triggered_by": j.triggered_by,
"started_at": j.started_at.isoformat() if j.started_at else None,
"completed_at": j.completed_at.isoformat() if j.completed_at else None,
"progress_pct": pct,
})
return {
"patients": {
"total": patient_total or 0,
},
"trials": {
"total": trial_total or 0,
"recruiting": trial_recruiting or 0,
},
"matching": {
"total": match_total or 0,
"eligible": match_eligible or 0,
"ineligible": match_ineligible or 0,
"pending_review": match_pending or 0,
"needs_more_info": match_needs_info or 0,
},
"batch_jobs": {
"total": batch_total or 0,
"completed": batch_completed or 0,
"failed": batch_failed or 0,
"running": batch_running or 0,
},
"notifications": {
"unread": notif_unread or 0,
},
"recent_batch_jobs": recent_jobs_list,
}
...@@ -8,12 +8,12 @@ from app.schemas.diagnosis import DiagnosisCreate, DiagnosisResponse ...@@ -8,12 +8,12 @@ from app.schemas.diagnosis import DiagnosisCreate, DiagnosisResponse
router = APIRouter(prefix="/diagnoses", tags=["diagnoses"]) router = APIRouter(prefix="/diagnoses", tags=["diagnoses"])
@router.get("/", response_model=list[DiagnosisResponse]) @router.get("", response_model=list[DiagnosisResponse])
async def list_diagnoses(db: AsyncSession = Depends(get_db)): async def list_diagnoses(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Diagnosis)) result = await db.execute(select(Diagnosis))
return result.scalars().all() return result.scalars().all()
@router.post("/", response_model=DiagnosisResponse, status_code=201) @router.post("", response_model=DiagnosisResponse, status_code=201)
async def create_diagnosis(data: DiagnosisCreate, db: AsyncSession = Depends(get_db)): async def create_diagnosis(data: DiagnosisCreate, db: AsyncSession = Depends(get_db)):
diag = Diagnosis(id=str(uuid.uuid4()), **data.model_dump()) diag = Diagnosis(id=str(uuid.uuid4()), **data.model_dump())
db.add(diag) db.add(diag)
......
...@@ -3,8 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession ...@@ -3,8 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_db from app.database import get_db
from app.models.matching import MatchingResult, MatchStatus from app.models.matching import MatchingResult, MatchStatus
from app.models.user import User
from app.schemas.matching import MatchingRequest, MatchingResultResponse, ReviewRequest from app.schemas.matching import MatchingRequest, MatchingResultResponse, ReviewRequest
from app.services.matching_service import run_matching from app.services.matching_service import run_matching
from app.core.deps import get_current_user
from datetime import datetime from datetime import datetime
router = APIRouter(prefix="/matching", tags=["matching"]) router = APIRouter(prefix="/matching", tags=["matching"])
...@@ -52,6 +54,7 @@ async def list_results( ...@@ -52,6 +54,7 @@ async def list_results(
trial_id: str | None = None, trial_id: str | None = None,
patient_id: str | None = None, patient_id: str | None = None,
status: str | None = None, status: str | None = None,
reviewed_by: str | None = None,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, le=100), limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
...@@ -63,6 +66,8 @@ async def list_results( ...@@ -63,6 +66,8 @@ async def list_results(
stmt = stmt.where(MatchingResult.patient_id == patient_id) stmt = stmt.where(MatchingResult.patient_id == patient_id)
if status: if status:
stmt = stmt.where(MatchingResult.status == status) stmt = stmt.where(MatchingResult.status == status)
if reviewed_by:
stmt = stmt.where(MatchingResult.reviewed_by == reviewed_by)
stmt = stmt.offset(skip).limit(limit).order_by(MatchingResult.matched_at.desc()) stmt = stmt.offset(skip).limit(limit).order_by(MatchingResult.matched_at.desc())
result = await db.execute(stmt) result = await db.execute(stmt)
results = result.scalars().all() results = result.scalars().all()
...@@ -109,11 +114,12 @@ async def review_result( ...@@ -109,11 +114,12 @@ async def review_result(
result_id: str, result_id: str,
req: ReviewRequest, req: ReviewRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
r = await db.get(MatchingResult, result_id) r = await db.get(MatchingResult, result_id)
if not r: if not r:
raise HTTPException(status_code=404, detail="Matching result not found") raise HTTPException(status_code=404, detail="Matching result not found")
r.reviewed_by = req.doctor_id r.reviewed_by = current_user.username
r.reviewed_at = datetime.utcnow() r.reviewed_at = datetime.utcnow()
r.review_notes = req.notes r.review_notes = req.notes
if req.decision == "approve": if req.decision == "approve":
......
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from datetime import datetime from datetime import datetime
from app.database import get_db from app.database import get_db
from app.models.notification import Notification from app.models.notification import Notification
from app.models.user import User
from app.schemas.notification import NotificationResponse, UnreadCountResponse from app.schemas.notification import NotificationResponse, UnreadCountResponse
from app.core.deps import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
DEMO_DOCTOR_ID = "default_doctor"
def _to_response(n: Notification) -> NotificationResponse: def _to_response(n: Notification) -> NotificationResponse:
return NotificationResponse( return NotificationResponse(
...@@ -24,15 +24,15 @@ def _to_response(n: Notification) -> NotificationResponse: ...@@ -24,15 +24,15 @@ def _to_response(n: Notification) -> NotificationResponse:
) )
@router.get("/", response_model=list[NotificationResponse]) @router.get("", response_model=list[NotificationResponse])
async def list_notifications( async def list_notifications(
doctor_id: str = Query(DEMO_DOCTOR_ID),
unread_only: bool = False, unread_only: bool = False,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, le=100), limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
stmt = select(Notification).where(Notification.recipient_doctor_id == doctor_id) stmt = select(Notification).where(Notification.recipient_doctor_id == current_user.username)
if unread_only: if unread_only:
stmt = stmt.where(Notification.is_read == False) # noqa: E712 stmt = stmt.where(Notification.is_read == False) # noqa: E712
stmt = stmt.offset(skip).limit(limit).order_by(Notification.created_at.desc()) stmt = stmt.offset(skip).limit(limit).order_by(Notification.created_at.desc())
...@@ -42,12 +42,12 @@ async def list_notifications( ...@@ -42,12 +42,12 @@ async def list_notifications(
@router.get("/unread-count", response_model=UnreadCountResponse) @router.get("/unread-count", response_model=UnreadCountResponse)
async def unread_count( async def unread_count(
doctor_id: str = Query(DEMO_DOCTOR_ID),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(func.count(Notification.id)).where( select(func.count(Notification.id)).where(
Notification.recipient_doctor_id == doctor_id, Notification.recipient_doctor_id == current_user.username,
Notification.is_read == False, # noqa: E712 Notification.is_read == False, # noqa: E712
) )
) )
...@@ -55,10 +55,13 @@ async def unread_count( ...@@ -55,10 +55,13 @@ async def unread_count(
@router.patch("/{notification_id}/read", response_model=NotificationResponse) @router.patch("/{notification_id}/read", response_model=NotificationResponse)
async def mark_read(notification_id: str, db: AsyncSession = Depends(get_db)): async def mark_read(
notification_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
n = await db.get(Notification, notification_id) n = await db.get(Notification, notification_id)
if not n: if not n:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Notification not found") raise HTTPException(status_code=404, detail="Notification not found")
n.is_read = True n.is_read = True
n.read_at = datetime.utcnow() n.read_at = datetime.utcnow()
...@@ -69,12 +72,12 @@ async def mark_read(notification_id: str, db: AsyncSession = Depends(get_db)): ...@@ -69,12 +72,12 @@ async def mark_read(notification_id: str, db: AsyncSession = Depends(get_db)):
@router.patch("/read-all") @router.patch("/read-all")
async def mark_all_read( async def mark_all_read(
doctor_id: str = Query(DEMO_DOCTOR_ID),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(Notification).where( select(Notification).where(
Notification.recipient_doctor_id == doctor_id, Notification.recipient_doctor_id == current_user.username,
Notification.is_read == False, # noqa: E712 Notification.is_read == False, # noqa: E712
) )
) )
......
...@@ -10,7 +10,7 @@ from app.services.masking_service import mask_patient ...@@ -10,7 +10,7 @@ from app.services.masking_service import mask_patient
router = APIRouter(prefix="/patients", tags=["patients"]) router = APIRouter(prefix="/patients", tags=["patients"])
@router.get("/", response_model=list[PatientResponse]) @router.get("", response_model=list[PatientResponse])
async def list_patients( async def list_patients(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, le=100), limit: int = Query(20, le=100),
...@@ -40,7 +40,7 @@ async def get_patient(patient_id: str, db: AsyncSession = Depends(get_db)): ...@@ -40,7 +40,7 @@ async def get_patient(patient_id: str, db: AsyncSession = Depends(get_db)):
return mask_patient(patient) return mask_patient(patient)
@router.post("/", response_model=PatientResponse, status_code=201) @router.post("", response_model=PatientResponse, status_code=201)
async def create_patient(data: PatientCreate, db: AsyncSession = Depends(get_db)): async def create_patient(data: PatientCreate, db: AsyncSession = Depends(get_db)):
# Check for duplicate MRN # Check for duplicate MRN
existing = await db.execute(select(Patient).where(Patient.mrn == data.mrn)) existing = await db.execute(select(Patient).where(Patient.mrn == data.mrn))
......
This diff is collapsed.
...@@ -34,7 +34,7 @@ def _trial_to_response(trial: Trial, criteria: list[Criterion]) -> TrialResponse ...@@ -34,7 +34,7 @@ def _trial_to_response(trial: Trial, criteria: list[Criterion]) -> TrialResponse
) )
@router.get("/", response_model=list[TrialResponse]) @router.get("", response_model=list[TrialResponse])
async def list_trials( async def list_trials(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, le=100), limit: int = Query(20, le=100),
...@@ -70,7 +70,7 @@ async def get_trial(trial_id: str, db: AsyncSession = Depends(get_db)): ...@@ -70,7 +70,7 @@ async def get_trial(trial_id: str, db: AsyncSession = Depends(get_db)):
return _trial_to_response(trial, criteria) return _trial_to_response(trial, criteria)
@router.post("/", response_model=TrialResponse, status_code=201) @router.post("", response_model=TrialResponse, status_code=201)
async def create_trial(data: TrialCreate, db: AsyncSession = Depends(get_db)): async def create_trial(data: TrialCreate, db: AsyncSession = Depends(get_db)):
trial = Trial( trial = Trial(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
......
...@@ -18,5 +18,7 @@ async def get_db() -> AsyncSession: ...@@ -18,5 +18,7 @@ async def get_db() -> AsyncSession:
async def init_db(): async def init_db():
from app.models import patient, trial, matching, notification, diagnosis # noqa: F401 from app.models import patient, trial, matching, notification, diagnosis # noqa: F401
from app.models import user, department, role, permission, user_role # noqa: F401 from app.models import user, department, role, permission, user_role # noqa: F401
from app.models import batch_matching, sync_source # noqa: F401
from app.models import data_domain_type, connection_profile # noqa: F401
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
...@@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware ...@@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db from app.database import init_db
from app.api.routes import patients, trials, matching, notifications, diagnoses from app.api.routes import patients, trials, matching, notifications, diagnoses
from app.api.routes import auth, users, roles, permissions, departments from app.api.routes import auth, users, roles, permissions, departments
from app.api.routes import batch_matching, sync, connection_profiles, dashboard
@asynccontextmanager @asynccontextmanager
...@@ -17,6 +18,7 @@ app = FastAPI( ...@@ -17,6 +18,7 @@ app = FastAPI(
version="0.1.0", version="0.1.0",
description="基于AI的临床试验患者招募系统", description="基于AI的临床试验患者招募系统",
lifespan=lifespan, lifespan=lifespan,
redirect_slashes=False,
) )
app.add_middleware( app.add_middleware(
...@@ -37,8 +39,12 @@ app.include_router(departments.router, prefix="/api/v1") ...@@ -37,8 +39,12 @@ app.include_router(departments.router, prefix="/api/v1")
app.include_router(patients.router, prefix="/api/v1") app.include_router(patients.router, prefix="/api/v1")
app.include_router(trials.router, prefix="/api/v1") app.include_router(trials.router, prefix="/api/v1")
app.include_router(matching.router, prefix="/api/v1") app.include_router(matching.router, prefix="/api/v1")
app.include_router(batch_matching.router, prefix="/api/v1")
app.include_router(notifications.router, prefix="/api/v1") app.include_router(notifications.router, prefix="/api/v1")
app.include_router(diagnoses.router, prefix="/api/v1") app.include_router(diagnoses.router, prefix="/api/v1")
app.include_router(sync.router, prefix="/api/v1")
app.include_router(connection_profiles.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
@app.get("/health") @app.get("/health")
......
...@@ -10,3 +10,9 @@ from app.models.department import Department # noqa: F401 ...@@ -10,3 +10,9 @@ from app.models.department import Department # noqa: F401
from app.models.role import Role # noqa: F401 from app.models.role import Role # noqa: F401
from app.models.permission import Permission, PermissionType # noqa: F401 from app.models.permission import Permission, PermissionType # noqa: F401
from app.models.user_role import user_roles, role_permissions # noqa: F401 from app.models.user_role import user_roles, role_permissions # noqa: F401
# 批量匹配与数据同步
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus # noqa: F401
from app.models.sync_source import SyncSource # noqa: F401
from app.models.data_domain_type import DataDomainType # noqa: F401
from app.models.connection_profile import ConnectionProfile # noqa: F401
import uuid
import enum
from datetime import datetime
from sqlalchemy import String, Text, Integer, Boolean, DateTime, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class BatchJobStatus(str, enum.Enum):
pending = "pending"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class BatchMatchingJob(Base):
__tablename__ = "batch_matching_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
status: Mapped[BatchJobStatus] = mapped_column(SAEnum(BatchJobStatus), default=BatchJobStatus.pending)
total_pairs: Mapped[int] = mapped_column(Integer, default=0)
completed_pairs: Mapped[int] = mapped_column(Integer, default=0)
failed_pairs: Mapped[int] = mapped_column(Integer, default=0)
triggered_by: Mapped[str] = mapped_column(String(255))
trial_id: Mapped[str] = mapped_column(String(36), nullable=True)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
error_log: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ConnectionProfile(Base):
__tablename__ = "connection_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(String(500), nullable=True)
connection_type: Mapped[str] = mapped_column(String(20), default="http") # "http" | "database"
base_url: Mapped[str] = mapped_column(String(500), nullable=True)
auth_config: Mapped[dict] = mapped_column(JSON, default=dict)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DataDomainType(Base):
__tablename__ = "data_domain_types"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
code: Mapped[str] = mapped_column(String(50), unique=True) # e.g. "HIS"
display_name: Mapped[str] = mapped_column(String(100)) # e.g. "医院信息系统"
color: Mapped[str] = mapped_column(String(30), default="primary") # MUI color key
description: Mapped[str] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
...@@ -38,5 +38,9 @@ class Patient(Base): ...@@ -38,5 +38,9 @@ class Patient(Base):
lab_report_text: Mapped[str] = mapped_column(Text, nullable=True) lab_report_text: Mapped[str] = mapped_column(Text, nullable=True)
pathology_report: Mapped[str] = mapped_column(Text, nullable=True) pathology_report: Mapped[str] = mapped_column(Text, nullable=True)
# External system tracking
source_system: Mapped[str] = mapped_column(String(100), nullable=True) # "HIS", "LIS", "PACS", "manual"
external_id: Mapped[str] = mapped_column(String(255), nullable=True) # ID in the source system
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, JSON, Boolean, DateTime, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SyncSource(Base):
__tablename__ = "sync_sources"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(255))
source_type: Mapped[str] = mapped_column(String(50)) # e.g. "HIS", free-form string
connection_profile_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
# Sync configuration: defines what data to fetch
# Example: {"resource_type": "Patient", "table_name": "patients", "filters": {...}, "field_mapping": {...}}
sync_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=True)
# Legacy connection fields kept for backward compatibility (not written by new code)
base_url: Mapped[str] = mapped_column(String(500), nullable=True)
auth_config: Mapped[dict] = mapped_column(JSON, default=dict)
connection_type: Mapped[str] = mapped_column(String(20), default="http")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sync_mode: Mapped[str] = mapped_column(String(20), default="full") # "full" | "incremental"
last_sync_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
last_sync_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
from pydantic import BaseModel
from typing import Optional
class BatchJobResponse(BaseModel):
id: str
status: str
total_pairs: int
completed_pairs: int
failed_pairs: int
triggered_by: str
trial_id: Optional[str] = None
trial_title: Optional[str] = None
cancel_requested: bool = False
started_at: Optional[str] = None
completed_at: Optional[str] = None
error_log: Optional[str] = None
created_at: str
progress_pct: float # computed: completed_pairs / total_pairs
model_config = {"from_attributes": True}
...@@ -45,6 +45,5 @@ class MatchingResultResponse(BaseModel): ...@@ -45,6 +45,5 @@ class MatchingResultResponse(BaseModel):
class ReviewRequest(BaseModel): class ReviewRequest(BaseModel):
doctor_id: str
decision: str # "approve" | "reject" decision: str # "approve" | "reject"
notes: str | None = None notes: str | None = None
import asyncio
from datetime import datetime
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.patient import Patient
from app.models.trial import Trial, TrialStatus
from app.models.matching import MatchingResult
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus
from app.services.matching_service import run_matching
async def run_batch_matching(job_id: str) -> None:
"""
Background task: match all patients × all recruiting trials.
Skips pairs where a MatchingResult already exists.
Uses its own DB session independent of the HTTP request session.
Rate-limits to 1 pair/second to avoid overwhelming the LLM API.
Checks cancel_requested after each pair and stops if set.
"""
async with AsyncSessionLocal() as db:
job = await db.get(BatchMatchingJob, job_id)
if not job:
return
job.status = BatchJobStatus.running
job.started_at = datetime.utcnow()
await db.commit()
try:
# Fetch all patients
patient_result = await db.execute(select(Patient))
patients = patient_result.scalars().all()
# Fetch the specified trial (must be recruiting)
if job.trial_id:
trial_result = await db.execute(
select(Trial).where(Trial.id == job.trial_id)
)
trial = trial_result.scalar_one_or_none()
trials = [trial] if trial else []
else:
trial_result = await db.execute(
select(Trial).where(Trial.status == TrialStatus.recruiting)
)
trials = trial_result.scalars().all()
# Build set of already-matched pairs to skip
existing_result = await db.execute(select(MatchingResult))
existing_pairs = {
(r.patient_id, r.trial_id)
for r in existing_result.scalars().all()
}
# Build work list (skip already matched pairs)
pairs = [
(p.id, t.id)
for p in patients
for t in trials
if (p.id, t.id) not in existing_pairs
]
job.total_pairs = len(pairs)
await db.commit()
await db.refresh(job)
# Check if cancel was requested before the loop even starts
if job.cancel_requested:
job.status = BatchJobStatus.cancelled
job.completed_at = datetime.utcnow()
await db.commit()
return
cancelled = False
for patient_id, trial_id in pairs:
try:
await run_matching(patient_id, trial_id, db)
job.completed_pairs += 1
except Exception as exc:
job.failed_pairs += 1
entry = f"[{patient_id}/{trial_id}] {exc}\n"
job.error_log = ((job.error_log or "") + entry)[-10000:]
finally:
await db.commit()
# Re-read job to pick up cancel_requested flag written by cancel endpoint
await db.refresh(job)
if job.cancel_requested:
cancelled = True
break
await asyncio.sleep(1)
if cancelled:
job.status = BatchJobStatus.cancelled
else:
job.status = BatchJobStatus.completed
job.completed_at = datetime.utcnow()
except Exception as e:
job.status = BatchJobStatus.failed
job.error_log = str(e)
job.completed_at = datetime.utcnow()
await db.commit()
...@@ -2,10 +2,13 @@ import uuid ...@@ -2,10 +2,13 @@ import uuid
from datetime import datetime, date from datetime import datetime, date
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models.patient import Patient from app.models.patient import Patient
from app.models.trial import Trial, Criterion from app.models.trial import Trial, Criterion
from app.models.matching import MatchingResult, MatchStatus from app.models.matching import MatchingResult, MatchStatus
from app.models.notification import Notification from app.models.notification import Notification
from app.models.user import User
from app.models.role import Role
from app.services.llm_service import run_ie_matching from app.services.llm_service import run_ie_matching
from app.core.config import settings from app.core.config import settings
...@@ -127,17 +130,31 @@ async def run_matching( ...@@ -127,17 +130,31 @@ async def run_matching(
db.add(matching_result) db.add(matching_result)
await db.flush() await db.flush()
# Create doctor notification # Determine recipient doctors by role
user_result = await db.execute(
select(User).options(selectinload(User.roles)).where(User.is_active == True) # noqa: E712
)
all_users = user_result.scalars().all()
doctor_usernames = [
u.username for u in all_users
if any(r.code in ("doctor", "attending_doctor", "resident_doctor") for r in u.roles)
]
# Fallback: notify all active users if no doctor role configured
if not doctor_usernames:
doctor_usernames = [u.username for u in all_users]
notify_message = (
f"AI匹配发现潜在受试者,试验:{trial.title[:50]},"
f"匹配评分:{llm_result.get('overall_score', 0.0):.0%},"
f"状态:{'符合入组标准' if match_status == MatchStatus.eligible else '需进一步审核'}"
)
for username in doctor_usernames:
notification = Notification( notification = Notification(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
matching_result_id=matching_result.id, matching_result_id=matching_result.id,
recipient_doctor_id="default_doctor", recipient_doctor_id=username,
title="潜在受试者推荐", title="潜在受试者推荐",
message=( message=notify_message,
f"AI匹配发现潜在受试者,试验:{trial.title[:50]},"
f"匹配评分:{llm_result.get('overall_score', 0.0):.0%},"
f"状态:{'符合入组标准' if match_status == MatchStatus.eligible else '需进一步审核'}"
),
) )
db.add(notification) db.add(notification)
await db.commit() await db.commit()
......
This diff is collapsed.
import uuid
from datetime import datetime, date
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.patient import Patient, GenderEnum
from app.models.sync_source import SyncSource
from app.models.connection_profile import ConnectionProfile
from app.services.sync_adapters import get_adapter, PatientSyncData
def apply_transform(value: Any, transform: str | None) -> Any:
"""Apply a transformation rule to a field value."""
if value is None:
return None
if not transform:
return value
str_val = str(value).strip() if value else ""
if transform == "trim":
return str_val
elif transform == "upper":
return str_val.upper()
elif transform == "lower":
return str_val.lower()
elif transform == "date":
# Try to parse various date formats and return ISO format
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y", "%Y%m%d"]:
try:
return datetime.strptime(str_val, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return str_val # Return as-is if no format matches
elif transform == "gender":
# Map Chinese/various gender values to standard enum
val_lower = str_val.lower()
if val_lower in ["男", "male", "m", "1"]:
return "male"
elif val_lower in ["女", "female", "f", "2"]:
return "female"
elif val_lower in ["其他", "other", "o"]:
return "other"
else:
return "unknown"
return value
def apply_field_mappings(raw_data: dict, field_mappings: list[dict]) -> dict:
"""
Apply field mappings to transform raw source data to patient model fields.
Args:
raw_data: Raw record from source system
field_mappings: List of {source_field, target_field, transform}
Returns:
Dict with target_field keys and transformed values
"""
result = {}
for mapping in field_mappings:
source_field = mapping.get("source_field", "")
target_field = mapping.get("target_field", "")
transform = mapping.get("transform", "")
if not source_field or not target_field:
continue
# Support nested field access with dot notation (e.g., "name.given")
value = raw_data
for key in source_field.split("."):
if isinstance(value, dict):
value = value.get(key)
elif isinstance(value, list) and key.isdigit():
idx = int(key)
value = value[idx] if idx < len(value) else None
else:
value = None
break
result[target_field] = apply_transform(value, transform)
return result
async def sync_source(source_id: str, db: AsyncSession) -> dict:
"""
Run sync for a single SyncSource.
Upserts patients by MRN: updates existing records, creates new ones.
Returns a summary dict.
"""
source = await db.get(SyncSource, source_id)
if not source or not source.is_active:
raise ValueError(f"Sync source '{source_id}' not found or inactive")
# Resolve connection parameters: profile takes priority over legacy inline fields
if source.connection_profile_id:
profile = await db.get(ConnectionProfile, source.connection_profile_id)
if not profile:
raise ValueError(f"连接档案 '{source.connection_profile_id}' 不存在")
base_url = profile.base_url
auth_config = profile.auth_config or {}
else:
base_url = source.base_url
auth_config = source.auth_config or {}
# Get sync config with field mappings
sync_config = source.sync_config or {}
field_mappings = sync_config.get("field_mappings", [])
adapter = get_adapter(
source_type=source.source_type,
source_id=source.id,
base_url=base_url,
auth_config=auth_config,
sync_config=sync_config,
)
# Incremental sync: only fetch records updated since last_sync_at
since = source.last_sync_at if (source.sync_mode == "incremental" and source.last_sync_at) else None
patients_data: list[PatientSyncData] = await adapter.fetch_patients(since=since)
created = 0
updated = 0
for pdata in patients_data:
# If field_mappings are provided and adapter returns raw data, apply mappings
# Otherwise use the normalized PatientSyncData directly
if field_mappings and isinstance(pdata, dict) and "mrn" not in pdata:
# Raw data needs mapping
mapped = apply_field_mappings(pdata, field_mappings)
pdata = {
"mrn": mapped.get("mrn", ""),
"name": mapped.get("name", ""),
"birth_date": mapped.get("birth_date", "1900-01-01"),
"gender": mapped.get("gender", "unknown"),
"external_id": mapped.get("external_id", str(uuid.uuid4())),
"source_system": source.source_type,
"admission_note": mapped.get("admission_note"),
"lab_report_text": mapped.get("lab_report_text"),
"pathology_report": mapped.get("pathology_report"),
"phone": mapped.get("phone"),
"id_number": mapped.get("id_number"),
"fhir_conditions": [],
"fhir_observations": [],
"fhir_medications": [],
}
if not pdata.get("mrn"):
continue # Skip records without MRN
existing_result = await db.execute(
select(Patient).where(Patient.mrn == pdata["mrn"])
)
existing = existing_result.scalar_one_or_none()
try:
gender_val = GenderEnum(pdata["gender"])
except ValueError:
gender_val = GenderEnum.unknown
try:
birth = date.fromisoformat(pdata["birth_date"])
except (ValueError, TypeError):
birth = date(1900, 1, 1)
if existing:
# Update clinical text; do NOT overwrite name_encrypted or other manually-set PII
if pdata.get("admission_note"):
existing.admission_note = pdata["admission_note"]
if pdata.get("lab_report_text"):
existing.lab_report_text = pdata["lab_report_text"]
if pdata.get("pathology_report"):
existing.pathology_report = pdata["pathology_report"]
existing.source_system = pdata.get("source_system", source.source_type)
existing.external_id = pdata.get("external_id")
existing.updated_at = datetime.utcnow()
updated += 1
else:
new_patient = Patient(
id=str(uuid.uuid4()),
mrn=pdata["mrn"],
name_encrypted=pdata.get("name", ""),
phone_encrypted=pdata.get("phone"),
id_number_encrypted=pdata.get("id_number"),
birth_date=birth,
gender=gender_val,
admission_note=pdata.get("admission_note"),
lab_report_text=pdata.get("lab_report_text"),
pathology_report=pdata.get("pathology_report"),
fhir_conditions=pdata.get("fhir_conditions", []),
fhir_observations=pdata.get("fhir_observations", []),
fhir_medications=pdata.get("fhir_medications", []),
source_system=pdata.get("source_system", source.source_type),
external_id=pdata.get("external_id"),
)
db.add(new_patient)
created += 1
source.last_sync_at = datetime.utcnow()
source.last_sync_count = len(patients_data)
await db.commit()
return {
"source_id": source_id,
"source_name": source.name,
"sync_mode": source.sync_mode,
"patients_fetched": len(patients_data),
"created": created,
"updated": updated,
}
...@@ -17,12 +17,16 @@ dependencies = [ ...@@ -17,12 +17,16 @@ dependencies = [
"httpx>=0.27.0", "httpx>=0.27.0",
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",
"passlib>=1.7.4", "passlib>=1.7.4",
"bcrypt==4.1.3", "bcrypt>=4.1.3",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"pymysql>=1.1.2",
"psycopg2-binary>=2.9.11",
"pymssql>=2.3.13",
"oracledb>=3.4.2",
] ]
[tool.uv] [dependency-groups]
dev-dependencies = [ dev = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=0.23.0", "pytest-asyncio>=0.23.0",
] ]
......
This diff is collapsed.
...@@ -5,12 +5,14 @@ import { AuthProvider } from './contexts/AuthContext' ...@@ -5,12 +5,14 @@ import { AuthProvider } from './contexts/AuthContext'
import { PrivateRoute } from './components/auth/PrivateRoute' import { PrivateRoute } from './components/auth/PrivateRoute'
import { AppShell } from './components/layout/AppShell' import { AppShell } from './components/layout/AppShell'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
import { PatientsPage } from './pages/PatientsPage' import { PatientsPage } from './pages/PatientsPage'
import { TrialsPage } from './pages/TrialsPage' import { TrialsPage } from './pages/TrialsPage'
import { MatchingPage } from './pages/MatchingPage' import { MatchingPage } from './pages/MatchingPage'
import { WorkStationPage } from './pages/WorkStationPage' import { WorkStationPage } from './pages/WorkStationPage'
import { DiagnosesPage } from './pages/DiagnosesPage' import { DiagnosesPage } from './pages/DiagnosesPage'
import { SystemPage } from './pages/SystemPage' import { SystemPage } from './pages/SystemPage'
import { DataSyncPage } from './pages/DataSyncPage'
export default function App() { export default function App() {
return ( return (
...@@ -31,7 +33,10 @@ export default function App() { ...@@ -31,7 +33,10 @@ export default function App() {
</PrivateRoute> </PrivateRoute>
} }
> >
<Route index element={<Navigate to="/patients" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
{/* 数据概览 */}
<Route path="dashboard" element={<DashboardPage />} />
{/* 患者管理 */} {/* 患者管理 */}
<Route <Route
...@@ -83,6 +88,16 @@ export default function App() { ...@@ -83,6 +88,16 @@ export default function App() {
} }
/> />
{/* 数据同步 */}
<Route
path="data-sync"
element={
<PrivateRoute requiredPermission="menu:system">
<DataSyncPage />
</PrivateRoute>
}
/>
{/* 系统管理 */} {/* 系统管理 */}
<Route <Route
path="system" path="system"
......
...@@ -3,20 +3,24 @@ import { ...@@ -3,20 +3,24 @@ import {
Drawer, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Drawer, List, ListItem, ListItemButton, ListItemIcon, ListItemText,
Toolbar, Typography, Divider, Box, Toolbar, Typography, Divider, Box,
} from '@mui/material' } from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import PeopleIcon from '@mui/icons-material/People' import PeopleIcon from '@mui/icons-material/People'
import ScienceIcon from '@mui/icons-material/Science' import ScienceIcon from '@mui/icons-material/Science'
import PsychologyIcon from '@mui/icons-material/Psychology' import PsychologyIcon from '@mui/icons-material/Psychology'
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart' import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
import LocalHospitalIcon from '@mui/icons-material/LocalHospital' import LocalHospitalIcon from '@mui/icons-material/LocalHospital'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import SyncIcon from '@mui/icons-material/Sync'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ label: '数据概览', path: '/dashboard', icon: <DashboardIcon />, permission: 'menu:patients' },
{ label: '患者管理', path: '/patients', icon: <PeopleIcon />, permission: 'menu:patients' }, { label: '患者管理', path: '/patients', icon: <PeopleIcon />, permission: 'menu:patients' },
{ label: '临床试验', path: '/trials', icon: <ScienceIcon />, permission: 'menu:trials' }, { label: '临床试验', path: '/trials', icon: <ScienceIcon />, permission: 'menu:trials' },
{ label: '诊断管理', path: '/diagnoses', icon: <LocalHospitalIcon />, permission: 'menu:diagnoses' }, { label: '诊断管理', path: '/diagnoses', icon: <LocalHospitalIcon />, permission: 'menu:diagnoses' },
{ label: 'AI 智能匹配', path: '/matching', icon: <PsychologyIcon />, permission: 'menu:matching' }, { label: 'AI 智能匹配', path: '/matching', icon: <PsychologyIcon />, permission: 'menu:matching' },
{ label: '医生工作站', path: '/workstation', icon: <MonitorHeartIcon />, permission: 'menu:workstation' }, { label: '医生工作站', path: '/workstation', icon: <MonitorHeartIcon />, permission: 'menu:workstation' },
{ label: '数据同步', path: '/data-sync', icon: <SyncIcon />, permission: 'menu:system' },
{ label: '系统管理', path: '/system', icon: <SettingsIcon />, permission: 'menu:system' }, { label: '系统管理', path: '/system', icon: <SettingsIcon />, permission: 'menu:system' },
] ]
......
...@@ -24,6 +24,8 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) { ...@@ -24,6 +24,8 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
const { user, logout } = useAuth() const { user, logout } = useAuth()
useEffect(() => { useEffect(() => {
// 只在已登录状态下拉取通知数量,避免过期 token 触发全局 401 跳转
if (!user) return
const fetchCount = () => { const fetchCount = () => {
notificationService.unreadCount() notificationService.unreadCount()
.then(r => setUnreadCount(r.count)) .then(r => setUnreadCount(r.count))
...@@ -32,7 +34,7 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) { ...@@ -32,7 +34,7 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
fetchCount() fetchCount()
const interval = setInterval(fetchCount, 30000) const interval = setInterval(fetchCount, 30000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [user])
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget) setAnchorEl(event.currentTarget)
......
...@@ -32,11 +32,7 @@ export function MatchingDetailDrawer({ result, open, onClose, onAuditSuccess }: ...@@ -32,11 +32,7 @@ export function MatchingDetailDrawer({ result, open, onClose, onAuditSuccess }:
const handleAudit = async (decision: 'approve' | 'reject') => { const handleAudit = async (decision: 'approve' | 'reject') => {
setLoading(true) setLoading(true)
try { try {
await matchingService.review(result.id, { await matchingService.review(result.id, { decision, notes })
doctor_id: 'default_doctor',
decision,
notes
})
onAuditSuccess() onAuditSuccess()
onClose() onClose()
} catch (error) { } catch (error) {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { import {
Box, Typography, Card, CardContent, Stack, Button, Box, Typography, Card, CardContent, Stack, Button,
Chip, Divider, Badge, IconButton Chip, Divider, Badge, IconButton, FormControl, InputLabel, Select, MenuItem
} from '@mui/material' } from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid' import { DataGrid, GridColDef } from '@mui/x-data-grid'
import NotificationsIcon from '@mui/icons-material/Notifications' import NotificationsIcon from '@mui/icons-material/Notifications'
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart' import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
import VisibilityIcon from '@mui/icons-material/Visibility' import VisibilityIcon from '@mui/icons-material/Visibility'
import FilterListIcon from '@mui/icons-material/FilterList'
import { RecommendationDrawer } from '../components/workstation/RecommendationDrawer' import { RecommendationDrawer } from '../components/workstation/RecommendationDrawer'
import { RecommendationSnackbar } from '../components/workstation/RecommendationSnackbar' import { RecommendationSnackbar } from '../components/workstation/RecommendationSnackbar'
import { MatchingDetailDrawer } from '../components/matching/MatchingDetailDrawer' import { MatchingDetailDrawer } from '../components/matching/MatchingDetailDrawer'
import { notificationService } from '../services/notificationService' import { notificationService } from '../services/notificationService'
import { matchingService } from '../services/matchingService' import { matchingService } from '../services/matchingService'
import { trialService } from '../services/trialService'
import type { NotificationItem } from '../types/notification' import type { NotificationItem } from '../types/notification'
import type { MatchingResult } from '../types/matching' import type { MatchingResult } from '../types/matching'
import type { TrialResponse } from '../types/trial'
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'eligible', label: '符合入组' },
{ value: 'ineligible', label: '不符合' },
{ value: 'pending_review', label: '待审核' },
{ value: 'needs_more_info', label: '需补充' },
]
export function WorkStationPage() { export function WorkStationPage() {
const [notifications, setNotifications] = useState<NotificationItem[]>([]) const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [results, setResults] = useState<MatchingResult[]>([]) const [results, setResults] = useState<MatchingResult[]>([])
const [trials, setTrials] = useState<TrialResponse[]>([])
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [snackNotification, setSnackNotification] = useState<NotificationItem | null>(null) const [snackNotification, setSnackNotification] = useState<NotificationItem | null>(null)
const [unreadCount, setUnreadCount] = useState(0) const [unreadCount, setUnreadCount] = useState(0)
const [statusFilter, setStatusFilter] = useState('')
const [trialFilter, setTrialFilter] = useState('')
// 新增:详情抽屉状态
const [selectedResult, setSelectedResult] = useState<MatchingResult | null>(null) const [selectedResult, setSelectedResult] = useState<MatchingResult | null>(null)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const shownNotificationIds = useRef<Set<string>>(new Set())
const resultColumns: GridColDef[] = [ const resultColumns: GridColDef[] = [
{ field: 'patient_name', headerName: '患者姓名', width: 130 }, { field: 'patient_name', headerName: '患者姓名', width: 130 },
...@@ -46,6 +60,12 @@ export function WorkStationPage() { ...@@ -46,6 +60,12 @@ export function WorkStationPage() {
field: 'overall_score', headerName: '评分', width: 90, type: 'number', field: 'overall_score', headerName: '评分', width: 90, type: 'number',
valueFormatter: (v: number) => `${Math.round(v * 100)}%`, valueFormatter: (v: number) => `${Math.round(v * 100)}%`,
}, },
{
field: 'reviewed_by', headerName: '审核人', width: 100,
renderCell: p => p.value ? (
<Chip label={p.value} size="small" variant="outlined" color="primary" />
) : <Typography variant="caption" color="text.disabled">未审核</Typography>,
},
{ {
field: 'actions', headerName: '操作', width: 80, sortable: false, field: 'actions', headerName: '操作', width: 80, sortable: false,
renderCell: (params) => ( renderCell: (params) => (
...@@ -64,22 +84,37 @@ export function WorkStationPage() { ...@@ -64,22 +84,37 @@ export function WorkStationPage() {
} }
] ]
const loadResults = useCallback(async () => {
const matchResults = await matchingService.list({
limit: 50,
status: statusFilter || undefined,
trial_id: trialFilter || undefined,
})
setResults(matchResults)
}, [statusFilter, trialFilter])
const loadNotifications = useCallback(async () => { const loadNotifications = useCallback(async () => {
const [notifs, count, matchResults] = await Promise.all([ const [notifs, count] = await Promise.all([
notificationService.list({ limit: 50 }), notificationService.list({ limit: 50 }),
notificationService.unreadCount(), notificationService.unreadCount(),
matchingService.list({ limit: 50 }),
]) ])
setNotifications(notifs) setNotifications(notifs)
setUnreadCount(count.count) setUnreadCount(count.count)
setResults(matchResults)
// 自动展示最新未读消息
const latestUnread = notifs.find(n => !n.is_read) const latestUnread = notifs.find(n => !n.is_read)
if (latestUnread && !snackNotification) { if (latestUnread && !shownNotificationIds.current.has(latestUnread.id)) {
shownNotificationIds.current.add(latestUnread.id)
setSnackNotification(latestUnread) setSnackNotification(latestUnread)
} }
}, [snackNotification]) }, [])
useEffect(() => {
trialService.list({ limit: 100 }).then(setTrials)
}, [])
useEffect(() => {
loadResults()
}, [loadResults])
useEffect(() => { useEffect(() => {
loadNotifications() loadNotifications()
...@@ -87,6 +122,11 @@ export function WorkStationPage() { ...@@ -87,6 +122,11 @@ export function WorkStationPage() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [loadNotifications]) }, [loadNotifications])
const handleRefresh = () => {
loadResults()
loadNotifications()
}
return ( return (
<Box> <Box>
<Stack direction="row" alignItems="center" spacing={2} mb={3}> <Stack direction="row" alignItems="center" spacing={2} mb={3}>
...@@ -129,7 +169,39 @@ export function WorkStationPage() { ...@@ -129,7 +169,39 @@ export function WorkStationPage() {
{/* 匹配结果表格 */} {/* 匹配结果表格 */}
<Card> <Card>
<CardContent sx={{ pb: '12px !important' }}> <CardContent sx={{ pb: '12px !important' }}>
<Typography variant="subtitle1" fontWeight={600} mb={1}>AI 智能匹配列表</Typography> <Stack direction="row" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="subtitle1" fontWeight={600}>AI 智能匹配列表</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<FilterListIcon fontSize="small" color="action" />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>状态筛选</InputLabel>
<Select
value={statusFilter}
label="状态筛选"
onChange={e => setStatusFilter(e.target.value)}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>试验筛选</InputLabel>
<Select
value={trialFilter}
label="试验筛选"
onChange={e => setTrialFilter(e.target.value)}
>
<MenuItem value="">全部试验</MenuItem>
{trials.map(t => (
<MenuItem key={t.id} value={t.id}>
{t.title.slice(0, 30)}{t.title.length > 30 ? '...' : ''}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Stack>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<Box sx={{ height: 420 }}> <Box sx={{ height: 420 }}>
<DataGrid <DataGrid
...@@ -157,7 +229,7 @@ export function WorkStationPage() { ...@@ -157,7 +229,7 @@ export function WorkStationPage() {
open={drawerOpen} open={drawerOpen}
notifications={notifications} notifications={notifications}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
onRefresh={loadNotifications} onRefresh={handleRefresh}
/> />
{/* 详情与审核抽屉 */} {/* 详情与审核抽屉 */}
...@@ -165,7 +237,7 @@ export function WorkStationPage() { ...@@ -165,7 +237,7 @@ export function WorkStationPage() {
open={detailOpen} open={detailOpen}
result={selectedResult} result={selectedResult}
onClose={() => setDetailOpen(false)} onClose={() => setDetailOpen(false)}
onAuditSuccess={loadNotifications} onAuditSuccess={handleRefresh}
/> />
{/* 消息提醒 */} {/* 消息提醒 */}
...@@ -177,4 +249,3 @@ export function WorkStationPage() { ...@@ -177,4 +249,3 @@ export function WorkStationPage() {
</Box> </Box>
) )
} }
...@@ -20,11 +20,14 @@ api.interceptors.request.use( ...@@ -20,11 +20,14 @@ api.interceptors.request.use(
) )
// 响应拦截器:处理 401 错误 // 响应拦截器:处理 401 错误
let isRedirectingToLogin = false
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401 && !isRedirectingToLogin) {
// 清除 token 并跳转到登录页 // 避免多个并发请求同时触发多次跳转
isRedirectingToLogin = true
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
localStorage.removeItem('user_info') localStorage.removeItem('user_info')
window.location.href = '/login' window.location.href = '/login'
......
import api from './api'
export interface BatchJob {
id: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
total_pairs: number
completed_pairs: number
failed_pairs: number
triggered_by: string
trial_id?: string
trial_title?: string
cancel_requested?: boolean
started_at?: string
completed_at?: string
error_log?: string
created_at: string
progress_pct: number
}
export const batchMatchingService = {
triggerRun: (trialId: string) =>
api.post<BatchJob>(`/matching/batch/run?trial_id=${trialId}`).then(r => r.data),
listJobs: (params?: { skip?: number; limit?: number; }) =>
api.get<BatchJob[]>('/matching/batch/jobs', { params }).then(r => r.data),
getJob: (id: string) =>
api.get<BatchJob>(`/matching/batch/jobs/${id}`).then(r => r.data),
cancelJob: (id: string) =>
api.post<BatchJob>(`/matching/batch/jobs/${id}/cancel`).then(r => r.data),
}
import api from './api'
export interface ConnectionProfile {
id: string
name: string
description?: string
connection_type: string // 'http' | 'database'
base_url?: string
auth_config: Record<string, string>
is_active: boolean
created_at: string
}
export interface ConnectionProfileCreate {
name: string
description?: string
connection_type: string
base_url?: string
auth_config?: Record<string, string>
is_active?: boolean
}
export const connectionProfileService = {
list: () =>
api.get<ConnectionProfile[]>('/connection-profiles').then(r => r.data),
create: (data: ConnectionProfileCreate) =>
api.post<ConnectionProfile>('/connection-profiles', data).then(r => r.data),
update: (id: string, data: ConnectionProfileCreate) =>
api.put<ConnectionProfile>(`/connection-profiles/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/connection-profiles/${id}`),
}
import api from './api'
export interface DashboardStats {
patients: { total: number }
trials: { total: number; recruiting: number }
matching: {
total: number
eligible: number
ineligible: number
pending_review: number
needs_more_info: number
}
batch_jobs: {
total: number
completed: number
failed: number
running: number
}
notifications: { unread: number }
recent_batch_jobs: Array<{
id: string
status: string
trial_title: string | null
total_pairs: number
completed_pairs: number
failed_pairs: number
triggered_by: string
started_at: string | null
completed_at: string | null
progress_pct: number
}>
}
export const dashboardService = {
getStats: () =>
api.get<DashboardStats>('/dashboard/stats').then(r => r.data),
}
import api from './api'
export interface DataDomainType {
id: string
code: string
display_name: string
color: string
description?: string
is_active: boolean
created_at: string
}
export interface DataDomainCreate {
code: string
display_name: string
color?: string
description?: string
is_active?: boolean
}
export const dataDomainService = {
list: () =>
api.get<DataDomainType[]>('/data-domains').then(r => r.data),
create: (data: DataDomainCreate) =>
api.post<DataDomainType>('/data-domains', data).then(r => r.data),
update: (id: string, data: DataDomainCreate) =>
api.put<DataDomainType>(`/data-domains/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/data-domains/${id}`),
}
...@@ -5,12 +5,12 @@ export const matchingService = { ...@@ -5,12 +5,12 @@ export const matchingService = {
runSync: (data: MatchingRequest) => runSync: (data: MatchingRequest) =>
api.post<MatchingResult>('/matching/run/sync', data).then(r => r.data), api.post<MatchingResult>('/matching/run/sync', data).then(r => r.data),
list: (params?: { trial_id?: string; patient_id?: string; status?: string }) => list: (params?: { trial_id?: string; patient_id?: string; status?: string; limit?: number }) =>
api.get<MatchingResult[]>('/matching/results', { params }).then(r => r.data), api.get<MatchingResult[]>('/matching/results', { params }).then(r => r.data),
get: (id: string) => get: (id: string) =>
api.get<MatchingResult>(`/matching/results/${id}`).then(r => r.data), api.get<MatchingResult>(`/matching/results/${id}`).then(r => r.data),
review: (id: string, data: { doctor_id: string; decision: string; notes?: string }) => review: (id: string, data: { decision: string; notes?: string }) =>
api.put<MatchingResult>(`/matching/results/${id}/review`, data).then(r => r.data), api.put<MatchingResult>(`/matching/results/${id}/review`, data).then(r => r.data),
} }
import api from './api'
// 本系统 Patient 模型的目标字段
export const TARGET_PATIENT_FIELDS = [
{ field: 'mrn', label: '病历号 (MRN)', required: true, description: '患者唯一标识' },
{ field: 'name', label: '姓名', required: true, description: '患者姓名(系统会自动加密存储)' },
{ field: 'birth_date', label: '出生日期', required: true, description: '格式: YYYY-MM-DD' },
{ field: 'gender', label: '性别', required: true, description: 'male/female/other/unknown' },
{ field: 'phone', label: '电话', required: false, description: '联系电话(加密存储)' },
{ field: 'id_number', label: '身份证号', required: false, description: '身份证号(加密存储)' },
{ field: 'admission_note', label: '入院记录', required: false, description: '入院病历文本' },
{ field: 'lab_report_text', label: '检验报告', required: false, description: '实验室检验报告文本' },
{ field: 'pathology_report', label: '病理报告', required: false, description: '病理检查报告文本' },
]
// 字段映射类型
export interface FieldMapping {
source_field: string // 源数据库/API的字段名
target_field: string // 本系统Patient模型的字段名
transform?: string // 可选的转换规则: 'date', 'gender', 'trim', 'upper', 'lower'
}
// 同步配置结构
export interface SyncConfig {
resource_type?: string // FHIR资源类型: Patient, Observation, Condition 等
query?: string // DB模式: 完整SQL查询语句; HTTP模式: FHIR查询参数
incremental_field?: string // 增量同步的时间戳字段名,如 updated_at
incremental_condition?: string // 增量同步的 WHERE 条件片段,:last_sync_at 为占位符
// 示例: "updated_at > :last_sync_at"
// 联合: "updated_at > :last_sync_at OR created_at > :last_sync_at"
filters?: Record<string, any> // 筛选条件
field_mappings?: FieldMapping[] // 字段映射列表
batch_size?: number // 批量大小
}
export interface SyncSource {
id: string
name: string
source_type: string
connection_profile_id?: string
connection_profile_name?: string
sync_config?: SyncConfig
is_active: boolean
sync_mode: string // 'full' | 'incremental'
last_sync_at?: string
last_sync_count?: number
created_at: string
}
export interface SyncSourceCreate {
name: string
source_type: string
connection_profile_id?: string
sync_config?: SyncConfig
is_active?: boolean
sync_mode?: string
}
export interface SyncRunResult {
status: string
source_id: string
source_name: string
sync_mode: string
patients_fetched: number
created: number
updated: number
}
export interface PreviewRequest {
source_type: string
sql_query?: string
connection_profile_id?: string
limit?: number
}
export interface PreviewResult {
columns: string[]
rows: any[][]
error?: string
}
export interface TestConnectionRequest {
db_type: string
host: string
port: string
database: string
username?: string
password?: string
oracle_type?: string // Oracle only: 'sid' | 'service_name'
}
export interface TestConnectionResult {
success: boolean
message: string
}
export const syncService = {
listSources: () =>
api.get<SyncSource[]>('/sync/sources').then(r => r.data),
createSource: (data: SyncSourceCreate) =>
api.post<SyncSource>('/sync/sources', data).then(r => r.data),
updateSource: (id: string, data: SyncSourceCreate) =>
api.put<SyncSource>(`/sync/sources/${id}`, data).then(r => r.data),
deleteSource: (id: string) =>
api.delete(`/sync/sources/${id}`),
runSync: (sourceId: string) =>
api.post<SyncRunResult>(`/sync/run?source_id=${sourceId}`).then(r => r.data),
previewSource: (req: PreviewRequest) =>
api.post<PreviewResult>('/sync/preview', req).then(r => r.data),
testConnection: (req: TestConnectionRequest) =>
api.post<TestConnectionResult>('/sync/test-connection', req).then(r => r.data),
listTables: (profileId: string) =>
api.get<{ tables: string[] }>(`/sync/list-tables?connection_profile_id=${profileId}`).then(r => r.data.tables),
}
...@@ -20,6 +20,8 @@ export interface MatchingResult { ...@@ -20,6 +20,8 @@ export interface MatchingResult {
id: string id: string
patient_id: string patient_id: string
trial_id: string trial_id: string
patient_name?: string
trial_title?: string
status: 'eligible' | 'ineligible' | 'pending_review' | 'needs_more_info' status: 'eligible' | 'ineligible' | 'pending_review' | 'needs_more_info'
overall_score: number overall_score: number
criterion_details: CriterionMatchDetail[] criterion_details: CriterionMatchDetail[]
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment