Add LandSAR cluster worker deployment

This commit is contained in:
2026-06-24 14:10:58 +08:00
parent 71c524967c
commit da09ba05cb
88 changed files with 9300 additions and 3011 deletions
-1
View File
@@ -26,7 +26,6 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
taskTypes: ['AI_DIAGNOSIS'],
showRecent: true,
recentLimit: 1,
pollRecentMs: 10000,
});
// 状态
+2086 -412
View File
File diff suppressed because it is too large Load Diff
+4 -44
View File
@@ -4,7 +4,6 @@ import { useShallow } from 'zustand/react/shallow';
import 'leaflet/dist/leaflet.css';
import './App.css';
import LoginPage from './LoginPage';
import AppLogPanel from './components/app/AppLogPanel';
import AppMapWorkspace from './components/app/AppMapWorkspace';
import AppOverlays from './components/app/AppOverlays';
import AppSidePanel from './components/app/AppSidePanel';
@@ -18,7 +17,6 @@ import {
} from './store';
import useAppAuthLifecycle from './hooks/useAppAuthLifecycle';
import useGlobalTaskControl from './hooks/useGlobalTaskControl';
import usePanelResize from './hooks/usePanelResize';
import useRegionAoiHandlers from './hooks/useRegionAoiHandlers';
import usePaginationControls from './hooks/usePaginationControls';
import useRadarSearch from './hooks/useRadarSearch';
@@ -222,20 +220,13 @@ function App() {
setPendingTaskIds: state.setPendingTaskIds,
})));
const {
leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth,
rightPanelWidth, setRightPanelWidth, isResizing, setIsResizing,
setShowStats, setShowDataInfo, setSelectedDataInfo, showDates,
leftPanelTab, setLeftPanelTab, leftPanelWidth,
setShowDataInfo, setSelectedDataInfo, showDates,
baseLayerKey, setBaseLayerKey, isLoading, setIsLoading, addLog,
} = useUiStore(useShallow((state) => ({
leftPanelTab: state.leftPanelTab,
setLeftPanelTab: state.setLeftPanelTab,
leftPanelWidth: state.leftPanelWidth,
setLeftPanelWidth: state.setLeftPanelWidth,
rightPanelWidth: state.rightPanelWidth,
setRightPanelWidth: state.setRightPanelWidth,
isResizing: state.isResizing,
setIsResizing: state.setIsResizing,
setShowStats: state.setShowStats,
setShowDataInfo: state.setShowDataInfo,
setSelectedDataInfo: state.setSelectedDataInfo,
showDates: state.showDates,
@@ -423,7 +414,6 @@ function App() {
const hazardLayersRef = useRef({});
const dinsarResultLayersRef = useRef({});
const sbasAnalysisLayersRef = useRef({});
const resizeStateRef = useRef({ side: null, startX: 0, startLeft: 0, startRight: 0 });
const allDataRef = useRef(allData);
const dinsarResultsRef = useRef(dinsarResults);
const mapBatchRef = useRef({ frameId: null, token: 0 });
@@ -446,7 +436,7 @@ function App() {
cancelAnimationFrame(frameId);
window.clearTimeout(timeoutId);
};
}, [isStandaloneLeftPage, leftPanelWidth, rightPanelWidth]);
}, [isStandaloneLeftPage, leftPanelWidth]);
const getVisibleLayerRefs = useCallback(() => ({
activeLayersRef: activeLayersRef.current,
@@ -717,16 +707,6 @@ function App() {
setShowPsModal,
});
const { startResize } = usePanelResize({
isResizing,
setIsResizing,
leftPanelWidth,
rightPanelWidth,
setLeftPanelWidth,
setRightPanelWidth,
resizeStateRef,
});
const {
handleLoginSuccess,
handleLogout,
@@ -1238,7 +1218,7 @@ function App() {
? '-'
: `${(Number(result.ai_score) * 100).toFixed(0)}%`;
const engine = getDinsarEngineMeta(result.engine_code);
const strategy = escapeHtml(result.selection_strategy || 'legacy');
const strategy = escapeHtml(result.selection_strategy || '标准选择');
const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-');
const pairKey = escapeHtml(result.pair_key || '-');
const pairUid = escapeHtml(result.pair_uid || '-');
@@ -2027,10 +2007,6 @@ function App() {
fetchHealthStatus({ refresh: true });
}, [fetchHealthStatus]);
const openStatisticsDashboard = useCallback(() => {
setShowStats(true);
}, [setShowStats]);
const refreshDinsarResults = useCallback(() => {
fetchDinsarResults({ offset: 0 });
}, [fetchDinsarResults]);
@@ -2046,7 +2022,6 @@ function App() {
showRadarPageInputError,
radarPageInputValidationError,
onSearchAll: searchAllRadarData,
onShowStats: openStatisticsDashboard,
onSearch: applyRadarSearch,
onReset: resetRadarSearch,
onAoiModeChange: handleRadarSearchAoiModeChange,
@@ -2178,12 +2153,6 @@ function App() {
sbasAnalysisPanel={sbasAnalysisPanel}
/>
<div
className="panel-resizer"
onMouseDown={(event) => startResize('left', event)}
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
/>
<div
style={{
display: isStandaloneLeftPage ? 'none' : 'flex',
@@ -2212,15 +2181,6 @@ function App() {
/>
</div>
<div
className="panel-resizer"
onMouseDown={(event) => startResize('right', event)}
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
/>
<div style={{ display: isStandaloneLeftPage ? 'none' : undefined }}>
<AppLogPanel width={rightPanelWidth} />
</div>
</div>
<AppOverlays
+1 -38
View File
@@ -5,7 +5,6 @@ import {
listAssetIssues,
listOrbitAssets,
listSourceAssets,
scanAssetInventory,
} from './api/assets';
const PAGE_SIZE = 100;
@@ -58,7 +57,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
const [activeTab, setActiveTab] = useState('sources');
const [family, setFamily] = useState('all');
const [loading, setLoading] = useState(false);
const [scanLoading, setScanLoading] = useState(false);
const [auditLoading, setAuditLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
@@ -90,35 +88,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
refresh();
}, [refresh]);
const handleScan = async (scanPayload = {}, label = '源数据/精轨资产扫描') => {
if (readOnly || scanLoading) return;
setScanLoading(true);
setMessage('');
setError('');
const requestPayload =
scanPayload && typeof scanPayload === 'object' && scanPayload.nativeEvent
? {}
: scanPayload;
try {
const result = await scanAssetInventory({
inventory_types: [],
root_ids: [],
bind_orbits: true,
families: INVENTORY_FAMILIES,
...requestPayload,
});
setMessage(`资产扫描任务已入队: ${result.task_id}`);
onTaskStart?.(result.task_id, '源数据/精轨资产扫描已入队', {
taskType: 'SCAN_ASSET_INVENTORY',
nonBlocking: true,
});
} catch (err) {
setError(err?.response?.data?.detail || err.message || '启动资产扫描失败');
} finally {
setScanLoading(false);
}
};
const handleArchiveIntegrityAudit = async (auditPayload = {}, label = '压缩包完整性审计') => {
if (readOnly || auditLoading) return;
setAuditLoading(true);
@@ -169,7 +138,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<div className="asset-toolbar">
<div>
<h3>源数据与精轨资产</h3>
<p>Sentinel-1 LT-1 的源产品精密轨道和绑定状态</p>
<p>查看 Sentinel-1 LT-1 的源产品精密轨道绑定状态和开放问题资产登记由数据接入与运维流程维护</p>
</div>
<div className="asset-actions">
<select value={family} onChange={(e) => setFamily(e.target.value)} disabled={loading}>
@@ -178,12 +147,6 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<option value="LT1">LT-1</option>
</select>
<button type="button" onClick={() => refresh()} disabled={loading}>刷新</button>
<button type="button" onClick={() => handleScan({ families: INVENTORY_FAMILIES }, '全部资产扫描')} disabled={readOnly || scanLoading}>全部扫描</button>
<button type="button" onClick={() => handleScan({ families: ['LT1'] }, 'LT-1资产扫描')} disabled={readOnly || scanLoading}>LT-1扫描</button>
<button type="button" onClick={() => handleScan({ families: ['S1'] }, 'Sentinel-1资产扫描')} disabled={readOnly || scanLoading}>S1扫描</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: INVENTORY_FAMILIES }, '全部精轨扫描')} disabled={readOnly || scanLoading}>全部精轨</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: ['LT1'] }, 'LT-1精轨扫描')} disabled={readOnly || scanLoading}>LT-1精轨</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: ['S1'] }, 'Sentinel-1精轨扫描')} disabled={readOnly || scanLoading}>S1精轨</button>
<button type="button" onClick={() => handleArchiveIntegrityAudit()} disabled={readOnly || auditLoading}>
{auditLoading ? '审计启动中' : '压缩包完整性审计'}
</button>
+1 -1
View File
@@ -53,7 +53,7 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
if (taskId && status === 'RUNNING') {
logIntervalRef.current = setInterval(() => {
fetchLogsRef.current?.();
}, 1000);
}, 3000);
} else if (logIntervalRef.current) {
clearInterval(logIntervalRef.current);
if (taskId) fetchLogsRef.current?.();
+65 -48
View File
@@ -85,6 +85,8 @@ const SCAN_TASK_TYPES = new Set([
'GF3_SARSCAPE_SYNC',
'GF3_QUICKLOOK_WEBP',
]);
const MONITOR_REFRESH_MS = 10000;
const ACTIVE_TASK_LOG_REFRESH_MS = 3000;
const TASK_TYPE_LABELS = {
SCAN_ASSET_INVENTORY: 'LT/S1 资产索引',
@@ -270,7 +272,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
};
fetchLogsAndTasks();
const intervalId = setInterval(fetchLogsAndTasks, 2000);
const intervalId = setInterval(fetchLogsAndTasks, MONITOR_REFRESH_MS);
return () => {
canceled = true;
@@ -282,6 +284,8 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
displayActiveTasks.filter((task) => SCAN_TASK_TYPES.has(task.task_type)),
recentScanTasks
).slice(0, 8);
const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null;
const selectedTaskActive = ['PENDING', 'RUNNING'].includes(String(selectedTask?.status || '').toUpperCase());
useEffect(() => {
if (!enabled) {
@@ -315,12 +319,17 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
}
};
fetchTaskLogs();
const intervalId = setInterval(fetchTaskLogs, 2000);
if (!selectedTaskActive) {
return () => {
canceled = true;
};
}
const intervalId = setInterval(fetchTaskLogs, ACTIVE_TASK_LOG_REFRESH_MS);
return () => {
canceled = true;
clearInterval(intervalId);
};
}, [apiEndpoint, enabled, selectedTaskId]);
}, [apiEndpoint, enabled, selectedTaskActive, selectedTaskId]);
useEffect(() => {
if (logEndRef.current) {
@@ -328,8 +337,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
}
}, [logs, selectedTaskLogs]);
const selectedTask = displayedScanTasks.find((task) => task.task_id === selectedTaskId) || displayedScanTasks[0] || null;
const sourceInventoryDirs = uniquePaths([...config.s1_source_dirs, ...config.s1_storage_dirs]);
const orbitInventoryDirs = uniquePaths([
...config.orbit_source_dirs,
@@ -344,6 +351,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const canRunSourceProductScan = !readOnly && configLoaded && hasSourceProductDirs;
const canRunOrbitAssetScan = !readOnly && configLoaded && hasOrbitAssetDirs;
const canRunGf3SarscapeProduce = !readOnly && configLoaded && hasGf3SarscapeNativeDirs && hasGf3StorageDirs;
const activeIngestTaskCount = displayedScanTasks.filter((task) =>
['PENDING', 'RUNNING'].includes(String(task.status || '').toUpperCase())
).length;
const availableStorageCount = config.storage_roots.filter((item) => ['ok', 'warning'].includes(String(item.status || '').toLowerCase())).length;
const handleClearScanTaskHistory = async () => {
if (readOnly) {
@@ -511,10 +522,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const sectionStyle = {
marginBottom: '12px',
padding: '10px 12px',
borderRadius: '10px',
borderRadius: '8px',
background: 'var(--color-panel-bg)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
};
const labelStyle = { minWidth: '100px', color: 'var(--color-text-muted)', flexShrink: 0 };
const rowStyle = { display: 'flex', gap: '8px' };
@@ -532,38 +542,43 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
return (
<div
className="monitor-panel"
style={{
padding: '15px',
backgroundColor: 'var(--color-panel-bg)',
borderTop: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
}}
className="monitor-panel data-ingest-panel"
>
<h3 style={{ marginTop: 0, marginBottom: '8px', fontSize: '1.1em', flexShrink: 0 }}>数据监控面板</h3>
<div className="data-ingest-header">
<div>
<h3>数据接入</h3>
<p>集中管理源数据精密轨道GF3 回传成果和接入任务记录确保生产前数据资产可追溯</p>
</div>
<div className="data-ingest-signals" aria-label="数据接入状态摘要">
<div className={`dinsar-production-signal tone-${configLoaded ? 'ready' : 'warn'}`}>
<span>配置状态</span>
<strong>{configLoaded ? '已加载' : '未加载'}</strong>
</div>
<div className={`dinsar-production-signal tone-${activeIngestTaskCount > 0 ? 'warn' : 'ready'}`}>
<span>接入任务</span>
<strong>{activeIngestTaskCount > 0 ? `${activeIngestTaskCount} 个运行中` : '空闲'}</strong>
</div>
<div className={`dinsar-production-signal tone-${hasSourceProductDirs ? 'ready' : 'neutral'}`}>
<span>源数据池</span>
<strong>{sourceInventoryDirs.length}</strong>
</div>
<div className={`dinsar-production-signal tone-${availableStorageCount > 0 ? 'ready' : 'neutral'}`}>
<span>可用存储</span>
<strong>{availableStorageCount}/{config.storage_roots.length || 0}</strong>
</div>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
<div
style={{
margin: '0 0 12px',
padding: '10px 12px',
borderRadius: '8px',
background: 'linear-gradient(90deg, var(--color-accent-soft) 0%, #fff 70%)',
border: '1px solid #c7ddff',
color: 'var(--color-accent-strong)',
fontSize: '0.9em',
}}
>
<div className={`data-ingest-notice ${configLoaded ? 'ok' : 'warn'}`}>
{configLoaded
? '仅手动模式。路径从 .env 读取;如需修改请更新 .env 并重启后端。'
: '未加载到监控状态,请检查后端 /api/monitor/status。'}
? '接入路径由环境配置统一管理;本页仅触发登记、审计和任务复核,不直接修改生产目录。'
: '未加载到接入状态,请检查后端运行维护接口。'}
</div>
<div style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>路径摘要</div>
<details className="data-ingest-directory" style={sectionStyle}>
<summary>接入路径与生产目录</summary>
<p>以下为服务器部署目录仅用于核对环境配置日常接入操作不需要展开</p>
<div style={gridStyle}>
<div style={rowStyle}><span style={labelStyle}>精轨源资产</span><span style={{ wordBreak: 'break-all' }}>{formatList(orbitInventoryDirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>LT-1 生产 TXT </span><span style={{ wordBreak: 'break-all' }}>{config.orbit_production_txt_pool || '未配置'}</span></div>
@@ -576,10 +591,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={rowStyle}><span style={labelStyle}>D-InSAR 结果</span><span style={{ wordBreak: 'break-all' }}>{config.dinsar_product_dir || '未配置'}</span></div>
<div style={rowStyle}><span style={labelStyle}>SBAS 结果</span><span style={{ wordBreak: 'break-all' }}>{config.sbas_product_root || '未配置'}</span></div>
</div>
</div>
</details>
<div style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>本机存储感知</div>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>本机存储状态</div>
<div style={{ ...gridStyle, rowGap: '8px' }}>
{config.storage_roots.length ? config.storage_roots.map((item, index) => (
<div
@@ -608,7 +623,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
</div>
<div style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>LT-1 / Sentinel-1 压缩包资产索引</div>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>LT-1 / Sentinel-1 源数据与精轨登记</div>
<div style={{ ...gridStyle, marginBottom: '8px' }}>
<div style={rowStyle}><span style={labelStyle}>压缩包源池</span><span style={{ wordBreak: 'break-all' }}>{formatList(sourceInventoryDirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>精轨源资产</span><span style={{ wordBreak: 'break-all' }}>{formatList(orbitInventoryDirs)}</span></div>
@@ -619,42 +634,42 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<button
onClick={() => handleAssetInventoryScan(
{ inventory_types: ['source_product'], families: ['LT1', 'S1'] },
'LT-1 / Sentinel-1 源压缩包扫描'
'LT-1 / Sentinel-1 源压缩包登记'
)}
disabled={s1ScanLoading || !canRunSourceProductScan}
style={actionBtnStyle(s1ScanLoading, !canRunSourceProductScan)}
>
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描压缩包')}
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记源压缩包')}
</button>
<button
onClick={() => handleAssetInventoryScan(
{ inventory_types: ['orbit_asset'], families: ['LT1', 'S1'] },
'LT-1 / Sentinel-1 精轨扫描'
'LT-1 / Sentinel-1 精轨登记'
)}
disabled={s1ScanLoading || !canRunOrbitAssetScan}
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
>
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描全部精轨')}
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记全部精轨')}
</button>
<button
onClick={() => handleAssetInventoryScan(
{ inventory_types: ['orbit_asset'], families: ['S1'] },
'Sentinel-1 精轨扫描'
'Sentinel-1 精轨登记'
)}
disabled={s1ScanLoading || !canRunOrbitAssetScan}
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
>
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描 S1 EOF')}
{s1ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '登记 S1 EOF')}
</button>
<button
onClick={() => handleAssetInventoryScan(
{ inventory_types: ['orbit_asset'], families: ['LT1'] },
'LT-1 精轨扫描'
'LT-1 精轨登记'
)}
disabled={s1ScanLoading || !canRunOrbitAssetScan}
style={actionBtnStyle(s1ScanLoading, !canRunOrbitAssetScan)}
>
{readOnly ? '只读模式' : '扫描 LT-1 TXT'}
{readOnly ? '只读模式' : '登记 LT-1 TXT'}
</button>
<button
onClick={handleArchiveIntegrityAudit}
@@ -703,7 +718,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', alignItems: 'center', marginBottom: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--color-text-primary)' }}>扫描任务状态</div>
<div style={{ fontWeight: 'bold', color: 'var(--color-text-primary)' }}>接入任务记录</div>
<button
type="button"
onClick={handleClearScanTaskHistory}
@@ -718,7 +733,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
fontSize: '0.8em',
}}
>
{clearScanHistoryLoading ? '清空中...' : '清空日志'}
{clearScanHistoryLoading ? '清空中...' : '清空记录'}
</button>
</div>
{clearScanHistoryMessage && (
@@ -761,7 +776,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
width: `${progress}%`,
height: '100%',
background: statusColor(task.status),
transition: 'width 0.2s ease',
}}
/>
</div>
@@ -784,7 +798,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={{ flexShrink: 0, borderTop: '1px solid var(--color-border)', paddingTop: '10px', marginTop: '6px' }}>
<div style={{ fontWeight: 'bold', marginBottom: '6px', color: 'var(--color-text-primary)' }}>
{selectedTask ? `${taskTitle(selectedTask)} 日志` : '实时日志'}
{selectedTask ? `${taskTitle(selectedTask)} 任务日志` : '实时执行日志'}
<span style={{ marginLeft: '8px', color: 'var(--color-text-muted)', fontWeight: 400, fontSize: '0.82em' }}>
日志用于追踪执行步骤issue 为本次解析已发现的问题计数
</span>
</div>
<div
style={{
+134 -14
View File
@@ -1,16 +1,15 @@
import React, { useCallback, useEffect, useState } from 'react';
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, listTaskRoots, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, listTaskRoots, previewPyintInputAssets, submitLandsarClusterRun, submitRun } from './api/dinsarProduction';
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getRecentTasks, getTaskLogs } from './api/tasks';
import { formatSatelliteFamilyLabel, inferSatelliteFamilyFromResultLike } from './utils/satelliteFamily';
import useTaskMonitor from './hooks/useTaskMonitor';
const card = {
background: '#fff',
padding: '12px',
padding: '14px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
marginBottom: '12px',
};
const EMPTY_ARRAY = [];
@@ -44,11 +43,12 @@ const ENGINE_LABEL = {
};
const TASK_TYPE_LABEL = {
PYINT_RUN: 'PyINT/Gamma生产',
LANDSAR_RUN: 'LandSAR生产',
IDL_RUN_DINSAR: 'ENVI生产',
PYINT_RUN: 'PyINT / Gamma 生产',
LANDSAR_RUN: 'LandSAR 生产',
LANDSAR_CLUSTER_RUN: 'LandSAR 集群生产',
IDL_RUN_DINSAR: 'ENVI 生产',
};
const DINSAR_PRODUCTION_TASK_TYPES = ['PYINT_RUN', 'LANDSAR_RUN', 'IDL_RUN_DINSAR'];
const DINSAR_PRODUCTION_TASK_TYPES = ['PYINT_RUN', 'LANDSAR_RUN', 'LANDSAR_CLUSTER_RUN', 'IDL_RUN_DINSAR'];
const STATUS_LABEL = {
PENDING: '等待中',
@@ -114,7 +114,7 @@ function formatStatus(status) {
function taskTypeToEngine(taskType) {
if (taskType === 'PYINT_RUN') return 'pyint';
if (taskType === 'LANDSAR_RUN') return 'landsar';
if (taskType === 'LANDSAR_RUN' || taskType === 'LANDSAR_CLUSTER_RUN') return 'landsar';
if (taskType === 'IDL_RUN_DINSAR') return 'sarscape';
return '';
}
@@ -582,6 +582,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [timeoutSec, setTimeoutSec] = useState('');
const [engineExtraParams, setEngineExtraParams] = useState({});
const [submitting, setSubmitting] = useState(false);
const [clusterSubmitting, setClusterSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState('');
const [submitError, setSubmitError] = useState(false);
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
@@ -635,7 +636,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
latestRunWithTask.engine === 'pyint'
? 'PYINT_RUN'
: latestRunWithTask.engine === 'landsar'
? 'LANDSAR_RUN'
? (latestRunWithTask.mode === 'cluster' ? 'LANDSAR_CLUSTER_RUN' : 'LANDSAR_RUN')
: 'IDL_RUN_DINSAR',
status: latestRunWithTask.raw_status || latestRunWithTask.status,
progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null,
@@ -645,6 +646,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
);
const logTaskId = monitoredTask?.task_id || '';
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
const activeRunCount = runs.filter(run => ['running', 'pending'].includes(String(run.status || '').toLowerCase())).length;
const failedRunCount = runs.filter(run => ['failed', 'FAILED'].includes(String(run.status || ''))).length;
const productionReady = !!currentEngineObj?.available && !!rootDir.trim() && !pyintPreviewBlocksSubmit && !readOnly;
const loadEngines = useCallback(async () => {
setEnginesLoading(true);
@@ -892,6 +896,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}
};
const handleSubmitCluster = async () => {
if (readOnly || clusterSubmitting) return;
if (selectedEngine !== 'landsar') {
setSubmitError(true);
setSubmitMsg('LandSAR 集群只支持 LandSAR 引擎。');
return;
}
if (!rootDir.trim()) {
setSubmitError(true);
setSubmitMsg('请输入或选择 D-InSAR 生产根目录。');
return;
}
setClusterSubmitting(true);
setSubmitMsg('');
setSubmitError(false);
try {
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
const result = await submitLandsarClusterRun({
engine_code: 'landsar',
profile: selectedProfile,
root_dir: rootDir.trim(),
num_to_process: Number(numToProcess) || 0,
rerun_mode: rerunMode,
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
extra,
});
const taskCount = result?.selected_task_count ? `,选中 ${result.selected_task_count} 个 pair` : '';
const skippedCompleted = Number(result?.skipped_completed_count || 0);
const skippedText = skippedCompleted > 0 ? `,跳过 ${skippedCompleted} 个已完成 pair` : '';
setSubmitError(false);
setSubmitMsg(`LandSAR 集群任务已入队:${result.task_id}${taskCount}${skippedText}`);
if (onJobQueued) onJobQueued(result.task_id);
await refreshMonitor();
} catch (err) {
setSubmitError(true);
setSubmitMsg(`LandSAR 集群提交失败:${err?.response?.data?.detail || err.message}`);
} finally {
setClusterSubmitting(false);
}
};
const handleViewLog = async run => {
const runId = typeof run === 'string' ? run : (run?.run_id || run?.task_id || '');
const source = typeof run !== 'string' && run?.log_source === 'task' ? 'task' : 'run';
@@ -1001,9 +1047,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]);
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || !rootDir.trim() || pyintPreviewBlocksSubmit;
const isClusterSubmitDisabled = readOnly || clusterSubmitting || selectedEngine !== 'landsar' || !rootDir.trim();
return (
<div style={{ padding: '16px 0', width: '100%' }}>
<div className="dinsar-production-shell">
{logModal.open && (
<div
style={{
@@ -1217,7 +1264,39 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
)}
<div style={card}>
<div className="dinsar-production-header">
<div>
<h3>D-InSAR 生产运行</h3>
<p>选择生产引擎任务根目录和处理模板完成预检后提交运行监控区用于查看近期 Task日志和审计记录</p>
</div>
<div className="dinsar-production-summary">
<div className={`dinsar-production-signal ${productionReady ? 'ok' : 'warn'}`}>
<span>提交状态</span>
<strong>{productionReady ? '可提交' : readOnly ? '只读' : '待检查'}</strong>
</div>
<div className="dinsar-production-signal">
<span>当前引擎</span>
<strong>{formatEngineLabel(selectedEngine, currentEngineObj?.engine_label)}</strong>
</div>
<div className="dinsar-production-signal">
<span>运行中</span>
<strong>{activeRunCount}</strong>
</div>
<div className={`dinsar-production-signal ${failedRunCount > 0 ? 'warn' : ''}`}>
<span>失败记录</span>
<strong>{failedRunCount}</strong>
</div>
</div>
</div>
<section className="dinsar-production-section">
<div className="dinsar-production-section-header">
<div>
<h4>引擎与能力</h4>
<p>先确认生产引擎可用再选择对应处理模板</p>
</div>
</div>
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<strong style={{ fontSize: 14 }}>引擎状态</strong>
<button
@@ -1249,8 +1328,16 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
))}
</div>
</div>
</section>
<div style={card}>
<section className="dinsar-production-section">
<div className="dinsar-production-section-header">
<div>
<h4>任务准备与提交</h4>
<p>生产任务根目录参数模板和预检结果共同决定是否允许提交</p>
</div>
</div>
<div style={card}>
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>提交生产任务</strong>
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
@@ -1635,15 +1722,48 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
>
{submitting ? '提交中...' : '提交任务'}
</button>
{selectedEngine === 'landsar' && (
<button
onClick={handleSubmitCluster}
disabled={isClusterSubmitDisabled}
title="按 pair 拆分为 LANDSAR_CLUSTER_ITEM,由本机或远端 LandSAR worker 领取执行。"
style={{
padding: '6px 16px',
borderRadius: 6,
border: '1px solid #0f766e',
background: isClusterSubmitDisabled ? '#f1f5f9' : '#0f766e',
color: isClusterSubmitDisabled ? '#94a3b8' : '#fff',
cursor: isClusterSubmitDisabled ? 'not-allowed' : 'pointer',
fontSize: 13,
fontWeight: 600,
}}
>
{clusterSubmitting ? '集群入队中...' : '提交 LandSAR 集群'}
</button>
)}
{submitMsg && (
<span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}>
{submitMsg}
</span>
)}
</div>
{selectedEngine === 'landsar' && (
<div style={{ marginTop: 8, fontSize: 12, color: '#64748b', lineHeight: 1.6 }}>
集群模式按 pair 拆分队列任务远端服务器需启动只领取 LANDSAR_CLUSTER_ITEM worker
本地 LandSAR 提交流程保持不变
</div>
)}
</div>
</section>
<div style={card}>
<section className="dinsar-production-section">
<div className="dinsar-production-section-header">
<div>
<h4>运行监控与审计记录</h4>
<p>监控近期 Task查看日志并保留运行记录删除等审计类操作</p>
</div>
</div>
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<strong style={{ fontSize: 14 }}>运行监控</strong>
<button
@@ -1703,7 +1823,6 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
height: '100%',
width: `${monitoredTask.progress}%`,
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
transition: 'width 0.3s',
}}
/>
</div>
@@ -1912,6 +2031,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
)}
</div>
</section>
</div>
);
}
+103 -83
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { scanDinsarResults } from './api/dinsar';
import { listTaskRoots } from './api/dinsarProduction';
import { extractDispResults } from './api/idl';
import { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks';
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
@@ -8,14 +9,10 @@ import useTaskMonitor from './hooks/useTaskMonitor';
const PRODUCT_TASK_TYPES = [
'SCAN_DINSAR',
'PUBLISH_DINSAR_PRODUCTS',
'REBUILD_DINSAR_CATALOG',
];
const TASK_TYPE_LABEL = {
SCAN_DINSAR: 'D-InSAR 结果扫描',
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 产物发布',
REBUILD_DINSAR_CATALOG: 'D-InSAR 目录重建',
};
const STATUS_LABEL = {
@@ -48,13 +45,12 @@ function getLogTone(level) {
}
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
const [extractRootDir, setExtractRootDir] = useState('');
const [extractDestDir, setExtractDestDir] = useState('');
const [productionRoot, setProductionRoot] = useState('');
const [productionRootReady, setProductionRootReady] = useState(false);
const [extractResult, setExtractResult] = useState(null);
const [extracting, setExtracting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [actionMessage, setActionMessage] = useState('');
const [actionError, setActionError] = useState(false);
const [scanning, setScanning] = useState(false);
const [taskLogs, setTaskLogs] = useState([]);
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
@@ -62,13 +58,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const taskMonitor = useTaskMonitor({
taskTypes: PRODUCT_TASK_TYPES,
showRecent: true,
recentLimit: 1,
showRecent: false,
});
const monitoredTask = taskMonitor.latestTask;
const logTaskId = monitoredTask?.task_id || '';
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
const actionTone = getMessageTone(actionMessage, actionError);
const activeTaskCount = taskMonitor.activeTasks?.length || 0;
const catalogSourceState = productionRootReady ? '已配置' : '未配置';
const taskStateLabel = activeTaskCount > 0
? `${activeTaskCount} 个运行中`
: '空闲';
const taskStateTone = activeTaskCount > 0 ? 'warn' : 'ready';
const loadTaskLogs = useCallback(async (taskId) => {
if (!taskId) {
@@ -87,8 +87,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
}, []);
const refreshMonitor = useCallback(async () => {
const nextRecentTasks = await taskMonitor.refreshRecentTasks();
const nextTaskId = taskMonitor.activeTasks[0]?.task_id || nextRecentTasks[0]?.task_id || logTaskId;
const nextTaskId = taskMonitor.activeTasks[0]?.task_id || logTaskId;
await loadTaskLogs(nextTaskId);
}, [loadTaskLogs, logTaskId, taskMonitor]);
@@ -96,6 +95,33 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
loadTaskLogs(logTaskId);
}, [loadTaskLogs, logTaskId]);
useEffect(() => {
if (!taskMonitor.isBusy || !logTaskId) return undefined;
const timer = window.setInterval(() => {
void loadTaskLogs(logTaskId);
}, 3000);
return () => window.clearInterval(timer);
}, [loadTaskLogs, logTaskId, taskMonitor.isBusy]);
useEffect(() => {
let canceled = false;
listTaskRoots()
.then((data) => {
if (canceled) return;
const root = String(data?.root || '').trim();
setProductionRoot(root);
setProductionRootReady(Boolean(root && data?.root_exists));
})
.catch(() => {
if (canceled) return;
setProductionRoot('');
setProductionRootReady(false);
});
return () => {
canceled = true;
};
}, []);
const handleDeleteTaskLog = useCallback(async (logId) => {
const taskId = logTaskId;
if (!taskId || !logId || taskLogActionLoading) return;
@@ -132,63 +158,70 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
}
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
const handleExtract = async () => {
if (!extractRootDir.trim()) return;
setExtracting(true);
const handleExtractAndScan = async () => {
if (readOnly || !productionRootReady || !productionRoot.trim()) return;
setSyncing(true);
setExtractResult(null);
setActionMessage('');
setActionError(false);
try {
const result = await extractDispResults(extractRootDir.trim(), extractDestDir.trim() || null);
const result = await extractDispResults(productionRoot.trim(), null);
setExtractResult(result);
} catch (err) {
setExtractResult({ error: err?.response?.data?.detail || err.message });
} finally {
setExtracting(false);
}
};
const handleScan = async () => {
if (readOnly) return;
setScanning(true);
setActionMessage('');
setActionError(false);
try {
const result = await scanDinsarResults();
setActionMessage(result?.message || `D-InSAR 结果扫描任务已入队:${result?.task_id || '-'}`);
if (result?.task_id) {
onJobQueued?.(result.task_id);
const scanResult = await scanDinsarResults();
setActionMessage(scanResult?.message || `D-InSAR 结果登记任务已提交:${scanResult?.task_id || '-'}`);
if (scanResult?.task_id) {
onJobQueued?.(scanResult.task_id);
}
await refreshMonitor();
} catch (err) {
setActionError(true);
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败');
const message = err?.response?.data?.detail || err.message || 'D-InSAR 结果提取与登记失败';
setActionMessage(message);
setExtractResult((current) => current || { error: message });
} finally {
setScanning(false);
setSyncing(false);
}
};
const monitorTone = useMemo(() => {
if (!monitoredTask) return 'neutral';
if (showingRecentTask) return 'info';
return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral';
}, [monitoredTask, showingRecentTask]);
}, [monitoredTask]);
return (
<div className="dinsar-products-page">
<div className="dinsar-products-hero">
<div>
<strong>D-InSAR 结果提取与标准目录</strong>
<strong>D-InSAR 结果目录</strong>
<p>
这里负责把生产目录中的位移结果提取为标准成果包并触发统一扫描发布和编目
生产运行与参数配置现已收口到生产管理工作台中的 D-InSAR 运行 子视图
生产目录中的位移结果提取为标准成果包并触发结果登记和目录编目
生产参数与运行提交已归入D-InSAR 运行这里专注成果归档与资产登记
</p>
</div>
<div className="dinsar-products-hero-badges">
<span className={`dinsar-status-pill tone-${readOnly ? 'warn' : 'ready'}`}>
{readOnly ? '只读模式' : '可执行写操作'}
</span>
<span className="dinsar-status-pill tone-info">日志改为手动刷新</span>
<div className="dinsar-products-signals" aria-label="D-InSAR 结果目录状态摘要">
<div className={`dinsar-production-signal tone-${readOnly ? 'warn' : 'ready'}`}>
<span>操作模式</span>
<strong>{readOnly ? '只读' : '可维护'}</strong>
</div>
<div className={`dinsar-production-signal tone-${taskStateTone}`}>
<span>产物任务</span>
<strong>{taskStateLabel}</strong>
</div>
<div className={`dinsar-production-signal tone-${productionRootReady ? 'ready' : 'neutral'}`}>
<span>提取源</span>
<strong>{catalogSourceState}</strong>
</div>
<div className="dinsar-production-signal tone-info">
<span>日志</span>
<strong>手动刷新</strong>
</div>
</div>
</div>
<div className="dinsar-products-section-head">
<div>
<strong>成果提取与任务监控</strong>
<span>左侧执行受控提取与登记右侧核对后台任务与日志</span>
</div>
</div>
@@ -196,46 +229,27 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<section className="dinsar-products-card">
<div className="dinsar-products-card-head">
<div>
<strong>结果提取与重扫</strong>
<span>先提取标准结果包再按统一目录登记</span>
<strong>D-InSAR 结果提取与登记</strong>
<span>将已完成的生产成果归入标准结果目录</span>
</div>
</div>
<div className="dinsar-products-form-grid">
<label className="dinsar-products-field dinsar-products-field-wide">
<span>结果根目录</span>
<input
value={extractRootDir}
onChange={(event) => setExtractRootDir(event.target.value)}
placeholder="例如:D:\\Task_Pool\\DInSAR"
/>
</label>
<label className="dinsar-products-field">
<span>目标目录可选</span>
<input
value={extractDestDir}
onChange={(event) => setExtractDestDir(event.target.value)}
placeholder="留空则使用系统默认"
/>
</label>
<div className="dinsar-products-controlled-source">
<span>成果来源</span>
<strong>{productionRootReady ? '生产目录已就绪' : '生产目录待完善'}</strong>
<p>{productionRootReady ? '可将当前生产成果提取并登记为标准结果包。' : '请先完成 D-InSAR 生产目录配置。'}</p>
</div>
<div className="dinsar-products-actions">
<button
type="button"
className="primary"
onClick={handleExtract}
disabled={extracting || !extractRootDir.trim()}
onClick={handleExtractAndScan}
disabled={readOnly || syncing || !productionRootReady}
>
{extracting ? '提取中...' : '提取位移结果'}
</button>
<button
type="button"
onClick={handleScan}
disabled={readOnly || scanning}
>
{scanning ? '重扫中...' : '重扫结果'}
{syncing ? '处理中...' : '提取并登记结果'}
</button>
{!productionRootReady && <span className="dinsar-products-action-hint">请先在后端配置 D-InSAR 生产根目录</span>}
</div>
{actionMessage && (
@@ -270,7 +284,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<div className="dinsar-products-card-head">
<div>
<strong>产物任务监控</strong>
<span>当前不轮询按需手动刷新</span>
<span>任务运行时自动更新空闲时按需刷新</span>
</div>
<button type="button" onClick={refreshMonitor}>刷新</button>
</div>
@@ -281,7 +295,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
<div className="dinsar-monitor-card">
<div className="dinsar-monitor-top">
<div>
<strong>{showingRecentTask ? '最近一次任务' : '当前任务'}</strong>
<strong>当前任务</strong>
<span>{formatTaskType(monitoredTask.task_type)}</span>
</div>
<StatusSummary status={monitoredTask.status} />
@@ -303,7 +317,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
)}
<div className="dinsar-monitor-log-head">
<strong>{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}</strong>
<strong>当前任务日志</strong>
{!readOnly && (
<button
type="button"
@@ -354,11 +368,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
</section>
</div>
<DinsarCatalogPanel
readOnly={readOnly}
initialSourceDir={extractRootDir}
onTaskQueued={onJobQueued}
/>
<section className="dinsar-products-catalog-section">
<div className="dinsar-products-section-head">
<div>
<strong>标准目录与资产详情</strong>
<span>核对 AOI时间范围资产文件发布状态和目录一致性</span>
</div>
</div>
<DinsarCatalogPanel
readOnly={readOnly}
/>
</section>
</div>
);
}
+2 -1
View File
@@ -31,6 +31,7 @@ import { RADAR_SEARCH_DEFAULTS } from './config/appConstants';
const SEARCH_PAGE_SIZE = 30;
const LIST_PAGE_SIZE = 20;
const ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS = 15000;
const VIEWS = [
{ key: 'extract', label: '水体提取' },
@@ -649,7 +650,7 @@ export default function FloodAnalysisWorkspace({
if (!runningCount) return undefined;
const timer = window.setInterval(() => {
refreshAll();
}, 6000);
}, ACTIVE_WORKSPACE_REFRESH_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [refreshAll, runningCount]);
-1
View File
@@ -13,7 +13,6 @@ const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanC
taskTypes: ['SCAN_HAZARD'],
showRecent: true,
recentLimit: 1,
pollRecentMs: 10000,
});
const scanBusy = isLoading || scanTaskMonitor.isBusy;
+300 -314
View File
@@ -96,11 +96,6 @@ const renderOrbitSourceIssueDetails = (item, en, formatPathText) => {
{en ? 'ENVI: ' : 'ENVI'}{formatPathText(item.envi_path)}
</div>
)}
{item?.isce2_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ISCE2: ' : 'ISCE2'}{formatPathText(item.isce2_path)}
</div>
)}
{hasNulDetails && (
<div style={{ color: '#64748b' }}>
{en ? 'NUL bytes: ' : 'NUL 字节:'}{formatIntegerText(nulCount)}
@@ -201,10 +196,10 @@ const buildConsistencySummary = (stats, en = false) => {
};
};
const HEALTH_PANEL_POLL_INTERVAL_MS = 30000;
const HEALTH_PANEL_POLL_INTERVAL_MS = 5 * 60 * 1000;
const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const en = language === 'en';
const HealthCheckPanel = ({ currentUser }) => {
const en = false;
const isAdmin = currentUser?.role === 'admin';
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
@@ -222,8 +217,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const [wslChecking, setWslChecking] = useState(false);
const [orbitStatus, setOrbitStatus] = useState(null);
const [orbitSyncing, setOrbitSyncing] = useState(false);
const [orbitRepairing, setOrbitRepairing] = useState(false);
const [orbitQuarantining, setOrbitQuarantining] = useState(false);
const [orbitSyncResult, setOrbitSyncResult] = useState(null);
const statusFetchInFlightRef = useRef(false);
@@ -233,7 +226,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setOrbitStatus(data);
} catch (err) {
setOrbitStatus(null);
setOrbitSyncResult({ error: err.response?.data?.detail || err.message || (en ? 'Failed to fetch orbit status' : '精轨状态获取失败') });
setOrbitSyncResult({ error: err.response?.data?.detail || err.message || '精轨状态获取失败' });
}
}, [en]);
@@ -266,7 +259,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setStatus(healthData);
setLastChecked(new Date());
} catch (err) {
setError(err.response?.data?.detail || err.message || (en ? 'Health check failed' : '运维自检失败'));
setError(err.response?.data?.detail || err.message || '运维自检失败');
setStatus(null);
}
@@ -276,19 +269,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setConsistencyError('');
} catch (err) {
setConsistencySummary(null);
setConsistencyError(err.response?.data?.detail || err.message || (en ? 'Failed to fetch consistency stats' : '一致性统计获取失败'));
setConsistencyError(err.response?.data?.detail || err.message || '一致性统计获取失败');
}
// 引擎状态独立加载,不影响主健康检查。
await refreshEngineStatus();
// 轨道目录状态由专门接口维护,失败时由 refreshOrbitStatus 自己写回 UI。
await refreshOrbitStatus();
} finally {
setLoading(false);
statusFetchInFlightRef.current = false;
}
}, [en, refreshEngineStatus, refreshOrbitStatus]);
}, [en, refreshEngineStatus]);
useEffect(() => {
void fetchStatus();
@@ -297,9 +288,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
syncLoading ||
cleanupLoading ||
wslChecking ||
orbitSyncing ||
orbitRepairing ||
orbitQuarantining
orbitSyncing
) {
return;
}
@@ -309,8 +298,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
}, [
cleanupLoading,
fetchStatus,
orbitQuarantining,
orbitRepairing,
orbitSyncing,
syncLoading,
wslChecking,
@@ -322,6 +309,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</span>
);
const renderSignal = (ok, label, detail = '') => (
<div className={`health-signal ${ok ? 'ok' : 'fail'}`}>
<span className="health-signal-label">{label}</span>
<strong>{ok ? '正常' : '异常'}</strong>
{detail && <span className="health-signal-detail">{detail}</span>}
</div>
);
const formatIso = (iso) => {
if (!iso) return en ? 'Unknown' : '未知';
try {
@@ -342,6 +337,14 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
typeof timeseriesResultCatalog.needs_rebuild === 'boolean' ? timeseriesResultCatalog.needs_rebuild : null;
const sourceRoots = asObject(status?.source_roots);
const productPackages = asObject(status?.product_packages);
const assetInventory = asObject(status?.asset_inventory);
const hasAssetInventory = Boolean(status?.asset_inventory);
const assetSourceRoots = asObject(assetInventory.source_roots);
const assetOrbitRoots = asObject(assetInventory.orbit_roots);
const sourceAssets = asObject(assetInventory.source_assets);
const orbitAssets = asObject(assetInventory.orbit_assets);
const orbitBindings = asObject(assetInventory.bindings);
const assetIssues = asObject(assetInventory.issues);
const wslRuntime = asObject(status?.wsl_runtime);
const wslRuntimeItems = asArray(wslRuntime.runtimes);
const pairingSystem = asObject(status?.pairing_system);
@@ -374,58 +377,60 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const orbitPools = orbitStatus?.pools || {};
const orbitConsistency = orbitStatus?.consistency || {};
const orbitDatabase = orbitStatus?.database || {};
const orbitIsce2Enabled = Boolean(orbitPools.isce2?.enabled || orbitConsistency.isce2?.enabled || orbitDatabase.isce2_enabled);
const orbitMismatchCount = toNumber(orbitConsistency.mismatch_count);
const orbitDbMissingEnviCount = toNumber(orbitDatabase.stems_missing_in_envi_count);
const orbitDbMissingIsce2Count = orbitIsce2Enabled ? toNumber(orbitDatabase.stems_missing_in_isce2_count) : 0;
const orbitDbMissingPathCount = toNumber(orbitDatabase.db_missing_path_count);
const orbitDbFlagIssueCount =
toNumber(orbitDatabase.has_orbit_but_missing_path_count) +
toNumber(orbitDatabase.without_orbit_but_path_present_count);
const orbitScanErrorCount =
(orbitSource.errors?.length || 0) +
(orbitPools.envi?.errors?.length || 0) +
(orbitIsce2Enabled ? (orbitPools.isce2?.errors?.length || 0) : 0);
(orbitPools.envi?.errors?.length || 0);
const orbitDuplicateCount =
toNumber(orbitSource.duplicate_count) +
toNumber(orbitPools.envi?.duplicate_count) +
(orbitIsce2Enabled ? toNumber(orbitPools.isce2?.duplicate_count) : 0);
toNumber(orbitPools.envi?.duplicate_count);
const orbitSuspectBadCount = toNumber(orbitSource.suspect_bad_count);
const orbitSourceWithoutEnviCount = toNumber(orbitSource.source_without_envi_count);
const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count);
const orbitIsce2WithoutSourceCount = orbitIsce2Enabled ? toNumber(orbitSource.isce2_without_source_count) : 0;
const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path;
const orbitBadSourceSamples = asArray(orbitSource.bad_source_samples).filter(hasOrbitCorruptionSignal);
const orbitSuspectBadSamples = asArray(orbitSource.suspect_bad_samples);
const orbitSuspectWithoutCorruptionSamples = orbitSuspectBadSamples.filter(
(item) => !orbitBadSourceSamples.some((badItem) => badItem.name === item.name)
);
const orbitBadSourceSampleCount = toNumber(orbitSource.bad_source_sample_count || orbitBadSourceSamples.length);
const orbitOverallHealthy = Boolean(
orbitStatus &&
orbitMismatchCount === 0 &&
orbitDbMissingEnviCount === 0 &&
orbitDbMissingIsce2Count === 0 &&
orbitDbMissingPathCount === 0 &&
orbitDbFlagIssueCount === 0 &&
orbitScanErrorCount === 0 &&
orbitSuspectBadCount === 0 &&
orbitSourceWithoutEnviCount === 0 &&
orbitEnviWithoutSourceCount === 0 &&
orbitIsce2WithoutSourceCount === 0
orbitEnviWithoutSourceCount === 0
);
const assetInventoryHealthy = Boolean(hasAssetInventory && assetInventory.ok);
const orbitAssetRiskCount =
toNumber(orbitAssets.parse_failed_count) +
toNumber(orbitBindings.missing_count) +
toNumber(orbitBindings.ambiguous_count);
const productionBlockingCount = [
!status?.ok,
!status?.database?.ok,
!status?.database?.schema_ok,
!status?.worker?.ok,
consistencySummary && consistencySummary.critical > 0,
hasAssetInventory && !assetInventoryHealthy,
].filter(Boolean).length;
return (
<div className="health-panel">
<div className="health-header">
<div>
<div className="health-title">{en ? 'System Health' : '运维自检'}</div>
<div className="health-title">运行维护</div>
<div className="health-subtitle">
{lastChecked ? `${en ? 'Last check: ' : '上次检查:'}${lastChecked.toLocaleString()}` : (en ? 'Not checked yet' : '尚未检查')}
面向生产环境的系统健康数据一致性和维护操作入口
</div>
</div>
<button className="health-refresh" onClick={() => fetchStatus({ force: true })} disabled={loading}>
{loading ? (en ? 'Checking...' : '检查中...') : (en ? 'Refresh' : '刷新')}
{loading ? '检查中...' : '刷新自检'}
</button>
</div>
@@ -439,23 +444,37 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<>
<div className="health-summary">
<div className="health-summary-item">
<span>{en ? 'Overall Status' : '总体状态'}</span>
{renderBadge(status.ok)}
<span>生产就绪</span>
{renderBadge(status.ok && productionBlockingCount === 0, productionBlockingCount > 0 ? `${productionBlockingCount} 个阻断项` : '可运行')}
</div>
<div className="health-summary-item">
<span>{en ? 'Timestamp' : '时间戳'}</span>
<span>{formatIso(status.timestamp)}</span>
<span>最近检查</span>
<span>{lastChecked ? lastChecked.toLocaleString() : formatIso(status.timestamp)}</span>
</div>
<div className="health-summary-item">
<span>{en ? 'Consistency Issues' : '一致性异常'}</span>
<span>一致性异常</span>
{renderBadge(
!consistencySummary || consistencySummary.total === 0,
consistencySummary ? `${consistencySummary.total} ${en ? 'items' : '项'}` : (en ? 'Unknown' : '未知')
consistencySummary ? `${consistencySummary.total} ` : '未知'
)}
</div>
<div className="health-signal-row">
{renderSignal(!!status.database?.ok && !!status.database?.schema_ok, '数据库')}
{renderSignal(!!status.worker?.ok, 'Worker', `${status.worker?.worker_count ?? 0}`)}
{renderSignal(!!status.idl?.ok, 'IDL/ENVI')}
{renderSignal(!!status.nginx?.ok, 'Nginx')}
</div>
</div>
<div className="health-grid">
<div className="health-sections">
<section className="health-section">
<div className="health-section-header">
<div>
<h3>核心服务</h3>
<p>判断系统是否具备基础生产能力数据库PostGISschema 和任务执行器</p>
</div>
</div>
<div className="health-grid">
<div className="health-card">
<div className="health-card-title">{en ? 'Database' : '数据库'}</div>
<div className="health-card-row">
@@ -587,7 +606,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
)}
</div>
</div>
</section>
<section className="health-section">
<div className="health-section-header">
<div>
<h3>结果目录与生产索引</h3>
<p>检查 D-InSAR时序 InSAR配对基础表和兼容视图是否能支撑结果查询与生产流转</p>
</div>
</div>
<div className="health-grid">
<div className="health-card">
<div className="health-card-title">{en ? 'D-InSAR Result Catalog' : 'D-InSAR 结果目录'}</div>
<div className="health-card-row">
@@ -758,7 +787,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
{bridgeIssueCount === 0 ? (
<div className="health-card-note ok">
{en ? 'Catalog-first reads and legacy compat rows are aligned.' : '目录事实源与兼容视图当前一致。'}
{en ? 'Catalog reads and compatibility rows are aligned.' : '目录事实源与兼容视图当前一致。'}
</div>
) : (
<>
@@ -805,7 +834,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
)}
</div>
</div>
</section>
<section className="health-section">
<div className="health-section-header">
<div>
<h3>数据资产与运行时</h3>
<p>检查受管源路径标准结果包WSL runtimeIDL/ENVID-InSAR 引擎Ollama Nginx</p>
</div>
</div>
<div className="health-grid">
<div className="health-card">
<div className="health-card-title">{en ? 'Managed Source Roots' : '受管源路径'}</div>
<div className="health-card-row">
@@ -1037,7 +1076,115 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
)}
</div>
</div>
</section>
<section className="health-section">
<div className="health-section-header">
<div>
<h3>数据资产与精轨</h3>
<p>当前生产以 XML 抽取后的源产品精轨资产和场景绑定为准旧精轨池核对仅作为过渡诊断</p>
</div>
</div>
<div className="health-grid health-grid--ops">
<div className="health-card">
<div className="health-card-title">源产品资产</div>
<div className="health-card-row">
<span>总体状态</span>
{renderBadge(
assetInventoryHealthy,
assetInventory ? `${toNumber(sourceAssets.total_count)}` : '未知'
)}
</div>
<div className="health-card-row">
<span>LT-1 / Sentinel-1</span>
<span>{toNumber(sourceAssets.lt1_count)} / {toNumber(sourceAssets.s1_count)}</span>
</div>
<div className="health-card-row">
<span>解析异常</span>
<span>{toNumber(sourceAssets.parse_failed_count)}</span>
</div>
<div className="health-card-row">
<span>源数据根</span>
<span>{toNumber(assetSourceRoots.accessible_count)} / {toNumber(assetSourceRoots.configured_count)}</span>
</div>
<div className="health-card-row">
<span>需复扫</span>
<span>{toNumber(assetSourceRoots.needs_rescan_count)}</span>
</div>
{toNumber(sourceAssets.parse_failed_count) > 0 ? (
<div className="health-card-note error">
存在源产品解析异常请在数据资产中查看开放问题并复扫相关目录
</div>
) : (
<div className="health-card-note ok">
源产品资产已按 XML/元数据登记
</div>
)}
</div>
<div className="health-card">
<div className="health-card-title">精轨资产状态</div>
<div className="health-card-row">
<span>总体状态</span>
{renderBadge(
orbitAssetRiskCount === 0 && assetInventoryHealthy,
orbitAssetRiskCount > 0 ? `${orbitAssetRiskCount} 个风险项` : `${toNumber(orbitAssets.total_count)}`
)}
</div>
<div className="health-card-row">
<span>LT-1 / Sentinel-1</span>
<span>{toNumber(orbitAssets.lt1_count)} / {toNumber(orbitAssets.s1_count)}</span>
</div>
<div className="health-card-row">
<span>解析异常</span>
<span>{toNumber(orbitAssets.parse_failed_count)}</span>
</div>
<div className="health-card-row">
<span>精轨根</span>
<span>{toNumber(assetOrbitRoots.accessible_count)} / {toNumber(assetOrbitRoots.configured_count)}</span>
</div>
<div className="health-card-row">
<span>需复扫</span>
<span>{toNumber(assetOrbitRoots.needs_rescan_count)}</span>
</div>
<div className="health-card-note">
精轨可用性以资产登记和时间窗绑定结果为准不再以 ISCE2 XML 池作为生产判断
</div>
</div>
<div className="health-card">
<div className="health-card-title">场景精轨绑定</div>
<div className="health-card-row">
<span>已绑定场景</span>
<span>{toNumber(orbitBindings.matched_count)} / {toNumber(orbitBindings.scene_count)}</span>
</div>
<div className="health-card-row">
<span>缺失精轨</span>
<span>{toNumber(orbitBindings.missing_count)}</span>
</div>
<div className="health-card-row">
<span>候选歧义</span>
<span>{toNumber(orbitBindings.ambiguous_count)}</span>
</div>
<div className="health-card-row">
<span>开放问题</span>
<span>{toNumber(assetIssues.open_count)}</span>
</div>
<div className="health-card-row">
<span>错误 / 警告</span>
<span>{toNumber(assetIssues.error_count)} / {toNumber(assetIssues.warning_count)}</span>
</div>
{orbitAssetRiskCount > 0 ? (
<div className="health-card-note warn">
请在数据资产中复核缺失或歧义精轨生产配对会优先使用已选定的精轨资产
</div>
) : (
<div className="health-card-note ok">
当前开放问题未显示精轨绑定风险
</div>
)}
</div>
<div className="health-card">
<div className="health-card-title">{en ? 'Consistency Check' : '一致性检测'}</div>
<div className="health-card-row">
@@ -1103,12 +1250,30 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
)}
</div>
</div>
{/* 精轨管理 */}
{isAdmin && (
<div className="health-card">
<div className="health-card-title">{en ? 'Precise Orbit Management' : '精轨管理'}</div>
<details className="health-card health-legacy-diagnostic">
<summary>
<span>旧精轨池核对</span>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void refreshOrbitStatus();
}}
disabled={orbitSyncing}
className="health-inline-button"
>
{orbitStatus ? '重新核对' : '加载诊断'}
</button>
</summary>
<div className="health-card-note">
该诊断仅核对源精轨目录与 ENVI/Gamma 生产 TXT 用于排查历史目录生产判断以资产登记与场景绑定为准
</div>
{orbitStatus ? (
<>
<div className="health-card-title">{en ? 'Precise Orbit Management' : '文件池状态'}</div>
<div className="health-card-row">
<span>{en ? 'Overall' : '总体状态'}</span>
{renderBadge(
@@ -1120,14 +1285,10 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
<div className="health-card-row">
<span>
{orbitIsce2Enabled
? (en ? 'Source / ENVI-Gamma TXT / ISCE2 XML' : '源目录 / ENVI-Gamma TXT / ISCE2 XML')
: (en ? 'Source / ENVI-Gamma TXT' : '源目录 / ENVI-Gamma TXT')}
{en ? 'Source / production TXT' : '源目录 / 生产 TXT'}
</span>
<span>
{orbitIsce2Enabled
? `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)} / ${toNumber(orbitPools.isce2?.total)}`
: `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
{`${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
</span>
</div>
<div className="health-card-row">
@@ -1140,24 +1301,17 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<span>{en ? 'Pool mismatches' : '池不一致'}</span>
<span>{orbitMismatchCount}</span>
</div>
{orbitIsce2Enabled ? (
<div className="health-card-row">
<span>{en ? 'Suspect bad TXT / source-only' : '疑似坏 TXT / 仅源存在'}</span>
<span>{orbitSuspectBadCount} / {orbitSourceWithoutEnviCount}</span>
</div>
) : (
<div className="health-card-row">
<span>{en ? 'Source-only' : '仅源存在'}</span>
<span>{orbitSourceWithoutEnviCount}</span>
</div>
)}
<div className="health-card-row">
<span>{orbitIsce2Enabled ? (en ? 'TXT-only / ISCE2-only' : '仅 TXT / 仅 ISCE2') : (en ? 'TXT-only' : '仅 TXT')}</span>
<span>{orbitIsce2Enabled ? `${orbitEnviWithoutSourceCount} / ${orbitIsce2WithoutSourceCount}` : orbitEnviWithoutSourceCount}</span>
<span>{en ? 'Source-only' : '仅源存在'}</span>
<span>{orbitSourceWithoutEnviCount}</span>
</div>
<div className="health-card-row">
<span>{orbitIsce2Enabled ? (en ? 'DB missing in TXT / ISCE2' : '数据库在 TXT / ISCE2 缺失') : (en ? 'DB missing in TXT' : '数据库在 TXT 缺失')}</span>
<span>{orbitIsce2Enabled ? `${orbitDbMissingEnviCount} / ${orbitDbMissingIsce2Count}` : orbitDbMissingEnviCount}</span>
<span>{en ? 'Production TXT only' : '仅生产 TXT 存在'}</span>
<span>{orbitEnviWithoutSourceCount}</span>
</div>
<div className="health-card-row">
<span>{en ? 'DB missing in production TXT' : '数据库在生产 TXT 缺失'}</span>
<span>{orbitDbMissingEnviCount}</span>
</div>
<div className="health-card-row">
<span>{en ? 'Duplicate stems / scan errors' : '重复 stem / 扫描异常'}</span>
@@ -1166,21 +1320,11 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<div className="health-card-note" style={{ marginTop: 4 }}>
{en
? (orbitIsce2Enabled
? 'LT-1 orbit scans synchronize the production TXT pool and the legacy ISCE2 XML pool. S1 EOF files remain registered as source orbit assets.'
: 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets; ISCE2 XML is disabled.')
: (orbitIsce2Enabled
? 'LT-1 精轨扫描会同步生产 TXT 池和 legacy ISCE2 XML 池;S1 EOF 只登记为源精轨资产。'
: 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产,ISCE2 XML 已停用。')}
? 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets.'
: 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产。'}
</div>
<div className="health-card-note">{en ? 'Source path: ' : '源目录路径:'}{formatPathText(orbitSource.path)}</div>
<div className="health-card-note">{en ? 'Production TXT pool: ' : '生产 TXT 池:'}{formatPathText(orbitPools.envi?.path)}</div>
{orbitIsce2Enabled && (
<>
<div className="health-card-note">{en ? 'Legacy ISCE2 pool: ' : 'Legacy ISCE2 池:'}{formatPathText(orbitPools.isce2?.path)}</div>
<div className="health-card-note">{en ? 'Quarantine path: ' : '隔离目录:'}{formatPathText(orbitQuarantinePath)}</div>
</>
)}
{orbitDuplicateCount > 0 && (
<div className="health-card-note warn">
@@ -1203,13 +1347,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
: `数据库 orbit_file_path 指向不存在文件:${orbitDbMissingPathCount} / ${toNumber(orbitDatabase.distinct_orbit_path_count)}`}
</div>
)}
{orbitIsce2Enabled && orbitSuspectBadCount > 0 && (
<div className="health-card-note warn">
{en
? `Suspect bad source TXT (source exists but ISCE2 XML missing): ${orbitSuspectBadCount}`
: `疑似坏源 TXT(源文件存在但 ISCE2 XML 缺失):${orbitSuspectBadCount}`}
</div>
)}
{orbitBadSourceSampleCount > 0 && (
<div className="health-card-note error">
{en
@@ -1223,24 +1360,12 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{orbitDatabase.sample_missing_in_envi.slice(0, 5).join(', ')}
</div>
)}
{orbitIsce2Enabled && orbitDatabase.sample_missing_in_isce2?.length > 0 && (
<div className="health-card-note error">
{en ? 'DB expected but ISCE2 pool missing: ' : '数据库期望但 ISCE2 池缺失:'}
{orbitDatabase.sample_missing_in_isce2.slice(0, 5).join(', ')}
</div>
)}
{orbitBadSourceSamples.slice(0, 5).map((item) => (
<div key={`orbit-bad-source-${item.name}`} className="health-card-note error">
{item.name} - {item.error || (en ? 'Corruption signal detected' : '检测到损坏信号')}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{orbitIsce2Enabled && orbitSuspectWithoutCorruptionSamples.slice(0, 5).map((item) => (
<div key={`orbit-suspect-bad-${item.name}`} className="health-card-note warn">
{item.name}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{(orbitConsistency.mismatches || []).slice(0, 5).map((item) => (
<div key={item.name} className="health-card-note error">
{item.name} - {item.issue}
@@ -1249,11 +1374,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{en ? 'ENVI: ' : 'ENVI'}{formatPathText(item.envi_path)}
</div>
)}
{item.isce2_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ISCE2: ' : 'ISCE2'}{formatPathText(item.isce2_path)}
</div>
)}
</div>
))}
{orbitConsistency.mismatch_count > 5 && (
@@ -1278,11 +1398,6 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{en ? 'ENVI pool scan error: ' : 'ENVI 池扫描异常:'}{item}
</div>
))}
{orbitIsce2Enabled && (orbitPools.isce2?.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-isce2-error-${index}`} className="health-card-note error">
{en ? 'ISCE2 pool scan error: ' : 'ISCE2 池扫描异常:'}{item}
</div>
))}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
<button
@@ -1299,53 +1414,11 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
setOrbitSyncing(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
disabled={orbitSyncing}
style={{ padding: '4px 12px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitSyncing ? (en ? 'Checking...' : '检查中...') : (en ? 'Check Consistency' : '精轨一致性检查')}
</button>
{orbitIsce2Enabled && (
<>
<button
onClick={async () => {
setOrbitRepairing(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ repair: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitRepairing(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#0f766e', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitRepairing ? (en ? 'Repairing...' : '修复中...') : (en ? 'Repair Missing XML' : '修复缺失 XML')}
</button>
<button
onClick={async () => {
setOrbitQuarantining(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ quarantine_bad: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitQuarantining(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#b45309', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitQuarantining ? (en ? 'Quarantining...' : '隔离中...') : (en ? 'Quarantine Bad TXT' : '隔离坏精轨')}
</button>
</>
)}
</div>
{orbitSyncResult && (
@@ -1354,176 +1427,89 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<div className="health-card-note error">{orbitSyncResult.error}</div>
) : (
<>
{'confirmed_bad_count' in orbitSyncResult ? (
<>
<div className={`health-card-note ${toNumber(orbitSyncResult.confirmed_bad_count) === 0 ? 'ok' : 'warn'}`}>
{en
? `Quarantine finished. Confirmed bad TXT: ${toNumber(orbitSyncResult.confirmed_bad_count)} / validated ${toNumber(orbitSyncResult.validated_count)}`
: `隔离完成。已确认坏 TXT ${toNumber(orbitSyncResult.confirmed_bad_count)} 项 / 已校验 ${toNumber(orbitSyncResult.validated_count)}`}
</div>
<div className="health-card-note">
{en
? `Quarantine root: ${formatPathText(orbitSyncResult.quarantine_root)}`
: `隔离目录:${formatPathText(orbitSyncResult.quarantine_root)}`}
</div>
{(orbitSyncResult.confirmed_bad || []).slice(0, 5).map((item, index) => (
<div key={`orbit-quarantine-bad-${index}`} className="health-card-note error">
{item.name} - {item.error}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
{item.quarantined_source && (
<div style={{ color: '#64748b' }}>
{en ? 'Moved source to: ' : '源文件已移至:'}{formatPathText(item.quarantined_source)}
</div>
)}
{item.quarantined_envi && (
<div style={{ color: '#64748b' }}>
{en ? 'Moved ENVI to: ' : 'ENVI 已移至:'}{formatPathText(item.quarantined_envi)}
</div>
)}
{item.quarantined_isce2 && (
<div style={{ color: '#64748b' }}>
{en ? 'Moved ISCE2 to: ' : 'ISCE2 已移至:'}{formatPathText(item.quarantined_isce2)}
</div>
)}
</div>
))}
{(orbitSyncResult.skipped_valid || []).slice(0, 3).map((item, index) => (
<div key={`orbit-quarantine-skip-${index}`} className="health-card-note ok">
{item.name} - {item.reason}
</div>
))}
{(orbitSyncResult.errors || []).slice(0, 5).map((item, index) => (
<div key={`orbit-quarantine-error-${index}`} className="health-card-note error">
{item.name} - {item.scope} - {item.error}
</div>
))}
</>
) : 'before' in orbitSyncResult ? (
<>
<div className={`health-card-note ${orbitSyncResult.healthy ? 'ok' : 'warn'}`}>
{en
? `Repair finished. Before mismatches: ${toNumber(orbitSyncResult.before?.mismatch_count)}, after mismatches: ${toNumber(orbitSyncResult.after?.mismatch_count)}`
: `修复完成。修复前不一致 ${toNumber(orbitSyncResult.before?.mismatch_count)} 项,修复后不一致 ${toNumber(orbitSyncResult.after?.mismatch_count)}`}
</div>
<div className="health-card-note">
{en
? `Recovered from ENVI TXT: ${(orbitSyncResult.repaired_from_envi || []).length}, repair errors: ${toNumber(orbitSyncResult.repair_error_count)}`
: `从 ENVI TXT 补转成功 ${(orbitSyncResult.repaired_from_envi || []).length} 项,修复失败 ${toNumber(orbitSyncResult.repair_error_count)}`}
</div>
<div className="health-card-note">
{en
? `Source scan ${toNumber(orbitSyncResult.sync_result?.total_source)}, TXT copied ${(orbitSyncResult.sync_result?.envi?.copied || []).length}, TXT refreshed ${(orbitSyncResult.sync_result?.envi?.updated || []).length}${orbitSyncResult.isce2_enabled ? `, ISCE2 converted ${(orbitSyncResult.sync_result?.isce2?.converted || []).length}, ISCE2 refreshed ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}` : ', ISCE2 disabled'}`
: `源目录扫描 ${toNumber(orbitSyncResult.sync_result?.total_source)} 项,TXT 新增 ${(orbitSyncResult.sync_result?.envi?.copied || []).length} 项、刷新 ${(orbitSyncResult.sync_result?.envi?.updated || []).length}${orbitSyncResult.isce2_enabled ? `ISCE2 新增转换 ${(orbitSyncResult.sync_result?.isce2?.converted || []).length} 项、重转 ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}` : 'ISCE2 已停用'}`}
</div>
{(orbitSyncResult.repaired_from_envi || []).slice(0, 5).length > 0 && (
<div className="health-card-note ok">
{en ? 'Recovered stems: ' : '已补转 stem'}
{(orbitSyncResult.repaired_from_envi || []).slice(0, 5).join(', ')}
<div className={`health-card-note ${orbitSyncResult.healthy ? 'ok' : 'error'}`}>
{orbitSyncResult.healthy
? (en ? 'Pools are consistent.' : '本地池一致。')
: (en ? `Detected ${toNumber(orbitSyncResult.mismatch_count)} mismatches.` : `检测到 ${toNumber(orbitSyncResult.mismatch_count)} 项不一致。`)}
</div>
<div className="health-card-note">
{en
? `TXT ${toNumber(orbitSyncResult.envi?.total)}, scan errors ${toNumber(orbitSyncResult.error_count)}`
: `TXT ${toNumber(orbitSyncResult.envi?.total)} 项,扫描异常 ${toNumber(orbitSyncResult.error_count)}`}
</div>
{(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => (
<div key={`orbit-check-mismatch-${index}`} className="health-card-note error">
{item.name} - {item.issue}
{item.envi_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ENVI: ' : 'ENVI'}{formatPathText(item.envi_path)}
</div>
)}
{(orbitSyncResult.repair_errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-repair-error-${index}`} className="health-card-note error">
{item.name} - {item.error}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{(orbitSyncResult.sync_result?.source?.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-repair-source-error-${index}`} className="health-card-note error">
{en ? 'Source scan error: ' : '源目录扫描异常:'}{item}
</div>
))}
{(orbitSyncResult.sync_result?.invalid_sources || []).slice(0, 5).map((item, index) => (
<div key={`orbit-repair-invalid-${index}`} className="health-card-note error">
{item.name} - {item.error}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{(orbitSyncResult.sync_result?.isce2?.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-repair-isce2-error-${index}`} className="health-card-note error">
{item.file} - {item.error}
</div>
))}
</>
) : (
<>
<div className={`health-card-note ${orbitSyncResult.healthy ? 'ok' : 'error'}`}>
{orbitSyncResult.healthy
? (en ? 'Pools are consistent.' : '本地池一致。')
: (en ? `Detected ${toNumber(orbitSyncResult.mismatch_count)} mismatches.` : `检测到 ${toNumber(orbitSyncResult.mismatch_count)} 项不一致。`)}
</div>
<div className="health-card-note">
{en
? `TXT ${toNumber(orbitSyncResult.envi?.total)}${orbitSyncResult.isce2?.enabled ? `, ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}` : ', ISCE2 disabled'}, scan errors ${toNumber(orbitSyncResult.error_count)}`
: `TXT ${toNumber(orbitSyncResult.envi?.total)}${orbitSyncResult.isce2?.enabled ? `ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}` : 'ISCE2 已停用'},扫描异常 ${toNumber(orbitSyncResult.error_count)}`}
</div>
{(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => (
<div key={`orbit-check-mismatch-${index}`} className="health-card-note error">
{item.name} - {item.issue}
{item.envi_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ENVI: ' : 'ENVI'}{formatPathText(item.envi_path)}
</div>
)}
{item.isce2_path && (
<div style={{ color: '#64748b' }}>
{en ? 'ISCE2: ' : 'ISCE2'}{formatPathText(item.isce2_path)}
</div>
)}
</div>
))}
{(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-check-error-${index}`} className="health-card-note error">
{item}
</div>
))}
</>
)}
</div>
))}
{(orbitSyncResult.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-check-error-${index}`} className="health-card-note error">
{item}
</div>
))}
</>
)}
</div>
)}
</div>
</>
) : (
<div className="health-card-note">展开后点击加载诊断获取旧精轨池状态</div>
)}
</details>
)}
</div>
</section>
{/* 系统维护 */}
{isAdmin && (
<div className="health-card">
<div className="health-card-title">{en ? 'System Maintenance' : '系统维护'}</div>
<div className="health-card-row" style={{ alignItems: 'center' }}>
<span>{en ? 'Expired Sessions' : '过期会话清理'}</span>
<button
onClick={async () => {
setCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupSessions();
setCleanupResult({ ok: true, message: data.message || `已清理 ${data.deleted_count}` });
} catch (e) {
setCleanupResult({ ok: false, message: e.message || '清理失败' });
} finally {
setCleanupLoading(false);
}
}}
disabled={cleanupLoading}
style={{ padding: '4px 12px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{cleanupLoading ? (en ? 'Cleaning...' : '清理中...') : (en ? 'Clean Up' : '清理')}
</button>
</div>
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`} style={{ marginTop: 4 }}>
{cleanupResult.message}
<section className="health-section">
<div className="health-section-header">
<div>
<h3>维护与审计</h3>
<p>低频维护动作与日志审计集中管理避免与实时健康状态混杂</p>
</div>
)}
<div className="health-card-note" style={{ marginTop: 4, color: '#94a3b8' }}>
{en ? 'Delete expired and revoked session records from the database.' : '删除数据库中已过期和已撤销的会话记录,释放空间。'}
</div>
</div>
)}
<div className="health-maintenance-stack">
{isAdmin && (
<div className="health-action-card">
<div>
<h4>会话记录维护</h4>
<p>清理已过期或已撤销的登录会话不影响当前有效登录</p>
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`}>
{cleanupResult.message}
</div>
)}
</div>
<button
className="health-action-button"
onClick={async () => {
setCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupSessions();
setCleanupResult({ ok: true, message: data.message || `已清理 ${data.deleted_count}` });
} catch (e) {
setCleanupResult({ ok: false, message: e.message || '清理失败' });
} finally {
setCleanupLoading(false);
}
}}
disabled={cleanupLoading}
>
{cleanupLoading ? '清理中...' : '执行清理'}
</button>
</div>
)}
{/* 日志管理 */}
<div className="health-card">
<LogManagementPanel isAdmin={isAdmin} />
{/* 日志管理 */}
<LogManagementPanel isAdmin={isAdmin} />
</div>
</section>
</div>
</>
)}
+5 -3
View File
@@ -41,7 +41,6 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'],
showRecent: true,
recentLimit: 1,
pollRecentMs: 10000,
});
const runningTask = idlTaskMonitor.activeTasks[0] || null;
@@ -75,9 +74,12 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
useEffect(() => {
refreshData().catch(() => {});
const timer = setInterval(() => refreshData().catch(() => {}), 10000);
if (!runningTask) {
return undefined;
}
const timer = setInterval(() => refreshData().catch(() => {}), 15000);
return () => clearInterval(timer);
}, [refreshData]);
}, [refreshData, runningTask]);
const runAction = async (action) => {
setIsBusy(true);
+101 -227
View File
@@ -13,21 +13,27 @@ const LogManagementPanel = ({ isAdmin }) => {
const [totalLines, setTotalLines] = useState(0);
const [currentOffset, setCurrentOffset] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [message, setMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const getErrorText = error => error.response?.data?.detail || error.message || '操作失败';
const loadLogs = useCallback(async () => {
setLoading(true);
setErrorMessage('');
try {
const data = await listLogs(filterType || null);
setLogs(data);
setLogs(Array.isArray(data) ? data : []);
} catch (error) {
console.error('加载日志列表失败:', error);
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`);
setErrorMessage(`日志列表加载失败:${getErrorText(error)}`);
} finally {
setLoading(false);
}
}, [filterType]);
const loadLogContent = useCallback(async (logPath, offset = 0) => {
setErrorMessage('');
try {
const data = await getLogContent(logPath, offset, PAGE_SIZE);
setLogContent(data.content || '');
@@ -35,7 +41,7 @@ const LogManagementPanel = ({ isAdmin }) => {
setCurrentOffset(offset);
} catch (error) {
console.error('加载日志内容失败:', error);
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`);
setErrorMessage(`日志内容加载失败:${getErrorText(error)}`);
}
}, []);
@@ -48,12 +54,13 @@ const LogManagementPanel = ({ isAdmin }) => {
setShowModal(true);
setCurrentOffset(0);
setSearchTerm('');
setMessage('');
await loadLogContent(log.path, 0);
};
const handleDeleteLog = async log => {
if (!isAdmin) {
alert('只有管理员可以删除日志。');
setErrorMessage('当前账号没有日志删除权限。');
return;
}
@@ -61,9 +68,11 @@ const LogManagementPanel = ({ isAdmin }) => {
return;
}
setMessage('');
setErrorMessage('');
try {
await deleteLog(log.path);
alert('日志文件已删除。');
setMessage('日志文件已删除。');
await loadLogs();
if (selectedLog && selectedLog.path === log.path) {
setShowModal(false);
@@ -74,28 +83,27 @@ const LogManagementPanel = ({ isAdmin }) => {
}
} catch (error) {
console.error('删除日志失败:', error);
alert(`删除日志失败:${error.response?.data?.detail || error.message}`);
setErrorMessage(`日志删除失败:${getErrorText(error)}`);
}
};
const handlePrevPage = () => {
if (selectedLog && currentOffset > 0) {
const newOffset = Math.max(0, currentOffset - PAGE_SIZE);
loadLogContent(selectedLog.path, newOffset);
loadLogContent(selectedLog.path, Math.max(0, currentOffset - PAGE_SIZE));
}
};
const handleNextPage = () => {
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
const newOffset = currentOffset + PAGE_SIZE;
loadLogContent(selectedLog.path, newOffset);
loadLogContent(selectedLog.path, currentOffset + PAGE_SIZE);
}
};
const formatSize = bytes => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
const size = Number(bytes) || 0;
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const getTypeLabel = type => {
@@ -105,17 +113,7 @@ const LogManagementPanel = ({ isAdmin }) => {
error: '错误日志',
other: '其他',
};
return labels[type] || type;
};
const getTypeColor = type => {
const colors = {
app: '#3b82f6',
task: '#10b981',
error: '#ef4444',
other: '#6b7280',
};
return colors[type] || '#6b7280';
return labels[type] || type || '其他';
};
const filteredContent = searchTerm
@@ -125,242 +123,118 @@ const LogManagementPanel = ({ isAdmin }) => {
.join('\n')
: logContent;
const pageStart = totalLines === 0 ? 0 : currentOffset + 1;
const pageEnd = Math.min(currentOffset + PAGE_SIZE, totalLines);
return (
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>日志管理</h3>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<label>类型筛选</label>
<select
value={filterType}
onChange={event => setFilterType(event.target.value)}
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">全部</option>
<option value="app">应用日志</option>
<option value="task">任务日志</option>
<option value="error">错误日志</option>
</select>
<button
onClick={loadLogs}
disabled={loading}
style={{
padding: '5px 15px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '加载中...' : '刷新'}
<div className="log-management-panel">
<div className="log-management-header">
<div>
<h3>日志审计</h3>
<p>集中查看应用任务与错误日志删除操作仅限管理员</p>
</div>
<div className="log-management-controls">
<label className="ops-field">
<span>日志类型</span>
<select value={filterType} onChange={event => setFilterType(event.target.value)}>
<option value="">全部日志</option>
<option value="app">应用日志</option>
<option value="task">任务日志</option>
<option value="error">错误日志</option>
</select>
</label>
<button className="ops-button ops-button--primary" onClick={loadLogs} disabled={loading}>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
</div>
{errorMessage && <div className="ops-message ops-message--error">{errorMessage}</div>}
{message && <div className="ops-message ops-message--success">{message}</div>}
{logs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div>
<div className="log-empty-state">{loading ? '正在读取日志目录...' : '暂无可展示的日志文件'}</div>
) : (
<table
style={{
width: '100%',
borderCollapse: 'collapse',
backgroundColor: 'white',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<thead>
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
<th style={{ padding: '12px', textAlign: 'left' }}>类型</th>
<th style={{ padding: '12px', textAlign: 'right' }}>大小</th>
<th style={{ padding: '12px', textAlign: 'left' }}>修改时间</th>
<th style={{ padding: '12px', textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
<td style={{ padding: '12px' }}>
<span
style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
backgroundColor: `${getTypeColor(log.type)}20`,
color: getTypeColor(log.type),
}}
>
{getTypeLabel(log.type)}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'right', fontFamily: 'monospace', fontSize: '13px' }}>
{formatSize(log.size)}
</td>
<td style={{ padding: '12px', fontSize: '13px' }}>{log.modified_at}</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', alignItems: 'center' }}>
<button
onClick={() => handleViewLog(log)}
style={{
padding: '4px 12px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
whiteSpace: 'nowrap',
}}
>
查看
</button>
{isAdmin && (
<button
onClick={() => handleDeleteLog(log)}
style={{
padding: '4px 12px',
backgroundColor: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
whiteSpace: 'nowrap',
}}
>
删除
</button>
)}
</div>
</td>
<div className="log-table-wrap">
<table className="log-table">
<thead>
<tr>
<th>文件名</th>
<th>类型</th>
<th>大小</th>
<th>修改时间</th>
<th>操作</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{logs.map(log => (
<tr key={log.path || log.name}>
<td className="log-file-name">{log.name}</td>
<td>
<span className={`log-type-badge log-type-badge--${log.type || 'other'}`}>
{getTypeLabel(log.type)}
</span>
</td>
<td className="log-number-cell">{formatSize(log.size)}</td>
<td>{log.modified_at || '-'}</td>
<td>
<div className="log-row-actions">
<button className="ops-button ops-button--secondary ops-button--sm" onClick={() => handleViewLog(log)}>
查看
</button>
{isAdmin && (
<button className="ops-button ops-button--danger ops-button--sm" onClick={() => handleDeleteLog(log)}>
删除
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showModal && selectedLog && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '8px',
width: '90%',
maxWidth: '1200px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '20px',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div className="log-modal-backdrop">
<div className="log-modal" role="dialog" aria-modal="true" aria-label="日志内容">
<div className="log-modal-header">
<div>
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
<div style={{ fontSize: '13px', color: '#6b7280' }}>
大小{formatSize(selectedLog.size)} | 修改时间{selectedLog.modified_at} | 总行数{totalLines}
</div>
<h3>{selectedLog.name}</h3>
<p>
大小{formatSize(selectedLog.size)} · 修改时间{selectedLog.modified_at || '-'} · 总行数{totalLines}
</p>
</div>
<button
onClick={() => setShowModal(false)}
style={{
padding: '8px 16px',
backgroundColor: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
<button className="ops-button ops-button--secondary" onClick={() => setShowModal(false)}>
关闭
</button>
</div>
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
<div className="log-modal-tools">
<input
type="text"
placeholder="搜索日志内容..."
placeholder="搜索日志内容"
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
style={{
flex: 1,
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '13px',
}}
/>
<div style={{ fontSize: '13px', color: '#6b7280' }}>
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
<div className="log-page-info">
显示行 {pageStart} - {pageEnd}
</div>
<button
onClick={handlePrevPage}
disabled={currentOffset === 0}
style={{
padding: '6px 12px',
backgroundColor: currentOffset === 0 ? '#e5e7eb' : '#3b82f6',
color: currentOffset === 0 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
fontSize: '13px',
}}
>
<button className="ops-button ops-button--secondary ops-button--sm" onClick={handlePrevPage} disabled={currentOffset === 0}>
上一页
</button>
<button
className="ops-button ops-button--secondary ops-button--sm"
onClick={handleNextPage}
disabled={currentOffset + PAGE_SIZE >= totalLines}
style={{
padding: '6px 12px',
backgroundColor: currentOffset + PAGE_SIZE >= totalLines ? '#e5e7eb' : '#3b82f6',
color: currentOffset + PAGE_SIZE >= totalLines ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: currentOffset + PAGE_SIZE >= totalLines ? 'not-allowed' : 'pointer',
fontSize: '13px',
}}
>
下一页
</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
<pre
style={{
margin: 0,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '12px',
lineHeight: '1.5',
color: '#d4d4d4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{filteredContent || '(空日志)'}
</pre>
<div className="log-content-view">
<pre>{filteredContent || '(空日志)'}</pre>
</div>
</div>
</div>
-2
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import apiClient from './api/client';
const LoginPage = ({ onLoginSuccess }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -105,5 +104,4 @@ const LoginPage = ({ onLoginSuccess }) => {
);
};
export default LoginPage;
+274 -306
View File
@@ -1,5 +1,4 @@
import { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import {
PRODUCTION_WORKSPACE_ENTRY_TO_VIEW,
PRODUCTION_WORKSPACE_TAB,
@@ -17,133 +16,151 @@ const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel'));
const LazyBatchPanel = lazy(() => import('./panels/BatchPanel'));
const LazyDataCopierPanel = lazy(() => import('./DataCopierPanel'));
const shellStyle = {
minHeight: '100%',
padding: '20px 24px 28px',
boxSizing: 'border-box',
background: 'linear-gradient(180deg, #f5f7fb 0%, #eef4ff 52%, #f8fafc 100%)',
};
const heroStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: 16,
marginBottom: 18,
};
const heroCardStyle = {
borderRadius: 24,
border: '1px solid #d7e0eb',
background: 'linear-gradient(135deg, #ffffff 0%, #f8fbff 56%, #eef6ff 100%)',
boxShadow: '0 16px 40px rgba(15, 23, 42, 0.06)',
};
const summaryCardStyle = {
padding: '12px 14px',
borderRadius: 18,
border: '1px solid #e2e8f0',
background: 'rgba(255, 255, 255, 0.82)',
};
const WORKFLOW_STEPS = [
'数据准备',
'配对/栈规划',
'生产运行',
'质量检查',
'成果发布',
];
const SENSOR_PRODUCTION_PLACEHOLDERS = {
lt1_production: {
title: '陆探一生产模块',
subtitle: '当前先占位纳入生产管理,执行链路保留 LandSAR、ENVI+SARscape、Gamma/PyINT。',
title: '陆探一生产占位',
note: '当前保留 LT-1 源压缩包本机登记与按需 materialize 入口。',
rows: [
['源压缩包', 'D:\\LuTan1_Image_Pool_Zip,只索引包内 XML/元数据,不做全量解包。'],
['精密轨道', 'D:\\LT1_data_lsarorbit,本机部署并绑定到源资产'],
['按需解包', '生产任务需要时才 materialize 到 D:\\Task_Pool\\DInSAR 或 D:\\Task_Pool\\SBAS。'],
['生产边界', 'D-InSARSBAS-InSAR 均使用本机 Task_Pool,不允许 UNC 参与运行。'],
['结果管理', '生成结果进入 D-InSAR/SBAS 产物目录,由生产管理结果页统一重建 catalog'],
['数据来源', '本机源压缩包 archive'],
['精轨策略', '按生产任务关联 orbit 资产'],
['准备方式', '按需 materialize 到 Task_Pool'],
['生产边界', 'D-InSAR/SBAS 不走 UNC'],
['结果管理', '进入统一产品 catalog'],
],
},
sentinel1_production: {
title: 'Sentinel-1 生产模块',
subtitle: '当前先占位纳入生产管理,D-InSAR 保留 Gamma/PyINT 路径,SBAS 仍为规划态。',
title: 'Sentinel-1 生产占位',
note: '当前主要沉淀数据与精轨管理约束,SBAS 仅保留规划能力。',
rows: [
['源压缩包', 'D:\\Sentinel1_Image_Pool_ZIP,本机登记 ZIP/SAFE 元数据。'],
['精密轨道', 'D:\\Sentinel1_EOF_Pool,本机保存 AUX_POEORB/RESORB。'],
['按需解包', '需要运行时才将 ZIP 解包到本机 Task_Pool,界面不提供全量解包按钮。'],
['D-InSAR', 'Gamma/PyINT 可作为生产方向,运行材料必须来自本机路径。'],
['SBAS', '当前仅做堆栈发现和规划,执行链路未启用。'],
['数据来源', 'ZIP/SAFE 本机 archive'],
['精轨策略', 'EOF 精轨本机管理'],
['准备方式', '按需解包到工作目录'],
['D-InSAR', '走统一生产任务队列'],
['SBAS', '保留序列规划能力'],
],
},
gf3_native_registration: {
title: '高分三结果登记',
subtitle: 'GF3 不在本机生产;另一台 SARscape 服务器完成 _geo 后复制到本机登记。',
note: 'GF3 由外部 SARscape 服务生产,本系统登记回传成果并生成预览。',
rows: [
['外部生产', '外部机器按 YYYYMMDD_geo/场景目录输出 SARscape 原生 _geo 二进制。'],
['本机落盘', '复制到 D:\\GaoFen3_Pool\\native_geo 后递归扫描登记。'],
['预览生成', 'WebP 从 *_geo 主二进制读取生成,不使用 *_geo_ql.tif 作为正式预览源。'],
['精轨', 'GF3 本链路无精密轨道管理。'],
['结果管理', '登记后的 GF3 资产进入数据管理,后续需要全影像时再提取/标准化。'],
['生产方式', '外部 SARscape 服务'],
['落地路径', '本机登记 _geo 二进制'],
['预览生成', '转换 WebP 供地图使用'],
['精轨策略', '按外部生产结果留痕'],
['结果管理', '进入统一产品 catalog'],
],
},
};
function SensorProductionPlaceholder({ viewKey }) {
const data = SENSOR_PRODUCTION_PLACEHOLDERS[viewKey];
if (!data) {
return null;
}
return (
<section style={{ ...heroCardStyle, padding: '18px 20px' }}>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>当前设计约定</div>
<h3 style={{ margin: '0 0 8px', fontSize: 20, color: '#0f172a' }}>{data.title}</h3>
<p style={{ margin: '0 0 16px', color: '#475569', fontSize: 13, lineHeight: 1.7 }}>
{data.subtitle}
</p>
<div style={{ display: 'grid', gap: 10 }}>
{data.rows.map(([label, value]) => (
<div
key={label}
style={{
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: 12,
padding: '10px 12px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
}}
>
<strong style={{ color: '#0f172a', fontSize: 13 }}>{label}</strong>
<span style={{ color: '#475569', fontSize: 13, lineHeight: 1.7, wordBreak: 'break-word' }}>{value}</span>
</div>
))}
</div>
</section>
);
}
const shellStyle = {
minHeight: '100%',
background: '#f8fafc',
color: '#0f172a',
};
function resolveView(entry) {
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[entry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
const headerStyle = {
padding: '18px 20px 14px',
borderBottom: '1px solid #e2e8f0',
background: '#ffffff',
};
const sectionStyle = {
padding: '16px 20px 22px',
};
const compactPanelStyle = {
border: '1px solid #e2e8f0',
borderRadius: 8,
background: '#ffffff',
};
const mutedTextStyle = {
color: '#64748b',
fontSize: 13,
lineHeight: 1.6,
};
function resolveView(activeEntry) {
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[activeEntry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
}
function resolveWorkbenchKey(viewKey) {
const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item => (
item.views.some(view => view.key === viewKey)
));
return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key || 'dinsar_workbench';
const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item =>
item.views.some(view => view.key === viewKey),
);
return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key;
}
function buttonStyle(active) {
return {
border: `1px solid ${active ? '#2563eb' : '#cbd5e1'}`,
background: active ? '#eff6ff' : '#ffffff',
color: active ? '#1d4ed8' : '#334155',
borderRadius: 6,
padding: '7px 10px',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
lineHeight: 1.3,
};
}
function PlaceholderView({ config }) {
if (!config) {
return (
<div style={{ ...compactPanelStyle, padding: 18 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>生产入口未配置</h3>
<p style={{ ...mutedTextStyle, margin: '8px 0 0' }}>当前视图尚未接入生产面板</p>
</div>
);
}
return (
<div style={{ ...compactPanelStyle, padding: 18 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>{config.title}</h3>
<p style={{ ...mutedTextStyle, margin: '8px 0 14px' }}>{config.note}</p>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(96px, 140px) 1fr', gap: 0, borderTop: '1px solid #e2e8f0' }}>
{config.rows.map(([label, value]) => (
<div key={label} style={{ display: 'contents' }}>
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', color: '#475569', background: '#f8fafc', fontSize: 13 }}>
{label}
</div>
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', color: '#0f172a', fontSize: 13 }}>
{value}
</div>
</div>
))}
</div>
</div>
);
}
export default function ProductionWorkspace({
activeEntry = PRODUCTION_WORKSPACE_TAB,
readOnly = false,
activeEntry,
readOnly,
onTaskStart,
apiEndpoint,
language,
foundPairs = [],
selectedPairsCount = 0,
isLoading = false,
hasEnoughRadarScenesForPlanning = false,
hasRadarSearched = false,
pairingPanel = {},
radarPanel = {},
pairsPanel = {},
foundPairs,
selectedPairsCount,
isLoading,
hasEnoughRadarScenesForPlanning,
hasRadarSearched,
pairingPanel,
radarPanel,
pairsPanel,
}) {
const [activeView, setActiveView] = useState(() => resolveView(activeEntry));
const [activeWorkbench, setActiveWorkbench] = useState(() => resolveWorkbenchKey(resolveView(activeEntry)));
const initialView = resolveView(activeEntry);
const [activeView, setActiveView] = useState(initialView);
const [activeWorkbench, setActiveWorkbench] = useState(resolveWorkbenchKey(initialView));
useEffect(() => {
const nextView = resolveView(activeEntry);
@@ -151,17 +168,16 @@ export default function ProductionWorkspace({
setActiveWorkbench(resolveWorkbenchKey(nextView));
}, [activeEntry]);
const activeViewMeta = useMemo(
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView) || PRODUCTION_WORKSPACE_VIEWS[0],
[activeView]
);
const activeWorkbenchMeta = useMemo(
const currentWorkbench = useMemo(
() => PRODUCTION_WORKSPACE_WORKBENCHES.find(item => item.key === activeWorkbench) || PRODUCTION_WORKSPACE_WORKBENCHES[0],
[activeWorkbench]
[activeWorkbench],
);
const currentView = useMemo(
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView),
[activeView],
);
const activeSubViews = activeWorkbenchMeta?.views || [];
const switchWorkbench = (workbench) => {
const switchWorkbench = workbench => {
setActiveWorkbench(workbench.key);
if (!workbench.views.some(view => view.key === activeView)) {
setActiveView(workbench.defaultView);
@@ -177,224 +193,176 @@ export default function ProductionWorkspace({
};
const handleDinsarPrepareQueued = taskId => {
onTaskStart?.(taskId, 'D-InSAR生产准备任务已入队,正在处理...', {
onTaskStart?.(taskId, 'D-InSAR 生产准备任务已入队,正在处理...', {
taskType: 'COPY_DATA',
nonBlocking: true,
});
};
const handleSbasProductQueued = taskId => {
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.', {
onTaskStart?.(taskId, 'SBAS-InSAR 结果 catalog 任务已入队。', {
taskType: 'REBUILD_SBAS_INSAR_CATALOG',
nonBlocking: true,
});
};
const renderContent = () => {
if (activeView === 'dinsar_pairing') {
return (
<LazyPairPlanningPanel
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
isReadOnlyUser={readOnly}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
onOpenPairingModal={pairingPanel?.openModal}
hasRadarSearched={hasRadarSearched}
onRefreshRadarSearch={radarPanel?.refresh}
onSearchAll={radarPanel?.searchAll}
onRefreshDinsar={pairsPanel?.refreshDinsar}
language={language}
/>
);
}
if (activeView === 'dinsar_pairs') {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.1fr) minmax(360px, 0.9fr)', gap: 14 }}>
<LazyPairsListPanel pairsPanel={pairsPanel} isReadOnlyUser={readOnly} language={language} />
<LazyBatchPanel pairsPanel={pairsPanel} isReadOnlyUser={readOnly} language={language} />
</div>
);
}
if (activeView === 'dinsar_prepare') {
return (
<LazyDataCopierPanel
apiEndpoint={apiEndpoint}
readOnly={readOnly}
onJobQueued={handleDinsarPrepareQueued}
/>
);
}
if (activeView === 'dinsar_runs') {
return <LazyDinsarProductionPanel readOnly={readOnly} onJobQueued={handleDinsarRunQueued} />;
}
if (activeView === 'dinsar_products') {
return <LazyDinsarProductsPanel readOnly={readOnly} onJobQueued={handleDinsarProductQueued} />;
}
if (['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView)) {
const focusMap = {
sbas_insar_planning: 'planning',
sbas_insar_batches: 'batches',
sbas_insar_prepare: 'prepare',
sbas_insar_runs: 'runs',
};
return (
<LazySbasInsarProductionPanel
readOnly={readOnly}
onTaskStart={onTaskStart}
initialFocus={focusMap[activeView]}
/>
);
}
if (activeView === 'sbas_insar_products') {
return <LazySbasInsarProductsPanel readOnly={readOnly} onJobQueued={handleSbasProductQueued} />;
}
return <PlaceholderView config={SENSOR_PRODUCTION_PLACEHOLDERS[activeView]} />;
};
return (
<div style={shellStyle}>
<div style={heroStyle}>
<section style={{ ...heroCardStyle, padding: '22px 24px' }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.08em', color: '#1d4ed8', textTransform: 'uppercase' }}>
Production Management
<div style={headerStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 13, color: '#475569', marginBottom: 4 }}>生产工作台</div>
<h2 style={{ margin: 0, fontSize: 22, lineHeight: 1.25, letterSpacing: 0 }}>InSAR 生产管理</h2>
<p style={{ ...mutedTextStyle, margin: '8px 0 0', maxWidth: 720 }}>
面向科研工程生产的任务编排入口统一组织数据准备规划运行质量检查与成果发布
</p>
</div>
<h2 style={{ margin: '10px 0 12px', fontSize: 32, lineHeight: 1.1, color: '#0f172a' }}>生产管理</h2>
<p style={{ margin: 0, maxWidth: 900, fontSize: 14, lineHeight: 1.8, color: '#475569' }}>
这里统一承载 D-InSAR 配对批次生产准备运行和产物管理以及 Gamma SBAS-InSAR 生产链
陆探与哨兵源数据按压缩包登记生产时再解包到本机 Task_Pool高分三只登记外部 SARscape 服务器复制回来的 _geo 结果
</p>
</section>
{readOnly && (
<div style={{ border: '1px solid #f59e0b', color: '#92400e', background: '#fffbeb', borderRadius: 6, padding: '8px 10px', fontSize: 13 }}>
当前为只读账号生产提交操作已禁用
</div>
)}
</div>
<section
style={{
...heroCardStyle,
padding: '18px',
display: 'grid',
gap: 12,
alignContent: 'start',
}}
>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>主生产链</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>D-InSAR / SBAS</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
D-InSAR 使用配对批次驱动SBAS 使用 Gamma IPTA 工作流驱动PS/旧时序入口不再作为主流程展示
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
{WORKFLOW_STEPS.map((step, index) => (
<div
key={step}
style={{
border: '1px solid #e2e8f0',
background: '#f8fafc',
borderRadius: 6,
padding: '6px 9px',
color: '#334155',
fontSize: 12,
lineHeight: 1.2,
}}
>
{index + 1}. {step}
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>运行边界</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>本机 Task_Pool</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
源压缩包先登记元数据生产需要时再按需解包D-InSAR/SBAS 不走 UNC
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>结果管理</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>产物 catalog</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
生产结果进入 D-InSARSBAS GF3 数据目录后续分析从结果 catalog 读取
</div>
</div>
</section>
))}
</div>
</div>
<section
style={{
...heroCardStyle,
padding: '14px',
marginBottom: 18,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
{PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => {
const isActive = workbench.key === activeWorkbench;
return (
<button
key={workbench.key}
type="button"
onClick={() => switchWorkbench(workbench)}
style={{
textAlign: 'left',
padding: '16px 18px',
borderRadius: 10,
border: `1px solid ${isActive ? '#93c5fd' : '#d7e0eb'}`,
background: isActive
? '#eff6ff'
: 'rgba(255, 255, 255, 0.88)',
boxShadow: isActive ? '0 10px 24px rgba(37, 99, 235, 0.12)' : 'none',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
<strong style={{ fontSize: 16, color: '#0f172a' }}>{workbench.label}</strong>
<span
style={{
padding: '4px 8px',
borderRadius: 8,
fontSize: 11,
fontWeight: 700,
color: isActive ? '#1d4ed8' : '#64748b',
background: isActive ? '#dbeafe' : '#f1f5f9',
}}
>
{isActive ? '当前视图' : '切换'}
<div style={sectionStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 280px) minmax(0, 1fr)', gap: 14, alignItems: 'start' }}>
<aside style={{ ...compactPanelStyle, padding: 12 }}>
<div style={{ color: '#475569', fontSize: 13, marginBottom: 8 }}>工作流</div>
<div style={{ display: 'grid', gap: 8 }}>
{PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => (
<button
key={workbench.key}
type="button"
onClick={() => switchWorkbench(workbench)}
style={{ ...buttonStyle(activeWorkbench === workbench.key), textAlign: 'left' }}
>
<span style={{ display: 'block' }}>{workbench.label}</span>
<span style={{ display: 'block', marginTop: 4, color: activeWorkbench === workbench.key ? '#2563eb' : '#64748b', fontWeight: 400 }}>
{workbench.description}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#475569' }}>{workbench.description}</div>
</button>
);
})}
</div>
</section>
<section
style={{
...heroCardStyle,
padding: '12px',
marginBottom: 18,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{activeSubViews.map(view => {
const isActive = view.key === activeView;
return (
<button
key={view.key}
type="button"
onClick={() => setActiveView(view.key)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 8,
border: `1px solid ${isActive ? '#2563eb' : '#d7e0eb'}`,
background: isActive ? '#ffffff' : '#f8fafc',
cursor: 'pointer',
}}
>
<strong style={{ display: 'block', marginBottom: 6, fontSize: 14, color: isActive ? '#1d4ed8' : '#0f172a' }}>
{view.label}
</strong>
<span style={{ display: 'block', fontSize: 12, lineHeight: 1.6, color: '#475569' }}>
{view.description}
</span>
</button>
);
})}
</div>
</section>
<section>
<div style={{ marginBottom: 12, fontSize: 12, color: '#64748b' }}>
{activeWorkbenchMeta?.label} / {activeViewMeta.label}
</div>
<Suspense fallback={<PanelLoadingBody message={`正在加载 ${activeViewMeta.label}...`} />}>
{SENSOR_PRODUCTION_PLACEHOLDERS[activeView] && (
<SensorProductionPlaceholder viewKey={activeView} />
)}
{activeView === 'dinsar_pairing' && (
<LazyPairPlanningPanel
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
isReadOnlyUser={readOnly}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
onOpenPairingModal={pairingPanel.onOpenPairingModal}
hasRadarSearched={hasRadarSearched}
onRefreshRadarSearch={pairingPanel.onRefreshRadarSearch}
onSearchAll={radarPanel.onSearchAll}
onRefreshDinsar={pairingPanel.onRefreshDinsar}
language={language}
/>
)}
{activeView === 'dinsar_pairs' && (
<div style={{ display: 'grid', gap: 16 }}>
<LazyPairsListPanel
onVisualizePair={pairsPanel.onVisualizePair}
onTogglePairVisibility={pairsPanel.onTogglePairVisibility}
onCreateDinsarBatch={pairsPanel.onCreateDinsarBatch}
/>
<LazyBatchPanel />
</button>
))}
</div>
)}
{activeView === 'dinsar_prepare' && (
<LazyDataCopierPanel
apiEndpoint={apiEndpoint}
readOnly={readOnly}
onJobQueued={handleDinsarPrepareQueued}
/>
)}
{activeView === 'dinsar_runs' && (
<LazyDinsarProductionPanel
readOnly={readOnly}
onJobQueued={handleDinsarRunQueued}
/>
)}
{['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView) && (
<LazySbasInsarProductionPanel
readOnly={readOnly}
onTaskStart={onTaskStart}
initialFocus={{
sbas_insar_planning: 'planning',
sbas_insar_batches: 'batches',
sbas_insar_prepare: 'prepare',
sbas_insar_runs: 'runs',
}[activeView]}
/>
)}
{activeView === 'sbas_insar_products' && (
<LazySbasInsarProductsPanel
readOnly={readOnly}
onJobQueued={handleSbasProductQueued}
/>
)}
{activeView === 'dinsar_products' && (
<LazyDinsarProductsPanel
readOnly={readOnly}
onJobQueued={handleDinsarProductQueued}
/>
)}
</Suspense>
</section>
</aside>
<main style={{ display: 'grid', gap: 14, minWidth: 0 }}>
<div style={{ ...compactPanelStyle, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<div style={{ color: '#475569', fontSize: 13 }}>{currentWorkbench?.label}</div>
<h3 style={{ margin: '3px 0 0', fontSize: 18 }}>{currentView?.label || '生产视图'}</h3>
</div>
<div style={{ ...mutedTextStyle, maxWidth: 560 }}>{currentView?.description}</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
{currentWorkbench?.views.map(view => (
<button
key={view.key}
type="button"
onClick={() => setActiveView(view.key)}
style={buttonStyle(activeView === view.key)}
>
{view.label}
</button>
))}
</div>
</div>
<Suspense fallback={<PanelLoadingBody message="正在加载生产面板..." />}>
{renderContent()}
</Suspense>
</main>
</div>
</div>
</div>
);
}
+418
View File
@@ -0,0 +1,418 @@
import { useEffect, useMemo, useState } from 'react';
import { exportDinsarResults, getDinsarResults } from './api/dinsar';
import { listSbasInsarProducts } from './api/sbasInsarProducts';
import { getDinsarEngineMeta } from './utils/dinsarEngines';
const DEFAULT_TARGET_DIR = String.raw`D:\Result_Export\DInSAR`;
const PAGE_SIZE = 100;
const PRODUCT_CHANNELS = [
{
key: 'dinsar',
group: 'InSAR 成果',
label: 'D-InSAR 结果',
state: 'ready',
stateText: '可提取',
description: '从已登记的 D-InSAR 成果中选择位移结果,复制到服务器指定交付目录。',
},
{
key: 'sbas',
group: 'InSAR 成果',
label: 'SBAS-InSAR 结果',
state: 'planned',
stateText: '目录可查',
description: '成果目录和预览已接入,统一提取接口待补齐。',
},
{
key: 'lt1_ortho',
group: '正射成果',
label: 'LT-1 正射结果',
state: 'placeholder',
stateText: '待接入',
description: '陆探一正射生产结果后续接入标准成果目录,并开放提取。',
},
{
key: 's1_ortho',
group: '正射成果',
label: 'Sentinel-1 正射结果',
state: 'placeholder',
stateText: '待接入',
description: 'Sentinel-1 正射生产占位,后续登记后统一提取。',
},
{
key: 'gf3_ortho',
group: '正射成果',
label: 'GF3 SARscape _geo',
state: 'placeholder',
stateText: '待接入',
description: 'GF3 外部生产后的 _geo 二进制和 WebP 已按本机登记思路设计,统一导出接口待接入。',
},
];
function formatNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return new Intl.NumberFormat('zh-CN').format(number);
}
function normalizeItems(payload) {
return Array.isArray(payload?.items) ? payload.items : [];
}
function extractTotal(payload, fallback = 0) {
const total = Number(payload?.total);
return Number.isFinite(total) ? total : fallback;
}
function resultDisplayName(result) {
return String(result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
}
function resultDateText(result) {
const name = resultDisplayName(result);
const matches = name.match(/(\d{8})/g);
if (matches?.length >= 2) return `${matches[0]} / ${matches[1]}`;
if (matches?.length === 1) return matches[0];
return '-';
}
function stateClass(state) {
if (state === 'ready') return 'ready';
if (state === 'planned') return 'planned';
return 'pending';
}
export default function ResultExtractionPanel({ readOnly = false }) {
const [activeChannel, setActiveChannel] = useState('dinsar');
const [dinsarPayload, setDinsarPayload] = useState({ items: [], total: 0 });
const [sbasPayload, setSbasPayload] = useState({ items: [], total: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [targetDir, setTargetDir] = useState(DEFAULT_TARGET_DIR);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState('');
const [exportResult, setExportResult] = useState(null);
const selectedChannel = PRODUCT_CHANNELS.find(channel => channel.key === activeChannel) || PRODUCT_CHANNELS[0];
const loadCatalogs = async () => {
setLoading(true);
setError('');
try {
const [dinsarData, sbasData] = await Promise.all([
getDinsarResults({ limit: PAGE_SIZE, offset: 0 }),
listSbasInsarProducts({ limit: 30, offset: 0 }),
]);
const dinsarItems = normalizeItems(dinsarData);
setDinsarPayload({ ...dinsarData, items: dinsarItems, total: extractTotal(dinsarData, dinsarItems.length) });
const sbasItems = normalizeItems(sbasData);
setSbasPayload({ ...sbasData, items: sbasItems, total: extractTotal(sbasData, sbasItems.length) });
setSelectedIds(new Set(dinsarItems.map(item => item.id).filter(id => id !== undefined && id !== null)));
} catch (err) {
setError(err?.response?.data?.detail || err.message || '结果目录加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCatalogs();
}, []);
const filteredDinsar = useMemo(() => {
const value = query.trim().toLowerCase();
const items = dinsarPayload.items || [];
if (!value) return items;
return items.filter(item => {
const haystack = [
item.name,
item.task_name,
item.task_alias,
item.pair_key,
item.product_id,
item.engine_code,
item.file_path,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(value);
});
}, [dinsarPayload.items, query]);
const filteredIds = useMemo(
() => filteredDinsar.map(item => item.id).filter(id => id !== undefined && id !== null),
[filteredDinsar],
);
const selectedCountInView = filteredIds.filter(id => selectedIds.has(id)).length;
const allVisibleSelected = filteredIds.length > 0 && selectedCountInView === filteredIds.length;
const orthoPlaceholderCount = PRODUCT_CHANNELS.filter(channel => channel.group === '正射成果').length;
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0);
const metrics = [
{
label: 'D-InSAR 可提取',
value: dinsarPayload.total,
note: `当前载入 ${filteredDinsar.length}/${dinsarPayload.items.length}`,
tone: 'primary',
},
{
label: 'SBAS 目录',
value: sbasPayload.total,
note: '统一提取接口待接入',
tone: 'neutral',
},
{
label: '当前接入目录',
value: currentCatalogTotal,
note: 'D-InSAR + SBAS 已接入清单',
tone: 'neutral',
},
{
label: '正射通道',
value: orthoPlaceholderCount,
note: 'LT-1 / S1 / GF3 占位',
tone: 'warning',
},
];
const toggleOne = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleVisible = () => {
setSelectedIds(prev => {
const next = new Set(prev);
if (allVisibleSelected) {
filteredIds.forEach(id => next.delete(id));
} else {
filteredIds.forEach(id => next.add(id));
}
return next;
});
};
const handleExport = async () => {
const dir = targetDir.trim();
if (!dir) {
setExportError('请输入服务器目标目录。');
return;
}
const ids = [...selectedIds].filter(id => filteredIds.includes(id));
if (ids.length === 0) {
setExportError('请至少选择一条 D-InSAR 结果。');
return;
}
setExporting(true);
setExportError('');
setExportResult(null);
try {
const response = await exportDinsarResults(ids, dir);
setExportResult(response);
} catch (err) {
setExportError(err?.response?.data?.detail || err.message || 'D-InSAR 结果提取失败');
} finally {
setExporting(false);
}
};
const renderDinsarWorkspace = () => (
<section className="result-extraction-main-card">
<div className="result-extraction-card-head">
<div>
<span>D-InSAR 交付提取</span>
<strong>选择已登记结果并复制到服务器目录</strong>
</div>
<button type="button" onClick={loadCatalogs} disabled={loading || exporting}>
刷新目录
</button>
</div>
<div className="result-extraction-controls">
<label className="result-extraction-field">
<span>结果检索</span>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="任务名、日期、pair_key、引擎"
disabled={loading || exporting}
/>
</label>
<label className="result-extraction-field result-extraction-field-wide">
<span>服务器目标目录</span>
<input
type="text"
value={targetDir}
onChange={event => setTargetDir(event.target.value)}
placeholder={DEFAULT_TARGET_DIR}
disabled={loading || exporting || readOnly}
/>
</label>
<div className="result-extraction-action-stack">
<button type="button" onClick={toggleVisible} disabled={loading || exporting || filteredIds.length === 0}>
{allVisibleSelected ? '取消本页' : '选择本页'}
</button>
<button
type="button"
className="primary"
onClick={handleExport}
disabled={readOnly || exporting || loading || selectedCountInView === 0 || !targetDir.trim()}
>
{exporting ? '正在提取' : `提取 ${selectedCountInView}`}
</button>
</div>
</div>
<div className="result-extraction-hint">
目标目录是服务器可访问路径后端会按任务名或成果名创建子目录避免直接覆盖同名成果
</div>
{error && <div className="result-extraction-message error">{error}</div>}
{exportError && <div className="result-extraction-message error">{exportError}</div>}
{exportResult && (
<div className="result-extraction-message success">
<strong>提取完成</strong>
<span>复制 {formatNumber(exportResult.copied)} 跳过 {formatNumber(exportResult.skipped)} 失败 {formatNumber(exportResult.failed)} </span>
<code>{exportResult.target_dir}</code>
</div>
)}
<div className="result-extraction-list-head">
<span>结果列表</span>
<strong>{selectedCountInView}/{filteredDinsar.length}</strong>
</div>
<div className="result-extraction-result-list">
{loading ? (
<div className="result-extraction-empty">正在加载成果目录...</div>
) : filteredDinsar.length === 0 ? (
<div className="result-extraction-empty">当前条件下没有可提取的 D-InSAR 结果</div>
) : (
filteredDinsar.map(result => {
const id = result.id;
const engineMeta = getDinsarEngineMeta(result.engine_code);
return (
<label key={id} className="result-extraction-result-row">
<input
type="checkbox"
checked={selectedIds.has(id)}
onChange={() => toggleOne(id)}
disabled={exporting}
/>
<span className="result-extraction-result-main">
<strong title={result.file_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
<span>
{resultDateText(result)}
{' · '}
{result.pair_key || result.product_id || '-'}
</span>
</span>
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
<span className="result-extraction-status-chip">{result.is_cached ? '预览就绪' : '预览待建'}</span>
</label>
);
})
)}
</div>
</section>
);
const renderPlaceholderWorkspace = () => (
<section className="result-extraction-main-card">
<div className="result-extraction-placeholder">
<span className={`result-extraction-state ${stateClass(selectedChannel.state)}`}>
{selectedChannel.stateText}
</span>
<strong>{selectedChannel.label}</strong>
<p>{selectedChannel.description}</p>
<div className="result-extraction-contract">
<div>
<span>登记入口</span>
<strong>{selectedChannel.key === 'sbas' ? 'SBAS-InSAR 成果目录' : '生产管理成果登记'}</strong>
</div>
<div>
<span>提取接口</span>
<strong>待实现</strong>
</div>
<div>
<span>交付目录</span>
<strong>服务器固定/指定路径</strong>
</div>
</div>
{selectedChannel.key === 'sbas' && (
<div className="result-extraction-sbas-sample">
<span>当前 SBAS 目录样例</span>
{sbasPayload.items.length === 0 ? (
<p>暂无可展示的 SBAS-InSAR 成果</p>
) : (
sbasPayload.items.slice(0, 5).map(item => (
<div key={item.id || item.product_id} className="result-extraction-sbas-row">
<strong>{item.product_id || item.name || `#${item.id}`}</strong>
<span>{item.status || 'UNKNOWN'}</span>
</div>
))
)}
</div>
)}
</div>
</section>
);
return (
<div className="result-extraction-page">
<section className="result-extraction-hero">
<div>
<span>成果交付出口</span>
<strong>结果提取工作台</strong>
<p>
将三类正射生产成果D-InSAR 成果和 SBAS-InSAR 成果集中管理当前 D-InSAR 已接入真实提取
其余链路先保留清晰占位避免把未完成流程误当成可执行功能
</p>
</div>
<div className="result-extraction-hero-meta">
<span>D-InSAR {formatNumber(dinsarPayload.total)}</span>
<span>SBAS {formatNumber(sbasPayload.total)}</span>
<span>{readOnly ? '只读账号' : '可执行账号'}</span>
</div>
</section>
<section className="result-extraction-metrics">
{metrics.map(metric => (
<div key={metric.label} className={`result-extraction-metric tone-${metric.tone}`}>
<span>{metric.label}</span>
<strong>{formatNumber(metric.value)}</strong>
<p>{metric.note}</p>
</div>
))}
</section>
<section className="result-extraction-layout">
<aside className="result-extraction-channel-list">
{PRODUCT_CHANNELS.map(channel => (
<button
key={channel.key}
type="button"
className={activeChannel === channel.key ? 'active' : ''}
onClick={() => setActiveChannel(channel.key)}
>
<span>{channel.group}</span>
<strong>{channel.label}</strong>
<em className={stateClass(channel.state)}>{channel.stateText}</em>
</button>
))}
</aside>
{activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()}
</section>
</div>
);
}
+22 -22
View File
@@ -285,33 +285,33 @@ function RuntimeStatusPanel({ status }) {
return (
<div style={{ border: '1px solid #bae6fd', borderRadius: 8, padding: 10, background: '#f0f9ff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={valueStyle}>Runtime Status</div>
<div style={valueStyle}>运行状态</div>
<StatusBadge value={status.active ? 'RUNNING' : (status.run_status || 'IDLE')} />
</div>
<div style={{ ...metricGridStyle, marginTop: 8 }}>
<Metric label="Current step" value={status.current_step?.id || '-'} />
<Metric label="Workflow updated" value={status.workflow_updated_at || '-'} />
<Metric label="Latest log" value={status.latest_log_updated_at || '-'} />
<Metric label="当前步骤" value={status.current_step?.id || '-'} />
<Metric label="Workflow 更新时间" value={status.workflow_updated_at || '-'} />
<Metric label="最近日志" value={status.latest_log_updated_at || '-'} />
<Metric
label="Common overlap"
label="公共重叠率"
value={`${formatPercent(gate.common_overlap_ratio)} / ${formatPercent(gate.min_common_overlap_ratio)}`}
/>
</div>
{(currentTask || currentJob) && (
<div style={{ ...mutedStyle, marginTop: 8, wordBreak: 'break-word' }}>
Task: {currentTask ? `${currentTask.task_type || '-'} ${currentTask.status || '-'} ${currentTask.progress ?? 0}%` : '-'}
Task{currentTask ? `${currentTask.task_type || '-'} ${currentTask.status || '-'} ${currentTask.progress ?? 0}%` : '-'}
{'; '}
Job: {currentJob ? `${currentJob.job_type || '-'} ${currentJob.status || '-'}` : '-'}
Job{currentJob ? `${currentJob.job_type || '-'} ${currentJob.status || '-'}` : '-'}
</div>
)}
{latestTaskLog && (
<div style={{ ...mutedStyle, marginTop: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
DB log: [{latestTaskLog.level || 'INFO'}] {latestTaskLog.message}
DB 日志[{latestTaskLog.level || 'INFO'}] {latestTaskLog.message}
</div>
)}
{latestFileLog?.tail && (
<details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}>
<summary style={compactSummaryStyle}>{latestFileLog.name || 'latest log'}</summary>
<summary style={compactSummaryStyle}>{latestFileLog.name || '最近日志'}</summary>
<pre
style={{
margin: '8px 0 0',
@@ -329,10 +329,10 @@ function RuntimeStatusPanel({ status }) {
</details>
)}
<details style={{ ...compactDetailsStyle, marginTop: 8, borderColor: '#bae6fd' }}>
<summary style={compactSummaryStyle}>WSL processes ({wslProcesses.length})</summary>
<summary style={compactSummaryStyle}>WSL 进程{wslProcesses.length}</summary>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
{wslProcesses.length === 0 && (
<div style={mutedStyle}>{status.wsl_processes?.error || 'No matching WSL process reported.'}</div>
<div style={mutedStyle}>{status.wsl_processes?.error || '未发现匹配的 WSL 进程。'}</div>
)}
{wslProcesses.map(item => (
<div key={`${item.pid}-${item.command}`} style={{ ...mutedStyle, fontFamily: 'monospace', wordBreak: 'break-word' }}>
@@ -1021,7 +1021,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
setLandsarRunDetail(detailData);
}
if (data?.task_id) {
onTaskStart?.(data.task_id, 'LandSAR SBAS workflow queued.', {
onTaskStart?.(data.task_id, 'LandSAR SBAS Workflow 已入队。', {
taskType: data.job_type || 'SBAS_LANDSAR_WORKFLOW',
nonBlocking: true,
});
@@ -1070,7 +1070,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
const data = await submitSbasInsarWorkflowJob(selectedRunId, workflowPayload);
setWorkflowJob(data);
if (data?.task_id) {
onTaskStart?.(data.task_id, 'Gamma SBAS workflow queued.', {
onTaskStart?.(data.task_id, 'Gamma SBAS Workflow 已入队。', {
taskType: data.job_type || 'SBAS_GAMMA_WORKFLOW',
nonBlocking: true,
});
@@ -1163,7 +1163,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
});
setCoregistrationJob(data);
if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS coregistration task queued.', {
onTaskStart?.(data.task_id, 'SBAS 共参考配准 Task 已入队。', {
taskType: data.job_type || 'SBAS_COREGISTRATION',
nonBlocking: true,
});
@@ -1210,7 +1210,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
});
setRdcDemJob(data);
if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS RDC DEM task queued.', {
onTaskStart?.(data.task_id, 'SBAS RDC DEM Task 已入队。', {
taskType: data.job_type || 'SBAS_RDC_DEM',
nonBlocking: true,
});
@@ -1261,7 +1261,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
});
setInterferogramJob(data);
if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS interferogram task queued.', {
onTaskStart?.(data.task_id, 'SBAS 干涉图 Task 已入队。', {
taskType: data.job_type || 'SBAS_INTERFEROGRAMS',
nonBlocking: true,
});
@@ -1310,7 +1310,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
});
setIptaTimeseriesJob(data);
if (data?.task_id) {
onTaskStart?.(data.task_id, 'SBAS IPTA timeseries task queued.', {
onTaskStart?.(data.task_id, 'SBAS IPTA 时序 Task 已入队。', {
taskType: data.job_type || 'SBAS_IPTA_TIMESERIES',
nonBlocking: true,
});
@@ -1379,7 +1379,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
}}
style={{ border: '1px solid #0369a1', borderRadius: 8, background: '#e0f2fe', color: '#0369a1', padding: '7px 11px', fontWeight: 750 }}
>
Open Runtime Status
打开运行状态
</button>
</div>
) : null;
@@ -2058,7 +2058,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
</div>
{running && (
<div style={{ marginTop: 6, color: '#0369a1', fontSize: 12, fontWeight: 700 }}>
正在运行已自动打开右侧 Runtime Status
正在运行已自动打开右侧运行状态
</div>
)}
</button>
@@ -2153,11 +2153,11 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
color: '#9a3412',
fontSize: 12,
}}>
Sentinel-1 Gamma SBAS is planning-only. Stack discovery, audit manifest and run record are enabled; Gamma execution is disabled until the S1 TOPS/SBAS scripts are verified.
Sentinel-1 Gamma SBAS 当前仅开放规划能力可进行栈发现审计 Manifest Run 记录管理Gamma 执行需等待 S1 TOPS/SBAS 脚本验证完成后启用
</div>
)}
<div style={{ ...mutedStyle, marginTop: 6 }}>
专家文档目录 + manifest + WSL runner 主路径旧分阶段执行仅作为兼容桥接
专家文档目录 + manifest + WSL runner 主路径生产执行以当前统一工作流为准
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button
@@ -2237,7 +2237,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
<details style={{ ...compactDetailsStyle, marginTop: 10 }}>
<summary style={compactSummaryStyle}>Expert document path ({expertDocumentSteps.length})</summary>
<div style={{ ...mutedStyle, marginTop: 4 }}>
{expertDocumentSteps.length} sections from the LT1 Gamma SBAS expert document. Commands are used as the acceptance checklist; completed workflow steps must pass the expert command audit.
已载入 LT1 Gamma SBAS 专家文档中的 {expertDocumentSteps.length} 个章节命令清单作为验收检查项已完成的 Workflow 步骤必须通过专家命令审计
</div>
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
{expertDocumentSteps.map(item => {
+51 -13
View File
@@ -40,7 +40,6 @@ const statusColors = {
ERROR: '#dc2626',
};
const panelStyle = { display: 'grid', gap: 12 };
const sectionStyle = {
background: '#ffffff',
border: '1px solid #d8dee8',
@@ -203,11 +202,11 @@ const assetRoleInfo = {
},
product_summary: {
label: '产品摘要',
description: '旧版托管产品摘要;专家 Gamma 模式下不作为必需产物。',
description: '托管产品摘要;专家 Gamma 模式下不作为必需产物。',
},
quality_summary: {
label: '质量摘要',
description: '旧版质量统计摘要;专家 Gamma 模式下统计由 GeoTIFF 派生。',
description: '质量统计摘要;专家 Gamma 模式下统计由 GeoTIFF 派生。',
},
monitor_points_summary: {
label: '监测点摘要',
@@ -271,7 +270,7 @@ const assetRoleInfo = {
},
alternate_geotiff: {
label: 'LOS 反向约定 GeoTIFF',
description: '旧版 away-from-radar 符号约定下的备用速率栅格。',
description: 'away-from-radar 符号约定下的备用速率栅格。',
},
quality_geotiff: {
label: 'LOS Sigma GeoTIFF',
@@ -1528,6 +1527,18 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
const sigmaStats = quality.los_sigma_mm_per_year_rdc || quality.los_sigma_m_per_year_rdc || {};
const hasSigmaStats = Object.keys(sigmaStats || {}).length > 0;
const catalogColor = statusColors[catalogStatus?.status] || '#64748b';
const catalogStatusValue = catalogStatus?.status || 'UNKNOWN';
const catalogIssueCount = catalogStatus?.issue_count ?? 0;
const productCount = products.length;
const catalogTone = catalogStatusValue === 'READY'
? 'ready'
: catalogStatusValue === 'ERROR'
? 'error'
: catalogStatusValue === 'REBUILDING'
? 'info'
: catalogStatusValue === 'UNKNOWN'
? 'neutral'
: 'warn';
const openAssetLightbox = useCallback((title, asset) => {
if (!detail?.id || !asset) return;
setLightboxImage({
@@ -1538,17 +1549,18 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
}, [detail?.id]);
return (
<div style={panelStyle}>
<div className="sbas-products-page">
<ImageLightbox image={lightboxImage} onClose={() => setLightboxImage(null)} />
<section style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<section className="sbas-products-header">
<div className="sbas-products-header-main">
<div>
<h3 style={{ margin: 0, color: '#0f172a', fontSize: 18 }}>SBAS-InSAR 结果管理</h3>
<div style={{ ...mutedStyle, marginTop: 5 }}>
<h3>SBAS-InSAR 结果目录</h3>
<p>
管理 Gamma SBAS 生产结果重要预览图GeoTIFF监测点曲线和发布资产
</div>
这里用于成果登记资产复核和目录重建生产提交仍归入 SBAS 运行视图
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="sbas-products-actions">
<button type="button" onClick={loadCatalog} disabled={loading || actionLoading} style={buttonStyle}>
{loading ? '刷新中...' : '刷新'}
</button>
@@ -1558,7 +1570,26 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10, marginTop: 12 }}>
<div className="sbas-products-signals" aria-label="SBAS-InSAR 结果目录状态摘要">
<div className={`dinsar-production-signal tone-${readOnly ? 'warn' : 'ready'}`}>
<span>操作模式</span>
<strong>{readOnly ? '只读' : '可维护'}</strong>
</div>
<div className={`dinsar-production-signal tone-${catalogTone}`}>
<span>目录状态</span>
<strong>{catalogStatusValue}</strong>
</div>
<div className={`dinsar-production-signal tone-${productCount > 0 ? 'ready' : 'neutral'}`}>
<span>登记产品</span>
<strong>{productCount}</strong>
</div>
<div className={`dinsar-production-signal tone-${catalogIssueCount > 0 ? 'warn' : 'ready'}`}>
<span>问题数</span>
<strong>{catalogIssueCount}</strong>
</div>
</div>
<div className="sbas-products-metrics">
<Metric label="目录状态" value={<StatusBadge value={catalogStatus?.status || 'UNKNOWN'} />} accent={catalogColor} />
<Metric label="需要重建" value={catalogStatus?.needs_rebuild ? '是' : '否'} accent={catalogStatus?.needs_rebuild ? '#dc2626' : '#15803d'} />
<Metric label="Run / DB" value={`${catalogStatus?.run_count ?? catalogStatus?.manifest_count ?? 0} / ${catalogStatus?.db_count ?? 0}`} />
@@ -1575,7 +1606,14 @@ export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }
)}
</section>
<section style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 380px) minmax(0, 1fr)', gap: 12, alignItems: 'start' }}>
<div className="sbas-products-section-head">
<div>
<strong>结果检索与资产复核</strong>
<span>左侧筛选已登记结果右侧查看预览图位置摘要统计下载资产和目录问题</span>
</div>
</div>
<section className="sbas-products-workspace">
<div style={sectionStyle}>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<input
File diff suppressed because it is too large Load Diff
+21 -5
View File
@@ -42,6 +42,20 @@ const STATUS_COLOR = {
PUBLISHED: '#16a34a',
};
const ACTIVE_RUN_STATUSES = new Set([
'PENDING',
'RUNNING',
'PREPARING',
'STACK_PREPARING',
'MATERIALIZING',
'STACK_RUNNING',
'MINTPY_RUNNING',
'EXPORTING',
'REGISTERING',
]);
const ACTIVE_RUN_REFRESH_INTERVAL_MS = 30000;
const PREPARED_STACK_STATE = {
not_prepared: { label: 'Not prepared', color: '#64748b' },
manifest_unreadable: { label: 'Manifest unreadable', color: '#dc2626' },
@@ -209,6 +223,9 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
const [wslReport, setWslReport] = useState(null);
const [preflightLoading, setPreflightLoading] = useState(false);
const [preflightReport, setPreflightReport] = useState(null);
const hasActiveRun = useMemo(() => (
runs.some(item => ACTIVE_RUN_STATUSES.has(String(item.status || '').toUpperCase()))
), [runs]);
const [retryingStepId, setRetryingStepId] = useState('');
const selectedBatch = useMemo(
@@ -355,9 +372,10 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
}, [loadBatches, loadRuns]);
useEffect(() => {
const timer = setInterval(loadRuns, 10000);
if (!hasActiveRun) return undefined;
const timer = setInterval(loadRuns, ACTIVE_RUN_REFRESH_INTERVAL_MS);
return () => clearInterval(timer);
}, [loadRuns]);
}, [hasActiveRun, loadRuns]);
useEffect(() => {
loadRunDetail(selectedRunId);
@@ -465,8 +483,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
}}
>
当前生产入口采用分层 SBAS 模型时序配对先形成候选大池提交 run 后由 prepare 冻结 prepared SBAS 小栈
ENVI/SARscape SBAS 后续只读取 prepared manifest selected_network_edges 审计图不再重新扫描全量数据
ISCE2 + MintPy 路径仍沿用 stack_prepmaterializestackMintPypublishregister 链路
ENVI/SARscape SBAS 后续只读取 prepared manifest selected_network_edges 审计图确保生产输入可复核可追溯
</div>
{wslReport && (
<div
@@ -560,7 +577,6 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
style={{ width: '100%', padding: '6px 8px', borderRadius: 6, border: '1px solid #cbd5e1' }}
>
<option value="sarscape_sbas">ENVI/SARscape SBAS</option>
<option value="isce2_stack_mintpy">ISCE2 + MintPy</option>
</select>
</div>
<div>
+2 -1
View File
@@ -10,6 +10,7 @@ const getStatusLabel = (lang) => lang === 'en'
? { PENDING: 'Pending', RUNNING: 'Processing', DONE: 'Done', FAILED: 'Failed' }
: { PENDING: '等待中', RUNNING: '处理中', DONE: '完成', FAILED: '失败' };
const STATUS_COLOR = { PENDING: '#64748b', RUNNING: '#2563eb', DONE: '#16a34a', FAILED: '#dc2626' };
const ACTIVE_SCENE_REFRESH_INTERVAL_MS = 15000;
const UI_COLORS = {
pageText: '#0f172a',
@@ -468,7 +469,7 @@ export default function WaterMonitorPanel({ readOnly, onShowOnMap, onShowFloodOn
const timer = setInterval(() => {
loadScenes(scenesPageRef.current);
loadStatusIds();
}, 5000);
}, ACTIVE_SCENE_REFRESH_INTERVAL_MS);
return () => clearInterval(timer);
}, [activeRadarIds, tab, loadScenes, loadStatusIds]);
+3
View File
@@ -18,6 +18,9 @@ export const runWslCheck = (payload = {}) =>
export const submitRun = (payload) =>
apiClient.post('/dinsar-production/run', payload).then(r => r.data);
export const submitLandsarClusterRun = (payload) =>
apiClient.post('/dinsar-production/landsar-cluster/run', payload).then(r => r.data);
// 运行历史
export const listRuns = (limit = 20, offset = 0) =>
apiClient.get(
+3
View File
@@ -2,3 +2,6 @@ import apiClient from './client';
export const getStatistics = (fresh = false) =>
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
export const getStatisticsDashboard = () =>
apiClient.get('/statistics/dashboard').then(r => r.data);
@@ -54,7 +54,6 @@ const createSentinelRows = (dataInfo, language, formatYmd) => {
field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, {
valueStyle: { wordBreak: 'break-all' },
}),
field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)),
];
};
@@ -77,7 +76,6 @@ const createDefaultRows = (dataInfo, language, formatYmd) => [
field(language === 'en' ? 'Orbit File:' : '轨道文件:', dataInfo.orbit_file_path, {
valueStyle: { wordBreak: 'break-all' },
}),
field(language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:', yesNo(dataInfo.is_envi_processed, language)),
];
const createRows = (dataInfo, language, formatYmd) => {
+8 -114
View File
@@ -6,8 +6,6 @@ import {
getDinsarProductDetail,
getDinsarProductCleanupPlan,
listDinsarProductPairs,
queueDinsarCatalogRebuild,
queueDinsarProductPublish,
} from '../api/dinsarProducts';
import {
DINSAR_ENGINE_ALL,
@@ -52,15 +50,6 @@ function formatBytes(value) {
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function parseDirectoryList(value) {
return [...new Set(
String(value || '')
.split(/[\r\n,;]+/)
.map((item) => item.trim())
.filter(Boolean)
)];
}
function getMessageTone(message) {
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
}
@@ -91,8 +80,6 @@ function MetaField({ label, value, multiline = false }) {
export default function DinsarCatalogPanel({
readOnly = false,
compact = false,
initialSourceDir = '',
onTaskQueued,
}) {
const [catalogStatus, setCatalogStatus] = useState(null);
const [products, setProducts] = useState([]);
@@ -104,27 +91,14 @@ export default function DinsarCatalogPanel({
const [cleanupPlanLoading, setCleanupPlanLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [actionMessage, setActionMessage] = useState('');
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
const [publishRoot, setPublishRoot] = useState('');
const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL);
const [queryDraft, setQueryDraft] = useState('');
const [queryApplied, setQueryApplied] = useState('');
const listLimit = compact ? 8 : 24;
const listLimit = compact ? 8 : 18;
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
useEffect(() => {
if (!initialSourceDir) return;
setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir));
}, [initialSourceDir]);
const sourceDirectories = useMemo(
() => parseDirectoryList(sourceDirectoriesText),
[sourceDirectoriesText]
);
const engineOptions = useMemo(
() => buildDinsarEngineOptions([], { includeKnown: true }),
[]
@@ -210,6 +184,11 @@ export default function DinsarCatalogPanel({
}
}, []);
const handleSelectProduct = useCallback((pairKey, productId) => {
setSelectedPairKey(pairKey);
setSelectedProductId((current) => (current === productId ? current : productId || null));
}, []);
const loadCleanupPlan = useCallback(async () => {
if (!selectedProductId) return;
setCleanupPlanLoading(true);
@@ -243,45 +222,6 @@ export default function DinsarCatalogPanel({
setQueryApplied('');
}, []);
const handleQueuePublish = async () => {
if (readOnly || sourceDirectories.length === 0) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queueDinsarProductPublish({
source_directories: sourceDirectories,
publish_root: publishRoot.trim() || null,
rebuild_catalog: true,
});
setActionMessage(`结果包发布任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const handleQueueRebuild = async () => {
if (readOnly) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queueDinsarCatalogRebuild({
publish_root: publishRoot.trim() || null,
full_rebuild: true,
});
setActionMessage(`结果目录重建任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral';
const actionTone = getMessageTone(actionMessage);
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
@@ -311,7 +251,7 @@ export default function DinsarCatalogPanel({
{selectedEngineMeta.shortLabel}
</span>
)}
<button onClick={loadCatalog} disabled={loading || actionLoading}>
<button onClick={loadCatalog} disabled={loading}>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
@@ -352,49 +292,6 @@ export default function DinsarCatalogPanel({
</div>
)}
{!compact && (
<div className="dinsar-catalog-manage">
<div className="dinsar-catalog-manage-copy">
<strong>手动发布与目录重建</strong>
<p>
这里用于把既有结果目录重新发布为标准结果包并按最新规则重建目录索引
同一对影像的 ENVI/SARscapeLandSARGamma/PyINT 结果会按任务聚合展示底层仍依赖 `engine_code` `run_key` 分别登记
</p>
</div>
<div className="dinsar-catalog-manage-form">
<textarea
value={sourceDirectoriesText}
onChange={(event) => setSourceDirectoriesText(event.target.value)}
placeholder="输入一个或多个结果源目录,支持换行、逗号或分号分隔"
disabled={readOnly || actionLoading}
/>
<input
value={publishRoot}
onChange={(event) => setPublishRoot(event.target.value)}
placeholder="可选:自定义标准结果包根目录,留空使用系统配置"
disabled={readOnly || actionLoading}
/>
<div className="dinsar-catalog-manage-actions">
<button
type="button"
className="primary"
onClick={handleQueuePublish}
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
>
{actionLoading ? '处理中...' : '发布结果包并重建'}
</button>
<button
type="button"
onClick={handleQueueRebuild}
disabled={readOnly || actionLoading}
>
仅重建目录
</button>
</div>
</div>
</div>
)}
<div className={`dinsar-catalog-workspace ${compact ? 'compact' : ''}`}>
<aside className="dinsar-catalog-list-card">
<div className="dinsar-catalog-card-head">
@@ -456,10 +353,7 @@ export default function DinsarCatalogPanel({
key={rowKey}
type="button"
className={`dinsar-catalog-list-item ${selectedPairKey === rowKey ? 'active' : ''}`}
onClick={() => {
setSelectedPairKey(rowKey);
setSelectedProductId(item.primary_product_id || null);
}}
onClick={() => handleSelectProduct(rowKey, item.primary_product_id)}
>
<div className="dinsar-catalog-list-item-top">
<strong>{item.task_alias || item.task_name || item.pair_key || '未命名任务'}</strong>
@@ -24,6 +24,8 @@ const statusColorMap = {
REBUILDING: '#2563eb',
};
const CATALOG_ACTIVE_REFRESH_INTERVAL_MS = 30000;
function formatDateTime(value) {
if (!value) return '-';
try {
@@ -125,13 +127,18 @@ export default function PsinsarCatalogPanel({
useEffect(() => {
loadCatalog();
}, [loadCatalog]);
useEffect(() => {
const status = String(catalogStatus?.status || '').toUpperCase();
if (status !== 'REBUILDING') return undefined;
const timer = setInterval(() => {
if (!actionLoading) {
loadCatalog();
}
}, 10000);
}, CATALOG_ACTIVE_REFRESH_INTERVAL_MS);
return () => clearInterval(timer);
}, [actionLoading, loadCatalog]);
}, [actionLoading, catalogStatus?.status, loadCatalog]);
useEffect(() => {
loadProductDetail(selectedProductId);
@@ -1,161 +0,0 @@
import { useMemo, useState } from 'react';
import { exportDinsarResults } from '../api/dinsar';
import { getDinsarEngineMeta } from '../utils/dinsarEngines';
const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`;
export default function ResultExportModal({ results = [], onClose }) {
const [targetDir, setTargetDir] = useState('');
const [selectedIds, setSelectedIds] = useState(() => new Set(results.map((result) => result.id)));
const [exporting, setExporting] = useState(false);
const [exportResult, setExportResult] = useState(null);
const [error, setError] = useState('');
const selectedCount = selectedIds.size;
const sortedResults = useMemo(() => [...results], [results]);
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === results.length) {
setSelectedIds(new Set());
return;
}
setSelectedIds(new Set(results.map((result) => result.id)));
};
const handleExport = async () => {
const dir = targetDir.trim();
if (!dir) {
setError('请输入目标路径。');
return;
}
if (selectedIds.size === 0) {
setError('请至少选择一个结果。');
return;
}
setError('');
setExporting(true);
setExportResult(null);
try {
const response = await exportDinsarResults([...selectedIds], dir);
setExportResult(response);
} catch (eventualError) {
setError(eventualError.response?.data?.detail || eventualError.message || '提取失败。');
} finally {
setExporting(false);
}
};
return (
<div className="modal-overlay visible" onClick={onClose}>
<div className="modal-content result-export-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>提取 D-InSAR 结果</h3>
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="关闭">
&times;
</button>
</div>
<div className="modal-body">
<div className="export-path-section">
<label>目标路径支持本地盘符或 UNC 路径例如 `D:\Export\Results`</label>
<input
type="text"
value={targetDir}
onChange={(event) => setTargetDir(event.target.value)}
placeholder={EXAMPLE_TARGET_DIR}
disabled={exporting}
className="export-path-input"
/>
</div>
<div className="export-select-section">
<div className="export-select-header">
<label>
<input
type="checkbox"
checked={selectedIds.size === results.length && results.length > 0}
onChange={toggleAll}
disabled={exporting || results.length === 0}
/>
全选 ({selectedCount}/{results.length})
</label>
</div>
<div className="export-select-hint">
导出时会优先按任务名创建子目录如果同名结果已存在且内容不同会自动追加后缀避免覆盖
</div>
<ul className="export-result-list">
{sortedResults.map((result) => {
const engineMeta = getDinsarEngineMeta(result.engine_code);
return (
<li key={result.id} className="export-result-item">
<label>
<input
type="checkbox"
checked={selectedIds.has(result.id)}
onChange={() => toggleSelect(result.id)}
disabled={exporting}
/>
<span className="export-result-name" title={result.file_path || result.name}>
{result.name}
</span>
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>
{engineMeta.shortLabel}
</span>
</label>
</li>
);
})}
</ul>
</div>
{error && <div className="export-error">{error}</div>}
{exportResult && (
<div className="export-summary">
<div className="export-summary-title">提取完成</div>
<div className="export-summary-stats">
<span className="stat-ok">复制: {exportResult.copied}</span>
<span className="stat-skip">跳过: {exportResult.skipped}</span>
{exportResult.failed > 0 && (
<span className="stat-fail">失败: {exportResult.failed}</span>
)}
</div>
<div className="export-summary-dir">
目标目录: {exportResult.target_dir}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onClose} disabled={exporting}>
关闭
</button>
<button
type="button"
onClick={handleExport}
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
className="btn-primary"
>
{exporting ? '提取中...' : `确定提取 ${selectedIds.size} 个结果`}
</button>
</div>
</div>
</div>
);
}
@@ -1,44 +1,44 @@
export function PanelLoadingBody({ message = '正在加载面板...' }) {
return (
<div style={{ padding: '16px' }}>
<p className="empty-state">{message}</p>
</div>
);
return (
<div style={{ padding: 16 }}>
<p className="empty-state">{message}</p>
</div>
);
}
export function PanelLoadingPanel({ message = '正在加载面板...' }) {
return (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<PanelLoadingBody message={message} />
</div>
);
return (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<PanelLoadingBody message={message} />
</div>
);
}
export function ModalLoadingFallback({ message = '正在加载内容...' }) {
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
}}
>
<div
style={{
background: '#fff',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px 28px',
minWidth: '280px',
boxShadow: '0 20px 45px rgba(15, 23, 42, 0.18)',
}}
>
<p className="empty-state" style={{ margin: 0 }}>{message}</p>
</div>
</div>
);
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
}}
>
<div
style={{
background: '#fff',
borderRadius: 8,
border: '1px solid #e2e8f0',
padding: '24px 28px',
minWidth: 280,
boxShadow: '0 16px 32px rgba(15, 23, 42, 0.16)',
}}
>
<p className="empty-state" style={{ margin: 0 }}>{message}</p>
</div>
</div>
);
}
@@ -1,27 +0,0 @@
import { useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext';
export default function AppLogPanel({ width }) {
const logs = useUiStore((state) => state.logs);
const { t } = useI18n();
return (
<aside className="panel right-panel" style={{ width }}>
<header>
<h3>日志</h3>
</header>
<div className="panel-content log-entries">
{logs.length === 0 ? (
<p className="empty-state">暂无日志</p>
) : (
logs.map((log, index) => (
<div key={index} className="log-entry">
<span className="log-time">[{log.time}]</span>
<span className="log-message" data-log-type={log.type}>{t(log.message)}</span>
</div>
))
)}
</div>
</aside>
);
}
@@ -9,7 +9,6 @@ import { ModalLoadingFallback } from './AppLoadingFallbacks';
const LazyPairingModal = lazy(() => import('../PairingModal'));
const LazyDataInfoModal = lazy(() => import('../DataInfoModal'));
const LazyGlobalTaskCenter = lazy(() => import('../GlobalTaskCenter'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyAiReportModal = lazy(() => import('../AiReportModal'));
const LazyMapExportModal = lazy(() => import('../MapExportModal'));
@@ -40,14 +39,10 @@ export default function AppOverlays({
showPairingModal: state.showPairingModal,
})));
const {
showStats,
setShowStats,
showDataInfo,
setShowDataInfo,
selectedDataInfo,
} = useUiStore(useShallow((state) => ({
showStats: state.showStats,
setShowStats: state.setShowStats,
showDataInfo: state.showDataInfo,
setShowDataInfo: state.setShowDataInfo,
selectedDataInfo: state.selectedDataInfo,
@@ -79,12 +74,6 @@ export default function AppOverlays({
</Suspense>
)}
{showStats && (
<Suspense fallback={<ModalLoadingFallback message="正在加载统计看板..." />}>
<LazyStatisticsDashboard onClose={() => setShowStats(false)} />
</Suspense>
)}
<LicenseOverlay
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
+24 -6
View File
@@ -10,7 +10,7 @@ import {
LEFT_TAB_SECTION,
PRODUCTION_WORKSPACE_ROUTE_TABS,
} from '../../config/appConstants';
import { getLeftTabLabel } from '../../utils/appUiHelpers';
import { getLeftTabDescription, getLeftTabLabel } from '../../utils/appUiHelpers';
import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
@@ -28,6 +28,8 @@ const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'
const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel'));
const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarMapAnalysisPanel'));
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
export default function AppSidePanel({
leftPanelWidth,
@@ -105,8 +107,8 @@ export default function AppSidePanel({
? '生产管理'
: getLeftTabLabel(leftPanelTab, leftTabLabelContext);
const standaloneDescription = isProductionWorkspace
? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 生产;旧 ISCE2/MintPy 时序入口已停用。'
: '当前模块已切换为独立工作区模式。';
? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 的数据准备、生产运行、质量检查和成果发布。'
: getLeftTabDescription(leftPanelTab);
return (
<aside
@@ -198,7 +200,6 @@ export default function AppSidePanel({
showRadarPageInputError={radarPanel.showRadarPageInputError}
radarPageInputValidationError={radarPanel.radarPageInputValidationError}
onSearchAll={radarPanel.onSearchAll}
onShowStats={radarPanel.onShowStats}
onSearch={radarPanel.onSearch}
onReset={radarPanel.onReset}
onAoiModeChange={radarPanel.onAoiModeChange}
@@ -220,6 +221,14 @@ export default function AppSidePanel({
/>
)}
{leftPanelTab === 'statistics' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载综合统计..." />}>
<LazyStatisticsDashboard />
</Suspense>
</div>
)}
{leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
@@ -235,7 +244,7 @@ export default function AppSidePanel({
{leftPanelTab === 'asset_inventory' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载资产库存..." />}>
<Suspense fallback={<PanelLoadingBody message="正在加载资产台账..." />}>
<LazyAssetInventoryPanel
readOnly={isReadOnlyUser}
onTaskStart={taskPanel.onTaskStart}
@@ -311,7 +320,7 @@ export default function AppSidePanel({
{leftPanelTab === 'health' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载运维自检面板..." />}>
<Suspense fallback={<PanelLoadingBody message="正在加载运行维护面板..." />}>
<LazyHealthCheckPanel
apiEndpoint={apiEndpoint}
language={language}
@@ -364,6 +373,7 @@ export default function AppSidePanel({
onToggleVisibility={dinsarPanel.onToggleVisibility}
onLabel={dinsarPanel.onLabel}
onAnalyze={dinsarPanel.onAnalyze}
onOpenResultExtraction={() => setLeftPanelTab('result_extraction')}
/>
</Suspense>
)}
@@ -405,6 +415,14 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'result_extraction' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载结果提取工作台..." />}>
<LazyResultExtractionPanel readOnly={isReadOnlyUser} />
</Suspense>
</div>
)}
{leftPanelTab === 'ai_quality' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载 AI 质量面板..." />}>
<LazyAiQualityPanel
+76 -103
View File
@@ -1,112 +1,85 @@
import { memo } from 'react';
import { formatUtc, getStatusClass } from '../../utils/appUiHelpers';
import defaultLogoUrl from '../../logo.jpg';
import { formatUtc } from '../../utils/appUiHelpers';
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || 'InSAR 自动化管理系统';
const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程生产平台';
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
function AppStatusHeader({
language,
setLanguage,
currentUser,
isAdmin,
isReadOnlyUser,
activeTasks,
avgTaskProgress,
licenseStatus,
healthStatus,
healthLoading,
healthError,
onRefreshHealth,
onLogout,
language,
currentUser,
isAdmin,
isReadOnlyUser,
activeTasks,
avgTaskProgress,
licenseStatus,
onLogout,
}) {
const hasHealthStatus = !!healthStatus;
const licenseOk = !!licenseStatus?.ok;
const dbOk = !!(healthStatus?.database?.ok && healthStatus?.database?.schema_ok && healthStatus?.database?.postgis_ok);
const workerOk = !!healthStatus?.worker?.ok;
const idlOk = !!healthStatus?.idl?.ok;
const aiOk = !!healthStatus?.ollama?.ok;
const nginxOk = !!healthStatus?.nginx?.ok;
const licenseOk = !!licenseStatus?.ok;
const hasActiveTasks = activeTasks.length > 0;
return (
<>
<div className="top-status-bar">
<div className="status-brand">
<div className="brand-title">InSAR 自动化管理系统</div>
<div className="brand-subtitle">科研工程模式 · {licenseOk ? '已授权' : '未授权'}</div>
</div>
<div className="status-items">
<div className="status-item">
<span className={`status-dot ${getStatusClass(dbOk, hasHealthStatus)}`}></span>DB
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(workerOk, hasHealthStatus)}`}></span>Worker
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(idlOk, hasHealthStatus)}`}></span>IDL
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(aiOk, hasHealthStatus)}`}></span>Ollama
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(nginxOk, hasHealthStatus)}`}></span>Nginx
</div>
</div>
<div className="status-actions">
<div className="status-lang-switch" data-no-i18n="true">
<button
className={`status-lang-btn ${language === 'zh' ? 'active' : ''}`}
onClick={() => setLanguage('zh')}
title="切换到中文"
>
中文
</button>
<button
className={`status-lang-btn ${language === 'en' ? 'active' : ''}`}
onClick={() => setLanguage('en')}
title="Switch to English"
>
EN
</button>
</div>
<div className={`user-role-chip ${isAdmin ? 'admin' : 'viewer'}`}>
<span className="user-role-name">{currentUser.username}</span>
<span className="user-role-divider">·</span>
<span>{isAdmin ? '管理员' : '只读账号'}</span>
</div>
<div className={`status-task ${activeTasks.length > 0 ? 'has-active-tasks' : ''}`}>
<span>{activeTasks.length > 0 ? `⚙️ 运行中 ${activeTasks.length}` : '✓ 空闲'}</span>
{activeTasks.length > 0 && (
<div className="status-task-bar">
<div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }}></div>
</div>
)}
</div>
{licenseStatus?.expires_at && (
<div className="status-license">
授权至 {formatUtc(licenseStatus.expires_at, language)}
</div>
)}
<button
className="status-refresh"
onClick={onRefreshHealth}
disabled={healthLoading}
title={healthError || '刷新自检状态'}
>
{healthLoading ? '自检中...' : '刷新自检'}
</button>
<button
className="status-refresh"
onClick={onLogout}
title="退出登录"
>
退出登录
</button>
</div>
</div>
{isReadOnlyUser && (
<div className="read-only-banner">
当前账号为只读权限可查看数据与状态但不能执行写操作
</div>
return (
<>
<div className="top-status-bar">
<div className="status-brand">
{LOGO_URL && (
<img
className="status-brand-logo"
src={LOGO_URL}
alt={`${ORGANIZATION_NAME} logo`}
/>
)}
<div className="status-brand-copy">
<div className="brand-org">{ORGANIZATION_NAME}</div>
<div className="brand-title">{SYSTEM_NAME}</div>
</div>
</div>
<div className="status-system-meta" aria-label="系统状态摘要">
<span>{SYSTEM_TAGLINE}</span>
<span className={`status-license-chip ${licenseOk ? 'ok' : 'fail'}`}>
{licenseOk ? '已授权' : '未授权'}
</span>
{licenseStatus?.expires_at && (
<span className="status-license">
授权至 {formatUtc(licenseStatus.expires_at, language)}
</span>
)}
</div>
<div className="status-actions">
<div className={`status-task ${hasActiveTasks ? 'has-active-tasks' : ''}`}>
<span>{hasActiveTasks ? `运行中 ${activeTasks.length}` : '任务空闲'}</span>
{hasActiveTasks && (
<div className="status-task-bar" aria-hidden="true">
<div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }} />
</div>
)}
</>
);
</div>
<div className={`user-role-chip ${isAdmin ? 'admin' : 'viewer'}`}>
<span className="user-role-name">{currentUser.username}</span>
<span className="user-role-divider">·</span>
<span>{isAdmin ? '管理员' : '只读账号'}</span>
</div>
<button
className="status-refresh"
type="button"
onClick={onLogout}
title="退出登录"
>
退出登录
</button>
</div>
</div>
{isReadOnlyUser && (
<div className="read-only-banner">
当前账号为只读权限可查看数据与状态但不能执行写操作
</div>
)}
</>
);
}
export default memo(AppStatusHeader);
@@ -73,7 +73,7 @@ function DinsarResultRow({
className="dinsar-trace-pill"
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
>
{result.selection_strategy || 'legacy'}
{result.selection_strategy || '标准选择'}
</span>
{result.run_key && (
<span
@@ -56,9 +56,6 @@ function RadarDataRow({
: (language === 'en' ? 'Rebuild' : '重建')}
</button>
)}
{item.is_envi_processed && (
<span className="envi-status" title={language === 'en' ? 'Processed to ENVI format' : '已处理为ENVI格式'}>E</span>
)}
<span
className={`orbit-status ${item.has_orbit_data ? 'has-orbit' : ''}`}
title={item.has_orbit_data
+22 -17
View File
@@ -86,12 +86,12 @@ export const PRODUCTION_WORKSPACE_DINSAR_VIEWS = [
{
key: 'dinsar_runs',
label: '生产运行',
description: '运行任务编排、引擎切换与过程监控',
description: '运行任务编排、引擎切换与过程监控',
},
{
key: 'dinsar_products',
label: '结果管理',
description: '结果提取、标准目录发布与产编目',
description: '结果提取、标准目录发布与产编目',
},
];
@@ -99,11 +99,11 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
{
key: 'sbas_insar_planning',
label: '序列规划',
description: '按生产区域发现 SBAS 候选序列,审覆盖、时序密度、精轨和公共重叠范围。',
description: '按生产区域发现 SBAS 候选序列,审覆盖、时序密度、精轨和公共重叠范围。',
},
{
key: 'sbas_insar_batches',
label: '候选栈与Run',
label: '候选栈与 Run',
description: '查看候选序列、Manifest 与已创建的 SBAS 生产 Run。',
},
{
@@ -119,21 +119,21 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
{
key: 'sbas_insar_products',
label: '结果管理',
description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog',
description: '管理 Gamma SBAS LOS velocityuncertaintycoverage 和监测点产品目录。',
},
];
export const PRODUCTION_WORKSPACE_WORKBENCHES = [
{
key: 'dinsar_workbench',
label: 'D-InSAR工作台',
description: '配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。',
label: 'D-InSAR 工作台',
description: '配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。',
defaultView: 'dinsar_pairing',
views: PRODUCTION_WORKSPACE_DINSAR_VIEWS,
},
{
key: 'sbas_workbench',
label: 'SBAS-InSAR工作台',
label: 'SBAS-InSAR 工作台',
description: '围绕 SBAS 序列发现、生产执行和结果 catalog 管理组织时序 InSAR 生产。',
defaultView: 'sbas_insar_planning',
views: PRODUCTION_WORKSPACE_SBAS_VIEWS,
@@ -145,18 +145,18 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
...PRODUCTION_WORKSPACE_SBAS_VIEWS,
{
key: 'lt1_production',
label: '陆探生产占位',
label: '陆探生产占位',
description: 'LT-1 源压缩包本机登记,按需 materialize 到 Task_PoolD-InSAR/SBAS 生产不走 UNC。',
},
{
key: 'sentinel1_production',
label: '哨兵生产占位',
label: 'Sentinel-1 生产占位',
description: 'Sentinel-1 ZIP/SAFE 与 EOF 精轨本机管理,按需解包;当前 SBAS 仅保留规划能力。',
},
{
key: 'gf3_native_registration',
label: '高分三结果登记',
description: 'GF3 在外部 SARscape 服务生产,本机只登记复制回来的 _geo 二进制并生成 WebP。',
description: 'GF3 在外部 SARscape 服务生产,本机只登记复制回来的 _geo 二进制并生成 WebP。',
},
];
@@ -179,10 +179,12 @@ export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
]);
export const LEFT_GROUP_LABELS = {
data: '数据管理',
data: '数据资产',
production_management: '生产管理',
insar_analysis: 'InSAR形变分析',
flood_analysis: '洪涝灾害分析',
result_extraction: '结果提取',
insar_analysis: '形变分析',
statistics: '综合统计',
flood_analysis: '灾害分析',
ops: '运行维护',
};
@@ -190,12 +192,12 @@ export const LEFT_GROUP_SECTIONS = {
insar_analysis: [
{
key: 'dinsar',
label: 'D-InSAR',
label: 'D-InSAR 判读',
tabs: ['dinsar_results', 'dinsar_analysis'],
},
{
key: 'psinsar',
label: 'SBAS',
label: 'SBAS 形变',
tabs: ['psinsar_analysis'],
},
],
@@ -204,7 +206,9 @@ export const LEFT_GROUP_SECTIONS = {
export const LEFT_GROUP_TABS = {
data: ['ingest', 'asset_inventory', 'data', 'hazard'],
production_management: [PRODUCTION_WORKSPACE_TAB],
result_extraction: ['result_extraction'],
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
statistics: ['statistics'],
flood_analysis: ['flood_analysis'],
ops: ['health', 'users', 'audit'],
};
@@ -231,6 +235,8 @@ export const LEFT_TAB_SECTION = Object.entries(LEFT_GROUP_SECTIONS).reduce((acc,
export const FULL_WIDTH_LEFT_TABS = new Set([
...PRODUCTION_WORKSPACE_ROUTE_TABS,
'statistics',
'result_extraction',
]);
export const ADMIN_ONLY_TABS = new Set([
@@ -271,7 +277,6 @@ export const RADAR_SEARCH_DEFAULTS = {
product_unique_id: '',
orbit_direction: '',
has_orbit_data: '',
is_envi_processed: '',
imaging_date_from: '',
imaging_date_to: '',
};
+7 -7
View File
@@ -4,16 +4,16 @@ const TASK_UI_POLICIES = {
DINSAR_RESULT_SCAN: { label: 'D-InSAR 结果扫描', featureScope: 'dinsar_products' },
AI_TRAIN: { label: '训练 AI 模型', featureScope: 'insar_analysis' },
AI_PREDICT: { label: '全量质量评估', featureScope: 'insar_analysis' },
AI_ANALYZE: { label: 'AI 智能诊断(旧)', featureScope: 'insar_analysis' },
AI_ANALYZE: { label: 'AI 智能诊断', featureScope: 'insar_analysis' },
AI_DIAGNOSIS: { label: 'D-InSAR诊断', featureScope: 'insar_analysis' },
AI_WARMUP: { label: 'AI 模型预热', featureScope: 'insar_analysis' },
COPY_DATA: { label: '数据分发拷贝', featureScope: 'data_monitor' },
SCAN_HAZARD: { label: '灾害点同步', featureScope: 'hazard' },
UNPACK_ARCHIVES: { label: 'LT-1 批量解包(旧)', featureScope: 'data_monitor' },
UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包(旧)', featureScope: 'data_monitor' },
GF3_UNPACK: { label: 'GF3 legacy 解包', featureScope: 'data_monitor' },
GF3_BATCH_PROCESS: { label: 'GF3 legacy 预处理', featureScope: 'data_monitor' },
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产(停用)', featureScope: 'data_monitor' },
UNPACK_ARCHIVES: { label: 'LT-1 批量解包', featureScope: 'data_monitor' },
UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包', featureScope: 'data_monitor' },
GF3_UNPACK: { label: 'GF3 数据解包', featureScope: 'data_monitor' },
GF3_BATCH_PROCESS: { label: 'GF3 数据预处理', featureScope: 'data_monitor' },
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产', featureScope: 'data_monitor' },
GF3_SARSCAPE_SYNC: { label: 'GF3 _geo 原生入库', featureScope: 'data_monitor' },
GF3_QUICKLOOK_WEBP: { label: 'GF3 _geo WebP', featureScope: 'data_monitor' },
GF3_SARSCAPE_CLEAN: { label: 'GF3 中间清理', featureScope: 'data_monitor' },
@@ -22,7 +22,7 @@ const TASK_UI_POLICIES = {
IDL_IMPORT: { label: 'ENVI 数据导入', featureScope: 'dinsar_production' },
IDL_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' },
IDL_RUN_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' },
ISCE2_RUN: { label: 'ISCE2 D-InSAR 生产', featureScope: 'dinsar_production' },
ISCE2_RUN: { label: 'D-InSAR 历史任务', featureScope: 'dinsar_production' },
PYINT_RUN: { label: 'PyINT D-InSAR 生产', featureScope: 'dinsar_production' },
LANDSAR_RUN: { label: 'LandSAR D-InSAR 生产', featureScope: 'dinsar_production' },
SBAS_GAMMA_WORKFLOW: { label: 'Gamma SBAS 工作流', featureScope: 'sbas_insar' },
+10 -3
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
import apiClient from '../api/client';
import { getHealth } from '../api/health';
const HEALTH_POLL_INTERVAL_MS = 30000;
const HEALTH_POLL_INTERVAL_MS = 5 * 60 * 1000;
const STARTUP_RETRY_DELAYS_MS = [750, 1500, 3000];
const sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -95,6 +95,8 @@ export default function useAppAuthLifecycle({
if (userFromLogin) {
setCurrentUser(userFromLogin);
setAuthChecked(true);
void fetchCurrentUser({ clearOnFailure: false });
return;
}
await fetchCurrentUser({ clearOnFailure: !userFromLogin });
}, [fetchCurrentUser, setCurrentUser, setAuthChecked]);
@@ -236,11 +238,16 @@ export default function useAppAuthLifecycle({
}, [fetchCurrentUser, fetchLicenseStatus]);
useEffect(() => {
void fetchHealthStatus();
const startupTimer = setTimeout(() => {
void fetchHealthStatus({ silent: true });
}, 1500);
const interval = setInterval(() => {
void fetchHealthStatus({ silent: true });
}, HEALTH_POLL_INTERVAL_MS);
return () => clearInterval(interval);
return () => {
clearTimeout(startupTimer);
clearInterval(interval);
};
}, [fetchHealthStatus]);
return {
+3 -1
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
const ACTIVE_TASK_FALLBACK_POLL_MS = 10000;
export default function useGlobalTaskControl({
currentUser,
licenseOk,
@@ -135,7 +137,7 @@ export default function useGlobalTaskControl({
es.close();
es = null;
if (!fallbackInterval) {
fallbackInterval = setInterval(syncActiveTasks, 5000);
fallbackInterval = setInterval(syncActiveTasks, ACTIVE_TASK_FALLBACK_POLL_MS);
}
};
};
-55
View File
@@ -1,55 +0,0 @@
import { useCallback, useEffect } from 'react';
import { clamp } from '../utils/appUiHelpers';
export default function usePanelResize({
isResizing,
setIsResizing,
leftPanelWidth,
rightPanelWidth,
setLeftPanelWidth,
setRightPanelWidth,
resizeStateRef,
}) {
const startResize = useCallback((side, event) => {
event.preventDefault();
resizeStateRef.current = {
side,
startX: event.clientX,
startLeft: leftPanelWidth,
startRight: rightPanelWidth,
};
setIsResizing(true);
}, [resizeStateRef, leftPanelWidth, rightPanelWidth, setIsResizing]);
useEffect(() => {
if (!isResizing) {
return;
}
const handleMove = (event) => {
const { side, startX, startLeft, startRight } = resizeStateRef.current;
const delta = event.clientX - startX;
if (side === 'left') {
setLeftPanelWidth(clamp(startLeft + delta, 320, 620));
} else if (side === 'right') {
setRightPanelWidth(clamp(startRight - delta, 280, 560));
}
};
const handleUp = () => {
setIsResizing(false);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [isResizing, resizeStateRef, setLeftPanelWidth, setRightPanelWidth, setIsResizing]);
return {
startResize,
};
}
+13 -192
View File
@@ -1,205 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { I18nContext } from './I18nContext';
import { SUPPORTED_LANGUAGES, translateText } from './translations';
const STORAGE_KEY = 'ims_ui_language';
const isSupportedLanguage = (value) => SUPPORTED_LANGUAGES.includes(value);
const getInitialLanguage = () => {
const stored = localStorage.getItem(STORAGE_KEY);
if (isSupportedLanguage(stored)) return stored;
return 'zh';
};
const shouldSkipNode = (node) => {
if (!node || !node.parentElement) return true;
const parent = node.parentElement;
if (parent.closest('[data-no-i18n="true"]')) return true;
const tag = parent.tagName;
if (!tag) return true;
return tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'CODE' || tag === 'PRE';
};
const getNodeSourceText = (textSourceMap, node) => {
if (!textSourceMap.has(node)) {
textSourceMap.set(node, node.nodeValue ?? '');
}
return textSourceMap.get(node);
};
const setNodeSourceText = (textSourceMap, node, sourceText) => {
if (!node) return;
textSourceMap.set(node, sourceText ?? '');
};
const getAttrSourceMap = (attrSourceMap, element) => {
if (!attrSourceMap.has(element)) {
attrSourceMap.set(element, new Map());
}
return attrSourceMap.get(element);
};
const getAttrSourceText = (attrSourceMap, element, attr, fallbackValue) => {
const elementMap = getAttrSourceMap(attrSourceMap, element);
if (!elementMap.has(attr)) {
elementMap.set(attr, fallbackValue ?? '');
}
return elementMap.get(attr);
};
const setAttrSourceText = (attrSourceMap, element, attr, sourceText) => {
if (!element) return;
const elementMap = getAttrSourceMap(attrSourceMap, element);
elementMap.set(attr, sourceText ?? '');
};
const translateElementAttrs = (element, language, attrSourceMap) => {
if (!element || element.closest('[data-no-i18n="true"]')) return;
['placeholder', 'title', 'aria-label'].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
const source = getAttrSourceText(attrSourceMap, element, attr, value);
const translated = translateText(source, language);
if (translated !== value) {
element.setAttribute(attr, translated);
}
});
if (element.tagName === 'INPUT') {
const type = (element.getAttribute('type') || '').toLowerCase();
if (type === 'button' || type === 'submit' || type === 'reset') {
const value = element.getAttribute('value');
if (!value) return;
const source = getAttrSourceText(attrSourceMap, element, 'value', value);
const translated = translateText(source, language);
if (translated !== value) {
element.setAttribute('value', translated);
}
}
}
};
const translateSubtree = (root, language, textSourceMap, attrSourceMap) => {
if (!root) return;
if (root.nodeType === Node.ELEMENT_NODE) {
translateElementAttrs(root, language, attrSourceMap);
}
if (root.nodeType === Node.TEXT_NODE) {
if (!shouldSkipNode(root)) {
const source = getNodeSourceText(textSourceMap, root);
const translated = translateText(source, language);
if (translated !== root.nodeValue) {
root.nodeValue = translated;
}
}
return;
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let current = walker.nextNode();
while (current) {
if (!shouldSkipNode(current)) {
const source = getNodeSourceText(textSourceMap, current);
const translated = translateText(source, language);
if (translated !== current.nodeValue) {
current.nodeValue = translated;
}
}
current = walker.nextNode();
}
if (root.querySelectorAll) {
root.querySelectorAll('*').forEach((element) => {
translateElementAttrs(element, language, attrSourceMap);
});
}
};
const FIXED_LANGUAGE = 'zh';
export const I18nProvider = ({ children }) => {
const [language, setLanguageState] = useState(getInitialLanguage);
const textSourceMapRef = useRef(new WeakMap());
const attrSourceMapRef = useRef(new WeakMap());
const setLanguage = useCallback((nextLanguage) => {
if (!isSupportedLanguage(nextLanguage)) return;
setLanguageState(nextLanguage);
const setLanguage = useCallback(() => {
localStorage.setItem('ims_ui_language', FIXED_LANGUAGE);
}, []);
const t = useCallback((text) => translateText(text, language), [language]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, language);
document.documentElement.setAttribute('lang', language === 'en' ? 'en-US' : 'zh-CN');
}, [language]);
useEffect(() => {
if (!document?.body) return undefined;
let isMutating = false;
const safeTranslate = (node) => {
if (!node) return;
isMutating = true;
try {
translateSubtree(node, language, textSourceMapRef.current, attrSourceMapRef.current);
} finally {
isMutating = false;
}
};
safeTranslate(document.body);
const observer = new MutationObserver((mutations) => {
if (isMutating) return;
const roots = new Set();
mutations.forEach((mutation) => {
if (mutation.type === 'characterData') {
setNodeSourceText(textSourceMapRef.current, mutation.target, mutation.target?.nodeValue ?? '');
const parent = mutation.target?.parentElement;
if (parent) roots.add(parent);
}
if (mutation.type === 'attributes' && mutation.attributeName) {
const targetElement = mutation.target;
const attrName = mutation.attributeName;
const attrValue = targetElement.getAttribute(attrName);
setAttrSourceText(attrSourceMapRef.current, targetElement, attrName, attrValue ?? '');
roots.add(targetElement);
}
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
roots.add(node);
} else if (node.nodeType === Node.TEXT_NODE) {
setNodeSourceText(textSourceMapRef.current, node, node.nodeValue ?? '');
if (node.parentElement) {
roots.add(node.parentElement);
}
} else if (node.parentElement) {
roots.add(node.parentElement);
}
});
});
if (roots.size === 0) return;
roots.forEach((node) => safeTranslate(node));
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['placeholder', 'title', 'aria-label', 'value'],
});
return () => observer.disconnect();
}, [language]);
const t = useCallback((text) => text, []);
const value = useMemo(() => ({
language,
language: FIXED_LANGUAGE,
en: false,
setLanguage,
t,
}), [language, setLanguage, t]);
}), [setLanguage, t]);
useEffect(() => {
localStorage.setItem('ims_ui_language', FIXED_LANGUAGE);
document.documentElement.setAttribute('lang', 'zh-CN');
}, []);
return (
<I18nContext.Provider value={value}>
-4
View File
@@ -61,9 +61,6 @@
{ zh: '有精轨:全部', en: 'Precise Orbit: All' },
{ zh: '有精轨:是', en: 'Precise Orbit: Yes' },
{ zh: '有精轨:否', en: 'Precise Orbit: No' },
{ zh: 'ENVI已处理:全部', en: 'ENVI Processed: All' },
{ zh: 'ENVI已处理:是', en: 'ENVI Processed: Yes' },
{ zh: 'ENVI已处理:否', en: 'ENVI Processed: No' },
{ zh: '源数据检索选项加载中...', en: 'Loading source data search options...' },
{ zh: '无源数据', en: 'No source data.' },
@@ -116,7 +113,6 @@
{ zh: '轨道方向:', en: 'Orbit Direction:' },
{ zh: '有精轨:', en: 'Has Orbit:' },
{ zh: '轨道文件:', en: 'Orbit File:' },
{ zh: 'ENVI已处理:', en: 'ENVI Processed:' },
{ zh: '关闭', en: 'Close' },
{ zh: '是', en: 'Yes' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

+6 -14
View File
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useDinsarStore, useHazardStore, useUiStore, useAuthStore } from '../store';
import { useI18n } from '../i18n/I18nContext';
import VirtualizedList from '../components/common/VirtualizedList';
import DinsarResultRow from '../components/panels/DinsarResultRow';
import ResultExportModal from '../components/ResultExportModal';
import { PAGE_SIZE_OPTIONS } from '../config/appConstants';
import { getPageHintText } from '../utils/appUiHelpers';
import {
@@ -36,6 +35,7 @@ export default function DinsarResultPanel({
onToggleVisibility,
onLabel,
onAnalyze,
onOpenResultExtraction,
}) {
const { language } = useI18n();
const {
@@ -76,8 +76,6 @@ export default function DinsarResultPanel({
})));
const { currentUser } = useAuthStore();
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
const [showExportModal, setShowExportModal] = useState(false);
const strategyOptions = useMemo(
() => buildDinsarStrategyOptions(dinsarResults),
[dinsarResults]
@@ -261,11 +259,11 @@ export default function DinsarResultPanel({
: (language === 'en' ? 'Show Dates' : '显示日期')}
</button>
<button
onClick={() => setShowExportModal(true)}
disabled={isLoading || filteredResults.length === 0}
onClick={onOpenResultExtraction}
disabled={isLoading || !onOpenResultExtraction}
title={language === 'en'
? 'Export visible results in the current filter scope'
: '按当前筛选范围导出结果文件'}
? 'Open the unified result extraction workspace'
: '进入统一结果提取工作台'}
>
{language === 'en' ? 'Export...' : '提取结果...'}
</button>
@@ -450,12 +448,6 @@ export default function DinsarResultPanel({
</>
)}
{showExportModal && (
<ResultExportModal
results={filteredResults}
onClose={() => setShowExportModal(false)}
/>
)}
</div>
);
}
+46 -67
View File
@@ -15,9 +15,7 @@ export default function RadarDataPanel({
radarTotalPages,
showRadarPageInputError,
radarPageInputValidationError,
// handlers
onSearchAll,
onShowStats,
onSearch,
onReset,
onAoiModeChange,
@@ -73,7 +71,6 @@ export default function RadarDataPanel({
[allData]
);
// Build visible satellite group buttons based on available satellites in the database
const visibleSatelliteGroups = useMemo(() => {
const allSatellites = radarSearchOptions.satellite || [];
return SATELLITE_GROUPS.filter((group) =>
@@ -86,15 +83,12 @@ export default function RadarDataPanel({
<div style={{ padding: '10px', borderBottom: '1px solid #eee', flex: '0 0 auto' }}>
<div className="header-buttons">
<button onClick={onSearchAll} disabled={isLoading} style={{ flex: 1 }}>
{language === 'en' ? 'Search All Source Data' : '搜索全部源数据'}
</button>
<button onClick={onShowStats} disabled={isLoading} style={{ width: 'auto' }}>
{language === 'en' ? 'Statistics' : '统计'}
搜索全部源数据
</button>
</div>
<div style={{ marginTop: '10px', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '10px', background: '#f8fafc' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', fontSize: '13px' }}>
{language === 'en' ? 'Source Data Search' : '源数据检索'}
源数据检索
</div>
{visibleSatelliteGroups.length > 0 && (
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
@@ -114,7 +108,7 @@ export default function RadarDataPanel({
cursor: 'pointer',
}}
>
{language === 'en' ? 'All' : '全部'}
全部
</button>
{visibleSatelliteGroups.map((group) => (
<button
@@ -141,7 +135,7 @@ export default function RadarDataPanel({
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={radarSearchDraft.imaging_mode} onChange={(e) => updateDraft('imaging_mode', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Imaging Mode: All' : '成像模式:全部'}</option>
<option value="">成像模式全部</option>
{radarSearchOptions.imaging_mode.map((item) => (
<option key={item} value={item}>{item}</option>
))}
@@ -150,28 +144,28 @@ export default function RadarDataPanel({
value={radarSearchDraft.imaging_date_from}
onChange={(nextValue) => updateDraft('imaging_date_from', nextValue)}
language={language}
title={language === 'en' ? 'Imaging Date From: Any' : '成像时间起:不限'}
ariaLabel={language === 'en' ? 'Imaging Date From' : '成像时间起'}
placeholder={language === 'en' ? 'Select start date' : '选择起始日期'}
title="成像时间起:不限"
ariaLabel="成像时间起"
placeholder="选择起始日期"
/>
<UnifiedDatePicker
value={radarSearchDraft.imaging_date_to}
onChange={(nextValue) => updateDraft('imaging_date_to', nextValue)}
language={language}
title={language === 'en' ? 'Imaging Date To: Any' : '成像时间止:不限'}
ariaLabel={language === 'en' ? 'Imaging Date To' : '成像时间止'}
placeholder={language === 'en' ? 'Select end date' : '选择结束日期'}
title="成像时间止:不限"
ariaLabel="成像时间止"
placeholder="选择结束日期"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={radarSearchDraft.polarization} onChange={(e) => updateDraft('polarization', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Polarization: All' : '极化方式:全部'}</option>
<option value="">极化方式全部</option>
{radarSearchOptions.polarization.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.product_level} onChange={(e) => updateDraft('product_level', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Product Level: All' : '产品级别:全部'}</option>
<option value="">产品级别全部</option>
{radarSearchOptions.product_level.map((item) => (
<option key={item} value={item}>{item}</option>
))}
@@ -179,16 +173,16 @@ export default function RadarDataPanel({
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={radarSearchAoiMode} onChange={(e) => onAoiModeChange(e.target.value)}>
<option value="none">{language === 'en' ? 'AOI: Any' : '空间范围:不限'}</option>
<option value="region">{language === 'en' ? 'AOI: Region' : '空间范围:行政区'}</option>
<option value="shp">{language === 'en' ? 'AOI: Upload SHP' : '空间范围:上传SHP'}</option>
<option value="none">空间范围不限</option>
<option value="region">空间范围行政区</option>
<option value="shp">空间范围上传 SHP</option>
</select>
<div style={{ display: 'flex', gap: '8px' }}>
<button type="button" onClick={onSearch} disabled={isLoading} style={{ flex: 1 }}>
{language === 'en' ? 'Search' : '搜索'}
搜索
</button>
<button type="button" onClick={onReset} disabled={isLoading} style={{ flex: 1 }}>
{language === 'en' ? 'Reset' : '重置'}
重置
</button>
</div>
</div>
@@ -199,7 +193,7 @@ export default function RadarDataPanel({
onChange={(e) => onProvinceChange(e.target.value)}
disabled={radarSearchRegionLoading}
>
<option value="">{language === 'en' ? 'Select Province' : '选择省份'}</option>
<option value="">选择省份</option>
{radarSearchRegionOptions.provinces.map((item) => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
@@ -209,7 +203,7 @@ export default function RadarDataPanel({
onChange={(e) => onCityChange(e.target.value)}
disabled={radarSearchRegionLoading || !radarSearchRegionSelection.province}
>
<option value="">{language === 'en' ? 'Select City (Optional)' : '选择地市(可选)'}</option>
<option value="">选择地市可选</option>
{radarSearchRegionOptions.cities.map((item) => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
@@ -231,66 +225,61 @@ export default function RadarDataPanel({
)}
<details>
<summary style={{ cursor: 'pointer', fontSize: '12px', color: '#334155' }}>
{language === 'en' ? 'Advanced Fields' : '高级字段'}
高级字段
</summary>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' }}>
<select value={radarSearchDraft.satellite_mode} onChange={(e) => updateDraft('satellite_mode', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Satellite Mode: All' : '卫星模式:全部'}</option>
<option value="">卫星模式全部</option>
{radarSearchOptions.satellite_mode.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.receiving_station} onChange={(e) => updateDraft('receiving_station', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Receiving Station: All' : '接收站:全部'}</option>
<option value="">接收站全部</option>
{radarSearchOptions.receiving_station.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.orbit_circle} onChange={(e) => updateDraft('orbit_circle', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Orbit Circle: All' : '轨道圈号:全部'}</option>
<option value="">轨道圈号全部</option>
{radarSearchOptions.orbit_circle.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.acquisition_time_utc} onChange={(e) => updateDraft('acquisition_time_utc', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Acquisition Time: All' : '采集时间:全部'}</option>
<option value="">采集时间全部</option>
{radarSearchOptions.acquisition_time_utc.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.product_type} onChange={(e) => updateDraft('product_type', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Product Type: All' : '产品类型:全部'}</option>
<option value="">产品类型全部</option>
{radarSearchOptions.product_type.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.product_unique_id} onChange={(e) => updateDraft('product_unique_id', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Product Unique ID: All' : '产品唯一ID:全部'}</option>
<option value="">产品唯一 ID全部</option>
{radarSearchOptions.product_unique_id.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.orbit_direction} onChange={(e) => updateDraft('orbit_direction', e.target.value)} disabled={radarSearchOptionsLoading}>
<option value="">{language === 'en' ? 'Orbit Direction: All' : '轨道方向:全部'}</option>
<option value="">轨道方向全部</option>
{radarSearchOptions.orbit_direction.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<select value={radarSearchDraft.has_orbit_data} onChange={(e) => updateDraft('has_orbit_data', e.target.value)}>
<option value="">{language === 'en' ? 'Precise Orbit: All' : '有精轨:全部'}</option>
<option value="true">{language === 'en' ? 'Precise Orbit: Yes' : '有精轨:是'}</option>
<option value="false">{language === 'en' ? 'Precise Orbit: No' : '有精轨:否'}</option>
</select>
<select value={radarSearchDraft.is_envi_processed} onChange={(e) => updateDraft('is_envi_processed', e.target.value)}>
<option value="">{language === 'en' ? 'ENVI Processed: All' : 'ENVI已处理:全部'}</option>
<option value="true">{language === 'en' ? 'ENVI Processed: Yes' : 'ENVI已处理:是'}</option>
<option value="false">{language === 'en' ? 'ENVI Processed: No' : 'ENVI已处理:否'}</option>
<option value="">精轨状态全部</option>
<option value="true">精轨状态</option>
<option value="false">精轨状态</option>
</select>
</div>
</details>
{radarSearchOptionsLoading && (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#64748b' }}>
{language === 'en' ? 'Loading source data search options...' : '源数据检索选项加载中...'}
源数据检索选项加载中...
</div>
)}
</div>
@@ -300,12 +289,8 @@ export default function RadarDataPanel({
<div className="empty-state">
<p>
{!hasRadarSearched
? (language === 'en'
? 'No query yet. Please run Search or Search All first.'
: '尚未执行检索,请先点击"搜索"或"搜索全部"。')
: (language === 'en'
? 'No data matched this query.'
: '当前检索未命中数据。')}
? '尚未执行检索,请先点击“搜索”或“搜索全部源数据”。'
: '当前检索条件下没有匹配的数据。'}
</p>
</div>
) : (
@@ -319,35 +304,31 @@ export default function RadarDataPanel({
onChange={onSelectAllVisibility}
/>
<label htmlFor="select-all-visibility">
{language === 'en'
? `Toggle all coverage (Current page ${allData.length} items / Total ${radarPagination.total} items)`
: `覆盖面全部显示/隐藏(当前页 ${allData.length} 条 / 总计 ${radarPagination.total} 条)`}
覆盖面全部显示/隐藏当前页 {allData.length} / 总计 {radarPagination.total}
</label>
</div>
<div className="toolbar-row">
<button type="button" onClick={() => onSetAllPreviewVisibility(true)} disabled={allData.length === 0}>
{language === 'en' ? 'Show All Source Previews' : '源影像一键显示'}
源影像一键显示
</button>
<button type="button" onClick={() => onSetAllPreviewVisibility(false)} disabled={allData.length === 0}>
{language === 'en' ? 'Hide All Source Previews' : '源影像一键隐藏'}
源影像一键隐藏
</button>
</div>
<div className="toolbar-row">
<button type="button" onClick={() => onPageChange(-1)} disabled={isLoading || !hasRadarSearched || radarPagination.offset <= 0}>
{language === 'en' ? 'Previous' : '上一页'}
上一页
</button>
<span style={{ fontSize: '12px', color: '#4a5568' }}>
{language === 'en'
? `Page ${radarCurrentPage}/${radarTotalPages}`
: `${radarCurrentPage}/${radarTotalPages}`}
{radarCurrentPage}/{radarTotalPages}
</span>
<button type="button" onClick={() => onPageChange(1)} disabled={isLoading || !hasRadarSearched || !radarPagination.hasMore}>
{language === 'en' ? 'Next' : '下一页'}
下一页
</button>
</div>
<div className="toolbar-row">
<label style={{ fontSize: '12px', color: '#4a5568' }}>
{language === 'en' ? 'Per page' : '每页'}
每页
<select
value={radarPagination.limit}
onChange={onPageSizeChange}
@@ -358,10 +339,10 @@ export default function RadarDataPanel({
<option key={size} value={size}>{size}</option>
))}
</select>
{language === 'en' ? 'items' : '条'}
</label>
<label style={{ fontSize: '12px', color: '#4a5568' }}>
{language === 'en' ? 'Go to' : '跳到'}
跳到
<input
type="number"
min={1}
@@ -387,10 +368,10 @@ export default function RadarDataPanel({
boxShadow: showRadarPageInputError ? '0 0 0 1px rgba(229,62,62,0.25)' : undefined,
}}
/>
{language === 'en' ? 'page' : '页'}
</label>
<button type="button" onClick={onGoToPage} disabled={isLoading || !hasRadarSearched}>
{language === 'en' ? 'Jump' : '跳转'}
跳转
</button>
</div>
<div className="toolbar-row">
@@ -398,10 +379,8 @@ export default function RadarDataPanel({
{showRadarPageInputError
? radarPageInputValidationError
: (hasRadarSearched
? getPageHintText(radarTotalPages, language)
: (language === 'en'
? 'Run a search first to enable pagination.'
: '请先执行检索,再使用分页。'))}
? getPageHintText(radarTotalPages)
: '请先执行检索,再使用分页。')}
</span>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ const RADAR_SEARCH_DEFAULTS = {
satellite: '', satellite_mode: '', receiving_station: '', imaging_mode: '',
orbit_circle: '', acquisition_time_utc: '', product_type: '', polarization: '',
product_level: '', product_unique_id: '', orbit_direction: '',
has_orbit_data: '', is_envi_processed: '', imaging_date_from: '', imaging_date_to: '',
has_orbit_data: '', imaging_date_from: '', imaging_date_to: '',
};
const RADAR_SEARCH_OPTIONS_DEFAULTS = {
+1 -7
View File
@@ -7,10 +7,7 @@ const s = (set, key) => (v) =>
export const useUiStore = create((set) => ({
leftPanelTab: 'ingest',
leftPanelWidth: 380,
rightPanelWidth: 360,
isResizing: false,
showStats: false,
leftPanelWidth: 620,
showDataInfo: false,
selectedDataInfo: null,
showDates: false,
@@ -19,9 +16,6 @@ export const useUiStore = create((set) => ({
logs: [],
setLeftPanelTab: s(set, 'leftPanelTab'),
setLeftPanelWidth: s(set, 'leftPanelWidth'),
setRightPanelWidth: s(set, 'rightPanelWidth'),
setIsResizing: s(set, 'isResizing'),
setShowStats: s(set, 'showStats'),
setShowDataInfo: s(set, 'showDataInfo'),
setSelectedDataInfo: s(set, 'selectedDataInfo'),
setShowDates: s(set, 'showDates'),
+71 -42
View File
@@ -32,57 +32,61 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
switch (tabKey) {
case 'ingest':
return '入库监控';
return '数据接入';
case 'asset_inventory':
return '资产库存';
return '资产台账';
case 'data':
return '数据列表';
return '影像检索';
case 'hazard':
return '灾害点';
return '灾害点';
case 'statistics':
return '综合统计';
case 'pairing':
return 'D-InSAR配对规划';
return 'D-InSAR 配对规划';
case 'pairs':
return `D-InSAR候选对与批次 (${pairCount})`;
return `D-InSAR 候选对与批次 (${pairCount})`;
case 'ps_results':
return `SBAS序列规划 (${psResultCount})`;
return `SBAS 序列规划 (${psResultCount})`;
case 'batches':
return 'D-InSAR候选对与批次';
return 'D-InSAR 候选对与批次';
case 'copier':
return 'D-InSAR生产准备';
return 'D-InSAR 生产准备';
case 'production_management':
return '生产管理';
case 'idl':
return 'D-InSAR生产(旧)';
return 'D-InSAR 生产';
case 'dinsar_production':
return 'D-InSAR生产运行';
return 'D-InSAR 生产运行';
case 'dinsar_products':
return 'D-InSAR结果管理';
return 'D-InSAR 成果目录';
case 'ps_production':
return 'SBAS-InSAR生产工作流';
return 'SBAS-InSAR 生产工作流';
case 'ps_products':
return 'SBAS-InSAR结果管理';
return 'SBAS-InSAR 成果目录';
case 'dinsar_results':
return `D-InSAR结果 (${dinsarTotal})`;
return `D-InSAR 结果判读 (${dinsarTotal})`;
case 'dinsar_analysis':
return 'D-InSAR分析';
return 'D-InSAR 专题分析';
case 'psinsar_results':
return 'SBAS-InSAR结果';
return 'SBAS-InSAR 结果';
case 'psinsar_analysis':
return 'SBAS-InSAR分析';
return 'SBAS 形变分析';
case 'result_extraction':
return '结果提取';
case 'ai_quality':
return 'AI质量评估';
return 'AI 质量评估';
case 'ai_diagnosis':
return 'D-InSAR诊断';
return 'D-InSAR 诊断';
case 'landslide_segmentation':
return '滑坡语义分割';
case 'uav_image_analysis':
return '无人机影像分析';
case 'water':
return '水体监测(旧)';
return '水体监测';
case 'flood_analysis':
return '洪涝灾害分析';
case 'health':
return '运维自检';
return '运行维护';
case 'users':
return '用户管理';
case 'audit':
@@ -92,6 +96,39 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
}
};
export const getLeftTabDescription = (tabKey) => {
switch (tabKey) {
case 'ingest':
return '监控源数据、精轨和派生资产的接入任务,集中处理扫描、登记和运行记录。';
case 'asset_inventory':
return '查看源产品、精密轨道、绑定状态和开放问题,作为生产前的数据资产台账。';
case 'data':
return '按卫星、日期、轨道、AOI 和产品属性检索 SAR 影像,并在地图上核对覆盖范围。';
case 'hazard':
return '管理灾害点位与专题分析对象,为形变判读和洪涝分析提供空间参照。';
case 'statistics':
return '汇总源数据、生产成果、质量判读和缓存一致性,形成面向生产管理的统计视图。';
case 'dinsar_results':
return '核对 D-InSAR 形变结果、质量评分、标签和空间分布,支撑成果判读。';
case 'dinsar_analysis':
return '围绕选定 D-InSAR 结果开展专题分析、诊断和报告辅助。';
case 'psinsar_analysis':
return '查看 SBAS-InSAR 速率场、监测点和时序曲线,开展区域形变分析。';
case 'result_extraction':
return '统一提取三类正射生产成果、D-InSAR 成果和 SBAS-InSAR 成果,作为系统对外成果交付出口。';
case 'flood_analysis':
return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
case 'health':
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
case 'users':
return '维护系统用户、角色与访问权限。';
case 'audit':
return '查看关键操作和生产任务的审计记录。';
default:
return '当前模块用于支撑科研工程生产流程。';
}
};
export const getSelectedRegionTreeId = selection => (
selection?.city || selection?.province || ''
);
@@ -145,56 +182,48 @@ export const normalizeRadarSearchCriteria = (draft, defaults) => {
return normalized;
};
export const formatUtc = (isoString, language) => {
if (!isoString) return language === 'en' ? 'Unknown' : '未知';
export const formatUtc = (isoString) => {
if (!isoString) return '未知';
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return language === 'en' ? 'Unknown' : '未知';
if (Number.isNaN(date.getTime())) return '未知';
const pad = n => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC`;
};
export const formatYmd = (rawValue, language) => {
export const formatYmd = (rawValue) => {
const value = String(rawValue ?? '').trim();
if (!value) return '-';
const compactMatch = value.match(/^(\d{4})(\d{2})(\d{2})$/);
if (compactMatch) {
const [, yyyy, mm, dd] = compactMatch;
return language === 'en'
? `${yyyy}-${mm}-${dd}`
: `${yyyy}${mm}${dd}`;
return `${yyyy}${mm}${dd}`;
}
const dashMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (dashMatch) {
const [, yyyy, mm, dd] = dashMatch;
return language === 'en'
? `${yyyy}-${mm}-${dd}`
: `${yyyy}${mm}${dd}`;
return `${yyyy}${mm}${dd}`;
}
return value;
};
export const getPageHintText = (totalPages, language) => (
language === 'en'
? `Tip: valid page range is 1-${totalPages}. Press Enter to jump.`
: `提示:可跳转页码范围为 1-${totalPages},按 Enter 可快速跳转。`
export const getPageHintText = (totalPages) => (
`提示:可跳转页码范围为 1-${totalPages},按 Enter 可快速跳转。`
);
export const getPageInputErrorText = (rawValue, totalPages, language) => {
export const getPageInputErrorText = (rawValue, totalPages) => {
const value = String(rawValue ?? '').trim();
if (!value) {
return language === 'en' ? 'Please enter a page number.' : '请输入页码。';
return '请输入页码。';
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || !Number.isInteger(numericValue)) {
return language === 'en' ? 'Page number must be an integer.' : '页码必须为整数。';
return '页码必须为整数。';
}
if (numericValue < 1 || numericValue > totalPages) {
return language === 'en'
? `Page must be between 1 and ${totalPages}.`
: `页码必须在 1-${totalPages} 之间。`;
return `页码必须在 1-${totalPages} 之间。`;
}
return '';
};