diff --git a/backend/app/routers/pairing.py b/backend/app/routers/pairing.py index 45f2079..5fd6b47 100644 --- a/backend/app/routers/pairing.py +++ b/backend/app/routers/pairing.py @@ -218,6 +218,8 @@ async def find_pairs_endpoint( except Exception as e: if isinstance(e, HTTPException): raise e + if isinstance(e, RuntimeError): + raise HTTPException(status_code=409, detail=str(e)) logger.exception("处理 AOI 或查找干涉对时发生错误") raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志") diff --git a/backend/app/services/spatial_service.py b/backend/app/services/spatial_service.py index 0419bbb..b9f064c 100644 --- a/backend/app/services/spatial_service.py +++ b/backend/app/services/spatial_service.py @@ -74,12 +74,12 @@ class SpatialService: if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0): raise RuntimeError( - "配对候选缓存当前不可用,请先在系统自检中执行“全量重建配对缓存”或“增量修复”。" + "配对候选缓存当前不可用,请先在生产规划页执行“修复配对基础”或“强制全量重建”。" ) if degraded: warnings.append( - f"配对候选缓存当前状态为 {cache_status},本次结果基于现有缓存生成,建议尽快执行增量修复或全量重建。" + f"配对候选缓存当前状态为 {cache_status},本次结果基于现有缓存生成,建议尽快在生产规划页执行缓存修复。" ) candidate_pool = await self._query_pairing_metric_cache( diff --git a/frontend/src/HealthCheckPanel.jsx b/frontend/src/HealthCheckPanel.jsx index b26048c..10e1e7d 100644 --- a/frontend/src/HealthCheckPanel.jsx +++ b/frontend/src/HealthCheckPanel.jsx @@ -6,6 +6,7 @@ import { cleanupSessions } from './api/auth'; import { syncWaterScenesFromDisk } from './api/water'; import { listEngines, runWslCheck } from './api/dinsarProduction'; import { getOrbitStatus, syncOrbitPools } from './api/orbit'; +import { rebuildPairingCache, reconcileDirtyPairingCache } from './api/pairing'; import LogManagementPanel from './LogManagementPanel'; import DinsarCatalogPanel from './components/DinsarCatalogPanel'; @@ -120,6 +121,19 @@ const buildConsistencySummary = (stats, en = false) => { const HEALTH_PANEL_POLL_INTERVAL_MS = 30000; +const formatPairingActionMode = (mode, en = false) => { + switch (mode) { + case 'full_rebuild': + return en ? 'Full rebuild' : '全量重建'; + case 'incremental_reconcile': + return en ? 'Incremental reconcile' : '增量修复'; + case 'noop': + return en ? 'No-op reconcile' : '无需修复'; + default: + return en ? 'Pairing cache action' : '配对缓存操作'; + } +}; + const HealthCheckPanel = ({ language = 'zh', currentUser }) => { const en = language === 'en'; const isAdmin = currentUser?.role === 'admin'; @@ -142,6 +156,9 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { const [orbitRepairing, setOrbitRepairing] = useState(false); const [orbitQuarantining, setOrbitQuarantining] = useState(false); const [orbitSyncResult, setOrbitSyncResult] = useState(null); + const [pairingRebuilding, setPairingRebuilding] = useState(false); + const [pairingReconciling, setPairingReconciling] = useState(false); + const [pairingActionResult, setPairingActionResult] = useState(null); const statusFetchInFlightRef = useRef(false); const refreshOrbitStatus = useCallback(async () => { @@ -166,6 +183,31 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { } }, []); + const handlePairingCacheAction = useCallback(async (mode) => { + if (!isAdmin) { + return; + } + + const setBusyState = mode === 'rebuild' ? setPairingRebuilding : setPairingReconciling; + setBusyState(true); + setPairingActionResult(null); + + try { + const result = mode === 'rebuild' + ? await rebuildPairingCache() + : await reconcileDirtyPairingCache(); + setPairingActionResult(result); + await fetchStatus({ force: true }); + } catch (err) { + setPairingActionResult({ + mode, + error: err.response?.data?.detail || err.message || (en ? 'Pairing cache action failed' : '配对缓存操作失败'), + }); + } finally { + setBusyState(false); + } + }, [en, fetchStatus, isAdmin]); + const fetchStatus = useCallback(async (options = {}) => { const { force = false } = options; if (statusFetchInFlightRef.current) { @@ -210,13 +252,32 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { useEffect(() => { void fetchStatus(); const timer = setInterval(() => { - if (syncLoading || cleanupLoading || wslChecking || orbitSyncing || orbitRepairing || orbitQuarantining) { + if ( + syncLoading || + cleanupLoading || + wslChecking || + orbitSyncing || + orbitRepairing || + orbitQuarantining || + pairingRebuilding || + pairingReconciling + ) { return; } void fetchStatus(); }, HEALTH_PANEL_POLL_INTERVAL_MS); return () => clearInterval(timer); - }, [cleanupLoading, fetchStatus, orbitQuarantining, orbitRepairing, orbitSyncing, syncLoading, wslChecking]); + }, [ + cleanupLoading, + fetchStatus, + orbitQuarantining, + orbitRepairing, + orbitSyncing, + pairingRebuilding, + pairingReconciling, + syncLoading, + wslChecking, + ]); const renderBadge = (ok, label = '') => ( @@ -287,6 +348,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count); const orbitIsce2WithoutSourceCount = toNumber(orbitSource.isce2_without_source_count); const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path; + const pairingActionBusy = pairingRebuilding || pairingReconciling; const orbitOverallHealthy = Boolean( orbitStatus && orbitMismatchCount === 0 && @@ -540,6 +602,65 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => { {pairingSystem.error} )} + {false && ( +
+ {en + ? 'Repair operations require an admin account. Open this panel as admin to rebuild or reconcile the pairing cache.' + : '配对缓存修复需要管理员账号。请使用管理员登录后,在此面板执行“全量重建配对缓存”或“增量修复”。'} +
+ )} + {pairingSystem.needs_rebuild && ( +
+ {en + ? 'Repair entry has moved to Pair Planning. Use the production planning page to repair or rebuild the pairing foundation.' + : '配对缓存修复入口已移到“生产规划 -> 配对规划”。请在生产规划页执行配对基础修复或强制全量重建。'} +
+ )} + {false && ( +
+ + +
+ )} + {false && ( +
+ {pairingActionResult.error ? ( +
{pairingActionResult.error}
+ ) : ( + <> +
+ {`${formatPairingActionMode(pairingActionResult.mode, en)}${en ? ' finished.' : '已完成。'}`} +
+
+ {en + ? `Scenes / pairs / dirty: ${toNumber(pairingActionResult.scene_count)} / ${toNumber(pairingActionResult.pair_count)} / ${toNumber(pairingActionResult.dirty_scene_count)}` + : `场景 / 候选对 / dirty:${toNumber(pairingActionResult.scene_count)} / ${toNumber(pairingActionResult.pair_count)} / ${toNumber(pairingActionResult.dirty_scene_count)}`} +
+
+ {en + ? `Resolved dirty rows: ${toNumber(pairingActionResult.resolved_dirty_rows)}, deleted pair rows: ${toNumber(pairingActionResult.deleted_pair_rows)}` + : `已解决 dirty 记录:${toNumber(pairingActionResult.resolved_dirty_rows)},删除旧候选对:${toNumber(pairingActionResult.deleted_pair_rows)}`} +
+ + )} +
+ )}
diff --git a/frontend/src/api/pairing.js b/frontend/src/api/pairing.js index 75476e2..cf4bbac 100644 --- a/frontend/src/api/pairing.js +++ b/frontend/src/api/pairing.js @@ -2,3 +2,8 @@ import apiClient from './client'; export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data); export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data); +export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data); +export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data); +export const reconcileDirtyPairingCache = (forceFull = false) => ( + apiClient.post('/pairing/reconcile-dirty', null, { params: { force_full: forceFull } }).then(r => r.data) +); diff --git a/frontend/src/components/PairingModal.jsx b/frontend/src/components/PairingModal.jsx index 961debf..ee5a2ae 100644 --- a/frontend/src/components/PairingModal.jsx +++ b/frontend/src/components/PairingModal.jsx @@ -101,6 +101,16 @@ function PairingModal({ } }, [showPairingModal]); + useEffect(() => { + if (!showPairingModal || availableSatellites.length === 0) { + return; + } + setSelectedSatellites((prev) => { + const normalized = prev.filter((satellite) => availableSatellites.includes(satellite)); + return normalized.length === prev.length ? prev : normalized; + }); + }, [availableSatellites, showPairingModal]); + // 同步 selectedSatellites 到 pairingParams useEffect(() => { if (selectedSatellites.length > 0) { diff --git a/frontend/src/components/app/AppSidePanel.jsx b/frontend/src/components/app/AppSidePanel.jsx index 578f172..75c4b80 100644 --- a/frontend/src/components/app/AppSidePanel.jsx +++ b/frontend/src/components/app/AppSidePanel.jsx @@ -25,7 +25,7 @@ const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel')); const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel')); const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel')); const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel')); -const LazyPairingPanel = lazy(() => import('../../panels/PairingPanel')); +const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel')); const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel')); const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel')); const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel')); diff --git a/frontend/src/hooks/usePairingLogic.js b/frontend/src/hooks/usePairingLogic.js index cb50263..723d5b3 100644 --- a/frontend/src/hooks/usePairingLogic.js +++ b/frontend/src/hooks/usePairingLogic.js @@ -103,7 +103,6 @@ export default function usePairingLogic({ const findPairs = async (e, externalRequireOrbitRef) => { e.preventDefault(); if (!ensureCanOperate()) return; - setShowPairingModal(false); setIsLoading(true); addLog('info', '开始寻找干涉对...'); setPairingAlert({ warnings: [], fallbackUsed: false }); @@ -182,13 +181,14 @@ export default function usePairingLogic({ addLog('warn', '当前配对结果来自降级缓存状态,建议尽快执行缓存修复。'); } addLog('success', `成功找到 ${pairs.length} 个干涉对(候选 ${candidateCount},入选 ${selectedEdgeCount})。`); + setShowPairingModal(false); + setPairingFiles(null); setLeftPanelTab('pairs'); } catch (error) { const errorMessage = error.response?.data?.detail || error.message; addLog('error', `寻找干涉对失败: ${errorMessage}`); } finally { setIsLoading(false); - setPairingFiles(null); } }; diff --git a/frontend/src/panels/PairPlanningPanel.jsx b/frontend/src/panels/PairPlanningPanel.jsx new file mode 100644 index 0000000..7e381a1 --- /dev/null +++ b/frontend/src/panels/PairPlanningPanel.jsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + getPairingHealth, + rebuildPairingCache, + reconcileDirtyPairingCache, +} from '../api/pairing'; + +const formatIso = (value, en = false) => { + if (!value) return en ? 'Never' : '未执行'; + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +}; + +const formatActionMode = (mode, en = false) => { + switch (mode) { + case 'full_rebuild': + return en ? 'Full rebuild' : '全量重建'; + case 'incremental_reconcile': + return en ? 'Incremental reconcile' : '增量修复'; + case 'noop': + return en ? 'No-op reconcile' : '无需修复'; + default: + return en ? 'Pairing cache action' : '配对缓存操作'; + } +}; + +export default function PairPlanningPanel({ + foundPairs, + selectedPairsCount, + isLoading, + isReadOnlyUser, + hasEnoughRadarScenesForPlanning, + onOpenPairingModal, + onOpenPsModal, + hasRadarSearched, + onRefreshRadarSearch, + onSearchAll, + onRefreshDinsar, + language, +}) { + const en = language === 'en'; + const [pairingStatus, setPairingStatus] = useState(null); + const [pairingStatusLoading, setPairingStatusLoading] = useState(false); + const [pairingStatusError, setPairingStatusError] = useState(''); + const [pairingRepairing, setPairingRepairing] = useState(false); + const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false); + const [pairingActionResult, setPairingActionResult] = useState(null); + + const refreshPairingStatus = useCallback(async () => { + if (isReadOnlyUser) { + setPairingStatus(null); + setPairingStatusError(''); + return; + } + + setPairingStatusLoading(true); + setPairingStatusError(''); + try { + const result = await getPairingHealth(); + setPairingStatus(result); + } catch (error) { + setPairingStatus(null); + setPairingStatusError( + error.response?.data?.detail || + error.message || + (en ? 'Failed to fetch pairing foundation status.' : '配对基础状态获取失败。') + ); + } finally { + setPairingStatusLoading(false); + } + }, [en, isReadOnlyUser]); + + useEffect(() => { + void refreshPairingStatus(); + }, [refreshPairingStatus]); + + const handlePairingCacheAction = useCallback(async (mode) => { + const setBusy = mode === 'auto' ? setPairingRepairing : setPairingFullRebuilding; + setBusy(true); + setPairingActionResult(null); + try { + const result = mode === 'auto' + ? await reconcileDirtyPairingCache() + : await rebuildPairingCache(); + setPairingActionResult(result); + await refreshPairingStatus(); + } catch (error) { + setPairingActionResult({ + mode, + error: error.response?.data?.detail || error.message || ( + en ? 'Pairing cache action failed.' : '配对缓存操作失败。' + ), + }); + } finally { + setBusy(false); + } + }, [en, refreshPairingStatus]); + + const pairingActionBusy = pairingRepairing || pairingFullRebuilding; + + return ( +
+
+
{en ? 'Pair Planning' : '配对规划'}
+

+ {en + ? 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.' + : '基于时间基线、空间基线和重叠率筛选干涉对,可选 AOI 约束范围。'} +

+
+ + +
+
+ +
+
{en ? 'Pairing Foundation' : '配对基础'}
+ {isReadOnlyUser ? ( +

+ {en ? 'Repair operations require an admin account.' : '配对缓存修复需要管理员账号。'} +

+ ) : pairingStatusLoading ? ( +

+ {en ? 'Loading pairing foundation status...' : '正在加载配对基础状态...'} +

+ ) : pairingStatusError ? ( +
{pairingStatusError}
+ ) : pairingStatus ? ( + <> +
+ {en ? 'Status' : '状态'} + {pairingStatus.status || (en ? 'Unknown' : '未知')} +
+
+ {en ? 'Scenes / pairs' : '场景 / 候选对'} + {Number(pairingStatus.scene_count || 0)} / {Number(pairingStatus.pair_count || 0)} +
+
+ {en ? 'Dirty scenes' : 'Dirty 场景'} + {Number(pairingStatus.dirty_scene_count || 0)} +
+
+ {en ? 'Last full rebuild' : '上次全量重建'} + {formatIso(pairingStatus.last_full_rebuild_at, en)} +
+
+ {en ? 'Last incremental reconcile' : '上次增量修复'} + {formatIso(pairingStatus.last_incremental_reconcile_at, en)} +
+
+ {pairingStatus.needs_rebuild + ? (en + ? 'Pairing candidate cache is not ready. Repair it here before running pair search.' + : '配对候选缓存当前不可直接用于配对。请先在这里修复,再执行配对搜索。') + : (en + ? 'Pairing foundation is ready. You can proceed with pair planning.' + : '配对基础已就绪,可以直接进行配对规划。')} +
+
+ + +
+
+ {en + ? '"Repair Pairing Foundation" will choose incremental reconcile or full rebuild automatically based on current dirty state.' + : '“修复配对基础”会根据当前 dirty 状态自动选择增量修复或全量重建;只有需要彻底重算时再使用“强制全量重建”。'} +
+ {pairingActionResult && ( +
+ {pairingActionResult.error ? ( +
{pairingActionResult.error}
+ ) : ( + <> +
+ {formatActionMode(pairingActionResult.mode, en)} +
+
+ {en + ? `Scenes / pairs / dirty: ${Number(pairingActionResult.scene_count || 0)} / ${Number(pairingActionResult.pair_count || 0)} / ${Number(pairingActionResult.dirty_scene_count || 0)}` + : `场景 / 候选对 / dirty:${Number(pairingActionResult.scene_count || 0)} / ${Number(pairingActionResult.pair_count || 0)} / ${Number(pairingActionResult.dirty_scene_count || 0)}`} +
+
+ {en + ? `Resolved dirty rows: ${Number(pairingActionResult.resolved_dirty_rows || 0)}, deleted pair rows: ${Number(pairingActionResult.deleted_pair_rows || 0)}` + : `已解决 dirty 记录:${Number(pairingActionResult.resolved_dirty_rows || 0)},删除旧候选对:${Number(pairingActionResult.deleted_pair_rows || 0)}`} +
+ + )} +
+ )} + + ) : ( +

+ {en ? 'Pairing foundation status unavailable.' : '配对基础状态暂不可用。'} +

+ )} +
+ +
+
{en ? 'Results & Refresh' : '结果与刷新'}
+
+ {en ? 'Generated Pairs' : '已生成配对'} + {foundPairs.length} +
+
+ {en ? 'Selected' : '已选中'} + {selectedPairsCount} +
+
+ + +
+
+
+ ); +}