Add LandSAR cluster worker deployment
This commit is contained in:
@@ -26,7 +26,6 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
|
||||
taskTypes: ['AI_DIAGNOSIS'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
|
||||
// 状态
|
||||
|
||||
+2086
-412
File diff suppressed because it is too large
Load Diff
+4
-44
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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>判断系统是否具备基础生产能力:数据库、PostGIS、schema 和任务执行器。</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 runtime、IDL/ENVI、D-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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-InSAR 与 SBAS-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-InSAR、SBAS 或 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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_prep、materialize、stack、MintPy、publish、register 链路。
|
||||
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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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/SARscape、LandSAR、Gamma/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="关闭">
|
||||
×
|
||||
</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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 velocity、uncertainty、coverage 和监测点产品目录。',
|
||||
},
|
||||
];
|
||||
|
||||
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_Pool;D-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: '',
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 '';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user