Commit 269c9325 authored by 375242562@qq.com's avatar 375242562@qq.com

feat: 同步运行状态持久化 — 刷新/切换页面后按钮仍保持禁用

- SyncSource 模型新增 is_running 字段(SQLite 已迁移)
- sync_service: 同步开始前设 is_running=True,try/finally 确保完成或报错后置 False
- SyncSourceResponse 返回 is_running 字段
- 前端: 按钮禁用以 source.is_running(后端状态)为准,而非本地临时状态
  有任务运行时每3秒自动轮询刷新,完成后停止轮询
  禁用状态下 Tooltip 提示「同步中,请等待完成...」
parent 92ccc7c8
...@@ -37,6 +37,7 @@ class SyncSourceResponse(BaseModel): ...@@ -37,6 +37,7 @@ class SyncSourceResponse(BaseModel):
connection_profile_name: Optional[str] connection_profile_name: Optional[str]
sync_config: Optional[dict] sync_config: Optional[dict]
is_active: bool is_active: bool
is_running: bool = False
sync_mode: str sync_mode: str
last_sync_at: Optional[str] last_sync_at: Optional[str]
last_sync_count: Optional[int] last_sync_count: Optional[int]
...@@ -58,6 +59,7 @@ async def _to_response(s: SyncSource, db: AsyncSession) -> SyncSourceResponse: ...@@ -58,6 +59,7 @@ async def _to_response(s: SyncSource, db: AsyncSession) -> SyncSourceResponse:
connection_profile_name=profile_name, connection_profile_name=profile_name,
sync_config=s.sync_config, sync_config=s.sync_config,
is_active=s.is_active, is_active=s.is_active,
is_running=s.is_running or False,
sync_mode=s.sync_mode or "full", sync_mode=s.sync_mode or "full",
last_sync_at=s.last_sync_at.isoformat() if s.last_sync_at else None, last_sync_at=s.last_sync_at.isoformat() if s.last_sync_at else None,
last_sync_count=s.last_sync_count, last_sync_count=s.last_sync_count,
......
...@@ -21,6 +21,7 @@ class SyncSource(Base): ...@@ -21,6 +21,7 @@ class SyncSource(Base):
auth_config: Mapped[dict] = mapped_column(JSON, default=dict) auth_config: Mapped[dict] = mapped_column(JSON, default=dict)
connection_type: Mapped[str] = mapped_column(String(20), default="http") connection_type: Mapped[str] = mapped_column(String(20), default="http")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_running: Mapped[bool] = mapped_column(Boolean, default=False) # True while sync is in progress
sync_mode: Mapped[str] = mapped_column(String(20), default="full") # "full" | "incremental" sync_mode: Mapped[str] = mapped_column(String(20), default="full") # "full" | "incremental"
last_sync_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) last_sync_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
last_sync_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True) last_sync_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
......
...@@ -107,9 +107,7 @@ async def sync_source(source_id: str, db: AsyncSession) -> dict: ...@@ -107,9 +107,7 @@ async def sync_source(source_id: str, db: AsyncSession) -> dict:
base_url = source.base_url base_url = source.base_url
auth_config = source.auth_config or {} auth_config = source.auth_config or {}
# Get sync config with field mappings
sync_config = source.sync_config or {} sync_config = source.sync_config or {}
field_mappings = sync_config.get("field_mappings", [])
adapter = get_adapter( adapter = get_adapter(
source_type=source.source_type, source_type=source.source_type,
...@@ -119,6 +117,25 @@ async def sync_source(source_id: str, db: AsyncSession) -> dict: ...@@ -119,6 +117,25 @@ async def sync_source(source_id: str, db: AsyncSession) -> dict:
sync_config=sync_config, sync_config=sync_config,
) )
# Mark source as running — persisted so UI survives page refresh
source.is_running = True
await db.commit()
try:
return await _do_sync(source, source_id, adapter, db)
finally:
# Always clear running flag, even if an exception is raised
source = await db.get(SyncSource, source_id)
if source:
source.is_running = False
await db.commit()
async def _do_sync(source: SyncSource, source_id: str, adapter, db: AsyncSession) -> dict:
"""Inner sync logic, called after is_running is set."""
sync_config = source.sync_config or {}
field_mappings = sync_config.get("field_mappings", [])
# Incremental sync: only fetch records updated since last_sync_at # 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 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) patients_data: list[PatientSyncData] = await adapter.fetch_patients(since=since)
......
...@@ -873,6 +873,14 @@ export function DataSyncPage() { ...@@ -873,6 +873,14 @@ export function DataSyncPage() {
const [batches, setBatches] = useState<SyncBatch[]>([]) const [batches, setBatches] = useState<SyncBatch[]>([])
const [deletingBatch, setDeletingBatch] = useState<string | null>(null) const [deletingBatch, setDeletingBatch] = useState<string | null>(null)
// 轮询:当有任务正在运行时,每3秒刷新一次任务列表
useEffect(() => {
const hasRunning = sources.some(s => s.is_running)
if (!hasRunning) return
const timer = setInterval(() => { loadData() }, 3000)
return () => clearInterval(timer)
}, [sources, loadData])
// 加载数据 // 加载数据
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
...@@ -1054,14 +1062,16 @@ export function DataSyncPage() { ...@@ -1054,14 +1062,16 @@ export function DataSyncPage() {
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Stack direction="row" spacing={0.5}> <Stack direction="row" spacing={0.5}>
<Tooltip title="立即同步"> <Tooltip title={source.is_running ? '同步中,请等待完成...' : '立即同步'}>
<span>
<IconButton <IconButton
color="primary" color="primary"
onClick={() => handleRunSync(source)} onClick={() => handleRunSync(source)}
disabled={isSyncing || !source.is_active} disabled={isSyncing || source.is_running || !source.is_active}
> >
{isSyncing ? <CircularProgress size={20} /> : <PlayArrowIcon />} {(isSyncing || source.is_running) ? <CircularProgress size={20} /> : <PlayArrowIcon />}
</IconButton> </IconButton>
</span>
</Tooltip> </Tooltip>
<Tooltip title="编辑"> <Tooltip title="编辑">
<IconButton onClick={() => { setEditSource(source); setTaskDialogOpen(true) }}> <IconButton onClick={() => { setEditSource(source); setTaskDialogOpen(true) }}>
......
...@@ -41,6 +41,7 @@ export interface SyncSource { ...@@ -41,6 +41,7 @@ export interface SyncSource {
connection_profile_name?: string connection_profile_name?: string
sync_config?: SyncConfig sync_config?: SyncConfig
is_active: boolean is_active: boolean
is_running: boolean
sync_mode: string // 'full' | 'incremental' sync_mode: string // 'full' | 'incremental'
last_sync_at?: string last_sync_at?: string
last_sync_count?: number last_sync_count?: number
......
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