Commit 38fb8311 authored by 375242562@qq.com's avatar 375242562@qq.com

Initial commit

parents
{
"permissions": {
"allow": [
"Bash(pip install:*)",
"Bash(uv sync:*)",
"Bash(npm install:*)",
"Bash(uv run:*)"
]
}
}
# CLAUDE.md - 智能患者招募系统 (Smart Recruitment)
## 项目概述
**Smart Recruitment** 是一个基于 AI 的临床试验患者招募系统。它高效汇聚患者入院信息、医生文书、检验检查、影像、病理及生物标志物等多源多模态诊疗信息,利用 LLM 进行关键信息识别与入排标准 (I/E Criteria) 自动匹配,并集成隐私脱敏保护机制。
## 应用场景
基于大数据技术,高效汇聚患者的入院信息、医生文书记录、检验检查、影像、病理、生物标志物等多源多模态诊疗信息。
基于语言大模型的语义理解和信息抽取能力,对患者进行关键信息识别和综合评估,自动匹配临床试验入排标准,实时监测并通过医生工作站向医生自动推荐潜在受试者,从而有效降低人力耗时,提前储备受试者,有效加速临床试验进程。
通过智能监测患者的反应和副作用,为临床决策提供支持。
强化患者隐私保护,在技术层面通过患者信息脱敏处理、设置权限隔离、过程监管等方法提升信息安全能力,促进和规范对医疗健康数据的合理利用途径,提升相关人员的信息安全意识和法律意识。
## 技术栈与标准
- **后端**: FastAPI (Python 3.10+), PostgreSQL + pgvector (向量检索)
- **前端**: React 18+ + TypeScript + **Material UI (MUI) v6** + Tailwind CSS (仅用于布局辅助)
- **医疗标准**: FHIR R4 (数据建模), ICD-10/SNOMED CT/LOINC (标准术语集)
- **AI/LLM**: LangChain/LlamaIndex, OpenAI/Anthropic API, 用于实体抽取 (NER) 和匹配推理
- **包管理**: `uv` (后端), `npm` (前端)
## 界面与交互规范 (医疗专业风格)
- **视觉风格**:
- **色调**: 采用“医疗蓝” (#1976d2) 为主色,搭配“安全绿” (#2e7d32) 表示符合项,“警戒红” (#d32f2f) 表示排除项。
- **排版**: 使用高信息密度的表格 (MUI DataGrid) 和清晰的层级结构。
- **组件**: 优先使用 MUI `Card`, `Table`, `Stepper` (用于匹配流程), `Chip` (用于标签)。
- **交互逻辑**:
- 匹配结果必须提供 **Evidence Panel**(证据面板),高亮显示原始病历中支撑匹配逻辑的片段。
- 医生工作站弹窗提醒必须非侵入式(如 MUI Snackbar 或侧边抽屉)。
## 编码规范与安全原则 (重要)
- **数据合规 (HIPAA/GDPR)**:
- 严禁在日志中记录任何患者 PII(姓名、电话、身份证号)。
- API 返回患者数据前必须经过 `masking_service` 进行脱敏处理。
- **医学严谨性**:
- 匹配逻辑必须包含 `Reasoning` 字段,说明 LLM 判定“符合”或“不符合”的具体医学依据。
- 涉及单位换算(如 μmol/L 到 mg/dL)必须在 `matching` 模块中进行标准化。
- **Python 规范**:
- 使用 Pydantic V2 的 `BaseModel` 映射 FHIR 资源。
- 异步操作统一使用 `async/await`,处理大数据量查询需使用流式传输 (Streaming)。
## Claude 指令建议
- **代码开发**: 结构清晰明了,不要包含无用的依赖和冗余的代码
- **UI 开发**: “请使用 MUI 组件创建一个[功能名称]界面,保持医疗风格的严谨感,使用 DataGrid 展示患者列表,并确保响应式布局。”
- **逻辑匹配**: “请编写一个 I/E 匹配函数,输入为 FHIR Patient 资源和试验标准,输出需包含匹配得分和详细的推理依据 (Reasoning),并编写相应的单元测试。”
- **安全检查**: “在编写此接口时,请确保添加了数据脱敏装饰器,并检查是否有 PHI 信息泄露风险。”
- **FHIR 转换**: “请根据 FHIR R4 标准,编写将原始病理报告文本转化为 `Observation` 资源的提示词逻辑。”
\ No newline at end of file
# Application
APP_ENV=development
SECRET_KEY=change-me-in-production-use-32-chars-min
# Database (SQLite for dev)
DATABASE_URL=sqlite+aiosqlite:///./smart_recruitment.db
# LLM Provider - set ONE of the following
OPENAI_API_KEY=sk-b512fecda75a4a5d9da5ed4fab947e23
ANTHROPIC_API_KEY=sk-ant-...
LLM_PROVIDER=openai
LLM_MODEL=qwen3-max
# PII Masking salt (rotate periodically)
MASKING_SALT=random-salt-value-change-me
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# Application
APP_ENV=development
SECRET_KEY=change-me-in-production-use-32-chars-min
# Database (SQLite for dev)
DATABASE_URL=sqlite+aiosqlite:///./smart_recruitment.db
# LLM Provider - set ONE of the following
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini
LLM_BASE_URL=https://api.openai.com/v1
# PII Masking salt (rotate periodically)
MASKING_SALT=random-salt-value-change-me
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.11 (backend)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="smart_recruitment" uuid="8757d668-990f-4f67-b13f-457aa70a8e9b">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:D:\ai-project\smart-recruitment\backend\smart_recruitment.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.51.1/org/xerial/sqlite-jdbc/3.51.1.0/sqlite-jdbc-3.51.1.0.jar</url>
</library>
</libraries>
</data-source>
</component>
</project>
\ No newline at end of file
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ourVersions">
<value>
<list size="2">
<item index="0" class="java.lang.String" itemvalue="3.10" />
<item index="1" class="java.lang.String" itemvalue="3.11" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="10">
<item index="0" class="java.lang.String" itemvalue="torch" />
<item index="1" class="java.lang.String" itemvalue="pyinstaller" />
<item index="2" class="java.lang.String" itemvalue="paramiko" />
<item index="3" class="java.lang.String" itemvalue="customtkinter" />
<item index="4" class="java.lang.String" itemvalue="qrcode" />
<item index="5" class="java.lang.String" itemvalue="packaging" />
<item index="6" class="java.lang.String" itemvalue="requests" />
<item index="7" class="java.lang.String" itemvalue="pycryptodome" />
<item index="8" class="java.lang.String" itemvalue="Pillow" />
<item index="9" class="java.lang.String" itemvalue="nuitka" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="numpy.core.multiarray.ndarray.__getitem__" />
<option value="api.apps.grade_app.manager" />
<option value="property.setter" />
<option value="api.apps.evaluation_app.manager" />
<option value="api.apps.file_app.manager" />
<option value="api.chatwoot_app.manager" />
</list>
</option>
</inspection_tool>
</profile>
</component>
\ No newline at end of file
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (backend)" project-jdk-type="Python SDK" />
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
\ No newline at end of file
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.diagnosis import Diagnosis
from app.schemas.diagnosis import DiagnosisCreate, DiagnosisResponse
router = APIRouter(prefix="/diagnoses", tags=["diagnoses"])
@router.get("/", response_model=list[DiagnosisResponse])
async def list_diagnoses(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Diagnosis))
return result.scalars().all()
@router.post("/", response_model=DiagnosisResponse, status_code=201)
async def create_diagnosis(data: DiagnosisCreate, db: AsyncSession = Depends(get_db)):
diag = Diagnosis(id=str(uuid.uuid4()), **data.model_dump())
db.add(diag)
await db.commit()
await db.refresh(diag)
return diag
@router.put("/{diag_id}", response_model=DiagnosisResponse)
async def update_diagnosis(diag_id: str, data: DiagnosisCreate, db: AsyncSession = Depends(get_db)):
diag = await db.get(Diagnosis, diag_id)
if not diag:
raise HTTPException(status_code=404, detail="Diagnosis not found")
diag.description = data.description
diag.icd10_code = data.icd10_code
await db.commit()
await db.refresh(diag)
return diag
@router.delete("/{diag_id}", status_code=24)
async def delete_diagnosis(diag_id: str, db: AsyncSession = Depends(get_db)):
diag = await db.get(Diagnosis, diag_id)
if not diag:
raise HTTPException(status_code=404, detail="Diagnosis not found")
await db.delete(diag)
await db.commit()
return {"status": "success"}
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.matching import MatchingResult, MatchStatus
from app.schemas.matching import MatchingRequest, MatchingResultResponse, ReviewRequest
from app.services.matching_service import run_matching
from datetime import datetime
router = APIRouter(prefix="/matching", tags=["matching"])
def _result_to_response(r: MatchingResult) -> MatchingResultResponse:
return MatchingResultResponse(
id=r.id,
patient_id=r.patient_id,
trial_id=r.trial_id,
status=r.status.value,
overall_score=r.overall_score,
criterion_details=r.criterion_details or [],
evidence_spans=r.evidence_spans or [],
llm_model_used=r.llm_model_used,
matched_at=r.matched_at.isoformat(),
reviewed_by=r.reviewed_by,
reviewed_at=r.reviewed_at.isoformat() if r.reviewed_at else None,
review_notes=r.review_notes,
)
@router.post("/run")
async def run_matching_job(
req: MatchingRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
background_tasks.add_task(run_matching, req.patient_id, req.trial_id, db)
return {"status": "queued", "message": "AI匹配任务已启动,请稍后查询结果"}
@router.post("/run/sync", response_model=MatchingResultResponse)
async def run_matching_sync(
req: MatchingRequest,
db: AsyncSession = Depends(get_db),
):
"""Synchronous matching for direct result - use for small datasets."""
result = await run_matching(req.patient_id, req.trial_id, db)
return _result_to_response(result)
@router.get("/results", response_model=list[MatchingResultResponse])
async def list_results(
trial_id: str | None = None,
patient_id: str | None = None,
status: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
):
stmt = select(MatchingResult)
if trial_id:
stmt = stmt.where(MatchingResult.trial_id == trial_id)
if patient_id:
stmt = stmt.where(MatchingResult.patient_id == patient_id)
if status:
stmt = stmt.where(MatchingResult.status == status)
stmt = stmt.offset(skip).limit(limit).order_by(MatchingResult.matched_at.desc())
result = await db.execute(stmt)
results = result.scalars().all()
from app.models.patient import Patient
from app.models.trial import Trial
responses = []
for r in results:
p = await db.get(Patient, r.patient_id)
t = await db.get(Trial, r.trial_id)
# 使用 pydantic 转换并手动注入名称
res_dict = {
"id": r.id,
"patient_id": r.patient_id,
"trial_id": r.trial_id,
"patient_name": p.name_encrypted if p else "未知患者",
"trial_title": t.title if t else "未知试验",
"status": r.status.value,
"overall_score": r.overall_score,
"criterion_details": r.criterion_details or [],
"evidence_spans": r.evidence_spans or [],
"llm_model_used": r.llm_model_used,
"matched_at": r.matched_at.isoformat(),
"reviewed_by": r.reviewed_by,
"reviewed_at": r.reviewed_at.isoformat() if r.reviewed_at else None,
"review_notes": r.review_notes,
}
responses.append(MatchingResultResponse(**res_dict))
return responses
@router.get("/results/{result_id}", response_model=MatchingResultResponse)
async def get_result(result_id: str, db: AsyncSession = Depends(get_db)):
r = await db.get(MatchingResult, result_id)
if not r:
raise HTTPException(status_code=404, detail="Matching result not found")
return _result_to_response(r)
@router.put("/results/{result_id}/review", response_model=MatchingResultResponse)
async def review_result(
result_id: str,
req: ReviewRequest,
db: AsyncSession = Depends(get_db),
):
r = await db.get(MatchingResult, result_id)
if not r:
raise HTTPException(status_code=404, detail="Matching result not found")
r.reviewed_by = req.doctor_id
r.reviewed_at = datetime.utcnow()
r.review_notes = req.notes
if req.decision == "approve":
r.status = MatchStatus.eligible
elif req.decision == "reject":
r.status = MatchStatus.ineligible
await db.commit()
await db.refresh(r)
return _result_to_response(r)
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime
from app.database import get_db
from app.models.notification import Notification
from app.schemas.notification import NotificationResponse, UnreadCountResponse
router = APIRouter(prefix="/notifications", tags=["notifications"])
DEMO_DOCTOR_ID = "default_doctor"
def _to_response(n: Notification) -> NotificationResponse:
return NotificationResponse(
id=n.id,
matching_result_id=n.matching_result_id,
recipient_doctor_id=n.recipient_doctor_id,
title=n.title,
message=n.message,
is_read=n.is_read,
created_at=n.created_at.isoformat(),
read_at=n.read_at.isoformat() if n.read_at else None,
)
@router.get("/", response_model=list[NotificationResponse])
async def list_notifications(
doctor_id: str = Query(DEMO_DOCTOR_ID),
unread_only: bool = False,
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
):
stmt = select(Notification).where(Notification.recipient_doctor_id == doctor_id)
if unread_only:
stmt = stmt.where(Notification.is_read == False) # noqa: E712
stmt = stmt.offset(skip).limit(limit).order_by(Notification.created_at.desc())
result = await db.execute(stmt)
return [_to_response(n) for n in result.scalars().all()]
@router.get("/unread-count", response_model=UnreadCountResponse)
async def unread_count(
doctor_id: str = Query(DEMO_DOCTOR_ID),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(func.count(Notification.id)).where(
Notification.recipient_doctor_id == doctor_id,
Notification.is_read == False, # noqa: E712
)
)
return UnreadCountResponse(count=result.scalar() or 0)
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
async def mark_read(notification_id: str, db: AsyncSession = Depends(get_db)):
n = await db.get(Notification, notification_id)
if not n:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Notification not found")
n.is_read = True
n.read_at = datetime.utcnow()
await db.commit()
await db.refresh(n)
return _to_response(n)
@router.patch("/read-all")
async def mark_all_read(
doctor_id: str = Query(DEMO_DOCTOR_ID),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Notification).where(
Notification.recipient_doctor_id == doctor_id,
Notification.is_read == False, # noqa: E712
)
)
now = datetime.utcnow()
count = 0
for n in result.scalars().all():
n.is_read = True
n.read_at = now
count += 1
await db.commit()
return {"marked_read": count}
import uuid
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models.patient import Patient, GenderEnum
from app.schemas.fhir_patient import PatientCreate, PatientResponse
from app.services.masking_service import mask_patient
router = APIRouter(prefix="/patients", tags=["patients"])
@router.get("/", response_model=list[PatientResponse])
async def list_patients(
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
gender: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Patient)
if gender:
stmt = stmt.where(Patient.gender == gender)
stmt = stmt.offset(skip).limit(limit).order_by(Patient.created_at.desc())
result = await db.execute(stmt)
patients = result.scalars().all()
return [mask_patient(p) for p in patients]
@router.get("/count")
async def count_patients(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(func.count(Patient.id)))
return {"count": result.scalar()}
@router.get("/{patient_id}", response_model=PatientResponse)
async def get_patient(patient_id: str, db: AsyncSession = Depends(get_db)):
patient = await db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient not found")
return mask_patient(patient)
@router.post("/", response_model=PatientResponse, status_code=201)
async def create_patient(data: PatientCreate, db: AsyncSession = Depends(get_db)):
# Check for duplicate MRN
existing = await db.execute(select(Patient).where(Patient.mrn == data.mrn))
if existing.scalar():
raise HTTPException(status_code=409, detail="Patient with this MRN already exists")
patient = Patient(
id=str(uuid.uuid4()),
mrn=data.mrn,
name_encrypted=data.name_raw, # In production: encrypt with KMS
phone_encrypted=data.phone_raw or "",
id_number_encrypted=data.id_number_raw or "",
birth_date=data.birth_date,
gender=GenderEnum(data.gender),
fhir_conditions=data.fhir_conditions,
fhir_observations=data.fhir_observations,
fhir_medications=data.fhir_medications,
admission_note=data.admission_note,
lab_report_text=data.lab_report_text,
pathology_report=data.pathology_report,
)
db.add(patient)
await db.commit()
await db.refresh(patient)
return mask_patient(patient)
@router.put("/{patient_id}", response_model=PatientResponse)
async def update_patient(patient_id: str, data: PatientCreate, db: AsyncSession = Depends(get_db)):
patient = await db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient not found")
patient.mrn = data.mrn
patient.name_encrypted = data.name_raw
patient.phone_encrypted = data.phone_raw or ""
patient.id_number_encrypted = data.id_number_raw or ""
patient.birth_date = data.birth_date
patient.gender = GenderEnum(data.gender)
patient.fhir_conditions = data.fhir_conditions
patient.fhir_observations = data.fhir_observations
patient.fhir_medications = data.fhir_medications
patient.admission_note = data.admission_note
patient.lab_report_text = data.lab_report_text
patient.pathology_report = data.pathology_report
await db.commit()
await db.refresh(patient)
return mask_patient(patient)
@router.delete("/{patient_id}", status_code=204)
async def delete_patient(patient_id: str, db: AsyncSession = Depends(get_db)):
patient = await db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient not found")
await db.delete(patient)
await db.commit()
import uuid
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.trial import Trial, Criterion, CriterionType
from app.schemas.trial import TrialCreate, TrialResponse, CriterionCreate, CriterionResponse
router = APIRouter(prefix="/trials", tags=["trials"])
def _trial_to_response(trial: Trial, criteria: list[Criterion]) -> TrialResponse:
return TrialResponse(
id=trial.id,
title=trial.title,
sponsor=trial.sponsor,
phase=trial.phase,
status=trial.status.value,
description=trial.description,
nct_number=trial.nct_number,
target_enrollment=trial.target_enrollment,
criteria=[
CriterionResponse(
id=c.id,
trial_id=c.trial_id,
criterion_type=c.criterion_type.value,
text=c.text,
structured=c.structured,
order_index=c.order_index,
)
for c in criteria
],
created_at=trial.created_at.isoformat(),
)
@router.get("/", response_model=list[TrialResponse])
async def list_trials(
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
status: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Trial)
if status:
stmt = stmt.where(Trial.status == status)
stmt = stmt.offset(skip).limit(limit).order_by(Trial.created_at.desc())
result = await db.execute(stmt)
trials = result.scalars().all()
responses = []
for trial in trials:
crit_result = await db.execute(
select(Criterion).where(Criterion.trial_id == trial.id).order_by(Criterion.order_index)
)
criteria = crit_result.scalars().all()
responses.append(_trial_to_response(trial, criteria))
return responses
@router.get("/{trial_id}", response_model=TrialResponse)
async def get_trial(trial_id: str, db: AsyncSession = Depends(get_db)):
trial = await db.get(Trial, trial_id)
if not trial:
raise HTTPException(status_code=404, detail="Trial not found")
crit_result = await db.execute(
select(Criterion).where(Criterion.trial_id == trial_id).order_by(Criterion.order_index)
)
criteria = crit_result.scalars().all()
return _trial_to_response(trial, criteria)
@router.post("/", response_model=TrialResponse, status_code=201)
async def create_trial(data: TrialCreate, db: AsyncSession = Depends(get_db)):
trial = Trial(
id=str(uuid.uuid4()),
title=data.title,
sponsor=data.sponsor,
phase=data.phase,
status=data.status,
description=data.description,
nct_number=data.nct_number,
target_enrollment=data.target_enrollment,
)
db.add(trial)
await db.flush()
criteria = []
for i, c in enumerate(data.criteria):
criterion = Criterion(
id=str(uuid.uuid4()),
trial_id=trial.id,
criterion_type=CriterionType(c.criterion_type),
text=c.text,
structured=c.structured,
order_index=c.order_index if c.order_index else i,
)
db.add(criterion)
criteria.append(criterion)
await db.commit()
await db.refresh(trial)
return _trial_to_response(trial, criteria)
@router.post("/{trial_id}/criteria", response_model=CriterionResponse, status_code=201)
async def add_criterion(trial_id: str, data: CriterionCreate, db: AsyncSession = Depends(get_db)):
trial = await db.get(Trial, trial_id)
if not trial:
raise HTTPException(status_code=404, detail="Trial not found")
criterion = Criterion(
id=str(uuid.uuid4()),
trial_id=trial_id,
criterion_type=CriterionType(data.criterion_type),
text=data.text,
structured=data.structured,
order_index=data.order_index,
)
db.add(criterion)
await db.commit()
await db.refresh(criterion)
return CriterionResponse(
id=criterion.id,
trial_id=criterion.trial_id,
criterion_type=criterion.criterion_type.value,
text=criterion.text,
structured=criterion.structured,
order_index=criterion.order_index,
)
@router.put("/{trial_id}", response_model=TrialResponse)
async def update_trial(trial_id: str, data: TrialCreate, db: AsyncSession = Depends(get_db)):
trial = await db.get(Trial, trial_id)
if not trial:
raise HTTPException(status_code=404, detail="Trial not found")
trial.title = data.title
trial.sponsor = data.sponsor
trial.phase = data.phase
trial.status = data.status
trial.description = data.description
trial.nct_number = data.nct_number
trial.target_enrollment = data.target_enrollment
# 更新标准:先删除旧的,再添加新的(简化逻辑)
from sqlalchemy import delete
await db.execute(delete(Criterion).where(Criterion.trial_id == trial_id))
for i, c in enumerate(data.criteria):
criterion = Criterion(
id=str(uuid.uuid4()),
trial_id=trial.id,
criterion_type=CriterionType(c.criterion_type),
text=c.text,
structured=c.structured,
order_index=c.order_index if c.order_index else i,
)
db.add(criterion)
await db.commit()
await db.refresh(trial)
crit_result = await db.execute(
select(Criterion).where(Criterion.trial_id == trial.id).order_by(Criterion.order_index)
)
criteria = crit_result.scalars().all()
return _trial_to_response(trial, criteria)
@router.delete("/{trial_id}", status_code=204)
async def delete_trial(trial_id: str, db: AsyncSession = Depends(get_db)):
trial = await db.get(Trial, trial_id)
if not trial:
raise HTTPException(status_code=404, detail="Trial not found")
await db.delete(trial)
await db.commit()
@router.delete("/{trial_id}/criteria/{criterion_id}", status_code=204)
async def delete_criterion(trial_id: str, criterion_id: str, db: AsyncSession = Depends(get_db)):
criterion = await db.get(Criterion, criterion_id)
if not criterion or criterion.trial_id != trial_id:
raise HTTPException(status_code=404, detail="Criterion not found")
await db.delete(criterion)
await db.commit()
from app.core.config import settings # noqa: F401
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "development"
secret_key: str = "dev-secret-key-change-in-production"
database_url: str = "sqlite+aiosqlite:///./smart_recruitment.db"
openai_api_key: str = ""
anthropic_api_key: str = ""
llm_provider: str = "openai"
llm_model: str = "gpt-4o-mini"
llm_base_url: str | None = None
masking_salt: str = "default-salt-change-me"
settings = Settings()
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
async def init_db():
from app.models import patient, trial, matching, notification # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
from app.api.routes import patients, trials, matching, notifications, diagnoses
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="智能患者招募系统 API",
version="0.1.0",
description="基于AI的临床试验患者招募系统",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(patients.router, prefix="/api/v1")
app.include_router(trials.router, prefix="/api/v1")
app.include_router(matching.router, prefix="/api/v1")
app.include_router(notifications.router, prefix="/api/v1")
app.include_router(diagnoses.router, prefix="/api/v1")
@app.get("/health")
async def health():
return {"status": "ok", "service": "smart-recruitment"}
from app.models.patient import Patient, GenderEnum # noqa: F401
from app.models.trial import Trial, Criterion, TrialStatus, CriterionType # noqa: F401
from app.models.matching import MatchingResult, MatchStatus # noqa: F401
from app.models.notification import Notification # noqa: F401
from app.models.diagnosis import Diagnosis # noqa: F401
import uuid
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Diagnosis(Base):
__tablename__ = "diagnoses"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
description: Mapped[str] = mapped_column(String(255), index=True) # 诊断描述
icd10_code: Mapped[str] = mapped_column(String(50), index=True) # ICD-10 编码
def __repr__(self):
return f"<Diagnosis {self.description} ({self.icd10_code})>"
import uuid
import enum
from datetime import datetime
from sqlalchemy import String, Text, JSON, Enum as SAEnum, DateTime, ForeignKey, Float
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MatchStatus(str, enum.Enum):
eligible = "eligible"
ineligible = "ineligible"
pending_review = "pending_review"
needs_more_info = "needs_more_info"
class MatchingResult(Base):
__tablename__ = "matching_results"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
patient_id: Mapped[str] = mapped_column(ForeignKey("patients.id"))
trial_id: Mapped[str] = mapped_column(ForeignKey("trials.id"))
status: Mapped[MatchStatus] = mapped_column(SAEnum(MatchStatus))
overall_score: Mapped[float] = mapped_column(Float)
# Per-criterion results with reasoning (List[CriterionMatchDetail] as JSON)
criterion_details: Mapped[list] = mapped_column(JSON, default=list)
# Evidence spans with character offsets into source text
evidence_spans: Mapped[list] = mapped_column(JSON, default=list)
llm_model_used: Mapped[str] = mapped_column(String(100))
matched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
reviewed_by: Mapped[str] = mapped_column(String(255), nullable=True)
reviewed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
review_notes: Mapped[str] = mapped_column(Text, nullable=True)
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
matching_result_id: Mapped[str] = mapped_column(ForeignKey("matching_results.id"))
recipient_doctor_id: Mapped[str] = mapped_column(String(255))
title: Mapped[str] = mapped_column(String(500))
message: Mapped[str] = mapped_column(Text)
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
read_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
import uuid
import enum
from datetime import datetime, date
from sqlalchemy import String, Text, Date, JSON, Enum as SAEnum, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class GenderEnum(str, enum.Enum):
male = "male"
female = "female"
other = "other"
unknown = "unknown"
class Patient(Base):
__tablename__ = "patients"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
mrn: Mapped[str] = mapped_column(String(50), unique=True, index=True)
# PII - masked before API response
name_encrypted: Mapped[str] = mapped_column(String(255))
phone_encrypted: Mapped[str] = mapped_column(String(255), nullable=True)
id_number_encrypted: Mapped[str] = mapped_column(String(255), nullable=True)
# Non-PII clinical fields
birth_date: Mapped[date] = mapped_column(Date)
gender: Mapped[GenderEnum] = mapped_column(SAEnum(GenderEnum))
# FHIR R4 structured data (JSON blobs)
fhir_conditions: Mapped[list] = mapped_column(JSON, default=list)
fhir_observations: Mapped[list] = mapped_column(JSON, default=list)
fhir_medications: Mapped[list] = mapped_column(JSON, default=list)
# Raw clinical text for LLM NER
admission_note: 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)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
import uuid
import enum
from datetime import datetime
from sqlalchemy import String, Text, JSON, Enum as SAEnum, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TrialStatus(str, enum.Enum):
recruiting = "recruiting"
closed = "closed"
completed = "completed"
suspended = "suspended"
class CriterionType(str, enum.Enum):
inclusion = "inclusion"
exclusion = "exclusion"
class Trial(Base):
__tablename__ = "trials"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title: Mapped[str] = mapped_column(String(500))
sponsor: Mapped[str] = mapped_column(String(255))
phase: Mapped[str] = mapped_column(String(10))
status: Mapped[TrialStatus] = mapped_column(SAEnum(TrialStatus), default=TrialStatus.recruiting)
description: Mapped[str] = mapped_column(Text, nullable=True)
nct_number: Mapped[str] = mapped_column(String(20), nullable=True, unique=True)
target_enrollment: Mapped[int] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
criteria: Mapped[list["Criterion"]] = relationship("Criterion", back_populates="trial", cascade="all, delete-orphan")
class Criterion(Base):
__tablename__ = "criteria"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
trial_id: Mapped[str] = mapped_column(ForeignKey("trials.id", ondelete="CASCADE"))
criterion_type: Mapped[CriterionType] = mapped_column(SAEnum(CriterionType))
text: Mapped[str] = mapped_column(Text)
structured: Mapped[dict] = mapped_column(JSON, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0)
trial: Mapped["Trial"] = relationship("Trial", back_populates="criteria")
from app.schemas.fhir_patient import PatientResponse # noqa: F401
from app.schemas.trial import TrialResponse, CriterionResponse # noqa: F401
from app.schemas.matching import MatchingResultResponse # noqa: F401
from app.schemas.notification import NotificationResponse # noqa: F401
from pydantic import BaseModel
class DiagnosisBase(BaseModel):
description: str
icd10_code: str
class DiagnosisCreate(DiagnosisBase):
pass
class DiagnosisResponse(DiagnosisBase):
id: str
model_config = {"from_attributes": True}
from pydantic import BaseModel, Field
from typing import Literal
from datetime import date
class FHIRCoding(BaseModel):
system: str
code: str
display: str
class FHIRCondition(BaseModel):
resource_type: Literal["Condition"] = "Condition"
coding: list[FHIRCoding]
clinical_status: str = "active"
onset_date: date | None = None
class FHIRObservation(BaseModel):
resource_type: Literal["Observation"] = "Observation"
code: FHIRCoding
value_quantity: float | None = None
value_unit: str | None = None
value_string: str | None = None
effective_date: date | None = None
status: str = "final"
class PatientCreate(BaseModel):
mrn: str
name_raw: str
phone_raw: str | None = None
id_number_raw: str | None = None
birth_date: date
gender: str
admission_note: str | None = None
lab_report_text: str | None = None
pathology_report: str | None = None
fhir_conditions: list[dict] = Field(default_factory=list)
fhir_observations: list[dict] = Field(default_factory=list)
fhir_medications: list[dict] = Field(default_factory=list)
class PatientResponse(BaseModel):
id: str
mrn: str
display_name: str
birth_date: date
age: int
gender: str
conditions: list[dict]
observations: list[dict]
medications: list[dict]
admission_note: str | None = None
lab_report_text: str | None = None
pathology_report: str | None = None
has_admission_note: bool
has_lab_report: bool
has_pathology_report: bool
created_at: str
model_config = {"from_attributes": True}
from pydantic import BaseModel
class CriterionMatchDetail(BaseModel):
criterion_id: str
criterion_text: str
criterion_type: str
result: str # "met" | "not_met" | "uncertain"
score: float
reasoning: str # Required: LLM medical reasoning
evidence_texts: list[str] = []
class EvidenceSpan(BaseModel):
source_field: str
start: int
end: int
text: str
criterion_id: str
class MatchingRequest(BaseModel):
patient_id: str
trial_id: str
force_refresh: bool = False
class MatchingResultResponse(BaseModel):
id: str
patient_id: str
trial_id: str
patient_name: str | None = None
trial_title: str | None = None
status: str
overall_score: float
criterion_details: list[dict]
evidence_spans: list[dict]
llm_model_used: str
matched_at: str
reviewed_by: str | None = None
reviewed_at: str | None = None
review_notes: str | None = None
model_config = {"from_attributes": True}
class ReviewRequest(BaseModel):
doctor_id: str
decision: str # "approve" | "reject"
notes: str | None = None
from pydantic import BaseModel
class NotificationResponse(BaseModel):
id: str
matching_result_id: str
recipient_doctor_id: str
title: str
message: str
is_read: bool
created_at: str
read_at: str | None = None
model_config = {"from_attributes": True}
class UnreadCountResponse(BaseModel):
count: int
from pydantic import BaseModel
from datetime import datetime
class CriterionBase(BaseModel):
criterion_type: str
text: str
structured: dict | None = None
order_index: int = 0
class CriterionCreate(CriterionBase):
pass
class CriterionResponse(CriterionBase):
id: str
trial_id: str
model_config = {"from_attributes": True}
class TrialCreate(BaseModel):
title: str
sponsor: str
phase: str
status: str = "recruiting"
description: str | None = None
nct_number: str | None = None
target_enrollment: int | None = None
criteria: list[CriterionCreate] = []
class TrialResponse(BaseModel):
id: str
title: str
sponsor: str
phase: str
status: str
description: str | None
nct_number: str | None
target_enrollment: int | None
criteria: list[CriterionResponse] = []
created_at: str
model_config = {"from_attributes": True}
import json
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage
from app.core.config import settings
IE_MATCHING_SYSTEM_PROMPT = """你是临床试验入排标准评估专家AI。评估患者是否符合临床试验的入组/排除标准。
核心规则:
1. 仅基于明确记录的临床信息进行判断,不推断或假设缺失信息为阳性
2. 每条标准必须提供具体医学推理,引用原始病历中的具体证据
3. 发现单位不一致时必须进行标准化换算(如 μmol/L 转 mg/dL:肌酐乘以0.0113,葡萄糖乘以0.0556)
4. 信息不足时标记为 "uncertain",绝不猜测
5. 引用支持判断的原始文本(精确引用)
输出格式(严格JSON):
{
"overall_status": "eligible" | "ineligible" | "needs_more_info",
"overall_score": 0.0-1.0,
"criteria_results": [
{
"criterion_id": "<id>",
"criterion_text": "<原文>",
"criterion_type": "inclusion" | "exclusion",
"result": "met" | "not_met" | "uncertain",
"score": 0.0-1.0,
"reasoning": "<详细医学推理,含单位换算过程>",
"evidence_quotes": ["<原始文本精确引用>"]
}
]
}"""
def get_llm():
if settings.llm_provider == "anthropic":
return ChatAnthropic(
model=settings.llm_model,
api_key=settings.anthropic_api_key,
base_url=settings.llm_base_url,
max_tokens=4096,
)
return ChatOpenAI(
model=settings.llm_model,
api_key=settings.openai_api_key,
base_url=settings.llm_base_url,
temperature=0,
)
async def run_ie_matching(
patient_clinical_text: dict,
criteria: list[dict],
) -> dict:
llm = get_llm()
patient_section = "\n\n".join([
f"=== {k.upper().replace('_', ' ')} ===\n{v}"
for k, v in patient_clinical_text.items() if v
])
criteria_section = "\n".join([
f"[{c['criterion_type'].upper()} #{i+1}] (ID: {c['id']}): {c['text']}"
for i, c in enumerate(criteria)
])
user_message = f"""患者临床数据:
{patient_section}
待评估试验标准:
{criteria_section}
请逐条评估每项标准并返回指定格式的JSON。"""
messages = [
SystemMessage(content=IE_MATCHING_SYSTEM_PROMPT),
HumanMessage(content=user_message),
]
response = await llm.ainvoke(messages)
content = response.content
# Extract JSON from response (handle markdown code blocks)
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
return json.loads(content)
import hashlib
from datetime import date
from app.models.patient import Patient
from app.schemas.fhir_patient import PatientResponse
from app.core.config import settings
def _hash_token(value: str) -> str:
"""One-way deterministic token for pseudonymization."""
return hashlib.sha256(f"{settings.masking_salt}{value}".encode()).hexdigest()[:8].upper()
def _compute_age(birth_date: date) -> int:
today = date.today()
return today.year - birth_date.year - (
(today.month, today.day) < (birth_date.month, birth_date.day)
)
def mask_patient(patient: Patient) -> PatientResponse:
"""
直接返回原始姓名和 MRN,不再进行脱敏处理。
"""
return PatientResponse(
id=patient.id,
mrn=patient.mrn,
display_name=patient.name_encrypted,
birth_date=patient.birth_date,
age=_compute_age(patient.birth_date),
gender=patient.gender.value,
conditions=patient.fhir_conditions or [],
observations=patient.fhir_observations or [],
medications=patient.fhir_medications or [],
admission_note=patient.admission_note,
lab_report_text=patient.lab_report_text,
pathology_report=patient.pathology_report,
has_admission_note=bool(patient.admission_note),
has_lab_report=bool(patient.lab_report_text),
has_pathology_report=bool(patient.pathology_report),
created_at=patient.created_at.isoformat(),
)
import uuid
from datetime import datetime, date
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.patient import Patient
from app.models.trial import Trial, Criterion
from app.models.matching import MatchingResult, MatchStatus
from app.models.notification import Notification
from app.services.llm_service import run_ie_matching
from app.core.config import settings
UNIT_CONVERSIONS: dict[tuple[str, str], float] = {
("umol/l", "mg/dl"): 0.0113, # 肌酐
("mmol/l", "mg/dl"): 18.0, # 葡萄糖
("mmol/l", "meq/l"): 1.0, # 电解质
}
def standardize_value(value: float, from_unit: str, to_unit: str) -> float:
key = (from_unit.lower(), to_unit.lower())
if key in UNIT_CONVERSIONS:
return value * UNIT_CONVERSIONS[key]
return value
def _compute_age(birth_date: date) -> int:
today = date.today()
return today.year - birth_date.year - (
(today.month, today.day) < (birth_date.month, birth_date.day)
)
def _extract_evidence_spans(clinical_texts: dict, criteria_results: list) -> list:
"""Map LLM evidence quotes back to character offsets in source text."""
spans = []
for cr in criteria_results:
for quote in cr.get("evidence_quotes", []):
if not quote:
continue
for field, text in clinical_texts.items():
if not text:
continue
idx = text.find(quote)
if idx != -1:
spans.append({
"source_field": field,
"start": idx,
"end": idx + len(quote),
"text": quote,
"criterion_id": cr["criterion_id"],
})
break
return spans
async def run_matching(
patient_id: str,
trial_id: str,
db: AsyncSession,
) -> MatchingResult:
# Load patient
patient = await db.get(Patient, patient_id)
if not patient:
raise ValueError(f"Patient {patient_id} not found")
# Load trial + criteria
trial = await db.get(Trial, trial_id)
if not trial:
raise ValueError(f"Trial {trial_id} not found")
result_criteria = await db.execute(
select(Criterion).where(Criterion.trial_id == trial_id).order_by(Criterion.order_index)
)
criteria = result_criteria.scalars().all()
# Build patient context for LLM (include basic info for age/gender checks)
patient_info = {
"gender": "男" if patient.gender.value == "male" else "女",
"age": _compute_age(patient.birth_date),
}
clinical_texts = {
"patient_basic_info": f"性别:{patient_info['gender']},年龄:{patient_info['age']}岁",
"admission_note": patient.admission_note or "",
"lab_report_text": patient.lab_report_text or "",
"pathology_report": patient.pathology_report or "",
}
criteria_list = [
{"id": c.id, "criterion_type": c.criterion_type.value, "text": c.text}
for c in criteria
]
# Run LLM matching
llm_result = await run_ie_matching(
patient_clinical_text=clinical_texts,
criteria=criteria_list,
)
# Map LLM criteria results to match our criterion IDs
for cr in llm_result.get("criteria_results", []):
cr["criterion_id"] = cr.get("criterion_id", "")
cr["evidence_texts"] = cr.pop("evidence_quotes", [])
evidence_spans = _extract_evidence_spans(
clinical_texts, llm_result.get("criteria_results", [])
)
# Map status
status_map = {
"eligible": MatchStatus.eligible,
"ineligible": MatchStatus.ineligible,
"needs_more_info": MatchStatus.needs_more_info,
}
match_status = status_map.get(llm_result.get("overall_status", ""), MatchStatus.pending_review)
matching_result = MatchingResult(
id=str(uuid.uuid4()),
patient_id=patient_id,
trial_id=trial_id,
status=match_status,
overall_score=llm_result.get("overall_score", 0.0),
criterion_details=llm_result.get("criteria_results", []),
evidence_spans=evidence_spans,
llm_model_used=settings.llm_model,
)
db.add(matching_result)
await db.flush()
# Create doctor notification
notification = Notification(
id=str(uuid.uuid4()),
matching_result_id=matching_result.id,
recipient_doctor_id="default_doctor",
title="潜在受试者推荐",
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)
await db.commit()
await db.refresh(matching_result)
return matching_result
[project]
name = "smart-recruitment-backend"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.111.0",
"uvicorn[standard]>=0.29.0",
"sqlalchemy>=2.0.0",
"aiosqlite>=0.20.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.2.0",
"langchain>=0.2.0",
"langchain-openai>=0.1.0",
"langchain-anthropic>=0.1.0",
"python-dotenv>=1.0.0",
"httpx>=0.27.0",
]
[tool.uv]
dev-dependencies = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
]
[tool.hatch.build.targets.wheel]
packages = ["app"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# -*- coding: utf-8 -*-
import asyncio
import sys
import os
import uuid
from datetime import date, datetime
sys.path.insert(0, os.path.dirname(__file__))
from app.database import init_db, AsyncSessionLocal
from app.models.patient import Patient, GenderEnum
from app.models.trial import Trial, Criterion, CriterionType, TrialStatus
PATIENTS = [
{
"id": str(uuid.uuid4()),
"mrn": "PAT20240001",
"name_encrypted": "张三",
"phone_encrypted": "13800138001",
"birth_date": date(1968, 5, 15),
"gender": GenderEnum.male,
"fhir_conditions": [
{
"resource_type": "Condition",
"coding": [{"system": "http://hl7.org/fhir/sid/icd-10", "code": "C34.1", "display": "肺上叶恶性肿瘤"}],
"clinical_status": "active",
}
],
"fhir_observations": [
{
"resource_type": "Observation",
"code": {"system": "http://loinc.org", "code": "10334-1", "display": "EGFR基因突变"},
"value_string": "EGFR外显子19缺失突变阳性",
"status": "final",
}
],
"admission_note": "患者张某,男,56岁,主因咳嗽、咳血2个月入院。胸部CT示右上肺叶占位性病变,大小约3.2x2.8cm。ECOG评分1分。既往无慢性病史,无吸烟史。血压128/82mmHg,心率76次/分。",
"lab_report_text": "血常规:WBC 6.2x10^9/L,HGB 132g/L,PLT 215x10^9/L。肝功:ALT 28U/L,AST 32U/L。肾功:肌酐78umol/L。EGFR基因检测:阳性。",
"pathology_report": "肺腺癌,中低分化。EGFR外显子19缺失突变阳性。",
},
{
"id": str(uuid.uuid4()),
"mrn": "PAT20240002",
"name_encrypted": "李四",
"phone_encrypted": "13900139002",
"birth_date": date(1955, 11, 30),
"gender": GenderEnum.female,
"fhir_conditions": [
{
"resource_type": "Condition",
"coding": [{"system": "http://hl7.org/fhir/sid/icd-10", "code": "C50.9", "display": "乳腺恶性肿瘤"}],
"clinical_status": "active",
}
],
"fhir_observations": [
{
"resource_type": "Observation",
"code": {"system": "http://loinc.org", "code": "85319-2", "display": "HER2状态"},
"value_string": "HER2阳性(3+)",
"status": "final",
}
],
"admission_note": "患者李某,女,68岁,主因发现右乳肿物3个月入院。乳腺超声示右乳外上象限低回声肿物。ECOG评分0分。",
"lab_report_text": "HER2免疫组化3+,FISH扩增阳性。ER阴性,PR阴性。",
"pathology_report": "浸润性导管癌,II级。HER2(3+)。",
}
]
TRIALS = [
{
"id": str(uuid.uuid4()),
"title": "奥希替尼联合化疗一线治疗EGFR突变晚期非小细胞肺癌III期临床研究",
"sponsor": "阿斯利康制药",
"phase": "III",
"status": TrialStatus.recruiting,
"nct_number": "NCT05234567",
"target_enrollment": 120,
"description": "评估奥希替尼联合化疗对比单药治疗EGFR突变晚期NSCLC的疗效。",
"criteria": [
("inclusion", "晚期(IIIB/IV期)非小细胞肺癌"),
("inclusion", "EGFR敏感突变阳性"),
("inclusion", "年龄18-75岁"),
("exclusion", "已知脑转移且症状未控制"),
],
}
]
async def seed():
await init_db()
async with AsyncSessionLocal() as db:
from sqlalchemy import select, func
count = await db.execute(select(func.count(Patient.id)))
if count.scalar() > 0:
print("Database already seeded.")
return
print("Creating patients...")
for p_data in PATIENTS:
patient = Patient(**p_data)
db.add(patient)
print("Creating trials...")
for t_data in TRIALS:
criteria_data = t_data.pop("criteria")
trial = Trial(**t_data)
db.add(trial)
await db.flush()
for i, (ctype, text) in enumerate(criteria_data):
criterion = Criterion(
id=str(uuid.uuid4()),
trial_id=trial.id,
criterion_type=CriterionType(ctype),
text=text,
order_index=i,
)
db.add(criterion)
await db.commit()
print("Successfully seeded database.")
if __name__ == "__main__":
asyncio.run(seed())
# -*- coding: utf-8 -*-
import asyncio
import os
import sys
# 设置路径
sys.path.insert(0, os.path.dirname(__file__))
from app.database import AsyncSessionLocal
from app.models.patient import Patient
from app.models.trial import Trial
from app.services.matching_service import run_matching
from sqlalchemy import select
async def test_ai_match():
print("Starting AI matching logic test...")
async with AsyncSessionLocal() as db:
# 1. Get test patient
patient_result = await db.execute(select(Patient).filter(Patient.name_encrypted == "张三"))
patient = patient_result.scalar()
# 2. Get test trial
trial_result = await db.execute(select(Trial).limit(1))
trial = trial_result.scalar()
if not patient or not trial:
print("Error: DB is empty. Run seed.py first.")
return
print(f"Testing Patient: [{patient.name_encrypted}] vs Trial: [{trial.title[:20]}...]")
print("Calling LLM for medical reasoning...")
try:
# 3. Run matching
result = await run_matching(patient.id, trial.id, db)
print("\nMatch Task Completed!")
print(f"Overall Score: {result.overall_score * 100:.0f}%")
print(f"Status: {result.status.value}")
print("\nReasoning Details (First 2):")
for detail in result.criterion_details[:2]:
res_str = "Met" if detail['result'] == 'met' else "Not Met" if detail['result'] == 'not_met' else "Uncertain"
print(f"- Criterion: {detail['criterion_text'][:30]}...")
print(f" Result: {res_str} ({int(detail['score']*100)}%)")
print(f" Reasoning: {detail['reasoning']}")
print(f"\nEvidence Spans: {len(result.evidence_spans)} snippets found.")
except Exception as e:
print(f"Match Failed: {str(e)}")
if __name__ == "__main__":
asyncio.run(test_ai_match())
This source diff could not be displayed because it is too large. You can view the blob instead.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能患者招募系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "smart-recruitment-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.23.0",
"@mui/material": "^6.0.0",
"@mui/x-data-grid": "^7.0.0",
"@mui/icons-material": "^6.0.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ThemeProvider, CssBaseline } from '@mui/material'
import { medicalTheme } from './theme/medicalTheme'
import { AppShell } from './components/layout/AppShell'
import { PatientsPage } from './pages/PatientsPage'
import { TrialsPage } from './pages/TrialsPage'
import { MatchingPage } from './pages/MatchingPage'
import { WorkStationPage } from './pages/WorkStationPage'
import { DiagnosesPage } from './pages/DiagnosesPage'
export default function App() {
return (
<ThemeProvider theme={medicalTheme}>
<CssBaseline />
<BrowserRouter>
<Routes>
<Route path="/" element={<AppShell />}>
<Route index element={<Navigate to="/patients" replace />} />
<Route path="patients" element={<PatientsPage />} />
<Route path="trials" element={<TrialsPage />} />
<Route path="diagnoses" element={<DiagnosesPage />} />
<Route path="matching" element={<MatchingPage />} />
<Route path="workstation" element={<WorkStationPage />} />
</Route>
</Routes>
</BrowserRouter>
</ThemeProvider>
)
}
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import { Box } from '@mui/material'
import { Sidebar } from './Sidebar'
import { TopBar } from './TopBar'
const DRAWER_WIDTH = 240
export function AppShell() {
const [mobileOpen, setMobileOpen] = useState(false)
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<TopBar drawerWidth={DRAWER_WIDTH} onMenuClick={() => setMobileOpen(true)} />
<Sidebar drawerWidth={DRAWER_WIDTH} mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
mt: 8,
minHeight: '100vh',
background: '#f4f6f8',
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` }
}}
>
<Outlet />
</Box>
</Box>
)
}
import { useNavigate, useLocation } from 'react-router-dom'
import {
Drawer, List, ListItem, ListItemButton, ListItemIcon, ListItemText,
Toolbar, Typography, Divider, Box,
} from '@mui/material'
import PeopleIcon from '@mui/icons-material/People'
import ScienceIcon from '@mui/icons-material/Science'
import PsychologyIcon from '@mui/icons-material/Psychology'
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
import LocalHospitalIcon from '@mui/icons-material/LocalHospital'
const NAV_ITEMS = [
{ label: '患者管理', path: '/patients', icon: <PeopleIcon /> },
{ label: '临床试验', path: '/trials', icon: <ScienceIcon /> },
{ label: '诊断管理', path: '/diagnoses', icon: <LocalHospitalIcon /> },
{ label: 'AI 智能匹配', path: '/matching', icon: <PsychologyIcon /> },
{ label: '医生工作站', path: '/workstation', icon: <MonitorHeartIcon /> },
]
interface SidebarProps {
drawerWidth: number
mobileOpen: boolean
onClose: () => void
}
export function Sidebar({ drawerWidth, mobileOpen, onClose }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const drawerContent = (
<Box>
<Toolbar>
<Typography variant="h6" color="primary" fontWeight={700} noWrap>
智能招募系统
</Typography>
</Toolbar>
<Divider />
<List>
{NAV_ITEMS.map(item => (
<ListItem key={item.path} disablePadding>
<ListItemButton
selected={location.pathname.startsWith(item.path)}
onClick={() => { navigate(item.path); onClose() }}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'white',
'& .MuiListItemIcon-root': { color: 'white' },
'&:hover': { backgroundColor: 'primary.dark' },
},
borderRadius: 1,
mx: 1,
mb: 0.5,
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
)
return (
<Box component="nav" sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{ display: { xs: 'block', sm: 'none' }, '& .MuiDrawer-paper': { width: drawerWidth } }}
>
{drawerContent}
</Drawer>
<Drawer
variant="permanent"
sx={{ display: { xs: 'none', sm: 'block' }, '& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box' } }}
open
>
{drawerContent}
</Drawer>
</Box>
)
}
import { useEffect, useState } from 'react'
import { AppBar, Toolbar, Typography, IconButton, Badge, Box } from '@mui/material'
import MenuIcon from '@mui/icons-material/Menu'
import NotificationsIcon from '@mui/icons-material/Notifications'
import { useNavigate } from 'react-router-dom'
import { notificationService } from '../../services/notificationService'
interface TopBarProps {
drawerWidth: number
onMenuClick: () => void
}
export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
const [unreadCount, setUnreadCount] = useState(0)
const navigate = useNavigate()
useEffect(() => {
const fetchCount = () => {
notificationService.unreadCount()
.then(r => setUnreadCount(r.count))
.catch(() => {})
}
fetchCount()
const interval = setInterval(fetchCount, 30000)
return () => clearInterval(interval)
}, [])
return (
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
zIndex: (theme) => theme.zIndex.drawer + 1
}}
>
<Toolbar>
<IconButton color="inherit" edge="start" onClick={onMenuClick} sx={{ mr: 2, display: { sm: 'none' } }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
智能患者招募系统
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ opacity: 0.8 }}>医疗蓝 v0.1</Typography>
<IconButton color="inherit" onClick={() => navigate('/workstation')}>
<Badge badgeContent={unreadCount} color="error">
<NotificationsIcon />
</Badge>
</IconButton>
</Box>
</Toolbar>
</AppBar>
)
}
import { useMemo } from 'react'
import { Box, Paper, Typography } from '@mui/material'
import type { EvidenceSpan } from '../../types/matching'
interface EvidencePanelProps {
sourceText: string
sourceField: string
spans: EvidenceSpan[]
selectedCriterionId?: string
}
interface Segment {
text: string
isHighlighted: boolean
criterionType?: string
criterionId?: string
}
function buildSegments(text: string, spans: EvidenceSpan[], selectedId?: string): Segment[] {
if (!text) return []
// Filter spans for this source field and optionally by selected criterion
const relevant = spans.filter(s =>
(!selectedId || s.criterion_id === selectedId)
).sort((a, b) => a.start - b.start)
if (relevant.length === 0) return [{ text, isHighlighted: false }]
const segments: Segment[] = []
let cursor = 0
for (const span of relevant) {
if (span.start > cursor) {
segments.push({ text: text.slice(cursor, span.start), isHighlighted: false })
}
if (span.end > span.start) {
segments.push({
text: text.slice(span.start, span.end),
isHighlighted: true,
criterionId: span.criterion_id,
})
}
cursor = Math.max(cursor, span.end)
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), isHighlighted: false })
}
return segments
}
const fieldLabels: Record<string, string> = {
admission_note: '入院记录',
lab_report_text: '检验报告',
pathology_report: '病理报告',
}
export function EvidencePanel({ sourceText, sourceField, spans, selectedCriterionId }: EvidencePanelProps) {
const segments = useMemo(
() => buildSegments(sourceText, spans, selectedCriterionId),
[sourceText, spans, selectedCriterionId]
)
if (!sourceText) {
return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">暂无{fieldLabels[sourceField] ?? sourceField}文本</Typography>
</Paper>
)
}
return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="caption" color="text.secondary" display="block" mb={1} fontWeight={600}>
{fieldLabels[sourceField] ?? sourceField} — 高亮部分为匹配依据
</Typography>
<Box
sx={{
fontFamily: 'monospace',
fontSize: 13,
lineHeight: 2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 300,
overflow: 'auto',
}}
>
{segments.map((seg, i) =>
seg.isHighlighted ? (
<Box
key={i}
component="mark"
sx={{
backgroundColor: '#fff176',
borderRadius: '3px',
px: 0.5,
border: '1px solid #f9a825',
cursor: 'default',
}}
>
{seg.text}
</Box>
) : (
<span key={i}>{seg.text}</span>
)
)}
</Box>
</Paper>
)
}
import {
Drawer, Box, Typography, Divider, Stack, Button,
Alert, AlertTitle, TextField, IconButton
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import { useState, useEffect } from 'react'
import { MatchingResultCard } from './MatchingResultCard'
import { ReasoningTimeline } from './ReasoningTimeline'
import type { MatchingResult } from '../../types/matching'
import { matchingService } from '../../services/matchingService'
interface MatchingDetailDrawerProps {
result: MatchingResult | null
open: boolean
onClose: () => void
onAuditSuccess: () => void
}
export function MatchingDetailDrawer({ result, open, onClose, onAuditSuccess }: MatchingDetailDrawerProps) {
const [notes, setNotes] = useState('')
const [loading, setLoading] = useState(false)
// 当结果切换时重置备注
useEffect(() => {
setNotes(result?.review_notes || '')
}, [result])
if (!result) return null
const handleAudit = async (decision: 'approve' | 'reject') => {
setLoading(true)
try {
await matchingService.review(result.id, {
doctor_id: 'default_doctor',
decision,
notes
})
onAuditSuccess()
onClose()
} catch (error) {
console.error('Audit failed:', error)
alert('审核操作失败')
} finally {
setLoading(false)
}
}
return (
<Drawer anchor="right" open={open} onClose={onClose} PaperProps={{ sx: { width: 600 } }}>
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
<Box>
<Typography variant="h6">临床匹配复核</Typography>
<Typography variant="caption" color="text.secondary">
ID: {result.id.slice(0, 8)}... · 匹配于 {new Date(result.matched_at).toLocaleString('zh-CN')}
</Typography>
</Box>
<IconButton onClick={onClose}><CloseIcon /></IconButton>
</Stack>
<Alert severity={result.status === 'eligible' ? "success" : "info"} sx={{ mb: 3 }}>
<AlertTitle>AI 预判状态:{result.status === 'eligible' ? '符合入组' : '建议人工核对'}</AlertTitle>
请复核下方 AI 生成的推理依据及病历证据。
</Alert>
<Stack spacing={3}>
{/* 匹配总结卡片 */}
<MatchingResultCard result={result} />
<Divider />
{/* 医学推理分析 */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight={600} color="primary">
▶ 医学推理分析
</Typography>
<ReasoningTimeline details={result.criterion_details} />
</Box>
<Divider />
{/* 医生审核操作区 */}
<Box sx={{ bgcolor: '#f8fafc', p: 2, borderRadius: 2, border: '1px solid #e2e8f0' }}>
<Typography variant="subtitle2" gutterBottom fontWeight={600}>医生复核决策</Typography>
<TextField
fullWidth multiline rows={3}
placeholder="请输入审核理由或临床意见 (选填)..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
sx={{ mb: 2, bgcolor: 'white' }}
size="small"
/>
<Stack direction="row" spacing={2}>
<Button
fullWidth variant="contained" color="success"
startIcon={<CheckCircleIcon />}
disabled={loading}
onClick={() => handleAudit('approve')}
>
审核通过 (确认入组)
</Button>
<Button
fullWidth variant="outlined" color="error"
startIcon={<CancelIcon />}
disabled={loading}
onClick={() => handleAudit('reject')}
>
审核驳回 (不符合)
</Button>
</Stack>
</Box>
</Stack>
</Box>
</Drawer>
)
}
import { Box, Card, CardContent, Typography, LinearProgress, Stack, Chip } from '@mui/material'
import type { MatchingResult } from '../../types/matching'
import { StatusChip } from '../shared/StatusChip'
interface MatchingResultCardProps {
result: MatchingResult
}
export function MatchingResultCard({ result }: MatchingResultCardProps) {
const scorePct = Math.round(result.overall_score * 100)
const scoreColor = scorePct >= 70 ? 'success' : scorePct >= 40 ? 'warning' : 'error'
return (
<Card>
<CardContent>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6">AI 匹配总结</Typography>
<StatusChip status={result.status} />
</Stack>
<Box mb={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={0.5}>
<Typography variant="body2" color="text.secondary">综合匹配评分</Typography>
<Typography variant="h5" fontWeight={700} color={`${scoreColor}.main`}>
{scorePct}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={scorePct}
color={scoreColor}
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label={`符合 ${result.criterion_details.filter(d => d.result === 'met').length} 项`}
size="small" color="success" variant="outlined"
/>
<Chip
label={`不符合 ${result.criterion_details.filter(d => d.result === 'not_met').length} 项`}
size="small" color="error" variant="outlined"
/>
<Chip
label={`不确定 ${result.criterion_details.filter(d => d.result === 'uncertain').length} 项`}
size="small" color="warning" variant="outlined"
/>
</Stack>
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
模型:{result.llm_model_used} · {new Date(result.matched_at).toLocaleString('zh-CN')}
</Typography>
</CardContent>
</Card>
)
}
import { Box, Step, StepLabel, Stepper, Typography } from '@mui/material'
const STEPS = ['选择患者与试验', 'AI 分析中', '结果与证据']
interface MatchingStepperProps {
activeStep: number
}
export function MatchingStepper({ activeStep }: MatchingStepperProps) {
return (
<Box sx={{ mb: 3 }}>
<Stepper activeStep={activeStep} alternativeLabel>
{STEPS.map(label => (
<Step key={label}>
<StepLabel>
<Typography variant="caption">{label}</Typography>
</StepLabel>
</Step>
))}
</Stepper>
</Box>
)
}
import {
Stepper, Step, StepLabel, StepContent,
Typography, Alert, LinearProgress, Box, Chip, Stack,
} from '@mui/material'
import type { CriterionMatchDetail } from '../../types/matching'
interface ReasoningTimelineProps {
details: CriterionMatchDetail[]
selectedId?: string
onSelect?: (id: string) => void
}
type AlertColor = 'success' | 'error' | 'warning' | 'info'
const resultConfig: Record<string, { color: AlertColor; label: string }> = {
met: { color: 'success', label: '符合' },
not_met: { color: 'error', label: '不符合' },
uncertain: { color: 'warning', label: '不确定' },
}
export function ReasoningTimeline({ details, selectedId, onSelect }: ReasoningTimelineProps) {
if (details.length === 0) return null
return (
<Stepper orientation="vertical" nonLinear sx={{ '& .MuiStepConnector-line': { minHeight: 8 } }}>
{details.map((detail) => {
const cfg = resultConfig[detail.result] ?? { color: 'info' as AlertColor, label: detail.result }
const isSelected = selectedId === detail.criterion_id
return (
<Step key={detail.criterion_id} active expanded>
<StepLabel
onClick={() => onSelect?.(detail.criterion_id)}
sx={{ cursor: onSelect ? 'pointer' : 'default' }}
StepIconProps={{
sx: {
color: `${cfg.color}.main`,
'&.Mui-active': { color: `${cfg.color}.main` },
},
}}
optional={
<Stack direction="row" spacing={0.5}>
<Chip
label={detail.criterion_type === 'inclusion' ? '入组' : '排除'}
size="small"
color={detail.criterion_type === 'inclusion' ? 'success' : 'error'}
variant="outlined"
/>
<Chip label={cfg.label} size="small" color={cfg.color} />
</Stack>
}
>
<Typography
variant="body2"
fontWeight={isSelected ? 700 : 500}
color={isSelected ? 'primary.main' : 'text.primary'}
>
{detail.criterion_text}
</Typography>
</StepLabel>
<StepContent>
<Alert severity={cfg.color} sx={{ mb: 1, py: 0.5 }} icon={false}>
<Typography variant="body2">
<strong>医学依据:</strong>{detail.reasoning}
</Typography>
</Alert>
<Box>
<Stack direction="row" alignItems="center" spacing={1} mb={0.5}>
<LinearProgress
variant="determinate"
value={detail.score * 100}
color={cfg.color}
sx={{ flex: 1, height: 6, borderRadius: 3 }}
/>
<Typography variant="caption" color="text.secondary" noWrap>
{Math.round(detail.score * 100)}%
</Typography>
</Stack>
{detail.evidence_texts.length > 0 && (
<Typography variant="caption" color="text.secondary">
证据引用:"{detail.evidence_texts[0]?.slice(0, 80)}{(detail.evidence_texts[0]?.length ?? 0) > 80 ? '...' : ''}"
</Typography>
)}
</Box>
</StepContent>
</Step>
)
})}
</Stepper>
)
}
import { useState, useEffect } from 'react'
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Grid, MenuItem, FormControl, InputLabel, Select,
Stack, Typography, Divider, Box, Autocomplete, Checkbox
} from '@mui/material'
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'
import CheckBoxIcon from '@mui/icons-material/CheckBox'
import { patientService } from '../../services/patientService'
import { diagnosisService, Diagnosis } from '../../services/diagnosisService'
import type { PatientResponse } from '../../types/fhir'
interface AddPatientDialogProps {
open: boolean
patient: PatientResponse | null
onClose: () => void
onSuccess: () => void
}
export function AddPatientDialog({ open, patient, onClose, onSuccess }: AddPatientDialogProps) {
const [loading, setLoading] = useState(false)
const [allDiagnoses, setAllDiagnoses] = useState<Diagnosis[]>([])
const [formData, setFormData] = useState({
mrn: '',
name_raw: '',
phone_raw: '',
id_number_raw: '',
birth_date: '',
gender: 'male',
fhir_conditions: [] as any[],
admission_note: '',
lab_report_text: '',
pathology_report: ''
})
// 加载所有可选诊断
useEffect(() => {
if (open) {
diagnosisService.list().then(setAllDiagnoses).catch(console.error)
}
}, [open])
useEffect(() => {
if (patient) {
setFormData({
mrn: patient.mrn,
name_raw: patient.display_name,
phone_raw: '',
id_number_raw: '',
birth_date: patient.birth_date, // 使用后端返回的真实出生日期
gender: patient.gender,
fhir_conditions: patient.conditions || [],
admission_note: patient.admission_note || '',
lab_report_text: patient.lab_report_text || '',
pathology_report: patient.pathology_report || ''
})
} else {
setFormData({
mrn: '', name_raw: '', phone_raw: '', id_number_raw: '',
birth_date: '', gender: 'male', fhir_conditions: [],
admission_note: '', lab_report_text: '', pathology_report: ''
})
}
}, [patient, open])
const handleSubmit = async () => {
if (!formData.mrn || !formData.name_raw) {
alert('请填写必填项')
return
}
setLoading(true)
try {
const payload = {
...formData,
fhir_observations: [],
fhir_medications: []
}
if (patient) {
await patientService.update(patient.id, payload)
} else {
await patientService.create(payload)
}
onSuccess()
onClose()
} catch (error) {
console.error('Save failed:', error)
alert('保存失败')
} finally {
setLoading(false)
}
}
// 匹配当前表单中的诊断与库中诊断
const getSelectedDiagnoses = () => {
return allDiagnoses.filter(d =>
formData.fhir_conditions.some(c => c.coding?.[0]?.code === d.icd10_code)
)
}
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{patient ? '编辑患者信息' : '新增患者登记'}</DialogTitle>
<DialogContent dividers>
<Stack spacing={3}>
<Box>
<Typography variant="subtitle2" color="primary" gutterBottom>基本信息</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth label="病历号 (MRN)" required size="small"
value={formData.mrn}
onChange={(e) => setFormData({ ...formData, mrn: e.target.value })}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth label="患者姓名" required size="small"
value={formData.name_raw}
onChange={(e) => setFormData({ ...formData, name_raw: e.target.value })}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth label="出生日期" type="date" required size="small"
InputLabelProps={{ shrink: true }}
value={formData.birth_date}
onChange={(e) => setFormData({ ...formData, birth_date: e.target.value })}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth size="small">
<InputLabel>性别</InputLabel>
<Select
value={formData.gender}
label="性别"
onChange={(e) => setFormData({ ...formData, gender: e.target.value })}
>
<MenuItem value="male"></MenuItem>
<MenuItem value="female"></MenuItem>
<MenuItem value="other">其他</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<Autocomplete
multiple
options={allDiagnoses}
disableCloseOnSelect
getOptionLabel={(option) => `${option.description} (${option.icd10_code})`}
value={getSelectedDiagnoses()}
onChange={(_, newValue) => {
const newConditions = newValue.map(v => ({
resource_type: 'Condition',
coding: [{ system: 'http://hl7.org/fhir/sid/icd-10', code: v.icd10_code, display: v.description }],
clinical_status: 'active'
}))
setFormData({ ...formData, fhir_conditions: newConditions })
}}
renderOption={(props, option, { selected }) => (
<li {...props}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selected}
/>
{option.description} <Typography variant="caption" sx={{ ml: 1 }}>{option.icd10_code}</Typography>
</li>
)}
renderInput={(params) => (
<TextField {...params} label="主要诊断 (多选)" placeholder="从诊断库选择..." size="small" />
)}
/>
</Grid>
</Grid>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="primary" gutterBottom>临床文书</Typography>
<Stack spacing={2}>
<TextField
fullWidth multiline rows={3} label="入院记录"
value={formData.admission_note}
onChange={(e) => setFormData({ ...formData, admission_note: e.target.value })}
/>
<TextField
fullWidth multiline rows={3} label="检验报告"
value={formData.lab_report_text}
onChange={(e) => setFormData({ ...formData, lab_report_text: e.target.value })}
/>
<TextField
fullWidth multiline rows={3} label="病理报告"
value={formData.pathology_report}
onChange={(e) => setFormData({ ...formData, pathology_report: e.target.value })}
/>
</Stack>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} variant="contained" disabled={loading}>
{loading ? '保存中...' : '提交'}
</Button>
</DialogActions>
</Dialog>
)
}
import { useMemo } from 'react'
import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'
import { Box, Chip, Stack, IconButton } from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import type { PatientResponse, FHIRCondition } from '../../types/fhir'
import { StatusChip } from '../shared/StatusChip'
const genderMap: Record<string, string> = { male: '', female: '', other: '其他', unknown: '未知' }
interface PatientDataGridProps {
rows: PatientResponse[]
loading: boolean
onRowClick: (row: PatientResponse) => void
onEdit: (row: PatientResponse) => void
onDelete: (id: string) => void
}
export function PatientDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: PatientDataGridProps) {
const columns: GridColDef[] = [
{ field: 'mrn', headerName: '病历号', width: 140 },
{ field: 'display_name', headerName: '患者标识', width: 150 },
{ field: 'age', headerName: '年龄', width: 70, type: 'number' },
{
field: 'gender', headerName: '性别', width: 80,
renderCell: p => <Chip label={genderMap[p.value] ?? p.value} size="small" variant="outlined" />,
},
{
field: 'conditions', headerName: '主要诊断', flex: 1,
renderCell: p => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center', py: 0.5 }}>
{(p.value as FHIRCondition[]).map((c, i) => (
<Chip key={i} label={c.coding[0]?.display ?? c.coding[0]?.code} size="small" color="primary" variant="outlined" />
))}
</Box>
),
},
{
field: 'actions',
headerName: '操作',
width: 100,
sortable: false,
renderCell: (params) => (
<Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}>
<IconButton size="small" color="primary" onClick={(e) => {
e.stopPropagation();
onEdit(params.row as PatientResponse);
}}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={(e) => {
e.stopPropagation();
onDelete(params.row.id);
}}>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
),
}
]
const gridRows = useMemo(() => rows.map(r => ({ ...r, id: r.id })), [rows])
return (
<DataGrid
rows={gridRows}
columns={columns}
loading={loading}
pageSizeOptions={[20, 50]}
initialState={{ pagination: { paginationModel: { pageSize: 20 } } }}
onRowClick={(params: GridRowParams) => onRowClick(params.row as PatientResponse)}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-row:hover': { backgroundColor: '#e3f2fd', cursor: 'pointer' },
'& .MuiDataGrid-columnHeader': { backgroundColor: '#f8fafc' },
'& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' }
}}
/>
)
}
import {
Drawer, Box, Typography, Divider, Chip, Stack, IconButton,
List, ListItem, ListItemText, Grid,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import type { PatientResponse, FHIRCondition, FHIRObservation } from '../../types/fhir'
interface PatientDetailDrawerProps {
patient: PatientResponse | null
open: boolean
onClose: () => void
}
const systemLabel: Record<string, string> = {
'http://hl7.org/fhir/sid/icd-10': 'ICD-10',
'http://loinc.org': 'LOINC',
'http://snomed.info/sct': 'SNOMED',
}
export function PatientDetailDrawer({ patient, open, onClose }: PatientDetailDrawerProps) {
if (!patient) return null
return (
<Drawer anchor="right" open={open} onClose={onClose} PaperProps={{ sx: { width: 480 } }}>
<Box sx={{ p: 3 }}>
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6">患者详情</Typography>
<IconButton onClick={onClose}><CloseIcon /></IconButton>
</Box>
{/* Basic Info */}
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 2, mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>基本信息</Typography>
<Grid container spacing={1}>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">病历号</Typography>
<Typography variant="body2" fontWeight={600}>{patient.mrn}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">患者标识</Typography>
<Typography variant="body2" fontWeight={600}>{patient.display_name}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">年龄</Typography>
<Typography variant="body2">{patient.age}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">性别</Typography>
<Typography variant="body2">
{{ male: '', female: '', other: '其他', unknown: '未知' }[patient.gender] ?? patient.gender}
</Typography>
</Grid>
</Grid>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Conditions */}
{patient.conditions.length > 0 && (
<>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>诊断(FHIR Condition)</Typography>
<Stack spacing={1} mb={2}>
{patient.conditions.map((c: FHIRCondition, i) => (
<Box key={i} sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 1.5 }}>
{c.coding.map((code, j) => (
<Box key={j}>
<Chip label={systemLabel[code.system] ?? code.system} size="small" variant="outlined" sx={{ mr: 1 }} />
<Typography variant="body2" component="span" fontWeight={500}>{code.display}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>({code.code})</Typography>
</Box>
))}
<Chip label={c.clinical_status === 'active' ? '活动期' : '已缓解'} size="small"
color={c.clinical_status === 'active' ? 'error' : 'default'} sx={{ mt: 0.5 }} />
</Box>
))}
</Stack>
</>
)}
{/* Observations */}
{patient.observations.length > 0 && (
<>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>检验结果(FHIR Observation)</Typography>
<List dense>
{patient.observations.map((obs: FHIRObservation, i) => (
<ListItem key={i} divider sx={{ px: 0 }}>
<ListItemText
primary={obs.code?.display ?? obs.code?.code}
secondary={obs.value_string ?? (obs.value_quantity != null ? `${obs.value_quantity} ${obs.value_unit ?? ''}` : '')}
/>
</ListItem>
))}
</List>
</>
)}
{/* 临床文书详情展示 */}
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ mb: 2 }}>临床文书详情</Typography>
<Stack spacing={2.5}>
{/* 入院记录 */}
<Box>
<Typography variant="caption" color="primary" fontWeight={600} display="block" mb={0.5}>入院记录</Typography>
<Box sx={{ p: 1.5, bgcolor: '#f1f5f9', borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#334155' }}>
{patient.admission_note || '暂无内容'}
</Typography>
</Box>
</Box>
{/* 检验报告 */}
<Box>
<Typography variant="caption" color="primary" fontWeight={600} display="block" mb={0.5}>检验报告</Typography>
<Box sx={{ p: 1.5, bgcolor: '#f1f5f9', borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#334155' }}>
{patient.lab_report_text || '暂无内容'}
</Typography>
</Box>
</Box>
{/* 病理报告 */}
<Box>
<Typography variant="caption" color="primary" fontWeight={600} display="block" mb={0.5}>病理报告</Typography>
<Box sx={{ p: 1.5, bgcolor: '#f8fafc', borderRadius: 1, border: '1px dashed #cbd5e1' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#334155' }}>
{patient.pathology_report || '暂无内容'}
</Typography>
</Box>
</Box>
</Stack>
</Box>
</Drawer>
)
}
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material'
interface ConfirmDialogProps {
open: boolean
title: string
content: string
onClose: () => void
onConfirm: () => void
loading?: boolean
}
export function ConfirmDialog({ open, title, content, onClose, onConfirm, loading }: ConfirmDialogProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{content}</DialogContentText>
</DialogContent>
<DialogActions sx={{ pb: 2, px: 3 }}>
<Button onClick={onClose} disabled={loading}>取消</Button>
<Button onClick={onConfirm} color="error" variant="contained" disabled={loading} autoFocus>
确认删除
</Button>
</DialogActions>
</Dialog>
)
}
import { Alert } from '@mui/material'
export function ErrorAlert({ message }: { message: string }) {
return <Alert severity="error" sx={{ mb: 2 }}>{message}</Alert>
}
import { Box, CircularProgress, Typography } from '@mui/material'
export function LoadingOverlay({ message = '加载中...' }: { message?: string }) {
return (
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" minHeight={200} gap={2}>
<CircularProgress color="primary" />
<Typography variant="body2" color="text.secondary">{message}</Typography>
</Box>
)
}
import { Chip } from '@mui/material'
const statusConfig = {
eligible: { label: '符合入组', color: 'success' as const },
ineligible: { label: '不符合', color: 'error' as const },
pending_review: { label: '待审核', color: 'warning' as const },
needs_more_info: { label: '需补充信息', color: 'default' as const },
recruiting: { label: '招募中', color: 'success' as const },
closed: { label: '已关闭', color: 'error' as const },
completed: { label: '已完成', color: 'default' as const },
suspended: { label: '暂停', color: 'warning' as const },
active: { label: '活动期', color: 'success' as const },
resolved: { label: '已缓解', color: 'default' as const },
}
export function StatusChip({ status }: { status: string }) {
const cfg = statusConfig[status as keyof typeof statusConfig] ?? { label: status, color: 'default' as const }
return <Chip label={cfg.label} color={cfg.color} size="small" />
}
import { useState, useEffect } from 'react'
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Grid, MenuItem, FormControl, InputLabel, Select,
Stack, Typography, Divider, IconButton, Box
} from '@mui/material'
import DeleteIcon from '@mui/icons-material/Delete'
import AddIcon from '@mui/icons-material/Add'
import { trialService } from '../../services/trialService'
import { CriterionCreate, TrialResponse } from '../../types/trial'
interface AddTrialDialogProps {
open: boolean
trial: TrialResponse | null // 如果有值则为编辑模式
onClose: () => void
onSuccess: () => void
}
export function AddTrialDialog({ open, trial, onClose, onSuccess }: AddTrialDialogProps) {
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
title: '',
sponsor: '',
phase: 'III',
status: 'recruiting',
nct_number: '',
target_enrollment: 100,
description: '',
})
const [criteria, setCriteria] = useState<CriterionCreate[]>([
{ criterion_type: 'inclusion', text: '', order_index: 0 }
])
// 编辑模式数据回显
useEffect(() => {
if (trial) {
setFormData({
title: trial.title,
sponsor: trial.sponsor,
phase: trial.phase,
status: trial.status,
nct_number: trial.nct_number || '',
target_enrollment: trial.target_enrollment || 100,
description: trial.description || '',
})
setCriteria(trial.criteria.map(c => ({
criterion_type: c.criterion_type,
text: c.text,
order_index: c.order_index
})))
} else {
setFormData({ title: '', sponsor: '', phase: 'III', status: 'recruiting', nct_number: '', target_enrollment: 100, description: '' })
setCriteria([{ criterion_type: 'inclusion', text: '', order_index: 0 }])
}
}, [trial, open])
const handleAddCriterion = () => {
setCriteria([...criteria, { criterion_type: 'inclusion', text: '', order_index: criteria.length }])
}
const handleRemoveCriterion = (index: number) => {
setCriteria(criteria.filter((_, i) => i !== index))
}
const handleCriterionChange = (index: number, field: keyof CriterionCreate, value: any) => {
const newCriteria = [...criteria]
newCriteria[index] = { ...newCriteria[index], [field]: value }
setCriteria(newCriteria)
}
const handleSubmit = async () => {
if (!formData.title || !formData.sponsor) {
alert('请填写必填项')
return
}
setLoading(true)
try {
const payload = {
...formData,
criteria: criteria.filter(c => c.text.trim() !== '')
}
if (trial) {
await trialService.update(trial.id, payload)
} else {
await trialService.create(payload)
}
onSuccess()
onClose()
} catch (error) {
console.error('Save failed:', error)
alert('操作失败')
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{trial ? '编辑临床试验' : '新增临床试验'}</DialogTitle>
<DialogContent dividers>
<Stack spacing={3}>
{/* 基本信息 */}
<Box>
<Typography variant="subtitle2" color="primary" gutterBottom>基本信息</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth label="试验标题" required size="small"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth label="申办方" required size="small"
value={formData.sponsor}
onChange={(e) => setFormData({ ...formData, sponsor: e.target.value })}
/>
</Grid>
<Grid item xs={12} sm={3}>
<FormControl fullWidth size="small">
<InputLabel>期别</InputLabel>
<Select
value={formData.phase}
label="期别"
onChange={(e) => setFormData({ ...formData, phase: e.target.value })}
>
<MenuItem value="I">I 期</MenuItem>
<MenuItem value="II">II 期</MenuItem>
<MenuItem value="III">III 期</MenuItem>
<MenuItem value="IV">IV 期</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth label="NCT 编号" size="small"
value={formData.nct_number}
onChange={(e) => setFormData({ ...formData, nct_number: e.target.value })}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth multiline rows={2} label="试验描述" size="small"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</Grid>
</Grid>
</Box>
<Divider />
{/* 入排标准 */}
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="subtitle2" color="primary">入排标准 (用于 AI 匹配)</Typography>
<Button startIcon={<AddIcon />} size="small" onClick={handleAddCriterion}>添加标准</Button>
</Stack>
<Stack spacing={2}>
{criteria.map((criterion, index) => (
<Stack key={index} direction="row" spacing={1} alignItems="flex-start">
<FormControl size="small" sx={{ width: 120 }}>
<Select
value={criterion.criterion_type}
onChange={(e) => handleCriterionChange(index, 'criterion_type', e.target.value)}
>
<MenuItem value="inclusion">入组</MenuItem>
<MenuItem value="exclusion">排除</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth multiline size="small"
placeholder="请输入详细标准描述..."
value={criterion.text}
onChange={(e) => handleCriterionChange(index, 'text', e.target.value)}
/>
<IconButton color="error" size="small" onClick={() => handleRemoveCriterion(index)} disabled={criteria.length === 1}>
<DeleteIcon />
</IconButton>
</Stack>
))}
</Stack>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} variant="contained" disabled={loading}>
{loading ? '保存中...' : '提交'}
</Button>
</DialogActions>
</Dialog>
)
}
import { useState } from 'react'
import {
Box, Typography, Chip, Stack, IconButton, Collapse,
List, ListItem, ListItemText, ListItemSecondaryAction, Tooltip,
Button, TextField, Select, MenuItem, FormControl, InputLabel,
} from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import DeleteIcon from '@mui/icons-material/Delete'
import AddIcon from '@mui/icons-material/Add'
import type { TrialResponse, CriterionCreate } from '../../types/trial'
import { trialService } from '../../services/trialService'
interface CriteriaEditorProps {
trial: TrialResponse
onUpdate: () => void
}
export function CriteriaEditor({ trial, onUpdate }: CriteriaEditorProps) {
const [expanded, setExpanded] = useState(false)
const [adding, setAdding] = useState(false)
const [newCriterion, setNewCriterion] = useState<CriterionCreate>({
criterion_type: 'inclusion',
text: '',
})
const [saving, setSaving] = useState(false)
const inclusions = trial.criteria.filter(c => c.criterion_type === 'inclusion')
const exclusions = trial.criteria.filter(c => c.criterion_type === 'exclusion')
const handleAdd = async () => {
if (!newCriterion.text.trim()) return
setSaving(true)
try {
await trialService.addCriterion(trial.id, newCriterion)
setNewCriterion({ criterion_type: 'inclusion', text: '' })
setAdding(false)
onUpdate()
} finally {
setSaving(false)
}
}
const handleDelete = async (criterionId: string) => {
await trialService.deleteCriterion(trial.id, criterionId)
onUpdate()
}
return (
<Box>
<Box
display="flex" alignItems="center" justifyContent="space-between"
onClick={() => setExpanded(!expanded)}
sx={{ cursor: 'pointer', py: 1 }}
>
<Stack direction="row" spacing={1} alignItems="center">
<Chip label={`入组 ${inclusions.length}`} size="small" color="success" />
<Chip label={`排除 ${exclusions.length}`} size="small" color="error" />
</Stack>
<IconButton size="small">{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}</IconButton>
</Box>
<Collapse in={expanded}>
{['inclusion', 'exclusion'].map(type => (
<Box key={type} mb={2}>
<Typography variant="caption" fontWeight={600}
color={type === 'inclusion' ? 'success.main' : 'error.main'} gutterBottom>
{type === 'inclusion' ? '▶ 入组标准' : '▶ 排除标准'}
</Typography>
<List dense disablePadding>
{trial.criteria.filter(c => c.criterion_type === type).map((c, i) => (
<ListItem key={c.id} divider sx={{ pl: 1 }}>
<ListItemText
primary={<Typography variant="body2">{i + 1}. {c.text}</Typography>}
/>
<ListItemSecondaryAction>
<Tooltip title="删除">
<IconButton size="small" onClick={() => handleDelete(c.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Box>
))}
{adding ? (
<Box sx={{ border: '1px dashed #1976d2', borderRadius: 1, p: 2, mt: 1 }}>
<Stack spacing={2}>
<FormControl size="small" sx={{ width: 150 }}>
<InputLabel>类型</InputLabel>
<Select
value={newCriterion.criterion_type}
label="类型"
onChange={e => setNewCriterion(prev => ({ ...prev, criterion_type: e.target.value as 'inclusion' | 'exclusion' }))}
>
<MenuItem value="inclusion">入组标准</MenuItem>
<MenuItem value="exclusion">排除标准</MenuItem>
</Select>
</FormControl>
<TextField
multiline rows={2} size="small" fullWidth
placeholder="请输入标准文本..."
value={newCriterion.text}
onChange={e => setNewCriterion(prev => ({ ...prev, text: e.target.value }))}
/>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" onClick={handleAdd} disabled={saving}>保存</Button>
<Button size="small" onClick={() => setAdding(false)}>取消</Button>
</Stack>
</Stack>
</Box>
) : (
<Button startIcon={<AddIcon />} size="small" onClick={() => setAdding(true)} sx={{ mt: 1 }}>
添加标准
</Button>
)}
</Collapse>
</Box>
)
}
import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'
import { Chip, Box, Stack, IconButton } from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import type { TrialResponse } from '../../types/trial'
import { StatusChip } from '../shared/StatusChip'
const phaseColor: Record<string, 'default' | 'primary' | 'secondary' | 'warning'> = {
I: 'default', II: 'primary', III: 'secondary', IV: 'warning',
}
interface TrialDataGridProps {
rows: TrialResponse[]
loading: boolean
onRowClick: (row: TrialResponse) => void
onEdit: (row: TrialResponse) => void
onDelete: (id: string) => void
}
export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: TrialDataGridProps) {
const columns: GridColDef[] = [
{ field: 'nct_number', headerName: 'NCT编号', width: 130 },
{ field: 'title', headerName: '试验名称', flex: 1, minWidth: 200 },
{ field: 'sponsor', headerName: '申办方', width: 140 },
{
field: 'phase', headerName: '期别', width: 80,
renderCell: p => <Chip label={`${p.value}期`} size="small" color={phaseColor[p.value] ?? 'default'} />,
},
{
field: 'status', headerName: '状态', width: 100,
renderCell: p => <StatusChip status={p.value} />,
},
{
field: 'criteria', headerName: '标准数', width: 90, type: 'number',
valueGetter: (v: unknown[]) => v?.length ?? 0,
},
{
field: 'actions',
headerName: '操作',
width: 100,
sortable: false,
renderCell: (params) => (
<Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}>
<IconButton size="small" color="primary" onClick={(e) => {
e.stopPropagation();
onEdit(params.row as TrialResponse);
}}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={(e) => {
e.stopPropagation();
onDelete(params.row.id);
}}>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
),
}
]
return (
<DataGrid
rows={rows.map(r => ({ ...r, id: r.id }))}
columns={columns}
loading={loading}
pageSizeOptions={[20, 50]}
initialState={{ pagination: { paginationModel: { pageSize: 20 } } }}
onRowClick={(params: GridRowParams) => onRowClick(params.row as TrialResponse)}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-row:hover': { backgroundColor: '#e3f2fd', cursor: 'pointer' },
'& .MuiDataGrid-columnHeader': { backgroundColor: '#f8fafc' },
'& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' }
}}
/>
)
}
import {
Drawer, Box, Typography, IconButton, Divider, List,
ListItem, ListItemText, Chip, Button, Stack,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import type { NotificationItem } from '../../types/notification'
import { notificationService } from '../../services/notificationService'
interface RecommendationDrawerProps {
open: boolean
notifications: NotificationItem[]
onClose: () => void
onRefresh: () => void
}
export function RecommendationDrawer({ open, notifications, onClose, onRefresh }: RecommendationDrawerProps) {
const handleMarkRead = async (id: string) => {
await notificationService.markRead(id)
onRefresh()
}
const handleMarkAll = async () => {
await notificationService.markAllRead()
onRefresh()
}
return (
<Drawer anchor="right" open={open} onClose={onClose} PaperProps={{ sx: { width: 420 } }}>
<Box sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Typography variant="h6">推荐通知</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Button size="small" onClick={handleMarkAll}>全部已读</Button>
<IconButton onClick={onClose}><CloseIcon /></IconButton>
</Stack>
</Box>
<Divider />
</Box>
<List sx={{ px: 2, overflow: 'auto' }}>
{notifications.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>暂无通知</Typography>
)}
{notifications.map(n => (
<ListItem
key={n.id}
divider
alignItems="flex-start"
sx={{
bgcolor: n.is_read ? 'transparent' : '#e3f2fd',
borderRadius: 1,
mb: 0.5,
px: 1,
}}
>
<ListItemText
primary={
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" fontWeight={n.is_read ? 400 : 600}>{n.title}</Typography>
{!n.is_read && <Chip label="新" size="small" color="primary" />}
</Stack>
}
secondary={
<>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{n.message}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(n.created_at).toLocaleString('zh-CN')}
</Typography>
</>
}
/>
{!n.is_read && (
<IconButton size="small" onClick={() => handleMarkRead(n.id)} title="标为已读">
<CheckCircleIcon fontSize="small" color="primary" />
</IconButton>
)}
</ListItem>
))}
</List>
</Drawer>
)
}
import { Snackbar, Alert, AlertTitle, Button, IconButton } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import type { NotificationItem } from '../../types/notification'
interface RecommendationSnackbarProps {
notification: NotificationItem | null
onOpenDrawer: () => void
onDismiss: () => void
}
export function RecommendationSnackbar({ notification, onOpenDrawer, onDismiss }: RecommendationSnackbarProps) {
return (
<Snackbar
open={!!notification}
autoHideDuration={8000}
onClose={onDismiss}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
severity="info"
sx={{ width: 360, boxShadow: 4 }}
action={
<>
<Button size="small" color="inherit" onClick={onOpenDrawer}>查看详情</Button>
<IconButton size="small" color="inherit" onClick={onDismiss}><CloseIcon fontSize="small" /></IconButton>
</>
}
>
<AlertTitle>潜在受试者推荐</AlertTitle>
{notification?.message}
</Alert>
</Snackbar>
)
}
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Inter', 'Roboto', sans-serif; background: #f4f6f8; }
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
import { useState, useEffect } from 'react'
import {
Box, Typography, Button, Card, CardContent, Stack, TextField,
IconButton, Dialog, DialogTitle, DialogContent, DialogActions
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { diagnosisService, Diagnosis } from '../services/diagnosisService'
export function DiagnosesPage() {
const [items, setItems] = useState<Diagnosis[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Diagnosis | null>(null)
const [formData, setFormData] = useState({ description: '', icd10_code: '' })
const load = async () => {
setLoading(true)
try {
const data = await diagnosisService.list()
setItems(data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const handleOpen = (item?: Diagnosis) => {
if (item) {
setSelected(item)
setFormData({ description: item.description, icd10_code: item.icd10_code })
} else {
setSelected(null)
setFormData({ description: '', icd10_code: '' })
}
setOpen(true)
}
const handleSubmit = async () => {
if (selected) {
await diagnosisService.update(selected.id, formData)
} else {
await diagnosisService.create(formData)
}
setOpen(false)
load()
}
const handleDelete = async (id: string) => {
if (window.confirm('确定要删除该诊断标准吗?')) {
await diagnosisService.delete(id)
load()
}
}
const columns: GridColDef[] = [
{ field: 'description', headerName: '诊断描述', flex: 1 },
{ field: 'icd10_code', headerName: 'ICD-10 编码', width: 200 },
{
field: 'actions', headerName: '操作', width: 120, sortable: false,
renderCell: (params) => (
<Stack direction="row" spacing={1}>
<IconButton size="small" color="primary" onClick={() => handleOpen(params.row as Diagnosis)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(params.row.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
)
}
]
return (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3}>
<Box>
<Typography variant="h5">诊断管理</Typography>
<Typography variant="subtitle2">维护系统标准诊断库 (ICD-10)</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpen()}>
新增标准诊断
</Button>
</Stack>
<Card>
<CardContent>
<Box sx={{ height: 600 }}>
<DataGrid
rows={items}
columns={columns}
loading={loading}
disableRowSelectionOnClick
sx={{ border: 'none' }}
/>
</Box>
</CardContent>
</Card>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{selected ? '编辑诊断标准' : '新增标准诊断'}</DialogTitle>
<DialogContent dividers>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
fullWidth label="诊断描述" size="small" required
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
<TextField
fullWidth label="ICD-10 编码" size="small" required
value={formData.icd10_code}
onChange={(e) => setFormData({ ...formData, icd10_code: e.target.value })}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>取消</Button>
<Button variant="contained" onClick={handleSubmit}>保存</Button>
</DialogActions>
</Dialog>
</Box>
)
}
import { useState, useEffect } from 'react'
import {
Box, Typography, Card, CardContent, CardHeader, Grid,
FormControl, InputLabel, Select, MenuItem, Button, Stack,
Tabs, Tab, Alert, Divider,
} from '@mui/material'
import PsychologyIcon from '@mui/icons-material/Psychology'
import { MatchingStepper } from '../components/matching/MatchingStepper'
import { MatchingResultCard } from '../components/matching/MatchingResultCard'
import { ReasoningTimeline } from '../components/matching/ReasoningTimeline'
import { EvidencePanel } from '../components/matching/EvidencePanel'
import { LoadingOverlay } from '../components/shared/LoadingOverlay'
import { patientService } from '../services/patientService'
import { trialService } from '../services/trialService'
import { matchingService } from '../services/matchingService'
import type { PatientResponse } from '../types/fhir'
import type { TrialResponse } from '../types/trial'
import type { MatchingResult } from '../types/matching'
export function MatchingPage() {
const [patients, setPatients] = useState<PatientResponse[]>([])
const [trials, setTrials] = useState<TrialResponse[]>([])
const [selectedPatient, setSelectedPatient] = useState('')
const [selectedTrial, setSelectedTrial] = useState('')
const [running, setRunning] = useState(false)
const [result, setResult] = useState<MatchingResult | null>(null)
const [error, setError] = useState('')
const [activeStep, setActiveStep] = useState(0)
const [evidenceTab, setEvidenceTab] = useState(0)
const [selectedCriterionId, setSelectedCriterionId] = useState<string | undefined>()
useEffect(() => {
Promise.all([
patientService.list({ limit: 100 }),
trialService.list({ limit: 100 }),
]).then(([p, t]) => { setPatients(p); setTrials(t) })
}, [])
const handleRun = async () => {
if (!selectedPatient || !selectedTrial) return
setError('')
setResult(null)
setRunning(true)
setActiveStep(1)
try {
const r = await matchingService.runSync({ patient_id: selectedPatient, trial_id: selectedTrial })
setResult(r)
setActiveStep(2)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '匹配失败,请检查LLM配置'
setError(msg)
setActiveStep(0)
} finally {
setRunning(false)
}
}
const evidenceSources = ['admission_note', 'lab_report_text', 'pathology_report'] as const
const sourceLabels = ['入院记录', '检验报告', '病理报告']
// Get source text from current patient (we don't have it directly, so show evidence spans info)
const currentPatient = patients.find(p => p.id === selectedPatient)
return (
<Box>
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
<PsychologyIcon color="primary" sx={{ fontSize: 32 }} />
<Box>
<Typography variant="h5">AI 智能匹配</Typography>
<Typography variant="subtitle2">基于 LLM 的入排标准自动评估</Typography>
</Box>
</Stack>
<MatchingStepper activeStep={activeStep} />
{/* Step 0: Selection */}
<Card sx={{ mb: 2 }}>
<CardContent>
<Grid container spacing={2} alignItems="flex-end">
<Grid item xs={12} sm={4}>
<FormControl fullWidth size="small">
<InputLabel>选择患者</InputLabel>
<Select
value={selectedPatient}
label="选择患者"
onChange={e => setSelectedPatient(e.target.value)}
>
{patients.map(p => (
<MenuItem key={p.id} value={p.id}>
{p.display_name} · {p.mrn}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={5}>
<FormControl fullWidth size="small">
<InputLabel>选择临床试验</InputLabel>
<Select
value={selectedTrial}
label="选择临床试验"
onChange={e => setSelectedTrial(e.target.value)}
>
{trials.map(t => (
<MenuItem key={t.id} value={t.id}>
{t.title.slice(0, 40)}{t.title.length > 40 ? '...' : ''}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={3}>
<Button
fullWidth variant="contained" size="medium"
startIcon={<PsychologyIcon />}
onClick={handleRun}
disabled={!selectedPatient || !selectedTrial || running}
>
{running ? 'AI 分析中...' : '开始匹配'}
</Button>
</Grid>
</Grid>
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
</CardContent>
</Card>
{/* Step 1: Loading */}
{running && (
<Card>
<CardContent>
<LoadingOverlay message="LLM 正在评估入排标准,请稍候..." />
</CardContent>
</Card>
)}
{/* Step 2: Results */}
{result && !running && (
<Grid container spacing={2}>
{/* Left: Summary + Reasoning */}
<Grid item xs={12} md={5}>
<Stack spacing={2}>
<MatchingResultCard result={result} />
<Card>
<CardHeader title="逐条推理依据" titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }} />
<Divider />
<CardContent sx={{ maxHeight: 500, overflow: 'auto' }}>
<ReasoningTimeline
details={result.criterion_details}
selectedId={selectedCriterionId}
onSelect={id => setSelectedCriterionId(prev => prev === id ? undefined : id)}
/>
</CardContent>
</Card>
</Stack>
</Grid>
{/* Right: Evidence Panel */}
<Grid item xs={12} md={7}>
<Card sx={{ height: '100%' }}>
<CardHeader
title="证据面板"
subheader="点击左侧推理条目可高亮对应证据"
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
/>
<Divider />
<CardContent>
{currentPatient ? (
<>
<Tabs
value={evidenceTab}
onChange={(_, v) => setEvidenceTab(v)}
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
>
{sourceLabels.map((label, i) => (
<Tab key={i} label={label} sx={{ fontSize: 13 }} />
))}
</Tabs>
{evidenceSources.map((field, i) => (
evidenceTab === i && (
<EvidencePanel
key={field}
sourceText={(result as any).clinical_texts?.[field] || (currentPatient as any)?.[field] || ""}
sourceField={field}
spans={result.evidence_spans.filter(s => s.source_field === field)}
selectedCriterionId={selectedCriterionId}
/>
)
))}
{result.evidence_spans.length > 0 && (
<Box mt={2}>
<Typography variant="caption" color="text.secondary">
共提取 {result.evidence_spans.length} 处证据片段
</Typography>
</Box>
)}
</>
) : (
<Alert severity="info">请先选择患者以查看证据面板</Alert>
)}
</CardContent>
</Card>
</Grid>
</Grid>
)}
</Box>
)
}
import { useState, useEffect } from 'react'
import { Box, Typography, Button, Card, CardContent, Stack, TextField, InputAdornment } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import SearchIcon from '@mui/icons-material/Search'
import { PatientDataGrid } from '../components/patients/PatientDataGrid'
import { PatientDetailDrawer } from '../components/patients/PatientDetailDrawer'
import { AddPatientDialog } from '../components/patients/AddPatientDialog'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { patientService } from '../services/patientService'
import type { PatientResponse } from '../types/fhir'
export function PatientsPage() {
const [patients, setPatients] = useState<PatientResponse[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
// 详情/编辑/删除 状态管理
const [selectedForDetail, setSelectedForDetail] = useState<PatientResponse | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [selectedForEdit, setSelectedForEdit] = useState<PatientResponse | null>(null)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const load = async () => {
setLoading(true)
try {
const data = await patientService.list({ limit: 100 })
setPatients(data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
// 触发删除确认弹窗
const handleDeleteClick = (id: string) => {
setDeleteId(id)
setDeleteConfirmOpen(true)
}
// 执行真实删除操作
const handleConfirmDelete = async () => {
if (!deleteId) return
setDeleting(true)
try {
await patientService.delete(deleteId)
await load()
setDeleteConfirmOpen(false)
} catch (error) {
alert('删除失败')
} finally {
setDeleting(false)
setDeleteId(null)
}
}
const handleEdit = (row: PatientResponse) => {
setSelectedForEdit(row)
setAddDialogOpen(true)
}
const handleAddNew = () => {
setSelectedForEdit(null)
setAddDialogOpen(true)
}
const filtered = patients.filter(p =>
!search ||
p.display_name.toLowerCase().includes(search.toLowerCase()) ||
p.mrn.includes(search)
)
return (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3}>
<Box>
<Typography variant="h5">患者管理</Typography>
<Typography variant="subtitle2">{patients.length} 名患者</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddNew}>
新增患者登记
</Button>
</Stack>
<Card>
<CardContent sx={{ pb: '12px !important' }}>
<TextField
size="small"
placeholder="搜索病历号或患者姓名..."
value={search}
onChange={e => setSearch(e.target.value)}
InputProps={{ startAdornment: <SearchIcon fontSize="small" /> }}
sx={{ mb: 2, width: 320 }}
/>
<Box sx={{ height: 520 }}>
<PatientDataGrid
rows={filtered}
loading={loading}
onRowClick={row => { setSelectedForDetail(row); setDrawerOpen(true) }}
onEdit={handleEdit}
onDelete={handleDeleteClick}
/>
</Box>
</CardContent>
</Card>
<PatientDetailDrawer
patient={selectedForDetail}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
<AddPatientDialog
open={addDialogOpen}
patient={selectedForEdit}
onClose={() => setAddDialogOpen(false)}
onSuccess={load}
/>
<ConfirmDialog
open={deleteConfirmOpen}
title="确认删除患者记录"
content="确定要永久删除该患者及其所有临床文书信息吗?此操作不可撤销。"
loading={deleting}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleConfirmDelete}
/>
</Box>
)
}
import { useState, useEffect } from 'react'
import {
Box, Typography, Button, Card, CardContent, CardHeader,
Stack, Chip, Collapse, IconButton, Divider, TextField, InputAdornment,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import SearchIcon from '@mui/icons-material/Search'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import { TrialDataGrid } from '../components/trials/TrialDataGrid'
import { CriteriaEditor } from '../components/trials/CriteriaEditor'
import { AddTrialDialog } from '../components/trials/AddTrialDialog'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { trialService } from '../services/trialService'
import { StatusChip } from '../components/shared/StatusChip'
import { LoadingOverlay } from '../components/shared/LoadingOverlay'
import type { TrialResponse } from '../types/trial'
export function TrialsPage() {
const [trials, setTrials] = useState<TrialResponse[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
// 详情面板状态
const [selectedForDetail, setSelectedForDetail] = useState<TrialResponse | null>(null)
const [detailExpanded, setDetailExpanded] = useState(false)
// 编辑/新增/删除 状态管理
const [selectedForEdit, setSelectedForEdit] = useState<TrialResponse | null>(null)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const load = async () => {
setLoading(true)
try {
const data = await trialService.list({ limit: 100 })
setTrials(data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
// 触发删除确认
const handleDeleteClick = (id: string) => {
setDeleteId(id)
setDeleteConfirmOpen(true)
}
// 执行真实删除
const handleConfirmDelete = async () => {
if (!deleteId) return
setDeleting(true)
try {
await trialService.delete(deleteId)
await load()
setDeleteConfirmOpen(false)
if (selectedForDetail?.id === deleteId) setSelectedForDetail(null)
} catch (error) {
alert('删除失败')
} finally {
setDeleting(false)
setDeleteId(null)
}
}
const handleEdit = (row: TrialResponse) => {
setSelectedForEdit(row)
setAddDialogOpen(true)
}
const handleAddNew = () => {
setSelectedForEdit(null)
setAddDialogOpen(true)
}
const handleRowClick = (row: TrialResponse) => {
setSelectedForDetail(row)
setDetailExpanded(true)
}
const filtered = trials.filter(t =>
!search ||
t.title.toLowerCase().includes(search.toLowerCase()) ||
t.nct_number?.toLowerCase().includes(search.toLowerCase()) ||
t.sponsor.toLowerCase().includes(search.toLowerCase())
)
return (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3}>
<Box>
<Typography variant="h5">临床试验管理</Typography>
<Typography variant="subtitle2">{trials.length} 个试验项目</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddNew}>
发布新试验项目
</Button>
</Stack>
<Card sx={{ mb: 2 }}>
<CardContent sx={{ pb: '12px !important' }}>
<TextField
size="small"
placeholder="搜索试验名称、NCT编号或申办方..."
value={search}
onChange={e => setSearch(e.target.value)}
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
sx={{ mb: 2, width: 380 }}
/>
{loading ? <LoadingOverlay /> : (
<Box sx={{ height: 420 }}>
<TrialDataGrid
rows={filtered}
loading={loading}
onRowClick={handleRowClick}
onEdit={handleEdit}
onDelete={handleDeleteClick}
/>
</Box>
)}
</CardContent>
</Card>
{/* 试验详情面板 */}
{selectedForDetail && (
<Card>
<CardHeader
title={
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="h6" noWrap sx={{ maxWidth: 600 }}>{selectedForDetail.title}</Typography>
<StatusChip status={selectedForDetail.status} />
<Chip label={`${selectedForDetail.phase}期`} size="small" color="primary" />
</Stack>
}
subheader={`${selectedForDetail.sponsor}${selectedForDetail.nct_number ? ` · ${selectedForDetail.nct_number}` : ''}`}
action={
<IconButton onClick={() => setDetailExpanded(!detailExpanded)}>
{detailExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
}
/>
<Collapse in={detailExpanded}>
<Divider />
<CardContent>
{selectedForDetail.description && (
<Typography variant="body2" color="text.secondary" mb={2}>
{selectedForDetail.description}
</Typography>
)}
<Typography variant="subtitle2" gutterBottom>入排标准清单 (I/E Criteria)</Typography>
<CriteriaEditor
trial={selectedForDetail}
onUpdate={async () => {
await load()
const updated = trials.find(t => t.id === selectedForDetail.id)
if (updated) setSelectedForDetail(updated)
}}
/>
</CardContent>
</Collapse>
</Card>
)}
{/* 新增/编辑对话框 */}
<AddTrialDialog
open={addDialogOpen}
trial={selectedForEdit}
onClose={() => setAddDialogOpen(false)}
onSuccess={load}
/>
{/* 删除确认弹窗 */}
<ConfirmDialog
open={deleteConfirmOpen}
title="确认删除临床试验"
content="确定要永久移除该试验及其关联的入排标准吗?此操作不可撤销。"
loading={deleting}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleConfirmDelete}
/>
</Box>
)
}
import { useState, useEffect, useCallback } from 'react'
import {
Box, Typography, Card, CardContent, Stack, Button,
Chip, Divider, Badge, IconButton
} from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import NotificationsIcon from '@mui/icons-material/Notifications'
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
import VisibilityIcon from '@mui/icons-material/Visibility'
import { RecommendationDrawer } from '../components/workstation/RecommendationDrawer'
import { RecommendationSnackbar } from '../components/workstation/RecommendationSnackbar'
import { MatchingDetailDrawer } from '../components/matching/MatchingDetailDrawer'
import { notificationService } from '../services/notificationService'
import { matchingService } from '../services/matchingService'
import type { NotificationItem } from '../types/notification'
import type { MatchingResult } from '../types/matching'
export function WorkStationPage() {
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [results, setResults] = useState<MatchingResult[]>([])
const [drawerOpen, setDrawerOpen] = useState(false)
const [snackNotification, setSnackNotification] = useState<NotificationItem | null>(null)
const [unreadCount, setUnreadCount] = useState(0)
// 新增:详情抽屉状态
const [selectedResult, setSelectedResult] = useState<MatchingResult | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const resultColumns: GridColDef[] = [
{ field: 'patient_name', headerName: '患者姓名', width: 130 },
{ field: 'trial_title', headerName: '临床试验', flex: 1, minWidth: 200 },
{
field: 'status', headerName: '匹配状态', width: 130,
renderCell: p => {
const map: Record<string, { label: string; color: 'success' | 'error' | 'warning' | 'default' }> = {
eligible: { label: '符合入组', color: 'success' },
ineligible: { label: '不符合', color: 'error' },
pending_review: { label: '待审核', color: 'warning' },
needs_more_info: { label: '需补充', color: 'default' },
}
const cfg = map[p.value] ?? { label: p.value, color: 'default' as const }
return <Chip label={cfg.label} size="small" color={cfg.color} />
},
},
{
field: 'overall_score', headerName: '评分', width: 90, type: 'number',
valueFormatter: (v: number) => `${Math.round(v * 100)}%`,
},
{
field: 'actions', headerName: '操作', width: 80, sortable: false,
renderCell: (params) => (
<Stack direction="row" sx={{ height: '100%', alignItems: 'center' }}>
<IconButton
size="small" color="primary"
onClick={() => {
setSelectedResult(params.row as MatchingResult)
setDetailOpen(true)
}}
>
<VisibilityIcon fontSize="small" />
</IconButton>
</Stack>
)
}
]
const loadNotifications = useCallback(async () => {
const [notifs, count, matchResults] = await Promise.all([
notificationService.list({ limit: 50 }),
notificationService.unreadCount(),
matchingService.list({ limit: 50 }),
])
setNotifications(notifs)
setUnreadCount(count.count)
setResults(matchResults)
// 自动展示最新未读消息
const latestUnread = notifs.find(n => !n.is_read)
if (latestUnread && !snackNotification) {
setSnackNotification(latestUnread)
}
}, [snackNotification])
useEffect(() => {
loadNotifications()
const interval = setInterval(loadNotifications, 30000)
return () => clearInterval(interval)
}, [loadNotifications])
return (
<Box>
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
<MonitorHeartIcon color="primary" sx={{ fontSize: 32 }} />
<Box>
<Typography variant="h5">医生工作站</Typography>
<Typography variant="subtitle2">潜在受试者推荐与临床决策审核</Typography>
</Box>
<Box flex={1} />
<Button
variant="outlined"
startIcon={
<Badge badgeContent={unreadCount} color="error">
<NotificationsIcon />
</Badge>
}
onClick={() => setDrawerOpen(true)}
>
通知中心
</Button>
</Stack>
{/* 统计看板 */}
<Stack direction="row" spacing={2} mb={3}>
{[
{ label: '全部结果', value: results.length, color: '#1976d2' },
{ label: '符合入组', value: results.filter(r => r.status === 'eligible').length, color: '#2e7d32' },
{ label: '待审核', value: results.filter(r => r.status === 'pending_review').length, color: '#ed6c02' },
{ label: '未读通知', value: unreadCount, color: '#d32f2f' },
].map(stat => (
<Card key={stat.label} sx={{ flex: 1 }}>
<CardContent sx={{ py: '12px !important' }}>
<Typography variant="h4" fontWeight={700} color={stat.color}>{stat.value}</Typography>
<Typography variant="caption" color="text.secondary">{stat.label}</Typography>
</CardContent>
</Card>
))}
</Stack>
{/* 匹配结果表格 */}
<Card>
<CardContent sx={{ pb: '12px !important' }}>
<Typography variant="subtitle1" fontWeight={600} mb={1}>AI 智能匹配列表</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ height: 420 }}>
<DataGrid
rows={results}
columns={resultColumns}
pageSizeOptions={[20, 50]}
initialState={{ pagination: { paginationModel: { pageSize: 20 } } }}
onRowClick={(params) => {
setSelectedResult(params.row as MatchingResult)
setDetailOpen(true)
}}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-row:hover': { cursor: 'pointer', bgcolor: '#e3f2fd' },
'& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' }
}}
/>
</Box>
</CardContent>
</Card>
{/* 通知抽屉 */}
<RecommendationDrawer
open={drawerOpen}
notifications={notifications}
onClose={() => setDrawerOpen(false)}
onRefresh={loadNotifications}
/>
{/* 详情与审核抽屉 */}
<MatchingDetailDrawer
open={detailOpen}
result={selectedResult}
onClose={() => setDetailOpen(false)}
onAuditSuccess={loadNotifications}
/>
{/* 消息提醒 */}
<RecommendationSnackbar
notification={snackNotification}
onOpenDrawer={() => { setDrawerOpen(true); setSnackNotification(null) }}
onDismiss={() => setSnackNotification(null)}
/>
</Box>
)
}
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
headers: { 'Content-Type': 'application/json' },
})
export default api
import api from './api'
export interface Diagnosis {
id: string
description: string
icd10_code: string
}
export const diagnosisService = {
list: () => api.get<Diagnosis[]>('/diagnoses').then(r => r.data),
create: (data: Omit<Diagnosis, 'id'>) => api.post<Diagnosis>('/diagnoses', data).then(r => r.data),
update: (id: string, data: Omit<Diagnosis, 'id'>) => api.put<Diagnosis>(`/diagnoses/${id}`, data).then(r => r.data),
delete: (id: string) => api.delete(`/diagnoses/${id}`),
}
import api from './api'
import type { MatchingResult, MatchingRequest } from '../types/matching'
export const matchingService = {
runSync: (data: MatchingRequest) =>
api.post<MatchingResult>('/matching/run/sync', data).then(r => r.data),
list: (params?: { trial_id?: string; patient_id?: string; status?: string }) =>
api.get<MatchingResult[]>('/matching/results', { params }).then(r => r.data),
get: (id: string) =>
api.get<MatchingResult>(`/matching/results/${id}`).then(r => r.data),
review: (id: string, data: { doctor_id: string; decision: string; notes?: string }) =>
api.put<MatchingResult>(`/matching/results/${id}/review`, data).then(r => r.data),
}
import api from './api'
import type { NotificationItem } from '../types/notification'
export const notificationService = {
list: (params?: { unread_only?: boolean; limit?: number }) =>
api.get<NotificationItem[]>('/notifications', { params }).then(r => r.data),
unreadCount: () =>
api.get<{ count: number }>('/notifications/unread-count').then(r => r.data),
markRead: (id: string) =>
api.patch<NotificationItem>(`/notifications/${id}/read`).then(r => r.data),
markAllRead: () =>
api.patch('/notifications/read-all').then(r => r.data),
}
import api from './api'
import type { PatientResponse } from '../types/fhir'
export const patientService = {
list: (params?: { skip?: number; limit?: number; gender?: string }) =>
api.get<PatientResponse[]>('/patients', { params }).then(r => r.data),
get: (id: string) =>
api.get<PatientResponse>(`/patients/${id}`).then(r => r.data),
count: () =>
api.get<{ count: number }>('/patients/count').then(r => r.data),
create: (data: Record<string, unknown>) =>
api.post<PatientResponse>('/patients', data).then(r => r.data),
update: (id: string, data: Record<string, unknown>) =>
api.put<PatientResponse>(`/patients/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/patients/${id}`),
}
import api from './api'
import type { TrialResponse, TrialCreate, CriterionCreate } from '../types/trial'
export const trialService = {
list: (params?: { skip?: number; limit?: number; status?: string }) =>
api.get<TrialResponse[]>('/trials', { params }).then(r => r.data),
get: (id: string) =>
api.get<TrialResponse>(`/trials/${id}`).then(r => r.data),
create: (data: TrialCreate) =>
api.post<TrialResponse>('/trials', data).then(r => r.data),
update: (id: string, data: TrialCreate) =>
api.put<TrialResponse>(`/trials/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/trials/${id}`),
addCriterion: (trialId: string, data: CriterionCreate) =>
api.post(`/trials/${trialId}/criteria`, data).then(r => r.data),
deleteCriterion: (trialId: string, criterionId: string) =>
api.delete(`/trials/${trialId}/criteria/${criterionId}`),
}
import { createTheme } from '@mui/material/styles'
export const medicalTheme = createTheme({
palette: {
primary: { main: '#1976d2' },
success: { main: '#2e7d32' },
error: { main: '#d32f2f' },
warning: { main: '#ed6c02' },
background: { default: '#f4f6f8', paper: '#ffffff' },
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica Neue", sans-serif',
h5: { fontWeight: 600 },
h6: { fontWeight: 600 },
subtitle2: { color: '#546e7a' },
},
components: {
MuiChip: { styleOverrides: { root: { fontWeight: 500 } } },
MuiCard: {
styleOverrides: {
root: { borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' },
},
},
MuiButton: { styleOverrides: { root: { textTransform: 'none', borderRadius: 8 } } },
MuiTableCell: { styleOverrides: { head: { fontWeight: 600, backgroundColor: '#f8fafc' } } },
},
})
// FHIR R4 aligned types
export interface FHIRCoding {
system: string
code: string
display: string
}
export interface FHIRCondition {
resource_type: 'Condition'
coding: FHIRCoding[]
clinical_status: 'active' | 'resolved' | 'inactive'
onset_date?: string
}
export interface FHIRObservation {
resource_type: 'Observation'
code: FHIRCoding
value_quantity?: number
value_unit?: string
value_string?: string
effective_date?: string
status: 'final' | 'preliminary'
}
export interface PatientResponse {
id: string
mrn: string
display_name: string
birth_date: string
age: number
gender: string
conditions: FHIRCondition[]
observations: FHIRObservation[]
medications: Record<string, unknown>[]
admission_note?: string
lab_report_text?: string
pathology_report?: string
has_admission_note: boolean
has_lab_report: boolean
has_pathology_report: boolean
created_at: string
}
export interface CriterionMatchDetail {
criterion_id: string
criterion_text: string
criterion_type: 'inclusion' | 'exclusion'
result: 'met' | 'not_met' | 'uncertain'
score: number
reasoning: string
evidence_texts: string[]
}
export interface EvidenceSpan {
source_field: 'admission_note' | 'lab_report_text' | 'pathology_report'
start: number
end: number
text: string
criterion_id: string
}
export interface MatchingResult {
id: string
patient_id: string
trial_id: string
status: 'eligible' | 'ineligible' | 'pending_review' | 'needs_more_info'
overall_score: number
criterion_details: CriterionMatchDetail[]
evidence_spans: EvidenceSpan[]
llm_model_used: string
matched_at: string
reviewed_by?: string
reviewed_at?: string
review_notes?: string
}
export interface MatchingRequest {
patient_id: string
trial_id: string
force_refresh?: boolean
}
export interface NotificationItem {
id: string
matching_result_id: string
recipient_doctor_id: string
title: string
message: string
is_read: boolean
created_at: string
read_at?: string
}
export interface CriterionResponse {
id: string
trial_id: string
criterion_type: 'inclusion' | 'exclusion'
text: string
structured?: Record<string, unknown>
order_index: number
}
export interface TrialResponse {
id: string
title: string
sponsor: string
phase: string
status: 'recruiting' | 'closed' | 'completed' | 'suspended'
description?: string
nct_number?: string
target_enrollment?: number
criteria: CriterionResponse[]
created_at: string
}
export interface CriterionCreate {
criterion_type: 'inclusion' | 'exclusion'
text: string
order_index?: number
}
export interface TrialCreate {
title: string
sponsor: string
phase: string
status?: string
description?: string
nct_number?: string
target_enrollment?: number
criteria: CriterionCreate[]
}
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
important: '#root',
theme: { extend: {} },
plugins: [],
corePlugins: { preflight: false },
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
{ "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true } }
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
},
},
})
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