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

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

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

前端新增:
- DashboardPage: KPI卡片, 匹配状态分布, 最近批量记录, 系统概况
- DataSyncPage: 连接管理(支持Oracle SID/ServiceName), 3步向导同步任务
- 批量自动匹配: 必须选择试验项目, 操作日志分页(每页10条)
- AI匹配页面: Tab分离手动/批量匹配, 批量为默认
- 侧边栏新增数据概览入口, 首页跳转至 /dashboard
parent 21eca8c8
......@@ -6,7 +6,38 @@
"Bash(npm install:*)",
"Bash(uv run:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Bash(python:*)",
"Bash(.venv/Scripts/python.exe:*)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(uv pip:*)",
"Bash(uv add:*)",
"Bash(ping:*)",
"Bash(taskkill:*)",
"Bash(uv lock:*)",
"Bash(dir:*)",
"Bash(npx tsc:*)",
"Bash(netstat:*)",
"Bash(NO_PROXY=localhost,127.0.0.1 uv run:*)",
"Bash(tasklist:*)",
"Bash(cmd:*)",
"Bash(kill 24908:*)",
"Bash(npm run:*)",
"Bash(npx vite:*)",
"Bash(node:*)",
"Bash(while read pid)",
"Bash(do echo \"killing $pid\")",
"Bash(done)",
"Bash(powershell:*)",
"Bash(xargs -I{} taskkill //F //PID {})",
"Bash(cmd.exe:*)",
"Bash(wmic process:*)",
"Bash(ss:*)",
"Bash(ps:*)",
"Bash(kill:*)",
"Bash(taskkill.exe:*)",
"Bash(powershell.exe:*)"
]
}
}
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus
from app.models.trial import Trial
from app.schemas.batch_matching import BatchJobResponse
from app.services.batch_matching_service import run_batch_matching
router = APIRouter(prefix="/matching/batch", tags=["batch-matching"])
def _job_to_response(job: BatchMatchingJob, trial_title: str | None = None) -> BatchJobResponse:
pct = (job.completed_pairs / job.total_pairs) if job.total_pairs > 0 else 0.0
return BatchJobResponse(
id=job.id,
status=job.status.value,
total_pairs=job.total_pairs,
completed_pairs=job.completed_pairs,
failed_pairs=job.failed_pairs,
triggered_by=job.triggered_by,
trial_id=job.trial_id,
trial_title=trial_title,
cancel_requested=job.cancel_requested,
started_at=job.started_at.isoformat() if job.started_at else None,
completed_at=job.completed_at.isoformat() if job.completed_at else None,
error_log=job.error_log,
created_at=job.created_at.isoformat(),
progress_pct=pct,
)
@router.post("/run", response_model=BatchJobResponse)
async def trigger_batch_run(
background_tasks: BackgroundTasks,
trial_id: str = Query(..., description="指定要匹配的临床试验 ID"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Trigger a batch matching job for a specific trial. Only one job can run at a time."""
running = await db.execute(
select(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.running)
)
if running.scalar_one_or_none():
raise HTTPException(status_code=409, detail="已有批量匹配任务正在运行,请等待完成后再试")
job = BatchMatchingJob(
id=str(uuid.uuid4()),
triggered_by=current_user.username,
trial_id=trial_id,
created_at=datetime.utcnow(),
)
db.add(job)
await db.commit()
await db.refresh(job)
background_tasks.add_task(run_batch_matching, job.id)
trial = await db.get(Trial, trial_id)
return _job_to_response(job, trial.title if trial else None)
@router.get("/jobs", response_model=list[BatchJobResponse])
async def list_batch_jobs(
skip: int = Query(0, ge=0),
limit: int = Query(10, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(BatchMatchingJob)
.order_by(BatchMatchingJob.created_at.desc())
.offset(skip).limit(limit)
)
jobs = result.scalars().all()
# Batch-fetch trials to avoid N+1
trial_ids = {j.trial_id for j in jobs if j.trial_id}
trials_map: dict[str, str] = {}
for tid in trial_ids:
t = await db.get(Trial, tid)
if t:
trials_map[tid] = t.title
return [_job_to_response(j, trials_map.get(j.trial_id or "")) for j in jobs]
@router.get("/jobs/{job_id}", response_model=BatchJobResponse)
async def get_batch_job(
job_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
job = await db.get(BatchMatchingJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
trial = await db.get(Trial, job.trial_id) if job.trial_id else None
return _job_to_response(job, trial.title if trial else None)
@router.post("/jobs/{job_id}/cancel", response_model=BatchJobResponse)
async def cancel_batch_job(
job_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Request cancellation of a running batch job."""
job = await db.get(BatchMatchingJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.status not in (BatchJobStatus.running, BatchJobStatus.pending):
raise HTTPException(status_code=400, detail="只有运行中或待启动的任务才能停止")
job.cancel_requested = True
await db.commit()
await db.refresh(job)
trial = await db.get(Trial, job.trial_id) if job.trial_id else None
return _job_to_response(job, trial.title if trial else None)
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.connection_profile import ConnectionProfile
from app.models.sync_source import SyncSource
router = APIRouter(prefix="/connection-profiles", tags=["connection-profiles"])
class ConnectionProfileCreate(BaseModel):
name: str
description: Optional[str] = None
connection_type: str = "http" # "http" | "database"
base_url: Optional[str] = None
auth_config: dict = {}
is_active: bool = True
class ConnectionProfileResponse(BaseModel):
id: str
name: str
description: Optional[str]
connection_type: str
base_url: Optional[str]
auth_config: dict
is_active: bool
created_at: str
model_config = {"from_attributes": True}
def _to_response(p: ConnectionProfile) -> ConnectionProfileResponse:
return ConnectionProfileResponse(
id=p.id,
name=p.name,
description=p.description,
connection_type=p.connection_type,
base_url=p.base_url,
auth_config=p.auth_config or {},
is_active=p.is_active,
created_at=p.created_at.isoformat(),
)
@router.get("", response_model=list[ConnectionProfileResponse])
async def list_profiles(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(ConnectionProfile).order_by(ConnectionProfile.created_at.desc())
)
return [_to_response(p) for p in result.scalars().all()]
@router.post("", response_model=ConnectionProfileResponse, status_code=201)
async def create_profile(
data: ConnectionProfileCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
profile = ConnectionProfile(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
connection_type=data.connection_type,
base_url=data.base_url or None,
auth_config=data.auth_config,
is_active=data.is_active,
created_at=datetime.utcnow(),
)
db.add(profile)
await db.commit()
await db.refresh(profile)
return _to_response(profile)
@router.put("/{profile_id}", response_model=ConnectionProfileResponse)
async def update_profile(
profile_id: str,
data: ConnectionProfileCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
profile = await db.get(ConnectionProfile, profile_id)
if not profile:
raise HTTPException(status_code=404, detail="连接档案不存在")
profile.name = data.name
profile.description = data.description
profile.connection_type = data.connection_type
profile.base_url = data.base_url or None
profile.auth_config = data.auth_config
profile.is_active = data.is_active
await db.commit()
await db.refresh(profile)
return _to_response(profile)
@router.delete("/{profile_id}", status_code=204)
async def delete_profile(
profile_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
profile = await db.get(ConnectionProfile, profile_id)
if not profile:
raise HTTPException(status_code=404, detail="连接档案不存在")
# Check if any sync source references this profile
ref = await db.execute(
select(SyncSource).where(SyncSource.connection_profile_id == profile_id)
)
if ref.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该连接档案已被同步任务引用,无法删除")
await db.delete(profile)
await db.commit()
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.patient import Patient
from app.models.trial import Trial, TrialStatus
from app.models.matching import MatchingResult, MatchStatus
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus
from app.models.notification import Notification
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/stats")
async def get_dashboard_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# ── 患者 ──
patient_total = await db.scalar(select(func.count()).select_from(Patient))
# ── 试验 ──
trial_total = await db.scalar(select(func.count()).select_from(Trial))
trial_recruiting = await db.scalar(
select(func.count()).select_from(Trial).where(Trial.status == TrialStatus.recruiting)
)
# ── 匹配 ──
match_total = await db.scalar(select(func.count()).select_from(MatchingResult))
match_eligible = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.eligible)
)
match_ineligible = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.ineligible)
)
match_pending = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.pending_review)
)
match_needs_info = await db.scalar(
select(func.count()).select_from(MatchingResult).where(MatchingResult.status == MatchStatus.needs_more_info)
)
# ── 批量任务 ──
batch_total = await db.scalar(select(func.count()).select_from(BatchMatchingJob))
batch_completed = await db.scalar(
select(func.count()).select_from(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.completed)
)
batch_failed = await db.scalar(
select(func.count()).select_from(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.failed)
)
batch_running = await db.scalar(
select(func.count()).select_from(BatchMatchingJob).where(BatchMatchingJob.status == BatchJobStatus.running)
)
# ── 通知 ──
notif_unread = await db.scalar(
select(func.count()).select_from(Notification).where(Notification.is_read == False)
)
# ── 最近 5 条批量任务(附 trial 名称)──
recent_jobs_result = await db.execute(
select(BatchMatchingJob)
.order_by(BatchMatchingJob.created_at.desc())
.limit(5)
)
recent_jobs = recent_jobs_result.scalars().all()
recent_jobs_list = []
for j in recent_jobs:
trial_title = None
if j.trial_id:
t = await db.get(Trial, j.trial_id)
trial_title = t.title if t else None
pct = (j.completed_pairs / j.total_pairs) if j.total_pairs > 0 else 0.0
recent_jobs_list.append({
"id": j.id,
"status": j.status.value,
"trial_title": trial_title,
"total_pairs": j.total_pairs,
"completed_pairs": j.completed_pairs,
"failed_pairs": j.failed_pairs,
"triggered_by": j.triggered_by,
"started_at": j.started_at.isoformat() if j.started_at else None,
"completed_at": j.completed_at.isoformat() if j.completed_at else None,
"progress_pct": pct,
})
return {
"patients": {
"total": patient_total or 0,
},
"trials": {
"total": trial_total or 0,
"recruiting": trial_recruiting or 0,
},
"matching": {
"total": match_total or 0,
"eligible": match_eligible or 0,
"ineligible": match_ineligible or 0,
"pending_review": match_pending or 0,
"needs_more_info": match_needs_info or 0,
},
"batch_jobs": {
"total": batch_total or 0,
"completed": batch_completed or 0,
"failed": batch_failed or 0,
"running": batch_running or 0,
},
"notifications": {
"unread": notif_unread or 0,
},
"recent_batch_jobs": recent_jobs_list,
}
......@@ -8,12 +8,12 @@ from app.schemas.diagnosis import DiagnosisCreate, DiagnosisResponse
router = APIRouter(prefix="/diagnoses", tags=["diagnoses"])
@router.get("/", response_model=list[DiagnosisResponse])
@router.get("", response_model=list[DiagnosisResponse])
async def list_diagnoses(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Diagnosis))
return result.scalars().all()
@router.post("/", response_model=DiagnosisResponse, status_code=201)
@router.post("", response_model=DiagnosisResponse, status_code=201)
async def create_diagnosis(data: DiagnosisCreate, db: AsyncSession = Depends(get_db)):
diag = Diagnosis(id=str(uuid.uuid4()), **data.model_dump())
db.add(diag)
......
......@@ -3,8 +3,10 @@ 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.models.user import User
from app.schemas.matching import MatchingRequest, MatchingResultResponse, ReviewRequest
from app.services.matching_service import run_matching
from app.core.deps import get_current_user
from datetime import datetime
router = APIRouter(prefix="/matching", tags=["matching"])
......@@ -52,6 +54,7 @@ async def list_results(
trial_id: str | None = None,
patient_id: str | None = None,
status: str | None = None,
reviewed_by: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
......@@ -63,6 +66,8 @@ async def list_results(
stmt = stmt.where(MatchingResult.patient_id == patient_id)
if status:
stmt = stmt.where(MatchingResult.status == status)
if reviewed_by:
stmt = stmt.where(MatchingResult.reviewed_by == reviewed_by)
stmt = stmt.offset(skip).limit(limit).order_by(MatchingResult.matched_at.desc())
result = await db.execute(stmt)
results = result.scalars().all()
......@@ -109,11 +114,12 @@ async def review_result(
result_id: str,
req: ReviewRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
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_by = current_user.username
r.reviewed_at = datetime.utcnow()
r.review_notes = req.notes
if req.decision == "approve":
......
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime
from app.database import get_db
from app.models.notification import Notification
from app.models.user import User
from app.schemas.notification import NotificationResponse, UnreadCountResponse
from app.core.deps import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
DEMO_DOCTOR_ID = "default_doctor"
def _to_response(n: Notification) -> NotificationResponse:
return NotificationResponse(
......@@ -24,15 +24,15 @@ def _to_response(n: Notification) -> NotificationResponse:
)
@router.get("/", response_model=list[NotificationResponse])
@router.get("", response_model=list[NotificationResponse])
async def list_notifications(
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),
current_user: User = Depends(get_current_user),
):
stmt = select(Notification).where(Notification.recipient_doctor_id == doctor_id)
stmt = select(Notification).where(Notification.recipient_doctor_id == current_user.username)
if unread_only:
stmt = stmt.where(Notification.is_read == False) # noqa: E712
stmt = stmt.offset(skip).limit(limit).order_by(Notification.created_at.desc())
......@@ -42,12 +42,12 @@ async def list_notifications(
@router.get("/unread-count", response_model=UnreadCountResponse)
async def unread_count(
doctor_id: str = Query(DEMO_DOCTOR_ID),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(func.count(Notification.id)).where(
Notification.recipient_doctor_id == doctor_id,
Notification.recipient_doctor_id == current_user.username,
Notification.is_read == False, # noqa: E712
)
)
......@@ -55,10 +55,13 @@ async def unread_count(
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
async def mark_read(notification_id: str, db: AsyncSession = Depends(get_db)):
async def mark_read(
notification_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
n = await db.get(Notification, notification_id)
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()
......@@ -69,12 +72,12 @@ async def mark_read(notification_id: str, db: AsyncSession = Depends(get_db)):
@router.patch("/read-all")
async def mark_all_read(
doctor_id: str = Query(DEMO_DOCTOR_ID),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Notification).where(
Notification.recipient_doctor_id == doctor_id,
Notification.recipient_doctor_id == current_user.username,
Notification.is_read == False, # noqa: E712
)
)
......
......@@ -10,7 +10,7 @@ from app.services.masking_service import mask_patient
router = APIRouter(prefix="/patients", tags=["patients"])
@router.get("/", response_model=list[PatientResponse])
@router.get("", response_model=list[PatientResponse])
async def list_patients(
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
......@@ -40,7 +40,7 @@ async def get_patient(patient_id: str, db: AsyncSession = Depends(get_db)):
return mask_patient(patient)
@router.post("/", response_model=PatientResponse, status_code=201)
@router.post("", response_model=PatientResponse, status_code=201)
async def create_patient(data: PatientCreate, db: AsyncSession = Depends(get_db)):
# Check for duplicate MRN
existing = await db.execute(select(Patient).where(Patient.mrn == data.mrn))
......
import uuid
import asyncio
from datetime import datetime
from typing import Optional, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.sync_source import SyncSource
from app.models.connection_profile import ConnectionProfile
from app.services.sync_service import sync_source as do_sync
from app.services.sync_adapters import get_adapter, _sync_test_connection, _list_tables
router = APIRouter(prefix="/sync", tags=["data-sync"])
class SyncSourceCreate(BaseModel):
name: str
source_type: str # free-form, references DataDomainType.code
connection_profile_id: Optional[str] = None
sync_config: Optional[dict] = None # {"resource_type", "table_name", "filters", "field_mapping", ...}
is_active: bool = True
sync_mode: str = "full" # "full" | "incremental"
class SyncSourceResponse(BaseModel):
id: str
name: str
source_type: str
connection_profile_id: Optional[str]
connection_profile_name: Optional[str]
sync_config: Optional[dict]
is_active: bool
sync_mode: str
last_sync_at: Optional[str]
last_sync_count: Optional[int]
created_at: str
model_config = {"from_attributes": True}
async def _to_response(s: SyncSource, db: AsyncSession) -> SyncSourceResponse:
profile_name = None
if s.connection_profile_id:
profile = await db.get(ConnectionProfile, s.connection_profile_id)
profile_name = profile.name if profile else None
return SyncSourceResponse(
id=s.id,
name=s.name,
source_type=s.source_type,
connection_profile_id=s.connection_profile_id,
connection_profile_name=profile_name,
sync_config=s.sync_config,
is_active=s.is_active,
sync_mode=s.sync_mode or "full",
last_sync_at=s.last_sync_at.isoformat() if s.last_sync_at else None,
last_sync_count=s.last_sync_count,
created_at=s.created_at.isoformat(),
)
@router.get("/sources", response_model=list[SyncSourceResponse])
async def list_sources(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(SyncSource).order_by(SyncSource.created_at.desc()))
sources = result.scalars().all()
return [await _to_response(s, db) for s in sources]
@router.post("/sources", response_model=SyncSourceResponse, status_code=201)
async def create_source(
data: SyncSourceCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if data.connection_profile_id:
profile = await db.get(ConnectionProfile, data.connection_profile_id)
if not profile:
raise HTTPException(status_code=400, detail="连接档案不存在")
source = SyncSource(
id=str(uuid.uuid4()),
name=data.name,
source_type=data.source_type,
connection_profile_id=data.connection_profile_id,
sync_config=data.sync_config or {},
is_active=data.is_active,
sync_mode=data.sync_mode,
created_at=datetime.utcnow(),
)
db.add(source)
await db.commit()
await db.refresh(source)
return await _to_response(source, db)
@router.put("/sources/{source_id}", response_model=SyncSourceResponse)
async def update_source(
source_id: str,
data: SyncSourceCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
source = await db.get(SyncSource, source_id)
if not source:
raise HTTPException(status_code=404, detail="Sync source not found")
if data.connection_profile_id:
profile = await db.get(ConnectionProfile, data.connection_profile_id)
if not profile:
raise HTTPException(status_code=400, detail="连接档案不存在")
source.name = data.name
source.source_type = data.source_type
source.connection_profile_id = data.connection_profile_id
source.sync_config = data.sync_config or {}
source.is_active = data.is_active
source.sync_mode = data.sync_mode
await db.commit()
await db.refresh(source)
return await _to_response(source, db)
@router.delete("/sources/{source_id}", status_code=204)
async def delete_source(
source_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
source = await db.get(SyncSource, source_id)
if not source:
raise HTTPException(status_code=404, detail="Sync source not found")
await db.delete(source)
await db.commit()
@router.post("/run")
async def trigger_sync(
source_id: str = Query(..., description="ID of the sync source to run"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Trigger a synchronous patient sync for a single source."""
try:
summary = await do_sync(source_id, db)
return {"status": "success", **summary}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
class PreviewRequest(BaseModel):
source_type: str
sql_query: Optional[str] = None
connection_profile_id: Optional[str] = None
limit: int = 5
class PreviewResponse(BaseModel):
columns: list[str]
rows: list[list[Any]]
error: Optional[str] = None
@router.post("/preview", response_model=PreviewResponse)
async def preview_source(
data: PreviewRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Execute a preview query and return real column names + sample rows.
Works correctly for SELECT * — columns come from actual query execution,
not SQL text parsing. Read-only; does not modify any data.
"""
base_url: Optional[str] = None
auth_config: dict = {}
if data.connection_profile_id:
profile = await db.get(ConnectionProfile, data.connection_profile_id)
if not profile:
raise HTTPException(status_code=400, detail="连接档案不存在")
base_url = profile.base_url
auth_config = profile.auth_config or {}
try:
adapter = get_adapter(
source_type=data.source_type,
source_id="preview",
base_url=base_url,
auth_config=auth_config,
sync_config={"query": data.sql_query},
)
result = await adapter.preview(
sql_query=data.sql_query,
limit=max(1, min(data.limit, 20)),
)
return PreviewResponse(columns=result["columns"], rows=result["rows"])
except Exception as e:
return PreviewResponse(columns=[], rows=[], error=str(e))
# ── 真实数据库连接测试 ────────────────────────────────────────────────────────
class TestConnectionRequest(BaseModel):
db_type: str # mysql | postgresql | sqlserver | oracle
host: str
port: str = "1521"
database: str
username: str = ""
password: str = ""
oracle_type: str = "sid" # Oracle only: "sid" | "service_name"
class TestConnectionResponse(BaseModel):
success: bool
message: str
@router.post("/test-connection", response_model=TestConnectionResponse)
async def test_db_connection(
data: TestConnectionRequest,
current_user: User = Depends(get_current_user),
):
"""Test a real database connection with the given credentials.
Runs in a thread pool (sync SQLAlchemy drivers) and returns
success/failure with a plain-Chinese error message.
"""
try:
await asyncio.to_thread(
_sync_test_connection,
data.db_type, data.host, data.port,
data.database, data.username, data.password,
data.oracle_type,
)
return TestConnectionResponse(success=True, message="连接成功")
except Exception as e:
# Surface the raw driver error — useful for ops to diagnose
msg = str(e)
# Make common errors more readable
if "Access denied" in msg or "Authentication failed" in msg or "ORA-01017" in msg:
msg = f"认证失败:用户名或密码错误({msg})"
elif "Unknown database" in msg or ("database" in msg.lower() and "does not exist" in msg.lower()) or "ORA-12514" in msg:
msg = f"数据库/服务名不存在:{data.database}{msg})"
elif "DPY-3010" in msg:
msg = (
"Oracle 服务器版本过旧,thin 模式不支持。"
"请在服务器上安装 Oracle Instant Client,"
"并设置环境变量 ORACLE_CLIENT_PATH=<Instant Client 目录路径> 后重启后端服务。"
"下载地址:https://www.oracle.com/database/technologies/instant-client.html"
)
elif (
"Can't connect" in msg or "Connection refused" in msg
or "cannot connect" in msg.lower() or "timed out" in msg.lower()
or "WinError 10061" in msg or "WinError 10060" in msg
or "ORA-12541" in msg or "ORA-12170" in msg or "DPY-6005" in msg
):
msg = f"无法连接到服务器 {data.host}:{data.port},请检查地址和端口"
elif "暂不支持" in msg:
msg = msg
else:
msg = f"连接失败:{msg}"
return TestConnectionResponse(success=False, message=msg)
@router.get("/status")
async def sync_status(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(SyncSource))
sources = result.scalars().all()
return {
"sources": [await _to_response(s, db) for s in sources],
"total_active": sum(1 for s in sources if s.is_active),
}
@router.get("/list-tables")
async def list_tables(
connection_profile_id: str = Query(..., description="ID of the connection profile"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all tables in the target database for a given connection profile."""
profile = await db.get(ConnectionProfile, connection_profile_id)
if not profile:
raise HTTPException(status_code=404, detail="连接档案不存在")
ac = profile.auth_config or {}
try:
tables = await asyncio.to_thread(
_list_tables,
ac.get("db_type", "mysql"),
ac.get("host", ""),
str(ac.get("port", "3306")),
ac.get("database", ""),
ac.get("username", ""),
ac.get("password", ""),
ac.get("oracle_type", "sid"),
)
return {"tables": tables}
except Exception as e:
raise HTTPException(status_code=400, detail=f"获取数据表失败:{e}")
......@@ -34,7 +34,7 @@ def _trial_to_response(trial: Trial, criteria: list[Criterion]) -> TrialResponse
)
@router.get("/", response_model=list[TrialResponse])
@router.get("", response_model=list[TrialResponse])
async def list_trials(
skip: int = Query(0, ge=0),
limit: int = Query(20, le=100),
......@@ -70,7 +70,7 @@ async def get_trial(trial_id: str, db: AsyncSession = Depends(get_db)):
return _trial_to_response(trial, criteria)
@router.post("/", response_model=TrialResponse, status_code=201)
@router.post("", response_model=TrialResponse, status_code=201)
async def create_trial(data: TrialCreate, db: AsyncSession = Depends(get_db)):
trial = Trial(
id=str(uuid.uuid4()),
......
......@@ -18,5 +18,7 @@ async def get_db() -> AsyncSession:
async def init_db():
from app.models import patient, trial, matching, notification, diagnosis # noqa: F401
from app.models import user, department, role, permission, user_role # noqa: F401
from app.models import batch_matching, sync_source # noqa: F401
from app.models import data_domain_type, connection_profile # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
......@@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
from app.api.routes import patients, trials, matching, notifications, diagnoses
from app.api.routes import auth, users, roles, permissions, departments
from app.api.routes import batch_matching, sync, connection_profiles, dashboard
@asynccontextmanager
......@@ -17,6 +18,7 @@ app = FastAPI(
version="0.1.0",
description="基于AI的临床试验患者招募系统",
lifespan=lifespan,
redirect_slashes=False,
)
app.add_middleware(
......@@ -37,8 +39,12 @@ app.include_router(departments.router, prefix="/api/v1")
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(batch_matching.router, prefix="/api/v1")
app.include_router(notifications.router, prefix="/api/v1")
app.include_router(diagnoses.router, prefix="/api/v1")
app.include_router(sync.router, prefix="/api/v1")
app.include_router(connection_profiles.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
@app.get("/health")
......
......@@ -10,3 +10,9 @@ from app.models.department import Department # noqa: F401
from app.models.role import Role # noqa: F401
from app.models.permission import Permission, PermissionType # noqa: F401
from app.models.user_role import user_roles, role_permissions # noqa: F401
# 批量匹配与数据同步
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus # noqa: F401
from app.models.sync_source import SyncSource # noqa: F401
from app.models.data_domain_type import DataDomainType # noqa: F401
from app.models.connection_profile import ConnectionProfile # noqa: F401
import uuid
import enum
from datetime import datetime
from sqlalchemy import String, Text, Integer, Boolean, DateTime, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class BatchJobStatus(str, enum.Enum):
pending = "pending"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class BatchMatchingJob(Base):
__tablename__ = "batch_matching_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
status: Mapped[BatchJobStatus] = mapped_column(SAEnum(BatchJobStatus), default=BatchJobStatus.pending)
total_pairs: Mapped[int] = mapped_column(Integer, default=0)
completed_pairs: Mapped[int] = mapped_column(Integer, default=0)
failed_pairs: Mapped[int] = mapped_column(Integer, default=0)
triggered_by: Mapped[str] = mapped_column(String(255))
trial_id: Mapped[str] = mapped_column(String(36), nullable=True)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
error_log: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ConnectionProfile(Base):
__tablename__ = "connection_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(String(500), nullable=True)
connection_type: Mapped[str] = mapped_column(String(20), default="http") # "http" | "database"
base_url: Mapped[str] = mapped_column(String(500), nullable=True)
auth_config: Mapped[dict] = mapped_column(JSON, default=dict)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DataDomainType(Base):
__tablename__ = "data_domain_types"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
code: Mapped[str] = mapped_column(String(50), unique=True) # e.g. "HIS"
display_name: Mapped[str] = mapped_column(String(100)) # e.g. "医院信息系统"
color: Mapped[str] = mapped_column(String(30), default="primary") # MUI color key
description: Mapped[str] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
......@@ -38,5 +38,9 @@ class Patient(Base):
lab_report_text: Mapped[str] = mapped_column(Text, nullable=True)
pathology_report: Mapped[str] = mapped_column(Text, nullable=True)
# External system tracking
source_system: Mapped[str] = mapped_column(String(100), nullable=True) # "HIS", "LIS", "PACS", "manual"
external_id: Mapped[str] = mapped_column(String(255), nullable=True) # ID in the source system
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, JSON, Boolean, DateTime, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SyncSource(Base):
__tablename__ = "sync_sources"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(255))
source_type: Mapped[str] = mapped_column(String(50)) # e.g. "HIS", free-form string
connection_profile_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
# Sync configuration: defines what data to fetch
# Example: {"resource_type": "Patient", "table_name": "patients", "filters": {...}, "field_mapping": {...}}
sync_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=True)
# Legacy connection fields kept for backward compatibility (not written by new code)
base_url: Mapped[str] = mapped_column(String(500), nullable=True)
auth_config: Mapped[dict] = mapped_column(JSON, default=dict)
connection_type: Mapped[str] = mapped_column(String(20), default="http")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sync_mode: Mapped[str] = mapped_column(String(20), default="full") # "full" | "incremental"
last_sync_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
last_sync_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
from pydantic import BaseModel
from typing import Optional
class BatchJobResponse(BaseModel):
id: str
status: str
total_pairs: int
completed_pairs: int
failed_pairs: int
triggered_by: str
trial_id: Optional[str] = None
trial_title: Optional[str] = None
cancel_requested: bool = False
started_at: Optional[str] = None
completed_at: Optional[str] = None
error_log: Optional[str] = None
created_at: str
progress_pct: float # computed: completed_pairs / total_pairs
model_config = {"from_attributes": True}
......@@ -45,6 +45,5 @@ class MatchingResultResponse(BaseModel):
class ReviewRequest(BaseModel):
doctor_id: str
decision: str # "approve" | "reject"
notes: str | None = None
import asyncio
from datetime import datetime
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.patient import Patient
from app.models.trial import Trial, TrialStatus
from app.models.matching import MatchingResult
from app.models.batch_matching import BatchMatchingJob, BatchJobStatus
from app.services.matching_service import run_matching
async def run_batch_matching(job_id: str) -> None:
"""
Background task: match all patients × all recruiting trials.
Skips pairs where a MatchingResult already exists.
Uses its own DB session independent of the HTTP request session.
Rate-limits to 1 pair/second to avoid overwhelming the LLM API.
Checks cancel_requested after each pair and stops if set.
"""
async with AsyncSessionLocal() as db:
job = await db.get(BatchMatchingJob, job_id)
if not job:
return
job.status = BatchJobStatus.running
job.started_at = datetime.utcnow()
await db.commit()
try:
# Fetch all patients
patient_result = await db.execute(select(Patient))
patients = patient_result.scalars().all()
# Fetch the specified trial (must be recruiting)
if job.trial_id:
trial_result = await db.execute(
select(Trial).where(Trial.id == job.trial_id)
)
trial = trial_result.scalar_one_or_none()
trials = [trial] if trial else []
else:
trial_result = await db.execute(
select(Trial).where(Trial.status == TrialStatus.recruiting)
)
trials = trial_result.scalars().all()
# Build set of already-matched pairs to skip
existing_result = await db.execute(select(MatchingResult))
existing_pairs = {
(r.patient_id, r.trial_id)
for r in existing_result.scalars().all()
}
# Build work list (skip already matched pairs)
pairs = [
(p.id, t.id)
for p in patients
for t in trials
if (p.id, t.id) not in existing_pairs
]
job.total_pairs = len(pairs)
await db.commit()
await db.refresh(job)
# Check if cancel was requested before the loop even starts
if job.cancel_requested:
job.status = BatchJobStatus.cancelled
job.completed_at = datetime.utcnow()
await db.commit()
return
cancelled = False
for patient_id, trial_id in pairs:
try:
await run_matching(patient_id, trial_id, db)
job.completed_pairs += 1
except Exception as exc:
job.failed_pairs += 1
entry = f"[{patient_id}/{trial_id}] {exc}\n"
job.error_log = ((job.error_log or "") + entry)[-10000:]
finally:
await db.commit()
# Re-read job to pick up cancel_requested flag written by cancel endpoint
await db.refresh(job)
if job.cancel_requested:
cancelled = True
break
await asyncio.sleep(1)
if cancelled:
job.status = BatchJobStatus.cancelled
else:
job.status = BatchJobStatus.completed
job.completed_at = datetime.utcnow()
except Exception as e:
job.status = BatchJobStatus.failed
job.error_log = str(e)
job.completed_at = datetime.utcnow()
await db.commit()
......@@ -2,10 +2,13 @@ import uuid
from datetime import datetime, date
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
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.models.user import User
from app.models.role import Role
from app.services.llm_service import run_ie_matching
from app.core.config import settings
......@@ -127,19 +130,33 @@ async def run_matching(
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 '需进一步审核'}"
),
# Determine recipient doctors by role
user_result = await db.execute(
select(User).options(selectinload(User.roles)).where(User.is_active == True) # noqa: E712
)
all_users = user_result.scalars().all()
doctor_usernames = [
u.username for u in all_users
if any(r.code in ("doctor", "attending_doctor", "resident_doctor") for r in u.roles)
]
# Fallback: notify all active users if no doctor role configured
if not doctor_usernames:
doctor_usernames = [u.username for u in all_users]
notify_message = (
f"AI匹配发现潜在受试者,试验:{trial.title[:50]},"
f"匹配评分:{llm_result.get('overall_score', 0.0):.0%},"
f"状态:{'符合入组标准' if match_status == MatchStatus.eligible else '需进一步审核'}"
)
db.add(notification)
for username in doctor_usernames:
notification = Notification(
id=str(uuid.uuid4()),
matching_result_id=matching_result.id,
recipient_doctor_id=username,
title="潜在受试者推荐",
message=notify_message,
)
db.add(notification)
await db.commit()
await db.refresh(matching_result)
......
import uuid
import random
import asyncio
from abc import ABC, abstractmethod
from datetime import date, datetime
from typing import TypedDict, Any
class PreviewResult(TypedDict):
"""Column names and sample rows from a preview query."""
columns: list[str]
rows: list[list]
class PatientSyncData(TypedDict):
"""Normalized patient data from any source system."""
mrn: str
name: str
birth_date: str # ISO date string "YYYY-MM-DD"
gender: str # "male" | "female" | "unknown"
external_id: str
source_system: str
admission_note: str | None
lab_report_text: str | None
pathology_report: str | None
fhir_conditions: list
fhir_observations: list
fhir_medications: list
class BaseSyncAdapter(ABC):
"""Abstract interface for all source system adapters."""
def __init__(
self,
source_id: str,
base_url: str | None,
auth_config: dict,
sync_config: dict | None = None
):
self.source_id = source_id
self.base_url = base_url
self.auth_config = auth_config
self.sync_config = sync_config or {}
@abstractmethod
async def fetch_patients(self, since: datetime | None = None) -> list[PatientSyncData | dict[str, Any]]:
"""Fetch patients from the external system.
Args:
since: If provided (incremental mode), only return records updated after this datetime.
If None (full mode), return all records.
Returns:
List of PatientSyncData (normalized) or raw dicts (when field_mappings are used).
"""
...
@abstractmethod
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
"""Execute a lightweight preview and return real column names + sample rows.
This is the foundation of the schema-discovery UX: the frontend calls
this before configuring field mappings so users never have to type
column names manually — works correctly for SELECT * queries.
Args:
sql_query: Full SQL for DB adapters; ignored for API adapters.
limit: Maximum sample rows to return (clamped by the route handler).
"""
...
def _records_to_preview(records: list, limit: int) -> PreviewResult:
"""Shared helper: turn a list of dicts/TypedDicts into PreviewResult."""
sample = records[:limit]
if not sample:
return PreviewResult(columns=[], rows=[])
columns = list(sample[0].keys())
rows = [[rec.get(c) for c in columns] for rec in sample]
return PreviewResult(columns=columns, rows=rows)
class MockHISAdapter(BaseSyncAdapter):
"""Mock HIS adapter: generates synthetic inpatient data for dev/demo."""
async def fetch_patients(self, since: datetime | None = None) -> list[PatientSyncData]:
# Incremental sync returns fewer records to simulate changed data
count = random.randint(1, 2) if since is not None else 5
genders = ["male", "female"]
results = []
for i in range(count):
today = date.today()
birth_year = today.year - random.randint(30, 75)
results.append(PatientSyncData(
mrn=f"HIS-{random.randint(100000, 999999)}",
name=f"模拟患者{i + 1:03d}",
birth_date=f"{birth_year}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}",
gender=random.choice(genders),
external_id=str(uuid.uuid4()),
source_system="HIS",
admission_note=(
"主诉:发热3天伴咳嗽。现病史:患者于3天前无明显诱因出现发热,"
"体温最高38.5℃,伴咳嗽、咳少量白痰,无明显胸痛及呼吸困难。"
"既往史:高血压病史5年,规律服药控制。"
),
lab_report_text="血常规:WBC 12.3×10^9/L,中性粒细胞78%,淋巴细胞15%。",
pathology_report=None,
fhir_conditions=[],
fhir_observations=[],
fhir_medications=[],
))
return results
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
return _records_to_preview(await self.fetch_patients(), limit)
class MockLISAdapter(BaseSyncAdapter):
"""Mock LIS adapter: generates patients with laboratory results."""
async def fetch_patients(self, since: datetime | None = None) -> list[PatientSyncData]:
count = random.randint(0, 1) if since is not None else 3
results = []
for i in range(count):
results.append(PatientSyncData(
mrn=f"LIS-{random.randint(100000, 999999)}",
name=f"检验患者{i + 1:03d}",
birth_date=f"{date.today().year - random.randint(25, 70)}-01-15",
gender="female" if i % 2 == 0 else "male",
external_id=str(uuid.uuid4()),
source_system="LIS",
admission_note=None,
lab_report_text=(
f"肌酐:{random.randint(60, 200)} μmol/L\n"
f"尿素氮:{random.randint(3, 15):.1f} mmol/L\n"
f"血糖:{random.randint(4, 12):.1f} mmol/L\n"
f"ALT:{random.randint(10, 80)} U/L\n"
f"AST:{random.randint(10, 60)} U/L"
),
pathology_report=None,
fhir_conditions=[],
fhir_observations=[],
fhir_medications=[],
))
return results
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
return _records_to_preview(await self.fetch_patients(), limit)
class MockPACSAdapter(BaseSyncAdapter):
"""Mock PACS adapter: generates patients with imaging/pathology findings."""
async def fetch_patients(self, since: datetime | None = None) -> list[PatientSyncData]:
count = random.randint(0, 1) if since is not None else 2
results = []
for i in range(count):
results.append(PatientSyncData(
mrn=f"PACS-{random.randint(100000, 999999)}",
name=f"影像患者{i + 1:03d}",
birth_date=f"{date.today().year - random.randint(40, 80)}-06-20",
gender="male",
external_id=str(uuid.uuid4()),
source_system="PACS",
admission_note=None,
lab_report_text=None,
pathology_report=(
"CT胸部:右肺上叶见结节状高密度影,大小约1.2cm×0.9cm,"
"边缘尚清晰,无明显分叶及毛刺征象。纵隔未见明显肿大淋巴结。"
"病理:(右肺上叶)腺癌,EGFR基因检测:19外显子缺失突变阳性。"
),
fhir_conditions=[],
fhir_observations=[],
fhir_medications=[],
))
return results
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
return _records_to_preview(await self.fetch_patients(), limit)
class MockDatabaseAdapter(BaseSyncAdapter):
"""
Mock database adapter that simulates reading from a database table.
In real implementation, this would use SQLAlchemy or direct DB connection.
Returns raw dicts that will be transformed via field_mappings.
preview() is the key method for the schema-discovery UX: in a real adapter
it would execute `SELECT * FROM (<sql>) _q LIMIT :n` against the live DB,
returning cursor.description for column names — works correctly for SELECT *.
The mock version simply calls fetch_patients() and derives columns from keys.
"""
async def fetch_patients(self, since: datetime | None = None) -> list[dict[str, Any]]:
"""Simulate fetching raw records from a database table."""
table_name = self.sync_config.get("table_name", "patients")
batch_size = self.sync_config.get("batch_size", 100)
# Simulate raw database records with different column names
count = min(random.randint(2, 5), batch_size)
if since is not None:
count = min(1, count) # Fewer for incremental
raw_records = []
for i in range(count):
# Simulate typical HIS database column names (Chinese naming convention)
raw_records.append({
"patient_id": f"DB-{random.randint(100000, 999999)}",
"patient_name": f"数据库患者{i + 1:03d}",
"birth_date": f"{date.today().year - random.randint(30, 70)}/{random.randint(1, 12)}/{random.randint(1, 28)}",
"sex": random.choice(["男", "女"]),
"phone_number": f"138{random.randint(10000000, 99999999)}",
"id_card": f"110101{random.randint(1960, 2000)}{random.randint(1, 12):02d}{random.randint(1, 28):02d}{random.randint(1000, 9999)}",
"admission_record": "入院记录:患者主诉头痛2天...",
"lab_results": f"血红蛋白:{random.randint(90, 160)} g/L",
"created_time": datetime.now().isoformat(),
})
return raw_records
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
return _records_to_preview(await self.fetch_patients(), limit)
class GenericAdapter(BaseSyncAdapter):
async def fetch_patients(self, since: datetime | None = None) -> list[PatientSyncData]:
return []
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
return PreviewResult(columns=[], rows=[])
# ─────────────────────────────────────────────────────────────────────────────
# Real database adapter — connects to actual DB via pymysql / psycopg2 / pymssql
# ─────────────────────────────────────────────────────────────────────────────
def _build_db_url(db_type: str, host: str, port: str, database: str,
username: str, password: str) -> str:
"""Build a SQLAlchemy-compatible connection URL."""
db_type = db_type.lower()
# URL-encode password to handle special characters
from urllib.parse import quote_plus
pwd = quote_plus(password) if password else ""
user = quote_plus(username) if username else ""
if db_type == "mysql":
return f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{database}?charset=utf8mb4"
elif db_type == "postgresql":
return f"postgresql+psycopg2://{user}:{pwd}@{host}:{port}/{database}"
elif db_type == "sqlserver":
return f"mssql+pymssql://{user}:{pwd}@{host}:{port}/{database}"
elif db_type == "oracle":
# Oracle service name URL — used when oracle_type == "service_name"
return f"oracle+oracledb://{user}:{pwd}@{host}:{port}/?service_name={database}"
else:
raise ValueError(f"暂不支持的数据库类型: {db_type}。支持: MySQL、PostgreSQL、SQL Server、Oracle")
def _init_oracle_thick_mode() -> bool:
"""Try to initialize oracledb thick mode via ORACLE_CLIENT_PATH env var.
Returns True if thick mode is now active, False otherwise.
oracledb can only be initialized once per process, so we guard with is_thin_mode().
"""
import os
import oracledb
if not oracledb.is_thin_mode():
return True # already thick
client_path = os.environ.get("ORACLE_CLIENT_PATH", "").strip()
try:
if client_path:
oracledb.init_oracle_client(lib_dir=client_path)
else:
oracledb.init_oracle_client() # search PATH / LD_LIBRARY_PATH
return True
except Exception:
return False
def _create_oracle_engine(host: str, port: str, database: str,
username: str, password: str,
oracle_type: str = "sid", pool_timeout: int = 8):
"""Build a SQLAlchemy engine for Oracle, handling SID vs service name.
Tries thick mode first (wider DB version support); falls back to thin mode.
Thick mode requires Oracle Instant Client — set ORACLE_CLIENT_PATH env var
to point to the directory containing oci.dll / libclntsh.so.
"""
from sqlalchemy import create_engine
from urllib.parse import quote_plus
_init_oracle_thick_mode() # no-op if already thick or client missing
user = quote_plus(username) if username else ""
pwd = quote_plus(password) if password else ""
if oracle_type == "sid":
# SID connections require connect_args in thin mode; work in both modes
return create_engine(
f"oracle+oracledb://{user}:{pwd}@",
connect_args={"host": host, "port": int(port), "sid": database},
pool_timeout=pool_timeout,
)
else:
return create_engine(
f"oracle+oracledb://{user}:{pwd}@{host}:{port}/?service_name={database}",
pool_timeout=pool_timeout,
)
def _sync_test_connection(db_type: str, host: str, port: str, database: str,
username: str, password: str,
oracle_type: str = "sid") -> None:
"""Synchronous connection test — runs in a thread pool."""
from sqlalchemy import create_engine, text
if db_type.lower() == "oracle":
engine = _create_oracle_engine(host, port, database, username, password,
oracle_type=oracle_type, pool_timeout=8)
else:
url = _build_db_url(db_type, host, port, database, username, password)
engine = create_engine(url, pool_timeout=8, connect_args={"connect_timeout": 5}
if db_type.lower() in ("mysql", "postgresql") else {})
try:
with engine.connect() as conn:
# Oracle requires FROM DUAL; other DBs accept bare SELECT 1
ping_sql = "SELECT 1 FROM DUAL" if db_type.lower() == "oracle" else "SELECT 1"
conn.execute(text(ping_sql))
finally:
engine.dispose()
def _sync_query(db_type: str, host: str, port: str, database: str,
username: str, password: str, sql: str, limit: int,
oracle_type: str = "sid",
params: dict | None = None) -> PreviewResult:
"""Execute SQL and return column names + rows — runs in a thread pool."""
from sqlalchemy import create_engine, text
if db_type.lower() == "oracle":
engine = _create_oracle_engine(host, port, database, username, password,
oracle_type=oracle_type, pool_timeout=15)
else:
url = _build_db_url(db_type, host, port, database, username, password)
engine = create_engine(url, pool_timeout=15, connect_args={"connect_timeout": 10}
if db_type.lower() in ("mysql", "postgresql") else {})
try:
with engine.connect() as conn:
result = conn.execute(text(sql), params or {})
columns = list(result.keys())
rows = [list(row) for row in result.fetchmany(limit)]
return PreviewResult(columns=columns, rows=rows)
finally:
engine.dispose()
def _list_tables(db_type: str, host: str, port: str, database: str,
username: str, password: str,
oracle_type: str = "sid") -> list[str]:
"""Return all table names in the target database — runs in a thread pool."""
sql_map = {
"mysql": (
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' "
"ORDER BY TABLE_NAME"
),
"postgresql": (
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE' "
"ORDER BY table_name"
),
"oracle": "SELECT table_name FROM user_tables ORDER BY table_name",
"sqlserver": "SELECT name AS table_name FROM sys.tables ORDER BY name",
}
sql = sql_map.get(db_type.lower(), "")
if not sql:
return []
result = _sync_query(db_type, host, port, database, username, password,
sql, 1000, oracle_type)
return [str(row[0]) for row in result["rows"] if row and row[0]]
class RealDatabaseAdapter(BaseSyncAdapter):
"""Adapter that connects to a real database using credentials from auth_config."""
def _get_conn_params(self) -> dict:
ac = self.auth_config
return {
"db_type": ac.get("db_type", "mysql"),
"host": ac.get("host", "localhost"),
"port": str(ac.get("port", "3306")),
"database": ac.get("database", ""),
"username": ac.get("username", ""),
"password": ac.get("password", ""),
"oracle_type": ac.get("oracle_type", "sid"),
}
async def preview(self, sql_query: str | None = None, limit: int = 5) -> PreviewResult:
if not sql_query or not sql_query.strip():
return PreviewResult(columns=[], rows=[])
p = self._get_conn_params()
return await asyncio.to_thread(
_sync_query, p["db_type"], p["host"], p["port"],
p["database"], p["username"], p["password"], sql_query, limit,
p["oracle_type"], None,
)
async def fetch_patients(self, since: datetime | None = None) -> list[dict[str, Any]]:
p = self._get_conn_params()
base_sql = self.sync_config.get("query", "").rstrip().rstrip(";")
if not base_sql:
return []
params: dict = {}
incremental_field = self.sync_config.get("incremental_field", "")
if since and incremental_field:
# Wrap original SQL as subquery so we can safely append WHERE
sql = f"SELECT * FROM ({base_sql}) sync_q WHERE {incremental_field} > :last_sync"
params["last_sync"] = since
else:
sql = base_sql
result = await asyncio.to_thread(
_sync_query, p["db_type"], p["host"], p["port"],
p["database"], p["username"], p["password"], sql, 5000,
p["oracle_type"], params,
)
return [dict(zip(result["columns"], row)) for row in result["rows"]]
# ─────────────────────────────────────────────────────────────────────────────
ADAPTER_REGISTRY: dict[str, type[BaseSyncAdapter]] = {
"HIS": MockHISAdapter,
"LIS": MockLISAdapter,
"PACS": MockPACSAdapter,
"DATABASE": MockDatabaseAdapter,
}
def get_adapter(
source_type: str,
source_id: str,
base_url: str | None,
auth_config: dict,
sync_config: dict | None = None
) -> BaseSyncAdapter:
"""Return real DB adapter when credentials are present, otherwise mock."""
# Real DB connection: auth_config must have host + database fields
if auth_config.get("host") and auth_config.get("database"):
return RealDatabaseAdapter(
source_id=source_id,
base_url=base_url,
auth_config=auth_config,
sync_config=sync_config,
)
cls = ADAPTER_REGISTRY.get(source_type.upper(), GenericAdapter)
return cls(source_id=source_id, base_url=base_url,
auth_config=auth_config, sync_config=sync_config)
import uuid
from datetime import datetime, date
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.patient import Patient, GenderEnum
from app.models.sync_source import SyncSource
from app.models.connection_profile import ConnectionProfile
from app.services.sync_adapters import get_adapter, PatientSyncData
def apply_transform(value: Any, transform: str | None) -> Any:
"""Apply a transformation rule to a field value."""
if value is None:
return None
if not transform:
return value
str_val = str(value).strip() if value else ""
if transform == "trim":
return str_val
elif transform == "upper":
return str_val.upper()
elif transform == "lower":
return str_val.lower()
elif transform == "date":
# Try to parse various date formats and return ISO format
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y", "%Y%m%d"]:
try:
return datetime.strptime(str_val, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return str_val # Return as-is if no format matches
elif transform == "gender":
# Map Chinese/various gender values to standard enum
val_lower = str_val.lower()
if val_lower in ["男", "male", "m", "1"]:
return "male"
elif val_lower in ["女", "female", "f", "2"]:
return "female"
elif val_lower in ["其他", "other", "o"]:
return "other"
else:
return "unknown"
return value
def apply_field_mappings(raw_data: dict, field_mappings: list[dict]) -> dict:
"""
Apply field mappings to transform raw source data to patient model fields.
Args:
raw_data: Raw record from source system
field_mappings: List of {source_field, target_field, transform}
Returns:
Dict with target_field keys and transformed values
"""
result = {}
for mapping in field_mappings:
source_field = mapping.get("source_field", "")
target_field = mapping.get("target_field", "")
transform = mapping.get("transform", "")
if not source_field or not target_field:
continue
# Support nested field access with dot notation (e.g., "name.given")
value = raw_data
for key in source_field.split("."):
if isinstance(value, dict):
value = value.get(key)
elif isinstance(value, list) and key.isdigit():
idx = int(key)
value = value[idx] if idx < len(value) else None
else:
value = None
break
result[target_field] = apply_transform(value, transform)
return result
async def sync_source(source_id: str, db: AsyncSession) -> dict:
"""
Run sync for a single SyncSource.
Upserts patients by MRN: updates existing records, creates new ones.
Returns a summary dict.
"""
source = await db.get(SyncSource, source_id)
if not source or not source.is_active:
raise ValueError(f"Sync source '{source_id}' not found or inactive")
# Resolve connection parameters: profile takes priority over legacy inline fields
if source.connection_profile_id:
profile = await db.get(ConnectionProfile, source.connection_profile_id)
if not profile:
raise ValueError(f"连接档案 '{source.connection_profile_id}' 不存在")
base_url = profile.base_url
auth_config = profile.auth_config or {}
else:
base_url = source.base_url
auth_config = source.auth_config or {}
# Get sync config with field mappings
sync_config = source.sync_config or {}
field_mappings = sync_config.get("field_mappings", [])
adapter = get_adapter(
source_type=source.source_type,
source_id=source.id,
base_url=base_url,
auth_config=auth_config,
sync_config=sync_config,
)
# Incremental sync: only fetch records updated since last_sync_at
since = source.last_sync_at if (source.sync_mode == "incremental" and source.last_sync_at) else None
patients_data: list[PatientSyncData] = await adapter.fetch_patients(since=since)
created = 0
updated = 0
for pdata in patients_data:
# If field_mappings are provided and adapter returns raw data, apply mappings
# Otherwise use the normalized PatientSyncData directly
if field_mappings and isinstance(pdata, dict) and "mrn" not in pdata:
# Raw data needs mapping
mapped = apply_field_mappings(pdata, field_mappings)
pdata = {
"mrn": mapped.get("mrn", ""),
"name": mapped.get("name", ""),
"birth_date": mapped.get("birth_date", "1900-01-01"),
"gender": mapped.get("gender", "unknown"),
"external_id": mapped.get("external_id", str(uuid.uuid4())),
"source_system": source.source_type,
"admission_note": mapped.get("admission_note"),
"lab_report_text": mapped.get("lab_report_text"),
"pathology_report": mapped.get("pathology_report"),
"phone": mapped.get("phone"),
"id_number": mapped.get("id_number"),
"fhir_conditions": [],
"fhir_observations": [],
"fhir_medications": [],
}
if not pdata.get("mrn"):
continue # Skip records without MRN
existing_result = await db.execute(
select(Patient).where(Patient.mrn == pdata["mrn"])
)
existing = existing_result.scalar_one_or_none()
try:
gender_val = GenderEnum(pdata["gender"])
except ValueError:
gender_val = GenderEnum.unknown
try:
birth = date.fromisoformat(pdata["birth_date"])
except (ValueError, TypeError):
birth = date(1900, 1, 1)
if existing:
# Update clinical text; do NOT overwrite name_encrypted or other manually-set PII
if pdata.get("admission_note"):
existing.admission_note = pdata["admission_note"]
if pdata.get("lab_report_text"):
existing.lab_report_text = pdata["lab_report_text"]
if pdata.get("pathology_report"):
existing.pathology_report = pdata["pathology_report"]
existing.source_system = pdata.get("source_system", source.source_type)
existing.external_id = pdata.get("external_id")
existing.updated_at = datetime.utcnow()
updated += 1
else:
new_patient = Patient(
id=str(uuid.uuid4()),
mrn=pdata["mrn"],
name_encrypted=pdata.get("name", ""),
phone_encrypted=pdata.get("phone"),
id_number_encrypted=pdata.get("id_number"),
birth_date=birth,
gender=gender_val,
admission_note=pdata.get("admission_note"),
lab_report_text=pdata.get("lab_report_text"),
pathology_report=pdata.get("pathology_report"),
fhir_conditions=pdata.get("fhir_conditions", []),
fhir_observations=pdata.get("fhir_observations", []),
fhir_medications=pdata.get("fhir_medications", []),
source_system=pdata.get("source_system", source.source_type),
external_id=pdata.get("external_id"),
)
db.add(new_patient)
created += 1
source.last_sync_at = datetime.utcnow()
source.last_sync_count = len(patients_data)
await db.commit()
return {
"source_id": source_id,
"source_name": source.name,
"sync_mode": source.sync_mode,
"patients_fetched": len(patients_data),
"created": created,
"updated": updated,
}
......@@ -17,12 +17,16 @@ dependencies = [
"httpx>=0.27.0",
"python-jose[cryptography]>=3.3.0",
"passlib>=1.7.4",
"bcrypt==4.1.3",
"bcrypt>=4.1.3",
"python-multipart>=0.0.9",
"pymysql>=1.1.2",
"psycopg2-binary>=2.9.11",
"pymssql>=2.3.13",
"oracledb>=3.4.2",
]
[tool.uv]
dev-dependencies = [
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
]
......
......@@ -73,34 +73,72 @@ wheels = [
[[package]]
name = "bcrypt"
version = "4.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/e9/0b36987abbcd8c9210c7b86673d88ff0a481b4610630710fb80ba5661356/bcrypt-4.1.3.tar.gz", hash = "sha256:2ee15dd749f5952fe3f0430d0ff6b74082e159c50332a1413d51b5689cf06623", size = 26456, upload-time = "2024-05-04T04:12:51.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/4e/e424a74f0749998d8465c162c5cb9d9f210a5b60444f4120eff0af3fa800/bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:48429c83292b57bf4af6ab75809f8f4daf52aa5d480632e53707805cc1ce9b74", size = 506501, upload-time = "2024-05-04T04:12:07.711Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8d/ad2efe0ec57ed3c25e588c4543d946a1c72f8ee357a121c0e382d8aaa93f/bcrypt-4.1.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a8bea4c152b91fd8319fef4c6a790da5c07840421c2b785084989bf8bbb7455", size = 284345, upload-time = "2024-05-04T04:12:09.243Z" },
{ url = "https://files.pythonhosted.org/packages/2f/f6/9c0a6de7ef78d573e10d0b7de3ef82454e2e6eb6fada453ea6c2b8fb3f0a/bcrypt-4.1.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d3b317050a9a711a5c7214bf04e28333cf528e0ed0ec9a4e55ba628d0f07c1a", size = 283395, upload-time = "2024-05-04T04:12:11.116Z" },
{ url = "https://files.pythonhosted.org/packages/63/56/45312e49c195cd30e1bf4b7f0e039e8b3c46802cd55485947ddcb96caa27/bcrypt-4.1.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:094fd31e08c2b102a14880ee5b3d09913ecf334cd604af27e1013c76831f7b05", size = 284794, upload-time = "2024-05-04T04:12:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/4c/6a/ce950d4350c734bc5d9b7196a58fedbdc94f564c00b495a1222984431e03/bcrypt-4.1.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4fb253d65da30d9269e0a6f4b0de32bd657a0208a6f4e43d3e645774fb5457f3", size = 283689, upload-time = "2024-05-04T04:12:14.289Z" },
{ url = "https://files.pythonhosted.org/packages/af/a1/36aa84027ef45558b30a485bc5b0606d5e7357b27b10cc49dce3944f4d1d/bcrypt-4.1.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:193bb49eeeb9c1e2db9ba65d09dc6384edd5608d9d672b4125e9320af9153a15", size = 318065, upload-time = "2024-05-04T04:12:15.641Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e8/183ead5dd8124e463d0946dfaf86c658225adde036aede8384d21d1794d0/bcrypt-4.1.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8cbb119267068c2581ae38790e0d1fbae65d0725247a930fc9900c285d95725d", size = 315556, upload-time = "2024-05-04T04:12:17.921Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5e/edcb4ec57b056ca9d5f9fde31fcda10cc635def48867edff5cc09a348a4f/bcrypt-4.1.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6cac78a8d42f9d120b3987f82252bdbeb7e6e900a5e1ba37f6be6fe4e3848286", size = 324438, upload-time = "2024-05-04T04:12:19.823Z" },
{ url = "https://files.pythonhosted.org/packages/3b/5d/121130cc85009070fe4e4f5937b213a00db143147bc6c8677b3fd03deec8/bcrypt-4.1.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01746eb2c4299dd0ae1670234bf77704f581dd72cc180f444bfe74eb80495b64", size = 335368, upload-time = "2024-05-04T04:12:21.055Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ac/bcb7d3ac8a1107b103f4a95c5be088b984d8045d4150294459a657870bd9/bcrypt-4.1.3-cp37-abi3-win32.whl", hash = "sha256:037c5bf7c196a63dcce75545c8874610c600809d5d82c305dd327cd4969995bf", size = 167120, upload-time = "2024-05-04T04:12:23.021Z" },
{ url = "https://files.pythonhosted.org/packages/69/57/3856b1728018f5ce85bb678a76e939cb154a2e1f9c5aa69b83ec5652b111/bcrypt-4.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:8a893d192dfb7c8e883c4576813bf18bb9d59e2cfd88b68b725990f033f1b978", size = 158059, upload-time = "2024-05-04T04:12:25.256Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/fbea8d2b370a4cc7f5f0aff9f492177a5813e130edeab9dd388ddd3ef1dc/bcrypt-4.1.3-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d4cf6ef1525f79255ef048b3489602868c47aea61f375377f0d00514fe4a78c", size = 506522, upload-time = "2024-05-04T04:12:27.206Z" },
{ url = "https://files.pythonhosted.org/packages/a4/9a/4aa31d1de9369737cfa734a60c3d125ecbd1b3ae2c6499986d0ac160ea8b/bcrypt-4.1.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5698ce5292a4e4b9e5861f7e53b1d89242ad39d54c3da451a93cac17b61921a", size = 284401, upload-time = "2024-05-04T04:12:29.098Z" },
{ url = "https://files.pythonhosted.org/packages/12/d4/13b86b1bb2969a804c2347d0ad72fc3d3d9f5cf0d876c84451c6480e19bc/bcrypt-4.1.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec3c2e1ca3e5c4b9edb94290b356d082b721f3f50758bce7cce11d8a7c89ce84", size = 283414, upload-time = "2024-05-04T04:12:31.507Z" },
{ url = "https://files.pythonhosted.org/packages/29/3c/6e478265f68eff764571676c0773086d15378fdf5347ddf53e5025c8b956/bcrypt-4.1.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3a5be252fef513363fe281bafc596c31b552cf81d04c5085bc5dac29670faa08", size = 284951, upload-time = "2024-05-04T04:12:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/97/00/21e34b365b895e6faf9cc5d4e7b97dd419e08f8a7df119792ec206b4a3fa/bcrypt-4.1.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5f7cd3399fbc4ec290378b541b0cf3d4398e4737a65d0f938c7c0f9d5e686611", size = 283703, upload-time = "2024-05-04T04:12:34.794Z" },
{ url = "https://files.pythonhosted.org/packages/e0/c9/069b0c3683ce969b328b7b3e3218f9d5981d0629f6091b3b1dfa72928f75/bcrypt-4.1.3-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:c4c8d9b3e97209dd7111bf726e79f638ad9224b4691d1c7cfefa571a09b1b2d6", size = 317876, upload-time = "2024-05-04T04:12:36.108Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fd/0d2d7cc6fc816010f6c6273b778e2f147e2eca1144975b6b71e344b26ca0/bcrypt-4.1.3-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:31adb9cbb8737a581a843e13df22ffb7c84638342de3708a98d5c986770f2834", size = 315555, upload-time = "2024-05-04T04:12:37.536Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/283450ee672719e216a5e1b0e80cb0c8f225bc0814cbb893155ee4fdbb9e/bcrypt-4.1.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:551b320396e1d05e49cc18dd77d970accd52b322441628aca04801bbd1d52a73", size = 324408, upload-time = "2024-05-04T04:12:38.882Z" },
{ url = "https://files.pythonhosted.org/packages/9c/64/a016d23b6f513282d8b7f9dd91342929a2e970b2e2c2576d9b76f8f2ee5a/bcrypt-4.1.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6717543d2c110a155e6821ce5670c1f512f602eabb77dba95717ca76af79867d", size = 335334, upload-time = "2024-05-04T04:12:40.442Z" },
{ url = "https://files.pythonhosted.org/packages/75/35/036d69e46c60394f2ffb474c9a4b3783e84395bf4ad55176771f603069ca/bcrypt-4.1.3-cp39-abi3-win32.whl", hash = "sha256:6004f5229b50f8493c49232b8e75726b568535fd300e5039e255d919fc3a07f2", size = 167071, upload-time = "2024-05-04T04:12:42.256Z" },
{ url = "https://files.pythonhosted.org/packages/b1/46/fada28872f3f3e121868f4cd2d61dcdc7085a07821debf1320cafeabc0db/bcrypt-4.1.3-cp39-abi3-win_amd64.whl", hash = "sha256:2505b54afb074627111b5a8dc9b6ae69d0f01fea65c2fcaea403448c503d3991", size = 158124, upload-time = "2024-05-04T04:12:44.085Z" },
{ url = "https://files.pythonhosted.org/packages/20/02/c23ca3cf171798af16361576ef43bfc3192e3c3a679bc557e046b02c7188/bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:cb9c707c10bddaf9e5ba7cdb769f3e889e60b7d4fea22834b261f51ca2b89fed", size = 282631, upload-time = "2024-05-04T04:12:45.231Z" },
{ url = "https://files.pythonhosted.org/packages/f6/79/5dd98e9f67701be98d52f7ee181aa3b078d45eab62ed5f9aa987676d049c/bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9f8ea645eb94fb6e7bea0cf4ba121c07a3a182ac52876493870033141aa687bc", size = 281342, upload-time = "2024-05-04T04:12:47.174Z" },
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
]
[[package]]
......@@ -870,6 +908,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
]
[[package]]
name = "oracledb"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/02/70a872d1a4a739b4f7371ab8d3d5ed8c6e57e142e2503531aafcb220893c/oracledb-3.4.2.tar.gz", hash = "sha256:46e0f2278ff1fe83fbc33a3b93c72d429323ec7eed47bc9484e217776cd437e5", size = 855467, upload-time = "2026-01-28T17:25:39.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/5d/b8a0ca1c520fa43ae33260f6f8ca9bd468ade43da7986029bc214965df12/oracledb-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff3c89cecea62af8ca02aa33cab0f2edc0214c747eac7d3364ed6b2640cb55e4", size = 4243966, upload-time = "2026-01-28T17:25:45.05Z" },
{ url = "https://files.pythonhosted.org/packages/f6/43/26e2bbb2a6ee31392a339089e53cb2e386ca795ff4fbe2f673c167821bd6/oracledb-3.4.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e068ef844a327877bfefbef1bc6fb7284c727bb87af80095f08d95bcaf7b8bb2", size = 2426056, upload-time = "2026-01-28T17:25:47.176Z" },
{ url = "https://files.pythonhosted.org/packages/09/ba/11ee1d044295465a04ff45c6e3023d35400bb3f67bc5fed9408f0f2dc04c/oracledb-3.4.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f434a739405557bd57cb39b62238142bb27855a524a70dc6d397a2a8c576c9d", size = 2603062, upload-time = "2026-01-28T17:25:49.817Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bc/292f2f5f7b65a667787871e300889ab8f4a3b9cfd88c5d78f828a40f6d31/oracledb-3.4.2-cp310-cp310-win32.whl", hash = "sha256:00c79448017f367bb7ab6900efe0706658a53768abea2b4519a4c9b2d5743890", size = 1496639, upload-time = "2026-01-28T17:25:51.298Z" },
{ url = "https://files.pythonhosted.org/packages/21/23/81931c16663e771937c0161bb90460668d2a5f7982b5030ab7bef3b3a4f9/oracledb-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:574c8280d49cbbe21dbe03fc28356d9b9a5b9e300ebcde6c6d106e51453a7e65", size = 1837314, upload-time = "2026-01-28T17:25:52.718Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/be263b668ba32b258d07c85f7bfb6967a9677e016c299207b28734f04c4b/oracledb-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e4b8a852251cef09038b75f30fce1227010835f4e19cfbd436027acba2697c", size = 4228552, upload-time = "2026-01-28T17:25:54.844Z" },
{ url = "https://files.pythonhosted.org/packages/91/bc/e832a649529da7c60409a81be41f3213b4c7ffda4fe424222b2145e8d43c/oracledb-3.4.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1617a1db020346883455af005efbefd51be2c4d797e43b1b38455a19f8526b48", size = 2421924, upload-time = "2026-01-28T17:25:56.984Z" },
{ url = "https://files.pythonhosted.org/packages/86/21/d867c37e493a63b5521bd248110ad5b97b18253d64a30703e3e8f3d9631e/oracledb-3.4.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed78d7e7079a778062744ccf42141ce4806818c3f4dd6463e4a7edd561c9f86", size = 2599301, upload-time = "2026-01-28T17:25:58.529Z" },
{ url = "https://files.pythonhosted.org/packages/2a/de/9b1843ea27f7791449652d7f340f042c3053336d2c11caf29e59bab86189/oracledb-3.4.2-cp311-cp311-win32.whl", hash = "sha256:0e16fe3d057e0c41a23ad2ae95bfa002401690773376d476be608f79ac74bf05", size = 1492890, upload-time = "2026-01-28T17:26:00.662Z" },
{ url = "https://files.pythonhosted.org/packages/d6/10/cbc8afa2db0cec80530858d3e4574f9734fae8c0b7f1df261398aa026c5f/oracledb-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:f93cae08e8ed20f2d5b777a8602a71f9418389c661d2c937e84d94863e7e7011", size = 1843355, upload-time = "2026-01-28T17:26:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/8f/81/2e6154f34b71cd93b4946c73ea13b69d54b8d45a5f6bbffe271793240d21/oracledb-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a7396664e592881225ba66385ee83ce339d864f39003d6e4ca31a894a7e7c552", size = 4220806, upload-time = "2026-01-28T17:26:04.322Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a9/a1d59aaac77d8f727156ec6a3b03399917c90b7da4f02d057f92e5601f56/oracledb-3.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f04a2d62073407672f114d02529921de0677c6883ed7c64d8d1a3c04caa3238", size = 2233795, upload-time = "2026-01-28T17:26:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/94/ec/8c4a38020cd251572bd406ddcbde98ca052ec94b5684f9aa9ef1ddfcc68c/oracledb-3.4.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8d75e4f879b908be66cce05ba6c05791a5dbb4a15e39abc01aa25c8a2492bd9", size = 2424756, upload-time = "2026-01-28T17:26:07.35Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7d/c251c2a8567151ccfcfbe3467ea9a60fb5480dc4719342e2e6b7a9679e5d/oracledb-3.4.2-cp312-cp312-win32.whl", hash = "sha256:31b7ee83c23d0439778303de8a675717f805f7e8edb5556d48c4d8343bcf14f5", size = 1453486, upload-time = "2026-01-28T17:26:08.869Z" },
{ url = "https://files.pythonhosted.org/packages/4c/78/c939f3c16fb39400c4734d5a3340db5659ba4e9dce23032d7b33ccfd3fe5/oracledb-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:ac25a0448fc830fb7029ad50cd136cdbfcd06975d53967e269772cc5cb8c203a", size = 1794445, upload-time = "2026-01-28T17:26:10.66Z" },
{ url = "https://files.pythonhosted.org/packages/22/68/f7126f5d911c295b57720c6b1a0609a5a2667b4546946433552a4de46333/oracledb-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:643c25d301a289a371e37fcedb59e5fa5e54fb321708e5c12821c4b55bdd8a4d", size = 4205176, upload-time = "2026-01-28T17:26:12.463Z" },
{ url = "https://files.pythonhosted.org/packages/5d/93/2fced60f92dc82e66980a8a3ba5c1ea48110bf1dd81d030edb69d88f992e/oracledb-3.4.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55397e7eb43bb7017c03a981c736c25724182f5210951181dfe3fab0e5d457fb", size = 2231298, upload-time = "2026-01-28T17:26:14.497Z" },
{ url = "https://files.pythonhosted.org/packages/75/a7/4dd286f3a6348d786fef9e6ab2e6c9b74ca9195d9a756f2a67e45743cdf0/oracledb-3.4.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26a10f9c790bd141ffc8af68520803ed4a44a9258bf7d1eea9bfdd36bd6df7f", size = 2439430, upload-time = "2026-01-28T17:26:16.044Z" },
{ url = "https://files.pythonhosted.org/packages/19/28/94bc753e5e969c60ee5d9c914e2b4ef79999eaca8e91bcab2fbf0586b80b/oracledb-3.4.2-cp313-cp313-win32.whl", hash = "sha256:b974caec2c330c22bbe765705a5ac7d98ec3022811dec2042d561a3c65cb991b", size = 1458209, upload-time = "2026-01-28T17:26:17.652Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2b/593a9b2d4c12c9de3289e67d84fe023336d99f36ba51442a5a0f5ce6acf7/oracledb-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:3df8eee1410d25360599968b1625b000f10c5ae0e47274031a7842a9dc418890", size = 1793558, upload-time = "2026-01-28T17:26:19.914Z" },
{ url = "https://files.pythonhosted.org/packages/42/20/1e98f84c1555911c46b4fa870fbef2a80617bf7e0a5f178078ecf466c917/oracledb-3.4.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:59ad6438f56a25e8e1a4a3dd1b42235a5d09ab9ba417ff2ad14eae6596f3d06f", size = 4247459, upload-time = "2026-01-28T17:26:22.356Z" },
{ url = "https://files.pythonhosted.org/packages/7d/74/95963e2d94f84b9937a562a9a2529f72d050afbc2ffd88f6661e3a876f7d/oracledb-3.4.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404ec1451d0448653ee074213b87d6c5bd65eaa74b50083ddf2c9c3e11c71c71", size = 2271749, upload-time = "2026-01-28T17:26:24.078Z" },
{ url = "https://files.pythonhosted.org/packages/82/89/38ce85148a246087795379ee52c5b20726a00a69c87ba6ec266bcdad30fc/oracledb-3.4.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19fa80ef84f85ad74077aa626067bbe697e527bd39604b4209f9d86cb2876b89", size = 2452031, upload-time = "2026-01-28T17:26:26.08Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8d/51fe907fdec0267ad7c6e9a62998cbe878efcd168ea6e39f162fab62fdaa/oracledb-3.4.2-cp314-cp314-win32.whl", hash = "sha256:d7ce75c498bff758548ec6e4424ab4271aa257e5887cc436a54bc947fd46199a", size = 1480973, upload-time = "2026-01-28T17:26:27.584Z" },
{ url = "https://files.pythonhosted.org/packages/48/22/a37354f19786774e5e4041338043b516db060aacfdfcd5aca8bb92c2539a/oracledb-3.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:5d7befb014174c5ae11c3a08f5ed6668a25ab2335d8e7104dca70d54d54a5b3a", size = 1837756, upload-time = "2026-01-28T17:26:29.032Z" },
]
[[package]]
name = "orjson"
version = "3.11.7"
......@@ -1034,6 +1109,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/f2/8e377d29c2ecf99f6062d35ea606b036e8800720eccfec5fe3dd672c2b24/psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", size = 3756506, upload-time = "2025-10-10T11:10:30.144Z" },
{ url = "https://files.pythonhosted.org/packages/24/cc/dc143ea88e4ec9d386106cac05023b69668bd0be20794c613446eaefafe5/psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", size = 3863943, upload-time = "2025-10-10T11:10:34.586Z" },
{ url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" },
{ url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" },
{ url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" },
{ url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" },
{ url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" },
{ url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" },
{ url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" },
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.2"
......@@ -1213,6 +1351,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pymssql"
version = "2.3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/cc/843c044b7f71ee329436b7327c578383e2f2499313899f88ad267cdf1f33/pymssql-2.3.13.tar.gz", hash = "sha256:2137e904b1a65546be4ccb96730a391fcd5a85aab8a0632721feb5d7e39cfbce", size = 203153, upload-time = "2026-02-14T05:00:36.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/b5/77d2af3cab1a129891f75a4223a145664ec0443a340ee737518b499b4edb/pymssql-2.3.13-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:476f6f06b2ae5dfbfa0b169a6ecdd0d9ddfedb07f2d6dc97d2dd630ff2d6789a", size = 3173361, upload-time = "2026-02-14T04:59:16.582Z" },
{ url = "https://files.pythonhosted.org/packages/93/e4/d7833727a19cd1aca2e6de4d59b9ecd3c7c15c1b9c9f0fef8c6a38176f76/pymssql-2.3.13-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:17942dc9474693ab2229a8a6013e5b9cb1312a5251207552141bb85fcce8c131", size = 2976106, upload-time = "2026-02-14T04:59:19.833Z" },
{ url = "https://files.pythonhosted.org/packages/e9/16/121a3d77530ccfdec3cfdf30342d4ca67283441a0adc4f4daaf963f57454/pymssql-2.3.13-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d87237500def5f743a52e415cd369d632907212154fcc7b4e13f264b4e30021", size = 3043228, upload-time = "2026-02-14T04:59:21.603Z" },
{ url = "https://files.pythonhosted.org/packages/85/34/178a4293329c9cf61d438262a52553a92b4421a40419a9ef58f7783ce444/pymssql-2.3.13-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:612ac062027d2118879f11a5986e9d9d82d07ca3545bb98c93200b68826ea687", size = 3175449, upload-time = "2026-02-14T04:59:24.047Z" },
{ url = "https://files.pythonhosted.org/packages/00/aa/bcfc4b7488a19026bac62a819dd6ee389bc2e706f72d7c021d7c6fa8d08e/pymssql-2.3.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1897c1b767cc143e77d285123ae5fd4fa7379a1bfec5c515d38826caf084eb6", size = 3689919, upload-time = "2026-02-14T04:59:26.345Z" },
{ url = "https://files.pythonhosted.org/packages/8d/58/036bdbb7f036d97a3d502bf285f3530a9e800b07922ab6227ef69ce15da7/pymssql-2.3.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:48631c7b9fd14a1bd5675c521b6082590bf700b7961c65638d237817b3fde735", size = 3436776, upload-time = "2026-02-14T04:59:28.942Z" },
{ url = "https://files.pythonhosted.org/packages/83/45/92ef7f032c9ce46c5adcf1fa1513e99a9b533ca673bbf01748574ca0f2cc/pymssql-2.3.13-cp310-cp310-win_amd64.whl", hash = "sha256:79c759db6e991eeae473b000c2e0a7fb8da799b2da469fe5a10d30916315e0b5", size = 2009253, upload-time = "2026-02-14T04:59:31.034Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/3d270f2bcfe7cf73b6d17719998316856550ca719791ac00be07e5be7a47/pymssql-2.3.13-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:152be40c0d7f5e4b1323f7728b0a01f3ee0082190cfbadf84b2c2e930d57e00e", size = 3171876, upload-time = "2026-02-14T04:59:33.247Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b0/b4f1c7879a4fdac688f227cc55f31a5cfc8c36dc63117a10af472a399fc1/pymssql-2.3.13-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:d94da3a55545c5b6926cb4d1c6469396f0ae32ad5d6932c513f7a0bf569b4799", size = 2973968, upload-time = "2026-02-14T04:59:35.462Z" },
{ url = "https://files.pythonhosted.org/packages/3c/68/45157f1bb9b8499e4abe7b64195f44aaa2d6bf6aae305d4e8cf5df522424/pymssql-2.3.13-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51e42c5defc3667f0803c7ade85db0e6f24b9a1c5a18fcdfa2d09c36bff9b065", size = 3035519, upload-time = "2026-02-14T04:59:37.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/3aeffc2b3683c135d75e0536657090ddb5f07114eb5e528303e1f4880393/pymssql-2.3.13-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aa18944a121f996178e26cadc598abdbf73759f03dc3cd74263fdab1b28cd96", size = 3162703, upload-time = "2026-02-14T04:59:39.571Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c0/bd35090a223961d9190dfb884be14529358d561cad1b4211dc351b20dcfd/pymssql-2.3.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:910404e0ec85c4cc7c633ec3df9b04a35f23bb74a844dd377a387026ae635e3a", size = 3681162, upload-time = "2026-02-14T04:59:42.181Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d4/d6a5d74c9942d1554538087cfd6ff489d3645bce484c53339f25c4cf6077/pymssql-2.3.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4b834c34e7600369eee7bc877948b53eb0fe6f3689f0888d005ae47dd53c0a66", size = 3424100, upload-time = "2026-02-14T04:59:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/27/38/7f1eff7dbbd286a16e341b813fd55c88258e07268a3ccebbfb0e9c46ca74/pymssql-2.3.13-cp311-cp311-win_amd64.whl", hash = "sha256:5c2e55b6513f9c5a2f58543233ed40baaa7f91c79e64a5f961ea3fc57a700b80", size = 2010201, upload-time = "2026-02-14T04:59:45.404Z" },
{ url = "https://files.pythonhosted.org/packages/ba/60/a2e8a8a38f7be21d54402e2b3365cd56f1761ce9f2706c97f864e8aa8300/pymssql-2.3.13-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cf4f32b4a05b66f02cb7d55a0f3bcb0574a6f8cf0bee4bea6f7b104038364733", size = 3158689, upload-time = "2026-02-14T04:59:46.982Z" },
{ url = "https://files.pythonhosted.org/packages/43/9e/0cf0ffb9e2f73238baf766d8e31d7237b5bee3cc1bb29a376b404610994a/pymssql-2.3.13-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:2b056eb175955f7fb715b60dc1c0c624969f4d24dbdcf804b41ab1e640a2b131", size = 2960018, upload-time = "2026-02-14T04:59:48.668Z" },
{ url = "https://files.pythonhosted.org/packages/93/ea/bc27354feaca717faa4626911f6b19bb62985c87dda28957c63de4de5895/pymssql-2.3.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:319810b89aa64b99d9c5c01518752c813938df230496fa2c4c6dda0603f04c4c", size = 3065719, upload-time = "2026-02-14T04:59:50.369Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7a/8028681c96241fb5fc850b87c8959402c353e4b83c6e049a99ffa67ded54/pymssql-2.3.13-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0ea72641cb0f8bce7ad8565dbdbda4a7437aa58bce045f2a3a788d71af2e4be", size = 3190567, upload-time = "2026-02-14T04:59:52.202Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f1/ab5b76adbbd6db9ce746d448db34b044683522e7e7b95053f9dd0165297b/pymssql-2.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1493f63d213607f708a5722aa230776ada726ccdb94097fab090a1717a2534e0", size = 3710481, upload-time = "2026-02-14T04:59:54.01Z" },
{ url = "https://files.pythonhosted.org/packages/59/aa/2fa0951475cd0a1829e0b8bfbe334d04ece4bce11546a556b005c4100689/pymssql-2.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb3275985c23479e952d6462ae6c8b2b6993ab6b99a92805a9c17942cf3d5b3d", size = 3453789, upload-time = "2026-02-14T04:59:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/78/08/8cd2af9003f9fc03912b658a64f5a4919dcd68f0dd3bbc822b49a3d14fd9/pymssql-2.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:a930adda87bdd8351a5637cf73d6491936f34e525a5e513068a6eac742f69cdb", size = 1994709, upload-time = "2026-02-14T04:59:58.972Z" },
{ url = "https://files.pythonhosted.org/packages/d4/4f/ee15b1f6b11e7c3accdc7da7840a019b63f12ba09eaa008acc601182f516/pymssql-2.3.13-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:30918bb044242865c01838909777ef5e0f1b9ecd7f5882346aefa57f4414b29c", size = 3156333, upload-time = "2026-02-14T05:00:01.21Z" },
{ url = "https://files.pythonhosted.org/packages/79/03/aea5c77bad4a52649a1d9f786a1d9ce1c83d50f1a75df288e292737b6d80/pymssql-2.3.13-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1c6d0b2d7961f159a07e4f0d8cc81f70ceab83f5e7fd1e832a2d069e1d67ee4e", size = 2957990, upload-time = "2026-02-14T05:00:03.11Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f8/30ac16fba32ff066b05f12c392d7b812fe11f06cb62d1d86ca5177c50a8b/pymssql-2.3.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16c5957a3c9e51a03276bfd76a22431e2bc4c565e2e95f2cbb3559312edda230", size = 3065264, upload-time = "2026-02-14T05:00:05.377Z" },
{ url = "https://files.pythonhosted.org/packages/a9/98/7568447bf85921d21453fd56e19b6c9591d595fde0546c5a569f3ae937a8/pymssql-2.3.13-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fddd24efe9d18bbf174fab7c6745b0927773718387f5517cf8082241f721a68", size = 3190039, upload-time = "2026-02-14T05:00:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/35/f1/4d9d275ebaac42cdd49d40d504ccb648f27710660c8b60cc427752438c09/pymssql-2.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:123c55ee41bc7a82c76db12e2eb189b50d0d7a11222b4f8789206d1cda3b33b9", size = 3710151, upload-time = "2026-02-14T05:00:08.424Z" },
{ url = "https://files.pythonhosted.org/packages/6f/bd/a5cc6244fd27d3ea0cc82f12a7d38a24d7fd90b0022afd250014e8bfba15/pymssql-2.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e053b443e842f9e1698fcb2b23a4bff1ff3d410894d880064e754ad823d541e5", size = 3453156, upload-time = "2026-02-14T05:00:09.978Z" },
{ url = "https://files.pythonhosted.org/packages/26/d0/c20ff0bbffd18db528bcc7b0c68b25c12ad563ed67c56ceca87c58f7399e/pymssql-2.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:5c045c0f1977a679cc30d5acd9da3f8aeb2dc6e744895b26444b4a2f20dad9a0", size = 1995236, upload-time = "2026-02-14T05:00:11.495Z" },
{ url = "https://files.pythonhosted.org/packages/ec/5f/6b64f78181d680f655ab40ba7b34cb68c045a2f4e04a10a70d768cd383b7/pymssql-2.3.13-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fc5482969c813b0a45ce51c41844ae5bfa8044ad5ef8b4820ef6de7d4545b7f2", size = 3158377, upload-time = "2026-02-14T05:00:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/ff/24/155dbb0992c431496d440f47fb9d587cd0059ee20baf65e3d891794d862a/pymssql-2.3.13-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:ff5be7ab1d643dbce2ee3424d2ef9ae8e4146cf75bd20946bc7a6108e3ad1e47", size = 2959039, upload-time = "2026-02-14T05:00:15.883Z" },
{ url = "https://files.pythonhosted.org/packages/c9/89/b453dd1b1188779621fb974ac715ab2e738f4a0b69f7291ab014298bd80d/pymssql-2.3.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66ce0a249d2e3b57369048d71e1f00d08dfb90a758d134da0250ae7bc739c1", size = 3063862, upload-time = "2026-02-14T05:00:17.537Z" },
{ url = "https://files.pythonhosted.org/packages/02/e5/96f57c78162013678ecc3f3f7e5fb52c83ee07beef26906d0870770c3ef6/pymssql-2.3.13-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d663c908414a6a032f04d17628138b1782af916afc0df9fefac4751fa394c3ac", size = 3188155, upload-time = "2026-02-14T05:00:19.011Z" },
{ url = "https://files.pythonhosted.org/packages/cd/a2/4bee9484734ae0c55d10a2f6ff82dd4e416f52420755161b8760c817ad64/pymssql-2.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa5e07eff7e6e8bd4ba22c30e4cb8dd073e138cd272090603609a15cc5dbc75b", size = 3709344, upload-time = "2026-02-14T05:00:21.139Z" },
{ url = "https://files.pythonhosted.org/packages/37/cf/3520d96afa213c88db4f4a1988199db476d869a62afdd5d9c4635c184631/pymssql-2.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db77da1a3fc9b5b5c5400639d79d7658ba7ad620957100c5b025be608b562193", size = 3451799, upload-time = "2026-02-14T05:00:22.504Z" },
{ url = "https://files.pythonhosted.org/packages/25/50/4be9bd9cf4b43208a7175117a533ece200cfe4131a39f9909bdc7560ddeb/pymssql-2.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:7d7037d2b5b907acc7906d0479924db2935a70c720450c41339146a4ada2b93d", size = 2049139, upload-time = "2026-02-14T05:00:23.951Z" },
]
[[package]]
name = "pymysql"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
......@@ -1527,9 +1717,13 @@ dependencies = [
{ name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-openai" },
{ name = "oracledb" },
{ name = "passlib" },
{ name = "psycopg2-binary" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
{ name = "pymssql" },
{ name = "pymysql" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
......@@ -1546,16 +1740,20 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "bcrypt", specifier = "==4.1.3" },
{ name = "bcrypt", specifier = ">=4.1.3" },
{ name = "fastapi", specifier = ">=0.111.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-anthropic", specifier = ">=0.1.0" },
{ name = "langchain-openai", specifier = ">=0.1.0" },
{ name = "oracledb", specifier = ">=3.4.2" },
{ name = "passlib", specifier = ">=1.7.4" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pydantic", specifier = ">=2.7.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.7.0" },
{ name = "pydantic-settings", specifier = ">=2.2.0" },
{ name = "pymssql", specifier = ">=2.3.13" },
{ name = "pymysql", specifier = ">=1.1.2" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
......
......@@ -5,12 +5,14 @@ import { AuthProvider } from './contexts/AuthContext'
import { PrivateRoute } from './components/auth/PrivateRoute'
import { AppShell } from './components/layout/AppShell'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
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'
import { SystemPage } from './pages/SystemPage'
import { DataSyncPage } from './pages/DataSyncPage'
export default function App() {
return (
......@@ -31,7 +33,10 @@ export default function App() {
</PrivateRoute>
}
>
<Route index element={<Navigate to="/patients" replace />} />
<Route index element={<Navigate to="/dashboard" replace />} />
{/* 数据概览 */}
<Route path="dashboard" element={<DashboardPage />} />
{/* 患者管理 */}
<Route
......@@ -83,6 +88,16 @@ export default function App() {
}
/>
{/* 数据同步 */}
<Route
path="data-sync"
element={
<PrivateRoute requiredPermission="menu:system">
<DataSyncPage />
</PrivateRoute>
}
/>
{/* 系统管理 */}
<Route
path="system"
......
......@@ -3,20 +3,24 @@ import {
Drawer, List, ListItem, ListItemButton, ListItemIcon, ListItemText,
Toolbar, Typography, Divider, Box,
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
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'
import SettingsIcon from '@mui/icons-material/Settings'
import SyncIcon from '@mui/icons-material/Sync'
import { useAuth } from '../../contexts/AuthContext'
const NAV_ITEMS = [
{ label: '数据概览', path: '/dashboard', icon: <DashboardIcon />, permission: 'menu:patients' },
{ label: '患者管理', path: '/patients', icon: <PeopleIcon />, permission: 'menu:patients' },
{ label: '临床试验', path: '/trials', icon: <ScienceIcon />, permission: 'menu:trials' },
{ label: '诊断管理', path: '/diagnoses', icon: <LocalHospitalIcon />, permission: 'menu:diagnoses' },
{ label: 'AI 智能匹配', path: '/matching', icon: <PsychologyIcon />, permission: 'menu:matching' },
{ label: '医生工作站', path: '/workstation', icon: <MonitorHeartIcon />, permission: 'menu:workstation' },
{ label: '数据同步', path: '/data-sync', icon: <SyncIcon />, permission: 'menu:system' },
{ label: '系统管理', path: '/system', icon: <SettingsIcon />, permission: 'menu:system' },
]
......
......@@ -24,6 +24,8 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
const { user, logout } = useAuth()
useEffect(() => {
// 只在已登录状态下拉取通知数量,避免过期 token 触发全局 401 跳转
if (!user) return
const fetchCount = () => {
notificationService.unreadCount()
.then(r => setUnreadCount(r.count))
......@@ -32,7 +34,7 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
fetchCount()
const interval = setInterval(fetchCount, 30000)
return () => clearInterval(interval)
}, [])
}, [user])
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
......
......@@ -32,11 +32,7 @@ export function MatchingDetailDrawer({ result, open, onClose, onAuditSuccess }:
const handleAudit = async (decision: 'approve' | 'reject') => {
setLoading(true)
try {
await matchingService.review(result.id, {
doctor_id: 'default_doctor',
decision,
notes
})
await matchingService.review(result.id, { decision, notes })
onAuditSuccess()
onClose()
} catch (error) {
......
import { useState, useEffect } from 'react'
import {
Box, Typography, Grid, Card, CardContent, Stack, Chip,
LinearProgress, Divider, IconButton, Tooltip, Table,
TableBody, TableCell, TableHead, TableRow, Alert,
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import PeopleIcon from '@mui/icons-material/People'
import ScienceIcon from '@mui/icons-material/Science'
import PsychologyIcon from '@mui/icons-material/Psychology'
import BatchPredictionIcon from '@mui/icons-material/BatchPrediction'
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'
import RefreshIcon from '@mui/icons-material/Refresh'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import { dashboardService, DashboardStats } from '../services/dashboardService'
const JOB_STATUS_MAP: Record<string, { label: string; color: 'default' | 'info' | 'warning' | 'success' | 'error' }> = {
pending: { label: '待启动', color: 'default' },
running: { label: '运行中', color: 'info' },
completed: { label: '已完成', color: 'success' },
failed: { label: '失败', color: 'error' },
cancelled: { label: '已停止', color: 'default' },
}
interface StatCardProps {
title: string
value: number | string
subtitle?: string
icon: React.ReactNode
color: string
extra?: React.ReactNode
}
function StatCard({ title, value, subtitle, icon, color, extra }: StatCardProps) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="caption" color="text.secondary" fontWeight={500} textTransform="uppercase" letterSpacing={0.5}>
{title}
</Typography>
<Typography variant="h3" fontWeight={700} color={color} lineHeight={1.2} mt={0.5}>
{value}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary" mt={0.5}>{subtitle}</Typography>
)}
{extra}
</Box>
<Box sx={{
width: 48, height: 48, borderRadius: 2,
bgcolor: `${color}18`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color,
}}>
{icon}
</Box>
</Stack>
</CardContent>
</Card>
)
}
function MatchingDistribution({ matching }: { matching: DashboardStats['matching'] }) {
const total = matching.total
if (total === 0) {
return <Typography variant="body2" color="text.secondary">暂无匹配数据</Typography>
}
const items = [
{ label: '符合', value: matching.eligible, color: '#2e7d32', icon: <CheckCircleIcon fontSize="small" /> },
{ label: '不符合', value: matching.ineligible, color: '#d32f2f', icon: <CancelIcon fontSize="small" /> },
{ label: '待审核', value: matching.pending_review, color: '#ed6c02', icon: <HourglassEmptyIcon fontSize="small" /> },
{ label: '需补充', value: matching.needs_more_info, color: '#757575', icon: <HelpOutlineIcon fontSize="small" /> },
]
return (
<Stack spacing={1.5}>
{items.map(item => (
<Box key={item.label}>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={0.5}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: item.color }}>
{item.icon}
<Typography variant="body2" fontWeight={500}>{item.label}</Typography>
</Stack>
<Typography variant="body2" fontWeight={600}>
{item.value}
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
({total > 0 ? ((item.value / total) * 100).toFixed(0) : 0}%)
</Typography>
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={total > 0 ? (item.value / total) * 100 : 0}
sx={{
height: 6, borderRadius: 3,
bgcolor: `${item.color}18`,
'& .MuiLinearProgress-bar': { bgcolor: item.color, borderRadius: 3 },
}}
/>
</Box>
))}
</Stack>
)
}
export function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const load = async () => {
setLoading(true)
setError('')
try {
const data = await dashboardService.getStats()
setStats(data)
setLastUpdated(new Date())
} catch {
setError('数据加载失败,请检查后端服务是否正常运行')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
return (
<Box>
{/* 页头 */}
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3}>
<Stack direction="row" alignItems="center" spacing={2}>
<DashboardIcon color="primary" sx={{ fontSize: 32 }} />
<Box>
<Typography variant="h5">数据概览</Typography>
<Typography variant="subtitle2" color="text.secondary">
系统运行指标实时统计
</Typography>
</Box>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
{lastUpdated && (
<Typography variant="caption" color="text.secondary">
更新于 {lastUpdated.toLocaleTimeString('zh-CN')}
</Typography>
)}
<Tooltip title="刷新数据">
<IconButton onClick={load} disabled={loading} size="small">
<RefreshIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && !stats && (
<LinearProgress sx={{ mb: 2, borderRadius: 1 }} />
)}
{stats && (
<>
{/* ── 第一行:KPI 卡片 ── */}
<Grid container spacing={2} mb={2}>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="患者总数"
value={stats.patients.total}
subtitle="已录入系统的患者"
icon={<PeopleIcon />}
color="#1976d2"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="临床试验"
value={stats.trials.total}
icon={<ScienceIcon />}
color="#7b1fa2"
extra={
<Stack direction="row" spacing={0.5} mt={1}>
<Chip
label={`招募中 ${stats.trials.recruiting}`}
size="small"
color="success"
variant="outlined"
/>
<Chip
label={`其他 ${stats.trials.total - stats.trials.recruiting}`}
size="small"
variant="outlined"
/>
</Stack>
}
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="AI 匹配记录"
value={stats.matching.total}
subtitle={stats.matching.total > 0
? `符合率 ${((stats.matching.eligible / stats.matching.total) * 100).toFixed(1)}%`
: '暂无匹配记录'}
icon={<PsychologyIcon />}
color="#2e7d32"
extra={
stats.matching.pending_review > 0 ? (
<Chip
label={`${stats.matching.pending_review} 条待审核`}
size="small"
color="warning"
sx={{ mt: 1 }}
/>
) : undefined
}
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="批量匹配任务"
value={stats.batch_jobs.total}
icon={<BatchPredictionIcon />}
color="#e65100"
extra={
<Stack direction="row" spacing={0.5} mt={1} flexWrap="wrap" gap={0.5}>
<Chip label={`完成 ${stats.batch_jobs.completed}`} size="small" color="success" variant="outlined" />
{stats.batch_jobs.running > 0 && (
<Chip label={`运行中 ${stats.batch_jobs.running}`} size="small" color="info" variant="outlined" />
)}
{stats.batch_jobs.failed > 0 && (
<Chip label={`失败 ${stats.batch_jobs.failed}`} size="small" color="error" variant="outlined" />
)}
</Stack>
}
/>
</Grid>
</Grid>
{/* ── 第二行:匹配分布 + 最近批量任务 ── */}
<Grid container spacing={2} mb={2}>
{/* 匹配状态分布 */}
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1} mb={2}>
<PsychologyIcon color="action" fontSize="small" />
<Typography variant="subtitle1" fontWeight={600}>匹配状态分布</Typography>
</Stack>
<Divider sx={{ mb: 2 }} />
<MatchingDistribution matching={stats.matching} />
{stats.notifications.unread > 0 && (
<Box mt={3} pt={2} sx={{ borderTop: 1, borderColor: 'divider' }}>
<Stack direction="row" alignItems="center" spacing={1}>
<NotificationsActiveIcon color="warning" fontSize="small" />
<Typography variant="body2">
<strong>{stats.notifications.unread}</strong> 条未读通知待处理
</Typography>
</Stack>
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* 最近批量匹配任务 */}
<Grid item xs={12} md={8}>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ pb: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} mb={2}>
<BatchPredictionIcon color="action" fontSize="small" />
<Typography variant="subtitle1" fontWeight={600}>最近批量匹配记录</Typography>
</Stack>
</CardContent>
<Divider />
{stats.recent_batch_jobs.length > 0 ? (
<Table size="small">
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 600, bgcolor: 'grey.50' } }}>
<TableCell>试验项目</TableCell>
<TableCell>状态</TableCell>
<TableCell>进度</TableCell>
<TableCell>触发人</TableCell>
<TableCell>时间</TableCell>
</TableRow>
</TableHead>
<TableBody>
{stats.recent_batch_jobs.map(job => {
const s = JOB_STATUS_MAP[job.status] ?? { label: job.status, color: 'default' as const }
return (
<TableRow key={job.id} hover>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{job.trial_title ?? ''}
</Typography>
</TableCell>
<TableCell>
<Chip label={s.label} color={s.color} size="small" />
</TableCell>
<TableCell>
<Typography variant="body2">
{job.completed_pairs}/{job.total_pairs}
</Typography>
{job.status === 'running' && job.total_pairs > 0 && (
<LinearProgress
variant="determinate"
value={job.progress_pct * 100}
sx={{ mt: 0.5, height: 3, borderRadius: 2 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2">{job.triggered_by}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary">
{job.started_at
? new Date(job.started_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: ''}
</Typography>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : (
<CardContent>
<Typography variant="body2" color="text.secondary">暂无批量匹配记录</Typography>
</CardContent>
)}
</Card>
</Grid>
</Grid>
{/* ── 第三行:系统概况 ── */}
<Card>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} mb={1.5}>系统概况</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={3}>
{[
{ label: '患者数据完整率', value: stats.patients.total > 0 ? 100 : 0, color: '#1976d2' },
{
label: '试验招募率',
value: stats.trials.total > 0 ? Math.round((stats.trials.recruiting / stats.trials.total) * 100) : 0,
color: '#7b1fa2',
},
{
label: '匹配符合率',
value: stats.matching.total > 0 ? Math.round((stats.matching.eligible / stats.matching.total) * 100) : 0,
color: '#2e7d32',
},
{
label: '批量任务成功率',
value: stats.batch_jobs.total > 0 ? Math.round((stats.batch_jobs.completed / stats.batch_jobs.total) * 100) : 0,
color: '#e65100',
},
].map(item => (
<Grid item xs={12} sm={6} md={3} key={item.label}>
<Box>
<Stack direction="row" justifyContent="space-between" mb={0.5}>
<Typography variant="body2" color="text.secondary">{item.label}</Typography>
<Typography variant="body2" fontWeight={700} color={item.color}>{item.value}%</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={item.value}
sx={{
height: 8, borderRadius: 4,
bgcolor: `${item.color}18`,
'& .MuiLinearProgress-bar': { bgcolor: item.color, borderRadius: 4 },
}}
/>
</Box>
</Grid>
))}
</Grid>
</CardContent>
</Card>
</>
)}
</Box>
)
}
import { useState, useEffect, useCallback } from 'react'
import {
Box, Typography, Card, CardContent, Stack, Chip, IconButton,
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, FormControl, InputLabel, Select, MenuItem,
FormControlLabel, Switch, Snackbar, Alert, CircularProgress,
Grid, Divider, Tooltip, Stepper, Step, StepLabel,
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
} from '@mui/material'
import SyncIcon from '@mui/icons-material/Sync'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ErrorIcon from '@mui/icons-material/Error'
import ScheduleIcon from '@mui/icons-material/Schedule'
import StorageIcon from '@mui/icons-material/Storage'
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import RefreshIcon from '@mui/icons-material/Refresh'
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
import TableChartIcon from '@mui/icons-material/TableChart'
import { syncService, SyncSource, SyncSourceCreate, TARGET_PATIENT_FIELDS, FieldMapping, PreviewResult } from '../services/syncService'
import { connectionProfileService, ConnectionProfile, ConnectionProfileCreate } from '../services/connectionProfileService'
// ─────────────────────────────────────────────────────────────────────────────
// 数据库类型选项
// ─────────────────────────────────────────────────────────────────────────────
const DB_TYPES = [
{ value: 'mysql', label: 'MySQL', defaultPort: '3306' },
{ value: 'postgresql', label: 'PostgreSQL', defaultPort: '5432' },
{ value: 'sqlserver', label: 'SQL Server', defaultPort: '1433' },
{ value: 'oracle', label: 'Oracle', defaultPort: '1521' },
]
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────────────────────────────────────
function useSnack() {
const [snack, setSnack] = useState<{ open: boolean; severity: 'success' | 'error' | 'info'; message: string }>({
open: false, severity: 'success', message: '',
})
const show = (severity: 'success' | 'error' | 'info', message: string) =>
setSnack({ open: true, severity, message })
const hide = () => setSnack(s => ({ ...s, open: false }))
return { snack, show, hide }
}
function formatDate(dateStr?: string) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
})
}
// ─────────────────────────────────────────────────────────────────────────────
// 新建/编辑同步任务弹窗 - 三步向导
// ─────────────────────────────────────────────────────────────────────────────
const WIZARD_STEPS = ['基本设置', '数据来源', '字段映射']
const FIELD_ALIASES: Record<string, string> = {
patient_id: 'mrn', patientid: 'mrn', pat_id: 'mrn', mrn: 'mrn', med_rec_no: 'mrn',
patient_name: 'name', patientname: 'name', name: 'name', xm: 'name', pat_name: 'name',
birth_date: 'birth_date', birthdate: 'birth_date', birthday: 'birth_date',
csrq: 'birth_date', birth: 'birth_date', dob: 'birth_date',
sex: 'gender', gender: 'gender', xb: 'gender',
phone: 'phone', mobile: 'phone', tel: 'phone', telephone: 'phone', lxdh: 'phone',
id_card: 'id_number', idcard: 'id_number', id_number: 'id_number', sfzh: 'id_number', id_no: 'id_number',
admission_note: 'admission_note', admission_record: 'admission_note', ryjl: 'admission_note',
lab_report: 'lab_report_text', lab_report_text: 'lab_report_text', jybg: 'lab_report_text',
pathology_report: 'pathology_report', blbg: 'pathology_report',
}
function autoMatch(col: string): string {
const lower = col.toLowerCase()
return FIELD_ALIASES[lower] || FIELD_ALIASES[lower.replace(/[_\s-]/g, '')] || ''
}
interface SyncTaskDialogProps {
open: boolean
onClose: () => void
onSaved: () => void
editSource: SyncSource | null
profiles: ConnectionProfile[]
snack: ReturnType<typeof useSnack>
}
function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }: SyncTaskDialogProps) {
const [step, setStep] = useState(0)
// Step 0
const [taskName, setTaskName] = useState('')
const [profileId, setProfileId] = useState('')
// Step 1
const [tableMode, setTableMode] = useState<'table' | 'custom'>('table')
const [tables, setTables] = useState<string[]>([])
const [loadingTables, setLoadingTables] = useState(false)
const [selectedTable, setSelectedTable] = useState('')
const [sqlQuery, setSqlQuery] = useState('')
const [columns, setColumns] = useState<string[]>([])
const [previewRows, setPreviewRows] = useState<any[][]>([])
const [previewing, setPreviewing] = useState(false)
const [previewDone, setPreviewDone] = useState(false)
const [previewError, setPreviewError] = useState('')
// Step 2
const [mappings, setMappings] = useState<Record<string, string>>({})
const [syncMode, setSyncMode] = useState<'full' | 'incremental'>('full')
const [incrementalField, setIncrementalField] = useState('')
const [isActive, setIsActive] = useState(true)
const [saving, setSaving] = useState(false)
// 初始化
useEffect(() => {
if (!open) return
setStep(0)
setPreviewDone(false)
setColumns([])
setPreviewRows([])
setPreviewError('')
setTables([])
setSelectedTable('')
if (editSource) {
const cfg = editSource.sync_config || {}
setTaskName(editSource.name)
setProfileId(editSource.connection_profile_id || '')
setSyncMode((editSource.sync_mode as 'full' | 'incremental') || 'full')
setSqlQuery(cfg.query || '')
setIncrementalField(cfg.incremental_field || '')
setIsActive(editSource.is_active)
setTableMode('custom')
const m: Record<string, string> = {}
;(cfg.field_mappings || []).forEach((f: FieldMapping) => { if (f.source_field) m[f.source_field] = f.target_field })
setMappings(m)
// restore columns from saved mappings so step 2 shows them
setColumns(Object.keys(m))
} else {
setTaskName('')
setProfileId('')
setSyncMode('full')
setSqlQuery('')
setIncrementalField('')
setIsActive(true)
setTableMode('table')
setMappings({})
}
}, [open, editSource])
// 选表后自动生成 SQL
useEffect(() => {
if (tableMode === 'table' && selectedTable) {
setSqlQuery(`SELECT * FROM ${selectedTable}`)
setPreviewDone(false)
setColumns([])
}
}, [selectedTable, tableMode])
const loadTables = useCallback(async (pid: string) => {
if (!pid) return
setLoadingTables(true)
try {
const list = await syncService.listTables(pid)
setTables(list)
} catch {
setTables([])
} finally {
setLoadingTables(false)
}
}, [])
const handlePreview = async () => {
if (!sqlQuery.trim()) { snack.show('error', '请填写 SQL 查询语句'); return }
setPreviewing(true)
setPreviewError('')
try {
const result = await syncService.previewSource({
source_type: 'DATABASE',
sql_query: sqlQuery,
connection_profile_id: profileId || undefined,
limit: 3,
})
if (result.error) {
setPreviewError(result.error)
setPreviewDone(false)
} else {
setColumns(result.columns)
setPreviewRows(result.rows)
setPreviewDone(true)
// 自动匹配,保留已有手动映射
const auto: Record<string, string> = {}
result.columns.forEach(col => { auto[col] = autoMatch(col) })
setMappings(prev => ({ ...auto, ...prev }))
}
} catch (e: any) {
setPreviewError(e?.response?.data?.detail || e?.message || '预览失败')
setPreviewDone(false)
} finally {
setPreviewing(false)
}
}
const handleNext = async () => {
if (step === 0) {
if (!taskName.trim()) { snack.show('error', '请填写任务名称'); return }
if (profileId && tables.length === 0) await loadTables(profileId)
} else if (step === 1) {
if (!previewDone && !editSource) { snack.show('error', '请先点击"预览数据"确认 SQL 可以正常执行'); return }
}
setStep(s => s + 1)
}
const requiredFields = TARGET_PATIENT_FIELDS.filter(f => f.required).map(f => f.field)
const mappedTargets = Object.values(mappings).filter(Boolean)
const missingRequired = requiredFields.filter(f => !mappedTargets.includes(f))
const handleSave = async () => {
if (missingRequired.length > 0) {
snack.show('error', `必填字段未映射:${missingRequired.map(f => TARGET_PATIENT_FIELDS.find(tf => tf.field === f)?.label?.split(' ')[0]).join('')}`)
return
}
if (syncMode === 'incremental' && !incrementalField) {
snack.show('error', '增量同步必须选择更新时间字段')
return
}
setSaving(true)
try {
const fieldMappings: FieldMapping[] = Object.entries(mappings)
.filter(([, t]) => t)
.map(([s, t]) => ({ source_field: s, target_field: t }))
const data: SyncSourceCreate = {
name: taskName,
source_type: 'DATABASE',
connection_profile_id: profileId || undefined,
sync_mode: syncMode,
is_active: isActive,
sync_config: {
query: sqlQuery,
field_mappings: fieldMappings,
...(syncMode === 'incremental' && incrementalField ? { incremental_field: incrementalField } : {}),
batch_size: 100,
},
}
if (editSource) {
await syncService.updateSource(editSource.id, data)
snack.show('success', '同步任务已更新')
} else {
await syncService.createSource(data)
snack.show('success', '同步任务已创建')
}
onSaved()
onClose()
} catch (e: any) {
snack.show('error', e?.response?.data?.detail || e?.message || '保存失败')
} finally {
setSaving(false)
}
}
const dbProfiles = profiles.filter(p => p.is_active && p.connection_type === 'database')
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={{ pb: 1 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<SyncIcon color="primary" />
<Typography variant="h6">{editSource ? '编辑同步任务' : '新建同步任务'}</Typography>
</Stack>
</DialogTitle>
<Box sx={{ px: 4, pt: 1, pb: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{WIZARD_STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent dividers sx={{ minHeight: 400 }}>
{/* ── Step 0: 基本设置 ── */}
{step === 0 && (
<Stack spacing={3} sx={{ maxWidth: 480, mx: 'auto', pt: 3 }}>
<TextField
fullWidth size="small" label="任务名称" required autoFocus
value={taskName} onChange={e => setTaskName(e.target.value)}
placeholder="如:HIS 患者数据同步"
/>
<FormControl fullWidth size="small">
<InputLabel>数据库连接</InputLabel>
<Select value={profileId} onChange={e => setProfileId(e.target.value)} label="数据库连接">
<MenuItem value=""><em>模拟数据(开发测试用)</em></MenuItem>
{dbProfiles.map(p => (
<MenuItem key={p.id} value={p.id}>
<Stack direction="row" alignItems="center" spacing={1}>
<StorageIcon fontSize="small" color="action" />
<span>{p.name}</span>
<Chip label={p.auth_config?.db_type?.toUpperCase() || 'DB'} size="small" sx={{ height: 18, fontSize: 10 }} />
</Stack>
</MenuItem>
))}
</Select>
</FormControl>
{!profileId && (
<Alert severity="info" sx={{ fontSize: 13 }}>
未选择数据库时使用模拟数据,适合功能测试。正式使用请先在页面右侧"新建数据库连接"中添加真实数据库。
</Alert>
)}
</Stack>
)}
{/* ── Step 1: 数据来源 ── */}
{step === 1 && (
<Grid container spacing={3} sx={{ pt: 1 }}>
<Grid item xs={12} md={5}>
<Stack spacing={2}>
{/* 选择表 / 自定义 SQL 切换 */}
{profileId && (
<Stack direction="row" spacing={1}>
<Chip label="选择数据表" size="small" clickable
variant={tableMode === 'table' ? 'filled' : 'outlined'}
color={tableMode === 'table' ? 'primary' : 'default'}
icon={<TableChartIcon sx={{ fontSize: 14 }} />}
onClick={() => setTableMode('table')}
/>
<Chip label="自定义 SQL" size="small" clickable
variant={tableMode === 'custom' ? 'filled' : 'outlined'}
color={tableMode === 'custom' ? 'primary' : 'default'}
onClick={() => setTableMode('custom')}
/>
</Stack>
)}
{/* 表选择器 */}
{profileId && tableMode === 'table' && (
<FormControl fullWidth size="small">
<InputLabel>选择数据表</InputLabel>
<Select value={selectedTable} onChange={e => setSelectedTable(e.target.value)}
label="选择数据表" disabled={loadingTables}
startAdornment={loadingTables ? <CircularProgress size={14} sx={{ mr: 1 }} /> : undefined}
>
{tables.length === 0 && !loadingTables && (
<MenuItem disabled><em>暂无数据(请先测试连接)</em></MenuItem>
)}
{tables.map(t => (
<MenuItem key={t} value={t} sx={{ fontFamily: 'monospace', fontSize: 13 }}>{t}</MenuItem>
))}
</Select>
</FormControl>
)}
{/* SQL 编辑器 */}
<TextField
fullWidth size="small" label="SQL 查询语句" multiline
rows={tableMode === 'custom' || !profileId ? 9 : 6}
value={sqlQuery}
onChange={e => { setSqlQuery(e.target.value); setPreviewDone(false) }}
placeholder={'SELECT\n patient_id,\n patient_name,\n birth_date,\n sex\nFROM his_patients\nWHERE status = \'在院\''}
InputProps={{ sx: { fontFamily: 'monospace', fontSize: 12 } }}
helperText="选择表后自动生成,也可手动修改"
/>
<Button variant="contained" fullWidth
startIcon={previewing ? <CircularProgress size={16} color="inherit" /> : <PlayArrowIcon />}
onClick={handlePreview} disabled={previewing || !sqlQuery.trim()}
>
{previewing ? '查询中...' : '预览数据(必须执行)'}
</Button>
{previewError && <Alert severity="error" sx={{ fontSize: 12 }}>{previewError}</Alert>}
{previewDone && (
<Alert severity="success" sx={{ fontSize: 12 }}>
成功:识别到 {columns.length} 个字段,{previewRows.length} 条样本
</Alert>
)}
</Stack>
</Grid>
{/* 预览数据表格 */}
<Grid item xs={12} md={7}>
{previewDone && columns.length > 0 ? (
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>数据预览</Typography>
<Box sx={{ overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, fontFamily: 'monospace' }}>
<thead>
<tr style={{ background: '#f5f5f5' }}>
{columns.map(col => (
<th key={col} style={{ padding: '6px 10px', textAlign: 'left', borderBottom: '1px solid #ddd', whiteSpace: 'nowrap' }}>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri} style={{ borderBottom: '1px solid #f0f0f0' }}>
{row.map((cell, ci) => (
<td key={ci} style={{ padding: '5px 10px', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cell == null ? <span style={{ color: '#ccc' }}>NULL</span> : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</Box>
</Box>
) : (
<Box sx={{ height: '100%', minHeight: 220, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, color: 'text.disabled', border: '2px dashed', borderColor: 'divider', borderRadius: 2 }}>
<TableChartIcon sx={{ fontSize: 52 }} />
<Typography variant="body2">点击"预览数据"查看表结构和样本</Typography>
</Box>
)}
</Grid>
</Grid>
)}
{/* ── Step 2: 字段映射 + 同步策略 ── */}
{step === 2 && (
<Grid container spacing={4} sx={{ pt: 1 }}>
{/* 字段映射 */}
<Grid item xs={12} md={7}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
字段映射
<Typography component="span" variant="caption" color="text.disabled" sx={{ ml: 1 }}>
将源数据库字段对应到系统患者模型字段(* 必填)
</Typography>
</Typography>
{missingRequired.length > 0 && (
<Alert severity="warning" sx={{ mb: 1.5, fontSize: 12 }}>
必填字段未映射:{missingRequired.map(f => TARGET_PATIENT_FIELDS.find(tf => tf.field === f)?.label?.split(' ')[0]).join('、')}
</Alert>
)}
{columns.length === 0 ? (
<Alert severity="info">请返回上一步执行预览,系统将自动识别字段并生成映射</Alert>
) : (
<Stack spacing={1}>
{columns.map(col => {
const target = mappings[col] || ''
const info = TARGET_PATIENT_FIELDS.find(f => f.field === target)
return (
<Stack key={col} direction="row" alignItems="center" spacing={1.5}>
<Chip label={col} size="small" variant="outlined"
sx={{ fontFamily: 'monospace', fontSize: 11, minWidth: 130, justifyContent: 'flex-start' }} />
<ArrowForwardIcon sx={{ fontSize: 14, color: 'text.disabled', flexShrink: 0 }} />
<FormControl size="small" sx={{ minWidth: 180 }}>
<Select value={target} displayEmpty
onChange={e => setMappings(prev => ({ ...prev, [col]: e.target.value }))}>
<MenuItem value=""><em style={{ color: '#bbb' }}>不导入</em></MenuItem>
{TARGET_PATIENT_FIELDS.map(f => (
<MenuItem key={f.field} value={f.field}>
{f.required ? '* ' : ''}{f.label.split(' ')[0]}
</MenuItem>
))}
</Select>
</FormControl>
{info?.required && target && <CheckCircleIcon color="success" sx={{ fontSize: 16 }} />}
</Stack>
)
})}
</Stack>
)}
</Grid>
{/* 同步策略 */}
<Grid item xs={12} md={5}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>同步策略</Typography>
<Stack spacing={2.5}>
<Box>
<Typography variant="body2" gutterBottom>同步模式</Typography>
<Stack direction="row" spacing={1}>
<Chip label="全量同步" icon={<RefreshIcon />} clickable
variant={syncMode === 'full' ? 'filled' : 'outlined'}
color={syncMode === 'full' ? 'primary' : 'default'}
onClick={() => setSyncMode('full')}
/>
<Chip label="增量同步" icon={<ScheduleIcon />} clickable
variant={syncMode === 'incremental' ? 'filled' : 'outlined'}
color={syncMode === 'incremental' ? 'success' : 'default'}
onClick={() => setSyncMode('incremental')}
/>
</Stack>
<Typography variant="caption" color="text.disabled" sx={{ mt: 0.5, display: 'block' }}>
{syncMode === 'full'
? '每次拉取全部数据,适合小表或初始导入'
: '仅拉取上次同步后更新的记录,速度快,适合有时间戳字段的大表'}
</Typography>
</Box>
{syncMode === 'incremental' && (
<FormControl fullWidth size="small">
<InputLabel>更新时间字段 *</InputLabel>
<Select value={incrementalField}
onChange={e => setIncrementalField(e.target.value)}
label="更新时间字段 *"
>
<MenuItem value=""><em>请选择</em></MenuItem>
{columns.map(col => (
<MenuItem key={col} value={col} sx={{ fontFamily: 'monospace', fontSize: 13 }}>{col}</MenuItem>
))}
</Select>
<Typography variant="caption" color="text.disabled" sx={{ mt: 0.5 }}>
系统将自动在 WHERE 条件中过滤此字段大于上次同步时间的记录
</Typography>
</FormControl>
)}
<Divider />
<FormControlLabel
control={<Switch checked={isActive} onChange={e => setIsActive(e.target.checked)} />}
label="立即启用此任务"
/>
</Stack>
</Grid>
</Grid>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between' }}>
<Button onClick={step === 0 ? onClose : () => setStep(s => s - 1)} disabled={saving}>
{step === 0 ? '取消' : '上一步'}
</Button>
{step < WIZARD_STEPS.length - 1 ? (
<Button variant="contained" onClick={handleNext}>下一步</Button>
) : (
<Button variant="contained" onClick={handleSave} disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}>
{saving ? '保存中...' : (editSource ? '更新任务' : '创建任务')}
</Button>
)}
</DialogActions>
</Dialog>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 数据库连接配置弹窗
// ─────────────────────────────────────────────────────────────────────────────
interface ConnectionDialogProps {
open: boolean
onClose: () => void
onSaved: () => void
editProfile: ConnectionProfile | null
snack: ReturnType<typeof useSnack>
}
function ConnectionDialog({ open, onClose, onSaved, editProfile, snack }: ConnectionDialogProps) {
const [name, setName] = useState('')
const [dbType, setDbType] = useState('mysql')
const [host, setHost] = useState('')
const [port, setPort] = useState('3306')
const [database, setDatabase] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [oracleType, setOracleType] = useState<'sid' | 'service_name'>('sid')
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testPassed, setTestPassed] = useState(false)
useEffect(() => {
if (!open) return
setTestPassed(false)
if (editProfile) {
const ac = editProfile.auth_config || {}
setName(editProfile.name)
setDbType(ac.db_type || 'mysql')
setHost(ac.host || '')
setPort(ac.port || '3306')
setDatabase(ac.database || '')
setUsername(ac.username || '')
setPassword('')
setOracleType(ac.oracle_type || 'sid')
// 编辑时默认已通过测试
setTestPassed(true)
} else {
setName('')
setDbType('mysql')
setHost('')
setPort('3306')
setDatabase('')
setUsername('')
setPassword('')
setOracleType('sid')
}
}, [open, editProfile])
// 连接信息变更时重置测试状态
const handleFieldChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
setter(e.target.value)
setTestPassed(false)
}
const handleDbTypeChange = (type: string) => {
setDbType(type)
const db = DB_TYPES.find(d => d.value === type)
if (db) setPort(db.defaultPort)
if (type === 'oracle') setOracleType('sid')
setTestPassed(false)
}
// 测试连接
const handleTestConnection = async () => {
if (!host.trim() || !database.trim()) {
snack.show('error', '请填写主机地址和数据库名')
return
}
setTesting(true)
try {
const result = await syncService.testConnection({
db_type: dbType,
host,
port,
database,
username,
password,
oracle_type: oracleType,
})
if (result.success) {
snack.show('success', result.message)
setTestPassed(true)
} else {
snack.show('error', result.message)
setTestPassed(false)
}
} catch (e: any) {
snack.show('error', e?.response?.data?.detail || e?.message || '连接测试失败')
setTestPassed(false)
} finally {
setTesting(false)
}
}
const handleSave = async () => {
if (!name.trim() || !host.trim() || !database.trim()) {
snack.show('error', '请填写完整的连接信息')
return
}
if (!testPassed) {
snack.show('error', '请先测试连接成功后再保存')
return
}
setSaving(true)
try {
const data: ConnectionProfileCreate = {
name,
connection_type: 'database',
auth_config: {
db_type: dbType,
host,
port,
database,
username,
password: password || (editProfile?.auth_config?.password || ''),
...(dbType === 'oracle' ? { oracle_type: oracleType } : {}),
},
is_active: true,
}
if (editProfile) {
await connectionProfileService.update(editProfile.id, data)
snack.show('success', '连接配置已更新')
} else {
await connectionProfileService.create(data)
snack.show('success', '连接配置已创建')
}
onSaved()
onClose()
} catch (e: any) {
snack.show('error', e?.response?.data?.detail || '保存失败')
} finally {
setSaving(false)
}
}
const canTest = host.trim() && database.trim()
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Stack direction="row" alignItems="center" spacing={1}>
<StorageIcon color="primary" />
<Typography variant="h6">{editProfile ? '编辑数据库连接' : '新建数据库连接'}</Typography>
</Stack>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2.5} sx={{ pt: 1 }}>
<TextField
fullWidth size="small" label="连接名称" required
value={name}
onChange={e => setName(e.target.value)}
placeholder="如:生产环境 HIS 数据库"
/>
<FormControl fullWidth size="small">
<InputLabel>数据库类型</InputLabel>
<Select value={dbType} onChange={e => handleDbTypeChange(e.target.value)} label="数据库类型">
{DB_TYPES.map(db => (
<MenuItem key={db.value} value={db.value}>{db.label}</MenuItem>
))}
</Select>
</FormControl>
{dbType === 'oracle' && (
<FormControl fullWidth size="small">
<InputLabel>连接类型</InputLabel>
<Select
value={oracleType}
onChange={e => { setOracleType(e.target.value as 'sid' | 'service_name'); setTestPassed(false) }}
label="连接类型"
>
<MenuItem value="sid">SID(系统标识符)</MenuItem>
<MenuItem value="service_name">Service Name(服务名)</MenuItem>
</Select>
</FormControl>
)}
<Stack direction="row" spacing={2}>
<TextField
size="small" label="主机地址" required
value={host}
onChange={handleFieldChange(setHost)}
placeholder="192.168.1.100"
sx={{ flex: 2 }}
/>
<TextField
size="small" label="端口" required
value={port}
onChange={handleFieldChange(setPort)}
sx={{ flex: 1 }}
/>
</Stack>
<TextField
fullWidth size="small" label="数据库名" required
value={database}
onChange={handleFieldChange(setDatabase)}
placeholder="his_db"
/>
<Stack direction="row" spacing={2}>
<TextField
size="small" label="用户名"
value={username}
onChange={handleFieldChange(setUsername)}
sx={{ flex: 1 }}
/>
<TextField
size="small" label="密码" type="password"
value={password}
onChange={handleFieldChange(setPassword)}
placeholder={editProfile ? '留空保持原密码' : ''}
sx={{ flex: 1 }}
/>
</Stack>
{/* 测试连接 */}
<Divider />
<Stack direction="row" spacing={2} alignItems="center">
<Button
variant="outlined"
onClick={handleTestConnection}
disabled={testing || !canTest}
startIcon={testing ? <CircularProgress size={16} /> : <PlayArrowIcon />}
>
{testing ? '测试中...' : '测试连接'}
</Button>
{testPassed && (
<Stack direction="row" spacing={0.5} alignItems="center">
<CheckCircleIcon color="success" fontSize="small" />
<Typography variant="body2" color="success.main">连接成功</Typography>
</Stack>
)}
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose}>取消</Button>
<Button variant="contained" onClick={handleSave} disabled={saving || !testPassed}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogActions>
</Dialog>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 主页面
// ─────────────────────────────────────────────────────────────────────────────
export function DataSyncPage() {
const { snack, show: showSnack, hide: hideSnack } = useSnack()
// 数据
const [sources, setSources] = useState<SyncSource[]>([])
const [profiles, setProfiles] = useState<ConnectionProfile[]>([])
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState<Record<string, boolean>>({})
// 弹窗状态
const [taskDialogOpen, setTaskDialogOpen] = useState(false)
const [editSource, setEditSource] = useState<SyncSource | null>(null)
const [connDialogOpen, setConnDialogOpen] = useState(false)
const [editProfile, setEditProfile] = useState<ConnectionProfile | null>(null)
// 加载数据
const loadData = useCallback(async () => {
try {
const [sourcesRes, profilesRes] = await Promise.all([
syncService.listSources(),
connectionProfileService.list(),
])
setSources(sourcesRes)
setProfiles(profilesRes)
} catch (e: any) {
showSnack('error', '加载数据失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
// 执行同步
const handleRunSync = async (source: SyncSource) => {
setSyncing(s => ({ ...s, [source.id]: true }))
try {
const result = await syncService.runSync(source.id)
showSnack('success', `同步完成:新增 ${result.created} 条,更新 ${result.updated} `)
loadData()
} catch (e: any) {
showSnack('error', e?.response?.data?.detail || '同步失败')
} finally {
setSyncing(s => ({ ...s, [source.id]: false }))
}
}
// 删除同步任务
const handleDeleteSource = async (source: SyncSource) => {
if (!window.confirm(`确定删除同步任务「${source.name}」吗?`)) return
try {
await syncService.deleteSource(source.id)
showSnack('success', '已删除')
loadData()
} catch (e: any) {
showSnack('error', '删除失败')
}
}
// 删除连接配置
const handleDeleteProfile = async (profile: ConnectionProfile) => {
if (!window.confirm(`确定删除连接配置「${profile.name}」吗?`)) return
try {
await connectionProfileService.delete(profile.id)
showSnack('success', '已删除')
loadData()
} catch (e: any) {
showSnack('error', '删除失败')
}
}
return (
<Box sx={{ p: 3, maxWidth: 1400, mx: 'auto' }}>
{/* 页面标题 */}
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 3 }}>
<Box>
<Typography variant="h5" fontWeight={600}>
<SyncIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
数据同步
</Typography>
<Typography variant="body2" color="text.secondary">
配置并管理患者数据同步任务,从 HIS/LIS/EMR 等系统拉取数据
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
startIcon={<StorageIcon />}
onClick={() => { setEditProfile(null); setConnDialogOpen(true) }}
>
新建数据库连接
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => { setEditSource(null); setTaskDialogOpen(true) }}
>
新建同步任务
</Button>
</Stack>
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
{/* 同步任务列表 */}
<Grid item xs={12} lg={8}>
<Card>
<CardContent sx={{ pb: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>
同步任务 ({sources.length})
</Typography>
</CardContent>
{sources.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center' }}>
<CloudDownloadIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">暂无同步任务</Typography>
<Button
variant="text"
startIcon={<AddIcon />}
onClick={() => { setEditSource(null); setTaskDialogOpen(true) }}
sx={{ mt: 1 }}
>
创建第一个同步任务
</Button>
</Box>
) : (
<List disablePadding>
{sources.map((source, idx) => {
const isSyncing = syncing[source.id]
const profileName = profiles.find(p => p.id === source.connection_profile_id)?.name
return (
<ListItem
key={source.id}
divider={idx < sources.length - 1}
sx={{ py: 2, px: 3 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>
{source.is_active ? (
<CheckCircleIcon color="success" />
) : (
<ErrorIcon color="disabled" />
)}
</ListItemIcon>
<ListItemText
primary={
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="subtitle2">{source.name}</Typography>
<Chip
label={source.sync_mode === 'incremental' ? '增量' : '全量'}
size="small"
color={source.sync_mode === 'incremental' ? 'info' : 'default'}
variant="outlined"
/>
</Stack>
}
secondary={
<Stack direction="row" spacing={2} sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
数据库:{profileName || '模拟数据'}
</Typography>
{source.last_sync_at && (
<Typography variant="caption" color="text.secondary">
上次同步:{formatDate(source.last_sync_at)},
{source.last_sync_count || 0} 条记录
</Typography>
)}
</Stack>
}
/>
<ListItemSecondaryAction>
<Stack direction="row" spacing={0.5}>
<Tooltip title="立即同步">
<IconButton
color="primary"
onClick={() => handleRunSync(source)}
disabled={isSyncing || !source.is_active}
>
{isSyncing ? <CircularProgress size={20} /> : <PlayArrowIcon />}
</IconButton>
</Tooltip>
<Tooltip title="编辑">
<IconButton onClick={() => { setEditSource(source); setTaskDialogOpen(true) }}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="删除">
<IconButton color="error" onClick={() => handleDeleteSource(source)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</ListItemSecondaryAction>
</ListItem>
)
})}
</List>
)}
</Card>
</Grid>
{/* 数据库连接列表 */}
<Grid item xs={12} lg={4}>
<Card>
<CardContent sx={{ pb: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>
数据库连接 ({profiles.filter(p => p.connection_type === 'database').length})
</Typography>
</CardContent>
{profiles.filter(p => p.connection_type === 'database').length === 0 ? (
<Box sx={{ py: 4, textAlign: 'center' }}>
<StorageIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">暂无数据库连接</Typography>
<Button
variant="text"
size="small"
startIcon={<AddIcon />}
onClick={() => { setEditProfile(null); setConnDialogOpen(true) }}
sx={{ mt: 1 }}
>
添加连接
</Button>
</Box>
) : (
<List disablePadding>
{profiles.filter(p => p.connection_type === 'database').map((profile, idx) => (
<ListItem
key={profile.id}
divider={idx < profiles.length - 1}
sx={{ py: 1.5, px: 3 }}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<StorageIcon color="action" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={profile.name}
secondary={
<Stack direction="row" spacing={1} alignItems="center">
<Chip
label={profile.auth_config?.db_type?.toUpperCase() || 'DB'}
size="small"
sx={{ height: 18, fontSize: 10 }}
/>
<Typography variant="caption" color="text.secondary">
{profile.auth_config?.host}:{profile.auth_config?.port}
</Typography>
</Stack>
}
/>
<ListItemSecondaryAction>
<IconButton size="small" onClick={() => { setEditProfile(profile); setConnDialogOpen(true) }}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDeleteProfile(profile)}>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</Card>
</Grid>
</Grid>
)}
{/* 弹窗 */}
<SyncTaskDialog
open={taskDialogOpen}
onClose={() => setTaskDialogOpen(false)}
onSaved={loadData}
editSource={editSource}
profiles={profiles}
snack={{ snack, show: showSnack, hide: hideSnack }}
/>
<ConnectionDialog
open={connDialogOpen}
onClose={() => setConnDialogOpen(false)}
onSaved={loadData}
editProfile={editProfile}
snack={{ snack, show: showSnack, hide: hideSnack }}
/>
{/* Snackbar */}
<Snackbar open={snack.open} autoHideDuration={4000} onClose={hideSnack} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}>
<Alert onClose={hideSnack} severity={snack.severity} variant="filled">{snack.message}</Alert>
</Snackbar>
</Box>
)
}
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
Box, Typography, Card, CardContent, CardHeader, Grid,
FormControl, InputLabel, Select, MenuItem, Stack,
Tabs, Tab, Alert, Divider,
Tabs, Tab, Alert, Divider, LinearProgress, Chip, Button,
Table, TableBody, TableCell, TableHead, TableRow,
Collapse, IconButton, Tooltip, TablePagination,
} from '@mui/material'
import HistoryIcon from '@mui/icons-material/History'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
import { PermissionButton } from '../components/auth/PermissionButton'
import PsychologyIcon from '@mui/icons-material/Psychology'
import BatchPredictionIcon from '@mui/icons-material/BatchPrediction'
import StopIcon from '@mui/icons-material/Stop'
import { MatchingStepper } from '../components/matching/MatchingStepper'
import { MatchingResultCard } from '../components/matching/MatchingResultCard'
import { ReasoningTimeline } from '../components/matching/ReasoningTimeline'
......@@ -14,32 +22,94 @@ import { LoadingOverlay } from '../components/shared/LoadingOverlay'
import { patientService } from '../services/patientService'
import { trialService } from '../services/trialService'
import { matchingService } from '../services/matchingService'
import { batchMatchingService, BatchJob } from '../services/batchMatchingService'
import type { PatientResponse } from '../types/fhir'
import type { TrialResponse } from '../types/trial'
import type { MatchingResult } from '../types/matching'
const PAGE_SIZE = 10
const JOB_STATUS_MAP: Record<string, { label: string; color: 'default' | 'info' | 'warning' | 'success' | 'error' }> = {
pending: { label: '待启动', color: 'default' },
running: { label: '运行中', color: 'info' },
completed: { label: '已完成', color: 'success' },
failed: { label: '失败', color: 'error' },
cancelled: { label: '已停止', color: 'default' },
}
export function MatchingPage() {
const [patients, setPatients] = useState<PatientResponse[]>([])
const [trials, setTrials] = useState<TrialResponse[]>([])
// Main tab: 0 = 批量自动匹配, 1 = 手动单对匹配
const [mainTab, setMainTab] = useState(0)
// ── 手动匹配 ──
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 [matchError, setMatchError] = useState('')
const [activeStep, setActiveStep] = useState(0)
const [evidenceTab, setEvidenceTab] = useState(0)
const [selectedCriterionId, setSelectedCriterionId] = useState<string | undefined>()
// ── 批量匹配 ──
const [batchJob, setBatchJob] = useState<BatchJob | null>(null)
const [batchLoading, setBatchLoading] = useState(false)
const [batchError, setBatchError] = useState('')
const [batchTrialId, setBatchTrialId] = useState('')
// ── 操作日志 ──
const [jobHistory, setJobHistory] = useState<BatchJob[]>([])
const [logTotal, setLogTotal] = useState(0)
const [logPage, setLogPage] = useState(0)
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null)
const loadJobHistory = useCallback(async (page = 0) => {
// fetch PAGE_SIZE+1 to detect if a next page exists
const jobs = await batchMatchingService.listJobs({ skip: page * PAGE_SIZE, limit: PAGE_SIZE + 1 })
const hasMore = jobs.length > PAGE_SIZE
setJobHistory(jobs.slice(0, PAGE_SIZE))
// approximate total: if hasMore we don't know the exact count, use sentinel
setLogTotal(hasMore ? (page + 2) * PAGE_SIZE : page * PAGE_SIZE + jobs.length)
}, [])
useEffect(() => {
Promise.all([
patientService.list({ limit: 100 }),
trialService.list({ limit: 100 }),
]).then(([p, t]) => { setPatients(p); setTrials(t) })
}, [])
batchMatchingService.listJobs({ limit: 1 }),
]).then(([p, t, jobs]) => {
setPatients(p)
setTrials(t)
if (jobs.length > 0) setBatchJob(jobs[0])
})
loadJobHistory(0)
}, [loadJobHistory])
// Poll while job is active; refresh history when job finishes
useEffect(() => {
if (!batchJob) return
const { status } = batchJob
if (status === 'completed' || status === 'failed' || status === 'cancelled') {
loadJobHistory(logPage)
return
}
const interval = setInterval(async () => {
try {
const updated = await batchMatchingService.getJob(batchJob.id)
setBatchJob(updated)
} catch {
clearInterval(interval)
}
}, 2000)
return () => clearInterval(interval)
}, [batchJob?.id, batchJob?.status, logPage, loadJobHistory])
const handleRun = async () => {
if (!selectedPatient || !selectedTrial) return
setError('')
setMatchError('')
setResult(null)
setRunning(true)
setActiveStep(1)
......@@ -49,22 +119,53 @@ export function MatchingPage() {
setActiveStep(2)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '匹配失败,请检查LLM配置'
setError(msg)
setMatchError(msg)
setActiveStep(0)
} finally {
setRunning(false)
}
}
const handleBatchRun = async () => {
if (!batchTrialId) return
setBatchError('')
setBatchLoading(true)
try {
const job = await batchMatchingService.triggerRun(batchTrialId)
setBatchJob(job)
setLogPage(0)
await loadJobHistory(0)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '启动批量匹配失败'
setBatchError(msg)
} finally {
setBatchLoading(false)
}
}
const handleBatchCancel = async () => {
if (!batchJob) return
try {
const updated = await batchMatchingService.cancelJob(batchJob.id)
setBatchJob(updated)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '停止失败'
setBatchError(msg)
}
}
const handleLogPageChange = (_: unknown, newPage: number) => {
setLogPage(newPage)
loadJobHistory(newPage)
}
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}>
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<PsychologyIcon color="primary" sx={{ fontSize: 32 }} />
<Box>
<Typography variant="h5">AI 智能匹配</Typography>
......@@ -72,138 +173,382 @@ export function MatchingPage() {
</Box>
</Stack>
<MatchingStepper activeStep={activeStep} />
{/* 主 Tab 切换 */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={mainTab} onChange={(_, v) => setMainTab(v)}>
<Tab icon={<BatchPredictionIcon fontSize="small" />} iconPosition="start" label="批量自动匹配" />
<Tab icon={<PsychologyIcon fontSize="small" />} iconPosition="start" label="手动单对匹配" />
</Tabs>
</Box>
{/* 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>
{/* ═══ Tab 0: 批量自动匹配 ═══ */}
{mainTab === 0 && (
<>
{/* 当前任务控制卡 */}
<Card sx={{ mb: 2 }}>
<CardHeader
title="批量自动匹配"
subheader="选择临床试验项目,对所有患者进行 AI 入排标准批量评估"
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
action={
<Stack direction="row" spacing={1} sx={{ mt: 1, mr: 1 }}>
{(batchJob?.status === 'running' || batchJob?.status === 'pending') && (
<Button
variant="outlined"
color="error"
size="small"
startIcon={<StopIcon />}
onClick={handleBatchCancel}
disabled={batchJob.cancel_requested}
>
{batchJob.cancel_requested ? '停止中...' : '停止匹配'}
</Button>
)}
<PermissionButton
permissionCode="btn:matching:execute"
variant="contained"
size="small"
startIcon={<BatchPredictionIcon />}
onClick={handleBatchRun}
disabled={batchLoading || batchJob?.status === 'running' || !batchTrialId}
showTooltip={false}
>
{batchLoading ? '启动中...' : batchJob?.status === 'running' ? '运行中...' : '开始批量匹配'}
</PermissionButton>
</Stack>
}
/>
<Divider />
<CardContent>
{batchError && <Alert severity="error" sx={{ mb: 2 }}>{batchError}</Alert>}
<FormControl size="small" sx={{ minWidth: 360, mb: 2 }}>
<InputLabel>选择临床试验项目 *</InputLabel>
<Select
value={selectedTrial}
label="选择临床试验"
onChange={e => setSelectedTrial(e.target.value)}
value={batchTrialId}
label="选择临床试验项目 *"
onChange={e => setBatchTrialId(e.target.value)}
disabled={batchJob?.status === 'running' || batchJob?.status === 'pending'}
>
{trials.map(t => (
<MenuItem key={t.id} value={t.id}>
{t.title.slice(0, 40)}{t.title.length > 40 ? '...' : ''}
{t.title.slice(0, 50)}{t.title.length > 50 ? '...' : ''}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={3}>
<PermissionButton
permissionCode="btn:matching:execute"
fullWidth variant="contained" size="medium"
startIcon={<PsychologyIcon />}
onClick={handleRun}
disabled={!selectedPatient || !selectedTrial || running}
showTooltip={false}
>
{running ? 'AI 分析中...' : '开始匹配'}
</PermissionButton>
</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 />
{!batchTrialId && (
<Alert severity="info" sx={{ mb: 2 }}>请先选择临床试验项目,再启动批量匹配</Alert>
)}
{batchJob && (
<Stack spacing={1}>
<Stack direction="row" spacing={2} alignItems="center">
<Chip
label={JOB_STATUS_MAP[batchJob.status]?.label ?? batchJob.status}
color={JOB_STATUS_MAP[batchJob.status]?.color ?? 'default'}
size="small"
/>
<Typography variant="body2" color="text.secondary">
触发人:{batchJob.triggered_by}
</Typography>
{batchJob.started_at && (
<Typography variant="body2" color="text.secondary">
启动于 {new Date(batchJob.started_at).toLocaleString('zh-CN')}
</Typography>
)}
</Stack>
{(batchJob.status === 'running' || batchJob.status === 'pending') && (
<LinearProgress
variant={batchJob.status === 'pending' || batchJob.total_pairs === 0 ? 'indeterminate' : 'determinate'}
value={batchJob.progress_pct * 100}
sx={{ height: 8, borderRadius: 4 }}
/>
)}
<Typography variant="caption" color="text.secondary">
进度:{batchJob.completed_pairs} / {batchJob.total_pairs}
{batchJob.failed_pairs > 0 && ` · 失败:${batchJob.failed_pairs} 对`}
</Typography>
{batchJob.status === 'completed' && (
<Alert severity="success" sx={{ py: 0.5 }}>
批量匹配完成,共处理 {batchJob.total_pairs} 对,成功 {batchJob.completed_pairs} 对。
请前往「医生工作站」查看结果。
</Alert>
)}
{batchJob.status === 'cancelled' && (
<Alert severity="warning" sx={{ py: 0.5 }}>
已手动停止匹配,已完成 {batchJob.completed_pairs} / {batchJob.total_pairs} 对。
</Alert>
)}
</Stack>
)}
</CardContent>
</Card>
{/* 操作日志 */}
<Card>
<CardHeader
avatar={<HistoryIcon color="action" />}
title="操作日志"
subheader="批量匹配任务历史记录,按时间倒序排列"
titleTypographyProps={{ variant: 'subtitle1', fontWeight: 600 }}
action={
<Button size="small" sx={{ mt: 1, mr: 1 }} onClick={() => { setLogPage(0); loadJobHistory(0) }}>
刷新
</Button>
}
/>
<Divider />
{jobHistory.length > 0 ? (
<>
<Table size="small">
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 600, bgcolor: 'grey.50' } }}>
<TableCell width={40} />
<TableCell>试验项目</TableCell>
<TableCell>状态</TableCell>
<TableCell>进度</TableCell>
<TableCell>触发人</TableCell>
<TableCell>开始时间</TableCell>
<TableCell>结束时间</TableCell>
</TableRow>
</TableHead>
<TableBody>
{jobHistory.map(job => {
const s = JOB_STATUS_MAP[job.status] ?? { label: job.status, color: 'default' as const }
const hasError = !!job.error_log
const isExpanded = expandedErrorId === job.id
return (
<>
<TableRow key={job.id} hover sx={{ '& td': { borderBottom: isExpanded ? 0 : undefined } }}>
<TableCell>
{hasError && (
<Tooltip title={isExpanded ? '收起错误日志' : '展开错误日志'}>
<IconButton size="small" onClick={() => setExpandedErrorId(isExpanded ? null : job.id)}>
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
</IconButton>
</Tooltip>
)}
</TableCell>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 240 }}>
{job.trial_title ?? job.trial_id ?? ''}
</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} alignItems="center">
<Chip label={s.label} color={s.color} size="small" />
{hasError && <ErrorOutlineIcon color="error" fontSize="small" />}
</Stack>
</TableCell>
<TableCell>
<Typography variant="body2">
{job.completed_pairs} / {job.total_pairs}
{job.failed_pairs > 0 && (
<Typography component="span" variant="caption" color="error.main" sx={{ ml: 0.5 }}>
(失败 {job.failed_pairs})
</Typography>
)}
</Typography>
{job.status === 'running' && job.total_pairs > 0 && (
<LinearProgress
variant="determinate"
value={job.progress_pct * 100}
sx={{ mt: 0.5, height: 4, borderRadius: 2 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2">{job.triggered_by}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary">
{job.started_at ? new Date(job.started_at).toLocaleString('zh-CN') : ''}
</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary">
{job.completed_at ? new Date(job.completed_at).toLocaleString('zh-CN') : ''}
</Typography>
</TableCell>
</TableRow>
{hasError && (
<TableRow key={`${job.id}-err`}>
<TableCell colSpan={7} sx={{ py: 0 }}>
<Collapse in={isExpanded}>
<Box sx={{ p: 1.5, bgcolor: 'grey.50', borderRadius: 1, my: 0.5 }}>
<Typography
variant="caption"
color="error.main"
component="pre"
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', m: 0, fontFamily: 'monospace', fontSize: 12 }}
>
{job.error_log}
</Typography>
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
<TablePagination
component="div"
count={logTotal}
page={logPage}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
onPageChange={handleLogPageChange}
labelDisplayedRows={({ from, to, count }) =>
`第 ${from}–${to} 条,共 ${count === (logPage + 2) * PAGE_SIZE ? `${count}+` : count} 条`
}
/>
</>
) : (
<CardContent>
{currentPatient ? (
<>
<Tabs
value={evidenceTab}
onChange={(_, v) => setEvidenceTab(v)}
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
<Typography variant="body2" color="text.secondary">暂无操作记录</Typography>
</CardContent>
)}
</Card>
</>
)}
{/* ═══ Tab 1: 手动单对匹配 ═══ */}
{mainTab === 1 && (
<>
<MatchingStepper activeStep={activeStep} />
<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)}
>
{sourceLabels.map((label, i) => (
<Tab key={i} label={label} sx={{ fontSize: 13 }} />
{patients.map(p => (
<MenuItem key={p.id} value={p.id}>
{p.display_name} · {p.mrn}
</MenuItem>
))}
</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>
)}
</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}>
<PermissionButton
permissionCode="btn:matching:execute"
fullWidth variant="contained" size="medium"
startIcon={<PsychologyIcon />}
onClick={handleRun}
disabled={!selectedPatient || !selectedTrial || running}
showTooltip={false}
>
{running ? 'AI 分析中...' : '开始匹配'}
</PermissionButton>
</Grid>
</Grid>
{matchError && <Alert severity="error" sx={{ mt: 2 }}>{matchError}</Alert>}
</CardContent>
</Card>
{running && (
<Card sx={{ mb: 2 }}>
<CardContent>
<LoadingOverlay message="LLM 正在评估入排标准,请稍候..." />
</CardContent>
</Card>
</Grid>
</Grid>
)}
{result && !running && (
<Grid container spacing={2}>
<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>
<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, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box, Typography, Card, CardContent, Stack, Button,
Chip, Divider, Badge, IconButton
Chip, Divider, Badge, IconButton, FormControl, InputLabel, Select, MenuItem
} 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 FilterListIcon from '@mui/icons-material/FilterList'
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 { trialService } from '../services/trialService'
import type { NotificationItem } from '../types/notification'
import type { MatchingResult } from '../types/matching'
import type { TrialResponse } from '../types/trial'
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'eligible', label: '符合入组' },
{ value: 'ineligible', label: '不符合' },
{ value: 'pending_review', label: '待审核' },
{ value: 'needs_more_info', label: '需补充' },
]
export function WorkStationPage() {
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [results, setResults] = useState<MatchingResult[]>([])
const [trials, setTrials] = useState<TrialResponse[]>([])
const [drawerOpen, setDrawerOpen] = useState(false)
const [snackNotification, setSnackNotification] = useState<NotificationItem | null>(null)
const [unreadCount, setUnreadCount] = useState(0)
const [statusFilter, setStatusFilter] = useState('')
const [trialFilter, setTrialFilter] = useState('')
// 新增:详情抽屉状态
const [selectedResult, setSelectedResult] = useState<MatchingResult | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const shownNotificationIds = useRef<Set<string>>(new Set())
const resultColumns: GridColDef[] = [
{ field: 'patient_name', headerName: '患者姓名', width: 130 },
......@@ -46,6 +60,12 @@ export function WorkStationPage() {
field: 'overall_score', headerName: '评分', width: 90, type: 'number',
valueFormatter: (v: number) => `${Math.round(v * 100)}%`,
},
{
field: 'reviewed_by', headerName: '审核人', width: 100,
renderCell: p => p.value ? (
<Chip label={p.value} size="small" variant="outlined" color="primary" />
) : <Typography variant="caption" color="text.disabled">未审核</Typography>,
},
{
field: 'actions', headerName: '操作', width: 80, sortable: false,
renderCell: (params) => (
......@@ -64,22 +84,37 @@ export function WorkStationPage() {
}
]
const loadResults = useCallback(async () => {
const matchResults = await matchingService.list({
limit: 50,
status: statusFilter || undefined,
trial_id: trialFilter || undefined,
})
setResults(matchResults)
}, [statusFilter, trialFilter])
const loadNotifications = useCallback(async () => {
const [notifs, count, matchResults] = await Promise.all([
const [notifs, count] = 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) {
if (latestUnread && !shownNotificationIds.current.has(latestUnread.id)) {
shownNotificationIds.current.add(latestUnread.id)
setSnackNotification(latestUnread)
}
}, [snackNotification])
}, [])
useEffect(() => {
trialService.list({ limit: 100 }).then(setTrials)
}, [])
useEffect(() => {
loadResults()
}, [loadResults])
useEffect(() => {
loadNotifications()
......@@ -87,6 +122,11 @@ export function WorkStationPage() {
return () => clearInterval(interval)
}, [loadNotifications])
const handleRefresh = () => {
loadResults()
loadNotifications()
}
return (
<Box>
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
......@@ -129,7 +169,39 @@ export function WorkStationPage() {
{/* 匹配结果表格 */}
<Card>
<CardContent sx={{ pb: '12px !important' }}>
<Typography variant="subtitle1" fontWeight={600} mb={1}>AI 智能匹配列表</Typography>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="subtitle1" fontWeight={600}>AI 智能匹配列表</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<FilterListIcon fontSize="small" color="action" />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>状态筛选</InputLabel>
<Select
value={statusFilter}
label="状态筛选"
onChange={e => setStatusFilter(e.target.value)}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>试验筛选</InputLabel>
<Select
value={trialFilter}
label="试验筛选"
onChange={e => setTrialFilter(e.target.value)}
>
<MenuItem value="">全部试验</MenuItem>
{trials.map(t => (
<MenuItem key={t.id} value={t.id}>
{t.title.slice(0, 30)}{t.title.length > 30 ? '...' : ''}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Stack>
<Divider sx={{ mb: 2 }} />
<Box sx={{ height: 420 }}>
<DataGrid
......@@ -157,7 +229,7 @@ export function WorkStationPage() {
open={drawerOpen}
notifications={notifications}
onClose={() => setDrawerOpen(false)}
onRefresh={loadNotifications}
onRefresh={handleRefresh}
/>
{/* 详情与审核抽屉 */}
......@@ -165,7 +237,7 @@ export function WorkStationPage() {
open={detailOpen}
result={selectedResult}
onClose={() => setDetailOpen(false)}
onAuditSuccess={loadNotifications}
onAuditSuccess={handleRefresh}
/>
{/* 消息提醒 */}
......@@ -177,4 +249,3 @@ export function WorkStationPage() {
</Box>
)
}
......@@ -20,11 +20,14 @@ api.interceptors.request.use(
)
// 响应拦截器:处理 401 错误
let isRedirectingToLogin = false
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 清除 token 并跳转到登录页
if (error.response?.status === 401 && !isRedirectingToLogin) {
// 避免多个并发请求同时触发多次跳转
isRedirectingToLogin = true
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
window.location.href = '/login'
......
import api from './api'
export interface BatchJob {
id: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
total_pairs: number
completed_pairs: number
failed_pairs: number
triggered_by: string
trial_id?: string
trial_title?: string
cancel_requested?: boolean
started_at?: string
completed_at?: string
error_log?: string
created_at: string
progress_pct: number
}
export const batchMatchingService = {
triggerRun: (trialId: string) =>
api.post<BatchJob>(`/matching/batch/run?trial_id=${trialId}`).then(r => r.data),
listJobs: (params?: { skip?: number; limit?: number; }) =>
api.get<BatchJob[]>('/matching/batch/jobs', { params }).then(r => r.data),
getJob: (id: string) =>
api.get<BatchJob>(`/matching/batch/jobs/${id}`).then(r => r.data),
cancelJob: (id: string) =>
api.post<BatchJob>(`/matching/batch/jobs/${id}/cancel`).then(r => r.data),
}
import api from './api'
export interface ConnectionProfile {
id: string
name: string
description?: string
connection_type: string // 'http' | 'database'
base_url?: string
auth_config: Record<string, string>
is_active: boolean
created_at: string
}
export interface ConnectionProfileCreate {
name: string
description?: string
connection_type: string
base_url?: string
auth_config?: Record<string, string>
is_active?: boolean
}
export const connectionProfileService = {
list: () =>
api.get<ConnectionProfile[]>('/connection-profiles').then(r => r.data),
create: (data: ConnectionProfileCreate) =>
api.post<ConnectionProfile>('/connection-profiles', data).then(r => r.data),
update: (id: string, data: ConnectionProfileCreate) =>
api.put<ConnectionProfile>(`/connection-profiles/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/connection-profiles/${id}`),
}
import api from './api'
export interface DashboardStats {
patients: { total: number }
trials: { total: number; recruiting: number }
matching: {
total: number
eligible: number
ineligible: number
pending_review: number
needs_more_info: number
}
batch_jobs: {
total: number
completed: number
failed: number
running: number
}
notifications: { unread: number }
recent_batch_jobs: Array<{
id: string
status: string
trial_title: string | null
total_pairs: number
completed_pairs: number
failed_pairs: number
triggered_by: string
started_at: string | null
completed_at: string | null
progress_pct: number
}>
}
export const dashboardService = {
getStats: () =>
api.get<DashboardStats>('/dashboard/stats').then(r => r.data),
}
import api from './api'
export interface DataDomainType {
id: string
code: string
display_name: string
color: string
description?: string
is_active: boolean
created_at: string
}
export interface DataDomainCreate {
code: string
display_name: string
color?: string
description?: string
is_active?: boolean
}
export const dataDomainService = {
list: () =>
api.get<DataDomainType[]>('/data-domains').then(r => r.data),
create: (data: DataDomainCreate) =>
api.post<DataDomainType>('/data-domains', data).then(r => r.data),
update: (id: string, data: DataDomainCreate) =>
api.put<DataDomainType>(`/data-domains/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/data-domains/${id}`),
}
......@@ -5,12 +5,12 @@ export const matchingService = {
runSync: (data: MatchingRequest) =>
api.post<MatchingResult>('/matching/run/sync', data).then(r => r.data),
list: (params?: { trial_id?: string; patient_id?: string; status?: string }) =>
list: (params?: { trial_id?: string; patient_id?: string; status?: string; limit?: number }) =>
api.get<MatchingResult[]>('/matching/results', { params }).then(r => r.data),
get: (id: string) =>
api.get<MatchingResult>(`/matching/results/${id}`).then(r => r.data),
review: (id: string, data: { doctor_id: string; decision: string; notes?: string }) =>
review: (id: string, data: { decision: string; notes?: string }) =>
api.put<MatchingResult>(`/matching/results/${id}/review`, data).then(r => r.data),
}
import api from './api'
// 本系统 Patient 模型的目标字段
export const TARGET_PATIENT_FIELDS = [
{ field: 'mrn', label: '病历号 (MRN)', required: true, description: '患者唯一标识' },
{ field: 'name', label: '姓名', required: true, description: '患者姓名(系统会自动加密存储)' },
{ field: 'birth_date', label: '出生日期', required: true, description: '格式: YYYY-MM-DD' },
{ field: 'gender', label: '性别', required: true, description: 'male/female/other/unknown' },
{ field: 'phone', label: '电话', required: false, description: '联系电话(加密存储)' },
{ field: 'id_number', label: '身份证号', required: false, description: '身份证号(加密存储)' },
{ field: 'admission_note', label: '入院记录', required: false, description: '入院病历文本' },
{ field: 'lab_report_text', label: '检验报告', required: false, description: '实验室检验报告文本' },
{ field: 'pathology_report', label: '病理报告', required: false, description: '病理检查报告文本' },
]
// 字段映射类型
export interface FieldMapping {
source_field: string // 源数据库/API的字段名
target_field: string // 本系统Patient模型的字段名
transform?: string // 可选的转换规则: 'date', 'gender', 'trim', 'upper', 'lower'
}
// 同步配置结构
export interface SyncConfig {
resource_type?: string // FHIR资源类型: Patient, Observation, Condition 等
query?: string // DB模式: 完整SQL查询语句; HTTP模式: FHIR查询参数
incremental_field?: string // 增量同步的时间戳字段名,如 updated_at
incremental_condition?: string // 增量同步的 WHERE 条件片段,:last_sync_at 为占位符
// 示例: "updated_at > :last_sync_at"
// 联合: "updated_at > :last_sync_at OR created_at > :last_sync_at"
filters?: Record<string, any> // 筛选条件
field_mappings?: FieldMapping[] // 字段映射列表
batch_size?: number // 批量大小
}
export interface SyncSource {
id: string
name: string
source_type: string
connection_profile_id?: string
connection_profile_name?: string
sync_config?: SyncConfig
is_active: boolean
sync_mode: string // 'full' | 'incremental'
last_sync_at?: string
last_sync_count?: number
created_at: string
}
export interface SyncSourceCreate {
name: string
source_type: string
connection_profile_id?: string
sync_config?: SyncConfig
is_active?: boolean
sync_mode?: string
}
export interface SyncRunResult {
status: string
source_id: string
source_name: string
sync_mode: string
patients_fetched: number
created: number
updated: number
}
export interface PreviewRequest {
source_type: string
sql_query?: string
connection_profile_id?: string
limit?: number
}
export interface PreviewResult {
columns: string[]
rows: any[][]
error?: string
}
export interface TestConnectionRequest {
db_type: string
host: string
port: string
database: string
username?: string
password?: string
oracle_type?: string // Oracle only: 'sid' | 'service_name'
}
export interface TestConnectionResult {
success: boolean
message: string
}
export const syncService = {
listSources: () =>
api.get<SyncSource[]>('/sync/sources').then(r => r.data),
createSource: (data: SyncSourceCreate) =>
api.post<SyncSource>('/sync/sources', data).then(r => r.data),
updateSource: (id: string, data: SyncSourceCreate) =>
api.put<SyncSource>(`/sync/sources/${id}`, data).then(r => r.data),
deleteSource: (id: string) =>
api.delete(`/sync/sources/${id}`),
runSync: (sourceId: string) =>
api.post<SyncRunResult>(`/sync/run?source_id=${sourceId}`).then(r => r.data),
previewSource: (req: PreviewRequest) =>
api.post<PreviewResult>('/sync/preview', req).then(r => r.data),
testConnection: (req: TestConnectionRequest) =>
api.post<TestConnectionResult>('/sync/test-connection', req).then(r => r.data),
listTables: (profileId: string) =>
api.get<{ tables: string[] }>(`/sync/list-tables?connection_profile_id=${profileId}`).then(r => r.data.tables),
}
......@@ -20,6 +20,8 @@ export interface MatchingResult {
id: string
patient_id: string
trial_id: string
patient_name?: string
trial_title?: string
status: 'eligible' | 'ineligible' | 'pending_review' | 'needs_more_info'
overall_score: number
criterion_details: CriterionMatchDetail[]
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment