Apply current workspace changes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+60
-26
@@ -31,6 +31,7 @@ import {
|
||||
NATIONAL_BOUNDARY_GEOJSON_URL,
|
||||
getBaseLayerConfig,
|
||||
DEFAULT_LIST_PAGE_SIZE,
|
||||
FULL_WIDTH_LEFT_TABS,
|
||||
} from './config/appConstants';
|
||||
import { escapeHtml, formatCoordinate } from './utils/appHelpers';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
DINSAR_STRATEGY_ALL,
|
||||
filterDinsarResults,
|
||||
} from './utils/dinsarResultFilters';
|
||||
import { DINSAR_ENGINE_ALL, getDinsarEngineMeta } from './utils/dinsarEngines';
|
||||
|
||||
const NATIONAL_BOUNDARY_STATIC_URL = '/geojson/\u5168\u56fd\u884c\u653f\u533a.geojson';
|
||||
|
||||
@@ -78,6 +80,7 @@ function App() {
|
||||
activeTasks, setActiveTasks, isGlobalLocked, setIsGlobalLocked,
|
||||
isCheckingTasks, setIsCheckingTasks,
|
||||
pendingTaskIds, setPendingTaskIds,
|
||||
nonBlockingTaskIds, setNonBlockingTaskIds,
|
||||
} = useTaskStore(useShallow((state) => ({
|
||||
activeTasks: state.activeTasks,
|
||||
setActiveTasks: state.setActiveTasks,
|
||||
@@ -87,6 +90,8 @@ function App() {
|
||||
setIsCheckingTasks: state.setIsCheckingTasks,
|
||||
pendingTaskIds: state.pendingTaskIds,
|
||||
setPendingTaskIds: state.setPendingTaskIds,
|
||||
nonBlockingTaskIds: state.nonBlockingTaskIds,
|
||||
setNonBlockingTaskIds: state.setNonBlockingTaskIds,
|
||||
})));
|
||||
const {
|
||||
leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth,
|
||||
@@ -170,7 +175,7 @@ function App() {
|
||||
const {
|
||||
dinsarResults, setDinsarResults, dinsarPagination,
|
||||
dinsarPageInput, setDinsarPageInput, dinsarPageInputTouched, setDinsarPageInputTouched,
|
||||
aiStatus, scoreFilter, setScoreFilter, traceSearch, strategyFilter,
|
||||
aiStatus, scoreFilter, setScoreFilter, engineFilter, traceSearch, strategyFilter,
|
||||
} = useDinsarStore(useShallow((state) => ({
|
||||
dinsarResults: state.dinsarResults,
|
||||
setDinsarResults: state.setDinsarResults,
|
||||
@@ -182,6 +187,7 @@ function App() {
|
||||
aiStatus: state.aiStatus,
|
||||
scoreFilter: state.scoreFilter,
|
||||
setScoreFilter: state.setScoreFilter,
|
||||
engineFilter: state.engineFilter,
|
||||
traceSearch: state.traceSearch,
|
||||
strategyFilter: state.strategyFilter,
|
||||
})));
|
||||
@@ -291,6 +297,19 @@ function App() {
|
||||
const mapBatchRef = useRef({ frameId: null, token: 0 });
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const isReadOnlyUser = !!currentUser && !isAdmin;
|
||||
const isStandaloneLeftPage = FULL_WIDTH_LEFT_TABS.has(leftPanelTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStandaloneLeftPage || !mapRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
mapRef.current?.invalidateSize(false);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [isStandaloneLeftPage, leftPanelWidth, rightPanelWidth]);
|
||||
|
||||
const getVisibleLayerRefs = useCallback(() => ({
|
||||
activeLayersRef: activeLayersRef.current,
|
||||
@@ -776,6 +795,8 @@ function App() {
|
||||
setActiveTasks,
|
||||
pendingTaskIds,
|
||||
setPendingTaskIds,
|
||||
nonBlockingTaskIds,
|
||||
setNonBlockingTaskIds,
|
||||
isGlobalLocked,
|
||||
setIsGlobalLocked,
|
||||
setIsCheckingTasks,
|
||||
@@ -926,7 +947,7 @@ function App() {
|
||||
});
|
||||
allDataRef.current = newAllData;
|
||||
setAllData(newAllData);
|
||||
addLog('info', `正在预览包含 ${stack.length} 个场景的PS时序栈。`);
|
||||
addLog('info', `正在预览包含 ${stack.length} 个场景的时序InSAR候选栈。`);
|
||||
};
|
||||
|
||||
const updateLayerTooltip = useCallback((layer, result, show) => {
|
||||
@@ -973,10 +994,12 @@ function App() {
|
||||
const aiScore = result.ai_score === null || result.ai_score === undefined
|
||||
? '-'
|
||||
: `${(Number(result.ai_score) * 100).toFixed(0)}%`;
|
||||
const engine = getDinsarEngineMeta(result.engine_code);
|
||||
const strategy = escapeHtml(result.selection_strategy || 'legacy');
|
||||
const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-');
|
||||
const pairKey = escapeHtml(result.pair_key || '-');
|
||||
const pairUid = escapeHtml(result.pair_uid || '-');
|
||||
const runKey = escapeHtml(result.run_key || '-');
|
||||
const networkRunId = escapeHtml(result.network_run_id || '-');
|
||||
const networkEdgeId = escapeHtml(result.network_edge_id ?? '-');
|
||||
const policyVersion = escapeHtml(result.policy_version || '-');
|
||||
@@ -990,9 +1013,11 @@ function App() {
|
||||
<div><strong>任务:</strong> ${taskAlias}</div>
|
||||
<div><strong>日期:</strong> ${dateText}</div>
|
||||
<div><strong>AI:</strong> ${aiScore}</div>
|
||||
<div><strong>引擎:</strong> ${escapeHtml(engine.label)} <span class="mono">(${escapeHtml(engine.code)})</span></div>
|
||||
<div><strong>策略:</strong> ${strategy}</div>
|
||||
<div><strong>edge:</strong> ${networkEdgeId}</div>
|
||||
<div><strong>run:</strong> <span class="mono">${networkRunId}</span></div>
|
||||
<div><strong>run_key:</strong> <span class="mono">${runKey}</span></div>
|
||||
<div><strong>pair_key:</strong> <span class="mono">${pairKey}</span></div>
|
||||
<div><strong>pair_uid:</strong> <span class="mono">${pairUid}</span></div>
|
||||
<div><strong>policy:</strong> ${policyVersion}</div>
|
||||
@@ -1071,6 +1096,7 @@ function App() {
|
||||
const currentResults = dinsarResultsRef.current;
|
||||
const currentFilters = {
|
||||
scoreFilter,
|
||||
engineFilter,
|
||||
strategyFilter,
|
||||
traceSearch,
|
||||
focusedHazardPoint,
|
||||
@@ -1083,6 +1109,9 @@ function App() {
|
||||
if (scoreFilter > 0) {
|
||||
filterNotes.push(`AI>=${Math.round(scoreFilter * 100)}%`);
|
||||
}
|
||||
if (engineFilter !== DINSAR_ENGINE_ALL) {
|
||||
filterNotes.push(`引擎:${getDinsarEngineMeta(engineFilter).shortLabel}`);
|
||||
}
|
||||
if (focusedHazardPoint?.hazard_name) {
|
||||
filterNotes.push(`点位:${focusedHazardPoint.hazard_name}`);
|
||||
}
|
||||
@@ -1124,7 +1153,7 @@ function App() {
|
||||
runMapBatch(changedResults, (result) => {
|
||||
updateDinsarLayer(result, result.isVisible);
|
||||
});
|
||||
}, [addLog, focusedHazardPoint, runMapBatch, scoreFilter, setDinsarResults, strategyFilter, traceSearch, updateDinsarLayer]);
|
||||
}, [addLog, engineFilter, focusedHazardPoint, runMapBatch, scoreFilter, setDinsarResults, strategyFilter, traceSearch, updateDinsarLayer]);
|
||||
|
||||
const handleScoreFilterChange = useCallback((e) => {
|
||||
cancelMapBatch();
|
||||
@@ -1562,11 +1591,12 @@ function App() {
|
||||
onRefreshHealth={handleRefreshHealth}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="main-layout">
|
||||
<div className={`main-layout${isStandaloneLeftPage ? ' main-layout--standalone' : ''}`}>
|
||||
<AppSidePanel
|
||||
leftPanelWidth={leftPanelWidth}
|
||||
leftPanelWidth={isStandaloneLeftPage ? '100%' : leftPanelWidth}
|
||||
leftPanelTab={leftPanelTab}
|
||||
setLeftPanelTab={setLeftPanelTab}
|
||||
isStandalone={isStandaloneLeftPage}
|
||||
isAdmin={isAdmin}
|
||||
isReadOnlyUser={isReadOnlyUser}
|
||||
currentUser={currentUser}
|
||||
@@ -1594,28 +1624,32 @@ function App() {
|
||||
psPanel={psPanel}
|
||||
/>
|
||||
|
||||
<div className="panel-resizer" onMouseDown={(event) => startResize('left', event)} />
|
||||
{!isStandaloneLeftPage && (
|
||||
<>
|
||||
<div className="panel-resizer" onMouseDown={(event) => startResize('left', event)} />
|
||||
|
||||
<AppMapWorkspace
|
||||
language={language}
|
||||
showMapRegionLocator={showMapRegionLocator}
|
||||
toggleMapRegionLocator={toggleMapRegionLocator}
|
||||
mapRegionOptions={mapRegionOptions}
|
||||
mapRegionSelection={mapRegionSelection}
|
||||
mapRegionLoading={mapRegionLoading}
|
||||
mapRegionLocating={mapRegionLocating}
|
||||
mapRegionError={mapRegionError}
|
||||
mapRegionLocatedName={mapRegionLocatedName}
|
||||
onMapRegionProvinceChange={handleMapRegionProvinceChange}
|
||||
onMapRegionCityChange={handleMapRegionCityChange}
|
||||
onLocateSelectedRegion={locateSelectedRegionOnMap}
|
||||
onClearMapRegionHighlight={clearMapRegionHighlight}
|
||||
baseLayerKey={baseLayerKey}
|
||||
setBaseLayerKey={setBaseLayerKey}
|
||||
onOpenExportModal={mapExport.openExportModal}
|
||||
/>
|
||||
<div className="panel-resizer" onMouseDown={(event) => startResize('right', event)} />
|
||||
<AppLogPanel width={rightPanelWidth} />
|
||||
<AppMapWorkspace
|
||||
language={language}
|
||||
showMapRegionLocator={showMapRegionLocator}
|
||||
toggleMapRegionLocator={toggleMapRegionLocator}
|
||||
mapRegionOptions={mapRegionOptions}
|
||||
mapRegionSelection={mapRegionSelection}
|
||||
mapRegionLoading={mapRegionLoading}
|
||||
mapRegionLocating={mapRegionLocating}
|
||||
mapRegionError={mapRegionError}
|
||||
mapRegionLocatedName={mapRegionLocatedName}
|
||||
onMapRegionProvinceChange={handleMapRegionProvinceChange}
|
||||
onMapRegionCityChange={handleMapRegionCityChange}
|
||||
onLocateSelectedRegion={locateSelectedRegionOnMap}
|
||||
onClearMapRegionHighlight={clearMapRegionHighlight}
|
||||
baseLayerKey={baseLayerKey}
|
||||
setBaseLayerKey={setBaseLayerKey}
|
||||
onOpenExportModal={mapExport.openExportModal}
|
||||
/>
|
||||
<div className="panel-resizer" onMouseDown={(event) => startResize('right', event)} />
|
||||
<AppLogPanel width={rightPanelWidth} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppOverlays
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './App.css'; // 复用现有样式
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import './App.css';
|
||||
import { useI18n } from './i18n/I18nContext';
|
||||
|
||||
const DEFAULT_MONITOR_CONFIG = {
|
||||
@@ -7,18 +7,42 @@ const DEFAULT_MONITOR_CONFIG = {
|
||||
orbit_dir: '',
|
||||
dinsar_dirs: [],
|
||||
gf3_source_dirs: [],
|
||||
gf3_storage_dirs: []
|
||||
gf3_storage_dirs: [],
|
||||
};
|
||||
|
||||
const DEFAULT_UNPACK_CONFIG = {
|
||||
source_dirs: [],
|
||||
insar_storage_dirs: [],
|
||||
min_disk_space_gb: 50,
|
||||
max_files_per_run: 0,
|
||||
max_runtime_minutes: 0,
|
||||
delete_archive: true,
|
||||
tmp_suffix: '.unpack_tmp',
|
||||
archive_exts: []
|
||||
archive_exts: [],
|
||||
};
|
||||
|
||||
const toArray = (value) => (Array.isArray(value) ? value : []);
|
||||
|
||||
const createUnpackRunOptions = (config = DEFAULT_UNPACK_CONFIG) => ({
|
||||
max_files_per_run: String(config?.max_files_per_run ?? 0),
|
||||
max_runtime_minutes: String(config?.max_runtime_minutes ?? 0),
|
||||
});
|
||||
|
||||
const parseUnpackRunValue = (rawValue, label) => {
|
||||
const value = String(rawValue ?? '').trim();
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new Error(`${label}必须是大于等于 0 的整数`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const formatList = (list) => (Array.isArray(list) && list.length ? list.join('; ') : '未配置');
|
||||
|
||||
const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled = true }) => {
|
||||
const { t } = useI18n();
|
||||
const [config, setConfig] = useState(DEFAULT_MONITOR_CONFIG);
|
||||
@@ -30,10 +54,15 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
const [message, setMessage] = useState('');
|
||||
const [unpackLoading, setUnpackLoading] = useState(false);
|
||||
const [unpackMessage, setUnpackMessage] = useState('');
|
||||
const [showUnpackDialog, setShowUnpackDialog] = useState(false);
|
||||
const [unpackRunOptions, setUnpackRunOptions] = useState(() => createUnpackRunOptions());
|
||||
const [unpackDialogError, setUnpackDialogError] = useState('');
|
||||
const [unpackTaskId, setUnpackTaskId] = useState('');
|
||||
const [unpackTaskTerminal, setUnpackTaskTerminal] = useState(false);
|
||||
const [gf3Loading, setGf3Loading] = useState(false);
|
||||
const [gf3Message, setGf3Message] = useState('');
|
||||
const logEndRef = useRef(null);
|
||||
const toArray = (value) => (Array.isArray(value) ? value : []);
|
||||
|
||||
const parseJsonSafe = async (response, fallback) => {
|
||||
try {
|
||||
return await response.json();
|
||||
@@ -41,12 +70,13 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const displayLogs = toArray(logs);
|
||||
const displayActiveTasks = toArray(activeTasks);
|
||||
const unpackActiveTask = displayActiveTasks.find((task) =>
|
||||
task.task_id === unpackTaskId || task.task_type === 'UNPACK_ARCHIVES'
|
||||
);
|
||||
|
||||
const formatList = (list) => (list && list.length ? list.join('; ') : '未配置');
|
||||
|
||||
// 获取初始状态
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setConfig(DEFAULT_MONITOR_CONFIG);
|
||||
@@ -63,21 +93,25 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then(data => {
|
||||
if (canceled) return;
|
||||
.then((data) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setConfig({
|
||||
...DEFAULT_MONITOR_CONFIG,
|
||||
...data,
|
||||
radar_dirs: toArray(data?.radar_dirs),
|
||||
dinsar_dirs: toArray(data?.dinsar_dirs),
|
||||
gf3_source_dirs: toArray(data?.gf3_source_dirs),
|
||||
gf3_storage_dirs: toArray(data?.gf3_storage_dirs)
|
||||
gf3_storage_dirs: toArray(data?.gf3_storage_dirs),
|
||||
});
|
||||
setConfigLoaded(true);
|
||||
})
|
||||
.catch(err => {
|
||||
if (canceled) return;
|
||||
console.error("Failed to fetch monitor status:", err);
|
||||
.catch((err) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to fetch monitor status:', err);
|
||||
setConfigLoaded(false);
|
||||
});
|
||||
|
||||
@@ -89,6 +123,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setUnpackConfig(DEFAULT_UNPACK_CONFIG);
|
||||
setShowUnpackDialog(false);
|
||||
setUnpackRunOptions(createUnpackRunOptions());
|
||||
setUnpackDialogError('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,18 +138,22 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then(data => {
|
||||
if (canceled) return;
|
||||
.then((data) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setUnpackConfig({
|
||||
...DEFAULT_UNPACK_CONFIG,
|
||||
...data,
|
||||
source_dirs: toArray(data?.source_dirs),
|
||||
insar_storage_dirs: toArray(data?.insar_storage_dirs)
|
||||
insar_storage_dirs: toArray(data?.insar_storage_dirs),
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
if (canceled) return;
|
||||
console.error("Failed to fetch unpack config:", err);
|
||||
.catch((err) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to fetch unpack config:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -120,11 +161,12 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
};
|
||||
}, [apiEndpoint, enabled]);
|
||||
|
||||
// 轮询日志
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLogs([]);
|
||||
setActiveTasks([]);
|
||||
setUnpackTaskId('');
|
||||
setUnpackTaskTerminal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,59 +175,121 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
try {
|
||||
const [logsRes, tasksRes] = await Promise.all([
|
||||
fetch(`${apiEndpoint}/monitor/logs`, { credentials: 'include' }),
|
||||
fetch(`${apiEndpoint}/tasks/active`, { credentials: 'include' })
|
||||
fetch(`${apiEndpoint}/tasks/active`, { credentials: 'include' }),
|
||||
]);
|
||||
const [logsData, tasksData] = await Promise.all([
|
||||
parseJsonSafe(logsRes, {}),
|
||||
parseJsonSafe(tasksRes, [])
|
||||
parseJsonSafe(tasksRes, []),
|
||||
]);
|
||||
|
||||
if (canceled) return;
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setLogs(logsRes.ok ? toArray(logsData?.logs) : []);
|
||||
setActiveTasks(tasksRes.ok ? toArray(tasksData) : []);
|
||||
} catch (err) {
|
||||
if (canceled) return;
|
||||
console.error("Failed to fetch logs or tasks:", err);
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to fetch logs or tasks:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogsAndTasks();
|
||||
const intervalId = setInterval(fetchLogsAndTasks, 2000);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [apiEndpoint, enabled]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setUnpackTaskId('');
|
||||
setUnpackTaskTerminal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unpackActiveTask) {
|
||||
if (!unpackTaskId) {
|
||||
setUnpackTaskId(unpackActiveTask.task_id);
|
||||
}
|
||||
setUnpackTaskTerminal(false);
|
||||
setUnpackMessage(
|
||||
`运行中 (${unpackActiveTask.progress || 0}%): ${unpackActiveTask.message || '正在解包 LT-1 归档...'}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unpackTaskId || unpackTaskTerminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
fetch(`${apiEndpoint}/tasks/${unpackTaskId}`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
const data = await parseJsonSafe(res, null);
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then((task) => {
|
||||
if (canceled || !task) {
|
||||
return;
|
||||
}
|
||||
if (task.status === 'COMPLETED') {
|
||||
setUnpackMessage(task.message || 'LT-1 解包已完成。');
|
||||
setUnpackTaskTerminal(true);
|
||||
} else if (task.status === 'FAILED') {
|
||||
setUnpackMessage(`失败:${task.message || 'LT-1 解包任务失败'}`);
|
||||
setUnpackTaskTerminal(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to fetch unpack task status:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [apiEndpoint, enabled, unpackActiveTask, unpackTaskId, unpackTaskTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const hasRadarDirs = Array.isArray(config.radar_dirs) && config.radar_dirs.length > 0;
|
||||
const hasRadarDirs = config.radar_dirs.length > 0;
|
||||
const hasOrbitDir = typeof config.orbit_dir === 'string' && config.orbit_dir.trim() !== '';
|
||||
const hasDinsarDirs = Array.isArray(config.dinsar_dirs) && config.dinsar_dirs.length > 0;
|
||||
const hasGf3SourceDirs = Array.isArray(config.gf3_source_dirs) && config.gf3_source_dirs.length > 0;
|
||||
const hasGf3StorageDirs = Array.isArray(config.gf3_storage_dirs) && config.gf3_storage_dirs.length > 0;
|
||||
const hasDinsarDirs = config.dinsar_dirs.length > 0;
|
||||
const hasGf3SourceDirs = config.gf3_source_dirs.length > 0;
|
||||
const hasGf3StorageDirs = config.gf3_storage_dirs.length > 0;
|
||||
|
||||
const canRunRadar = !readOnly && configLoaded && hasRadarDirs;
|
||||
const canRunOrbit = !readOnly && configLoaded && hasOrbitDir;
|
||||
const canRunDinsar = !readOnly && configLoaded && hasDinsarDirs;
|
||||
const canRunGf3Scan = !readOnly && configLoaded && hasGf3StorageDirs;
|
||||
const canRunGf3Process = !readOnly && configLoaded && hasGf3SourceDirs;
|
||||
const canOpenUnpackDialog = !readOnly && unpackConfig.source_dirs.length > 0;
|
||||
|
||||
const handleRunNow = async (target) => {
|
||||
if (readOnly) {
|
||||
setMessage('当前账号为只读模式,无法触发扫描。');
|
||||
setMessage('当前账户为只读模式,无法触发扫描。');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const targetMap = {
|
||||
'radar': 'LT-1 数据',
|
||||
'orbit': '精轨数据',
|
||||
'dinsar': 'D-InSAR 结果',
|
||||
'gf3': 'GF3 数据'
|
||||
radar: 'LT-1 数据',
|
||||
orbit: '精轨数据',
|
||||
dinsar: 'D-InSAR 结果',
|
||||
gf3: 'GF3 数据',
|
||||
};
|
||||
setMessage(`正在触发${targetMap[target] || '全部'}手动扫描...`);
|
||||
|
||||
@@ -193,12 +297,14 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
const url = target ? `${apiEndpoint}/monitor/run-now?target=${target}` : `${apiEndpoint}/monitor/run-now`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = await parseJsonSafe(res, {});
|
||||
if (res.ok) {
|
||||
setMessage(data.message);
|
||||
if (onTaskStart) onTaskStart(data.task_id, `已触发${targetMap[target] || '全部'}手动扫描...`);
|
||||
setMessage(data.message || '扫描任务已启动。');
|
||||
if (onTaskStart) {
|
||||
onTaskStart(data.task_id, `已触发${targetMap[target] || '全部'}手动扫描...`);
|
||||
}
|
||||
} else {
|
||||
setMessage(`触发失败: ${data.detail || '未知错误'}`);
|
||||
}
|
||||
@@ -209,27 +315,83 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpackRun = async () => {
|
||||
const handleOpenUnpackDialog = () => {
|
||||
if (readOnly) {
|
||||
setUnpackMessage('当前账号为只读模式,无法触发解包任务。');
|
||||
setUnpackMessage('当前账户为只读模式,无法触发解包任务。');
|
||||
return;
|
||||
}
|
||||
setUnpackDialogError('');
|
||||
setUnpackRunOptions(createUnpackRunOptions(unpackConfig));
|
||||
setShowUnpackDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseUnpackDialog = () => {
|
||||
if (unpackLoading) {
|
||||
return;
|
||||
}
|
||||
setShowUnpackDialog(false);
|
||||
setUnpackDialogError('');
|
||||
};
|
||||
|
||||
const handleUnpackOptionChange = (field, value) => {
|
||||
setUnpackRunOptions((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUnpackRun = async () => {
|
||||
if (readOnly) {
|
||||
setUnpackMessage('当前账户为只读模式,无法触发解包任务。');
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = {
|
||||
max_files_per_run: parseUnpackRunValue(unpackRunOptions.max_files_per_run, '单次解包数量'),
|
||||
max_runtime_minutes: parseUnpackRunValue(unpackRunOptions.max_runtime_minutes, '最长运行时间'),
|
||||
};
|
||||
} catch (err) {
|
||||
const errorText = err instanceof Error ? err.message : '解包参数校验失败';
|
||||
setUnpackDialogError(errorText);
|
||||
setUnpackMessage(`失败:${errorText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUnpackDialogError('');
|
||||
setUnpackLoading(true);
|
||||
setUnpackMessage('LT-1 解包任务启动中...');
|
||||
try {
|
||||
const res = await fetch(`${apiEndpoint}/unpack/run`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = await parseJsonSafe(res, {});
|
||||
if (res.ok) {
|
||||
setShowUnpackDialog(false);
|
||||
setUnpackTaskId(data.task_id || '');
|
||||
setUnpackTaskTerminal(false);
|
||||
setUnpackMessage(data.message || 'LT-1 解包任务已启动');
|
||||
if (onTaskStart) onTaskStart(data.task_id, 'LT-1 解包任务已启动。');
|
||||
if (onTaskStart) {
|
||||
onTaskStart(data.task_id, 'LT-1 解包任务已启动。', {
|
||||
nonBlocking: true,
|
||||
taskType: 'UNPACK_ARCHIVES',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setUnpackMessage(`失败:${data.detail || '未知错误'}`);
|
||||
const errorText = data.detail || '未知错误';
|
||||
setUnpackDialogError(errorText);
|
||||
setUnpackMessage(`失败:${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setUnpackMessage(`失败:${err.message}`);
|
||||
const errorText = err.message || '未知错误';
|
||||
setUnpackDialogError(errorText);
|
||||
setUnpackMessage(`失败:${errorText}`);
|
||||
} finally {
|
||||
setUnpackLoading(false);
|
||||
}
|
||||
@@ -237,7 +399,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
|
||||
const handleGf3BatchProcess = async () => {
|
||||
if (readOnly) {
|
||||
setGf3Message('当前账号为只读模式,无法触发 GF3 处理。');
|
||||
setGf3Message('当前账户为只读模式,无法触发 GF3 处理。');
|
||||
return;
|
||||
}
|
||||
setGf3Loading(true);
|
||||
@@ -245,12 +407,14 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
try {
|
||||
const res = await fetch(`${apiEndpoint}/monitor/gf3-process`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = await parseJsonSafe(res, {});
|
||||
if (res.ok) {
|
||||
setGf3Message(data.message || 'GF3 批量处理任务已启动');
|
||||
if (onTaskStart) onTaskStart(data.task_id, 'GF3 L1A→L2 批量处理已启动。');
|
||||
if (onTaskStart) {
|
||||
onTaskStart(data.task_id, 'GF3 L1A→L2 批量处理已启动。');
|
||||
}
|
||||
} else {
|
||||
setGf3Message(`失败:${data.detail || '未知错误'}`);
|
||||
}
|
||||
@@ -261,7 +425,14 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
}
|
||||
};
|
||||
|
||||
const sectionStyle = { marginBottom: '12px', padding: '10px 12px', borderRadius: '10px', background: 'var(--color-panel-bg)', border: '1px solid var(--color-border)', boxShadow: 'var(--shadow-soft)' };
|
||||
const sectionStyle = {
|
||||
marginBottom: '12px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '10px',
|
||||
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' };
|
||||
const gridStyle = { display: 'grid', rowGap: '6px', fontSize: '0.9em', color: 'var(--color-text-secondary)' };
|
||||
@@ -273,33 +444,52 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: (loading || !canRun) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85em'
|
||||
cursor: loading || !canRun ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85em',
|
||||
});
|
||||
|
||||
const actionBtnStyle = (isLoading, isReadOnly) => ({
|
||||
const actionBtnStyle = (isLoading, isDisabled) => ({
|
||||
padding: '6px 10px',
|
||||
backgroundColor: 'var(--color-accent)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: (isLoading || isReadOnly) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85em'
|
||||
cursor: isLoading || isDisabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85em',
|
||||
});
|
||||
|
||||
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' }}>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '8px', fontSize: '1.1em', flexShrink: 0 }}>数据监控面板</h3>
|
||||
|
||||
{/* 可滚动内容区 */}
|
||||
<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
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{configLoaded
|
||||
? '仅手动模式。路径从 .env 读取;如需修改请更新 .env 并重启后端。'
|
||||
: '未加载到监控状态,请检查后端 /api/monitor/status。'}
|
||||
</div>
|
||||
|
||||
{/* 路径摘要 — 按卫星分组 */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>路径摘要</div>
|
||||
<div style={gridStyle}>
|
||||
@@ -311,28 +501,34 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LT-1 归档解包 */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>LT-1 归档解包</div>
|
||||
<div style={{ ...gridStyle, marginBottom: '8px' }}>
|
||||
<div style={rowStyle}><span style={labelStyle}>来源目录</span><span style={{ wordBreak: 'break-all' }}>{formatList(unpackConfig.source_dirs)}</span></div>
|
||||
<div style={rowStyle}><span style={labelStyle}>LT-1 存储</span><span style={{ wordBreak: 'break-all' }}>{formatList(unpackConfig.insar_storage_dirs)}</span></div>
|
||||
<div style={rowStyle}><span style={labelStyle}>单次上限</span><span>{unpackConfig.max_files_per_run > 0 ? `${unpackConfig.max_files_per_run} 个压缩包` : '不限'}</span></div>
|
||||
<div style={rowStyle}><span style={labelStyle}>最长运行</span><span>{unpackConfig.max_runtime_minutes > 0 ? `${unpackConfig.max_runtime_minutes} 分钟` : '不限'}</span></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={handleUnpackRun}
|
||||
disabled={unpackLoading || readOnly}
|
||||
style={actionBtnStyle(unpackLoading, readOnly)}
|
||||
onClick={handleOpenUnpackDialog}
|
||||
disabled={unpackLoading || !canOpenUnpackDialog}
|
||||
style={actionBtnStyle(unpackLoading, !canOpenUnpackDialog)}
|
||||
>
|
||||
{unpackLoading ? '运行中...' : (readOnly ? '只读模式' : 'LT-1 解包')}
|
||||
</button>
|
||||
<div style={{ fontSize: '0.85em', color: unpackMessage.includes('失败') ? 'var(--color-danger)' : 'var(--color-text-muted)', alignSelf: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.85em',
|
||||
color: unpackMessage.includes('失败') ? 'var(--color-danger)' : 'var(--color-text-muted)',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
>
|
||||
{unpackMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GF3 L1A→L2 处理 */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>GF3 L1A → L2 处理</div>
|
||||
<div style={{ ...gridStyle, marginBottom: '8px' }}>
|
||||
@@ -347,20 +543,25 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
>
|
||||
{gf3Loading ? '运行中...' : (readOnly ? '只读模式' : 'GF3 L1A→L2')}
|
||||
</button>
|
||||
<div style={{ fontSize: '0.85em', color: gf3Message.includes('失败') ? 'var(--color-danger)' : 'var(--color-text-muted)', alignSelf: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.85em',
|
||||
color: gf3Message.includes('失败') ? 'var(--color-danger)' : 'var(--color-text-muted)',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
>
|
||||
{gf3Message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 活动任务 */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>活动任务</div>
|
||||
{displayActiveTasks.length === 0 ? (
|
||||
<div style={{ fontSize: '0.85em', color: 'var(--color-text-muted)' }}>当前无活动任务。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', rowGap: '8px' }}>
|
||||
{displayActiveTasks.slice(0, 4).map(task => (
|
||||
{displayActiveTasks.slice(0, 4).map((task) => (
|
||||
<div key={task.task_id} style={{ fontSize: '0.85em', color: 'var(--color-text-secondary)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span>{task.task_type}</span>
|
||||
@@ -376,19 +577,20 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 实时日志 */}
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<h4 style={{ margin: '0 0 5px 0', fontSize: '1em' }}>实时日志</h4>
|
||||
<div style={{
|
||||
height: '160px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#22c55e',
|
||||
padding: '10px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85em',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
height: '160px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#22c55e',
|
||||
padding: '10px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85em',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{displayLogs.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)' }}>暂无日志...</div>
|
||||
) : (
|
||||
@@ -401,7 +603,6 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 扫描按钮 — 固定在底部 */}
|
||||
<div style={{ flexShrink: 0, borderTop: '1px solid var(--color-border)', paddingTop: '10px', marginTop: '6px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '6px' }}>
|
||||
<button onClick={() => handleRunNow('radar')} disabled={loading || !canRunRadar} style={scanBtnStyle(canRunRadar)}>扫描 LT-1</button>
|
||||
@@ -411,6 +612,86 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
|
||||
</div>
|
||||
{message && <div style={{ color: message.includes('失败') ? 'red' : 'green', fontSize: '0.9em' }}>{message}</div>}
|
||||
</div>
|
||||
|
||||
{showUnpackDialog && (
|
||||
<div className="modal-overlay visible" onClick={handleCloseUnpackDialog}>
|
||||
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
|
||||
<h3 style={{ marginTop: 0 }}>LT-1 解包任务参数</h3>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleUnpackRun();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '14px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-panel-muted)',
|
||||
border: '1px solid var(--color-border)',
|
||||
fontSize: '0.9em',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
本次填写的参数只覆盖当前这一次解包任务,不会修改 `.env` 默认值。输入 `0` 表示不限。
|
||||
</div>
|
||||
|
||||
<div style={{ ...gridStyle, marginBottom: '14px' }}>
|
||||
<div style={rowStyle}><span style={labelStyle}>来源目录</span><span style={{ wordBreak: 'break-all' }}>{formatList(unpackConfig.source_dirs)}</span></div>
|
||||
<div style={rowStyle}><span style={labelStyle}>LT-1 存储</span><span style={{ wordBreak: 'break-all' }}>{formatList(unpackConfig.insar_storage_dirs)}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>单次最多解包数量</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={unpackRunOptions.max_files_per_run}
|
||||
onChange={(event) => handleUnpackOptionChange('max_files_per_run', event.target.value)}
|
||||
disabled={unpackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>最长运行时间(分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={unpackRunOptions.max_runtime_minutes}
|
||||
onChange={(event) => handleUnpackOptionChange('max_runtime_minutes', event.target.value)}
|
||||
disabled={unpackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{unpackDialogError && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: '0.9em',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{unpackDialogError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={handleCloseUnpackDialog} disabled={unpackLoading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={unpackLoading}>
|
||||
{unpackLoading ? '启动中...' : '启动解包任务'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { listEngines, listRuns, submitRun } from './api/dinsarProduction';
|
||||
import { listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
|
||||
import { getJobLog } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
|
||||
|
||||
@@ -34,11 +34,13 @@ const ENGINE_STATUS_LABEL = {
|
||||
const ENGINE_LABEL = {
|
||||
sarscape: 'SARscape',
|
||||
isce2: 'ISCE2',
|
||||
pyint: 'PyINT / Gamma',
|
||||
landsar: 'LANDSAR',
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
ISCE2_RUN: 'ISCE2生产',
|
||||
PYINT_RUN: 'PyINT生产',
|
||||
IDL_RUN_DINSAR: 'ENVI生产',
|
||||
};
|
||||
|
||||
@@ -55,6 +57,23 @@ const STATUS_LABEL = {
|
||||
pending: '等待中',
|
||||
};
|
||||
|
||||
const PYINT_DEM_MODE_LABEL = {
|
||||
local_fabdem: '本地 FABDEM',
|
||||
opentopo: 'OpenTopography',
|
||||
prepared_file: '现有 DEM',
|
||||
};
|
||||
|
||||
const PYINT_ORBIT_POLICY_LABEL = {
|
||||
validate_only: '仅校验',
|
||||
require_txt: '必须存在',
|
||||
stage_txt: '校验并留痕',
|
||||
};
|
||||
|
||||
const PYINT_PRECISE_ORBIT_MODE_LABEL = {
|
||||
replace: '状态向量替换',
|
||||
replace_and_validate: '替换并校验',
|
||||
};
|
||||
|
||||
function formatEngineLabel(engineCode, engineLabel = '') {
|
||||
return engineLabel || ENGINE_LABEL[engineCode] || engineCode || '-';
|
||||
}
|
||||
@@ -67,6 +86,61 @@ function formatStatus(status) {
|
||||
return STATUS_LABEL[status] || status || '-';
|
||||
}
|
||||
|
||||
function formatPyintDemMode(mode) {
|
||||
return PYINT_DEM_MODE_LABEL[mode] || mode || '-';
|
||||
}
|
||||
|
||||
function formatPyintOrbitPolicy(policy) {
|
||||
return PYINT_ORBIT_POLICY_LABEL[policy] || policy || '-';
|
||||
}
|
||||
|
||||
function formatPyintPreciseOrbitMode(mode) {
|
||||
return PYINT_PRECISE_ORBIT_MODE_LABEL[mode] || mode || '-';
|
||||
}
|
||||
|
||||
function PreviewIssueList({ title, items, tone = 'warning' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = tone === 'error'
|
||||
? {
|
||||
background: '#fef2f2',
|
||||
border: '#fecaca',
|
||||
title: '#b91c1c',
|
||||
text: '#7f1d1d',
|
||||
}
|
||||
: {
|
||||
background: '#fff7ed',
|
||||
border: '#fed7aa',
|
||||
title: '#c2410c',
|
||||
text: '#9a3412',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${palette.border}`,
|
||||
background: palette.background,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: palette.title, marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.slice(0, 6).map((item, index) => (
|
||||
<div key={`${title}-${index}`} style={{ fontSize: 11, lineHeight: 1.45, color: palette.text }}>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
{items.length > 6 && (
|
||||
<div style={{ fontSize: 11, color: palette.text }}>其余 {items.length - 6} 项已折叠。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineStatusCard({ engine, onSelect, selected }) {
|
||||
const color = ENGINE_STATUS_COLOR[engine.status] || '#94a3b8';
|
||||
const label = ENGINE_STATUS_LABEL[engine.status] || engine.status;
|
||||
@@ -238,6 +312,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitMsg, setSubmitMsg] = useState('');
|
||||
const [submitError, setSubmitError] = useState(false);
|
||||
const [pyintPreview, setPyintPreview] = useState(null);
|
||||
const [pyintPreviewLoading, setPyintPreviewLoading] = useState(false);
|
||||
const [pyintPreviewFeedback, setPyintPreviewFeedback] = useState({ message: '', error: false });
|
||||
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
@@ -253,12 +330,22 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
|
||||
const currentProfileObj = currentProfiles.find(profile => profile.code === selectedProfile) || null;
|
||||
const currentParamSchema = currentProfileObj?.params_schema || EMPTY_OBJECT;
|
||||
const currentDefaultTimeoutSec = Number(currentEngineObj?.default_timeout_seconds || 0) || 0;
|
||||
const currentParamHelpText = selectedEngine === 'pyint'
|
||||
? '这些参数影响 PyINT 的多视、并行度以及是否执行解缠/地理编码。建议先直接使用默认值,优先确认当前任务目录里的 LT-1 原始压缩包是否能被正常识别。'
|
||||
: '这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。';
|
||||
const pyintPreviewBlocksSubmit = selectedEngine === 'pyint' && pyintPreview && pyintPreview.allow_submit === false;
|
||||
const latestRunWithTask = runs.find(run => run?.task_id) || null;
|
||||
const monitoredTask = activeTask || (
|
||||
latestRunWithTask
|
||||
? {
|
||||
task_id: latestRunWithTask.task_id,
|
||||
task_type: latestRunWithTask.engine === 'isce2' ? 'ISCE2_RUN' : 'IDL_RUN_DINSAR',
|
||||
task_type:
|
||||
latestRunWithTask.engine === 'isce2'
|
||||
? 'ISCE2_RUN'
|
||||
: latestRunWithTask.engine === 'pyint'
|
||||
? 'PYINT_RUN'
|
||||
: 'IDL_RUN_DINSAR',
|
||||
status: latestRunWithTask.raw_status || latestRunWithTask.status,
|
||||
progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null,
|
||||
message: latestRunWithTask.message || '最近一次任务',
|
||||
@@ -299,7 +386,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
try {
|
||||
const data = await getActiveTasks();
|
||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
const relevantTask = tasks.find(task => ['ISCE2_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null;
|
||||
const relevantTask = tasks.find(task => ['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null;
|
||||
setActiveTask(relevantTask);
|
||||
return relevantTask;
|
||||
} catch {
|
||||
@@ -384,6 +471,19 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
setEngineExtraParams(buildDefaults(currentParamSchema));
|
||||
}, [selectedEngine, selectedProfile, currentParamSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDefaultTimeoutSec > 0) {
|
||||
setTimeoutSec(String(currentDefaultTimeoutSec));
|
||||
return;
|
||||
}
|
||||
setTimeoutSec('');
|
||||
}, [selectedEngine, currentDefaultTimeoutSec]);
|
||||
|
||||
useEffect(() => {
|
||||
setPyintPreview(null);
|
||||
setPyintPreviewFeedback({ message: '', error: false });
|
||||
}, [selectedEngine, rootDir, numToProcess]);
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
setEngineExtraParams(current => ({
|
||||
...current,
|
||||
@@ -391,12 +491,49 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePreviewPyint = useCallback(async () => {
|
||||
if (!rootDir.trim()) {
|
||||
setPyintPreview(null);
|
||||
setPyintPreviewFeedback({ message: '请先输入根目录。', error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setPyintPreviewLoading(true);
|
||||
setPyintPreviewFeedback({ message: '', error: false });
|
||||
try {
|
||||
const data = await previewPyintInputAssets({
|
||||
root_dir: rootDir.trim(),
|
||||
num_to_process: Number(numToProcess) || 0,
|
||||
});
|
||||
setPyintPreview(data);
|
||||
const taskCount = Number(data?.selected_task_count || data?.task_count || 0);
|
||||
const orbitMissingCount = Number(data?.orbits?.missing_task_count || 0);
|
||||
const detail = data?.allow_submit
|
||||
? `预检完成,可提交 ${taskCount} 个任务。`
|
||||
: `预检完成,存在阻塞项;涉及 ${orbitMissingCount} 个轨道未齐全任务。`;
|
||||
setPyintPreviewFeedback({ message: detail, error: !data?.allow_submit });
|
||||
} catch (err) {
|
||||
setPyintPreview(null);
|
||||
setPyintPreviewFeedback({
|
||||
message: `预检失败:${err?.response?.data?.detail || err.message}`,
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
setPyintPreviewLoading(false);
|
||||
}
|
||||
}, [numToProcess, rootDir]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!rootDir.trim()) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg('请输入根目录。');
|
||||
return;
|
||||
}
|
||||
if (pyintPreviewBlocksSubmit) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg('PyINT 输入资产预检未通过,请先修复阻塞项。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitMsg('');
|
||||
@@ -434,10 +571,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available;
|
||||
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || pyintPreviewBlocksSubmit;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 960 }}>
|
||||
<div style={{ padding: '16px 0', width: '100%' }}>
|
||||
{logModal.open && (
|
||||
<div
|
||||
style={{
|
||||
@@ -585,17 +722,27 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
|
||||
超时时间(秒,可选)
|
||||
超时时间(秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={60}
|
||||
value={timeoutSec}
|
||||
onChange={event => setTimeoutSec(event.target.value)}
|
||||
placeholder="默认"
|
||||
placeholder={currentDefaultTimeoutSec > 0 ? `默认 ${currentDefaultTimeoutSec}` : '默认'}
|
||||
disabled={readOnly}
|
||||
style={{ width: 140, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
||||
/>
|
||||
{selectedEngine === 'isce2' && currentDefaultTimeoutSec > 0 && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
|
||||
ISCE2 默认按单对任务使用 {currentDefaultTimeoutSec} 秒;批量目录会串行逐对套用该超时。
|
||||
</div>
|
||||
)}
|
||||
{selectedEngine === 'pyint' && currentDefaultTimeoutSec > 0 && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
|
||||
PyINT 默认按单对任务使用 {currentDefaultTimeoutSec} 秒;当前会逐对串行创建工作区并运行外部 PyINT / Gamma 流程。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -622,7 +769,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。
|
||||
{currentParamHelpText}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{Object.entries(currentParamSchema).map(([name, schema]) => (
|
||||
@@ -639,6 +786,185 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEngine === 'pyint' && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 10,
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#0f172a', fontWeight: 600 }}>PyINT 输入资产预检</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 4 }}>
|
||||
提交前检查 Task_* 结构、DEM 策略和 LT-1 轨道是否齐备。即使不手动预检,后端提交时也会做同样校验。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePreviewPyint}
|
||||
disabled={readOnly || pyintPreviewLoading || !rootDir.trim()}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '5px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #cbd5e1',
|
||||
cursor: readOnly || pyintPreviewLoading || !rootDir.trim() ? 'not-allowed' : 'pointer',
|
||||
background: '#fff',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
>
|
||||
{pyintPreviewLoading ? '预检中...' : '预检输入资产'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pyintPreviewFeedback.message && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: pyintPreview ? 10 : 0,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${pyintPreviewFeedback.error ? '#fecaca' : '#bbf7d0'}`,
|
||||
background: pyintPreviewFeedback.error ? '#fef2f2' : '#f0fdf4',
|
||||
color: pyintPreviewFeedback.error ? '#b91c1c' : '#15803d',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{pyintPreviewFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!pyintPreview && !pyintPreviewLoading && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8' }}>
|
||||
尚未执行预检。建议在首次处理新批次前先预检一次。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pyintPreview && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '任务数', value: pyintPreview.selected_task_count ?? pyintPreview.task_count ?? 0, color: '#0f172a' },
|
||||
{ label: 'DEM 策略', value: formatPyintDemMode(pyintPreview?.dem?.mode), color: '#1d4ed8' },
|
||||
{ label: '轨道策略', value: formatPyintOrbitPolicy(pyintPreview?.orbits?.policy), color: '#7c3aed' },
|
||||
{
|
||||
label: '精轨桥接',
|
||||
value: pyintPreview?.precise_orbit_bridge?.enabled
|
||||
? formatPyintPreciseOrbitMode(pyintPreview?.precise_orbit_bridge?.mode)
|
||||
: '关闭',
|
||||
color: pyintPreview?.precise_orbit_bridge?.enabled ? '#0f766e' : '#64748b',
|
||||
},
|
||||
{ label: '可提交', value: pyintPreview.allow_submit ? '是' : '否', color: pyintPreview.allow_submit ? '#15803d' : '#b91c1c' },
|
||||
{ label: '缺轨道任务', value: pyintPreview?.orbits?.missing_task_count ?? 0, color: (pyintPreview?.orbits?.missing_task_count || 0) > 0 ? '#b91c1c' : '#475569' },
|
||||
{ label: '无效目录', value: pyintPreview?.invalid_candidates?.length ?? 0, color: (pyintPreview?.invalid_candidates?.length || 0) > 0 ? '#c2410c' : '#475569' },
|
||||
].map(item => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>{item.label}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: item.color, wordBreak: 'break-word' }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<PreviewIssueList title="阻塞项" items={pyintPreview.blockers || []} tone="error" />
|
||||
<PreviewIssueList title="警告" items={pyintPreview.warnings || []} tone="warning" />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: '#475569', marginBottom: 6 }}>任务级预检结果</div>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 6,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(180px, 1.6fr) minmax(70px, 0.8fr) minmax(220px, 1.5fr) minmax(70px, 0.7fr)',
|
||||
minWidth: 560,
|
||||
gap: 0,
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{['任务', '源文件', '轨道解析', '提交'].map(header => (
|
||||
<div key={header} style={{ padding: '8px 10px', fontSize: 11, color: '#64748b', fontWeight: 600 }}>
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(pyintPreview.tasks || []).map(task => {
|
||||
const masterOrbit = task?.orbit_resolution?.master;
|
||||
const slaveOrbit = task?.orbit_resolution?.slave;
|
||||
return (
|
||||
<div
|
||||
key={task.task_dir}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(180px, 1.6fr) minmax(70px, 0.8fr) minmax(220px, 1.5fr) minmax(70px, 0.7fr)',
|
||||
minWidth: 560,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px 10px', minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, color: '#0f172a', fontWeight: 600 }}>{task.task_alias || task.task_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 4 }}>
|
||||
{task.master_date || '-'} / {task.slave_date || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, color: '#334155' }}>
|
||||
M {task?.archive_counts?.master ?? 0}
|
||||
<br />
|
||||
S {task?.archive_counts?.slave ?? 0}
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, lineHeight: 1.5 }}>
|
||||
<div style={{ color: masterOrbit?.resolved ? '#15803d' : '#b91c1c' }}>
|
||||
M {masterOrbit?.resolved ? `${masterOrbit.satellite}/${masterOrbit.date}` : '缺失'}
|
||||
</div>
|
||||
<div style={{ color: slaveOrbit?.resolved ? '#15803d' : '#b91c1c' }}>
|
||||
S {slaveOrbit?.resolved ? `${slaveOrbit.satellite}/${slaveOrbit.date}` : '缺失'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, fontWeight: 600, color: task.allow_submit ? '#15803d' : '#b91c1c' }}>
|
||||
{task.allow_submit ? '可提交' : '阻塞'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
import { extractDispResults } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel.rewrite';
|
||||
|
||||
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 = {
|
||||
PENDING: '等待中',
|
||||
RUNNING: '运行中',
|
||||
COMPLETED: '已完成',
|
||||
FAILED: '失败',
|
||||
CANCELLED: '已取消',
|
||||
CANCELED: '已取消',
|
||||
};
|
||||
|
||||
function formatTaskType(taskType) {
|
||||
return TASK_TYPE_LABEL[taskType] || taskType || '-';
|
||||
}
|
||||
|
||||
function formatStatus(status) {
|
||||
return STATUS_LABEL[status] || status || '-';
|
||||
}
|
||||
|
||||
function getMessageTone(message, fallbackError = false) {
|
||||
if (fallbackError) return 'error';
|
||||
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
||||
}
|
||||
|
||||
function getLogTone(level) {
|
||||
const normalized = String(level || '').toUpperCase();
|
||||
if (normalized === 'ERROR') return 'error';
|
||||
if (normalized === 'WARNING' || normalized === 'WARN') return 'warn';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
const [extractRootDir, setExtractRootDir] = useState('');
|
||||
const [extractDestDir, setExtractDestDir] = useState('');
|
||||
const [extractResult, setExtractResult] = useState(null);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const [actionError, setActionError] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const [recentTask, setRecentTask] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
||||
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
||||
const monitoredTask = activeTask || recentTask;
|
||||
const logTaskId = monitoredTask?.task_id || '';
|
||||
const showingRecentTask = !activeTask && !!recentTask;
|
||||
const actionTone = getMessageTone(actionMessage, actionError);
|
||||
|
||||
const loadActiveTask = useCallback(async () => {
|
||||
try {
|
||||
const data = await getActiveTasks();
|
||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
const relevantTask = tasks.find((task) => PRODUCT_TASK_TYPES.includes(task.task_type)) || null;
|
||||
setActiveTask(relevantTask);
|
||||
return relevantTask;
|
||||
} catch {
|
||||
setActiveTask(null);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecentTask = useCallback(async () => {
|
||||
try {
|
||||
const tasks = await getRecentTasks(PRODUCT_TASK_TYPES, [], 1, 0);
|
||||
const nextTask = Array.isArray(tasks) ? (tasks[0] || null) : null;
|
||||
setRecentTask(nextTask);
|
||||
return nextTask;
|
||||
} catch {
|
||||
setRecentTask(null);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTaskLogs = useCallback(async (taskId) => {
|
||||
if (!taskId) {
|
||||
setTaskLogs([]);
|
||||
return;
|
||||
}
|
||||
setTaskLogsLoading(true);
|
||||
try {
|
||||
const data = await getTaskLogs(taskId, 50, 0);
|
||||
setTaskLogs(data?.logs || []);
|
||||
} catch {
|
||||
setTaskLogs([]);
|
||||
} finally {
|
||||
setTaskLogsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshMonitor = useCallback(async () => {
|
||||
const [nextActiveTask, nextRecentTask] = await Promise.all([
|
||||
loadActiveTask(),
|
||||
loadRecentTask(),
|
||||
]);
|
||||
const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || '';
|
||||
await loadTaskLogs(nextTaskId);
|
||||
}, [loadActiveTask, loadRecentTask, loadTaskLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshMonitor();
|
||||
}, [refreshMonitor]);
|
||||
|
||||
const handleDeleteTaskLog = useCallback(async (logId) => {
|
||||
const taskId = logTaskId;
|
||||
if (!taskId || !logId || taskLogActionLoading) return;
|
||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
||||
|
||||
setTaskLogDeletingId(logId);
|
||||
setTaskLogActionLoading(true);
|
||||
try {
|
||||
await deleteTaskLog(taskId, logId);
|
||||
await loadTaskLogs(taskId);
|
||||
} catch (error) {
|
||||
setActionMessage(`删除日志失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionError(true);
|
||||
} finally {
|
||||
setTaskLogDeletingId(null);
|
||||
setTaskLogActionLoading(false);
|
||||
}
|
||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading]);
|
||||
|
||||
const handleClearTaskLogs = useCallback(async () => {
|
||||
const taskId = logTaskId;
|
||||
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
||||
if (!window.confirm(`确定要清空任务 ${taskId} 的全部日志吗?`)) return;
|
||||
|
||||
setTaskLogActionLoading(true);
|
||||
try {
|
||||
await clearTaskLogs(taskId);
|
||||
await loadTaskLogs(taskId);
|
||||
} catch (error) {
|
||||
setActionMessage(`清空日志失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionError(true);
|
||||
} finally {
|
||||
setTaskLogActionLoading(false);
|
||||
}
|
||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!extractRootDir.trim()) return;
|
||||
setExtracting(true);
|
||||
setExtractResult(null);
|
||||
setActionMessage('');
|
||||
setActionError(false);
|
||||
try {
|
||||
const result = await extractDispResults(extractRootDir.trim(), extractDestDir.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);
|
||||
}
|
||||
await refreshMonitor();
|
||||
} catch (err) {
|
||||
setActionError(true);
|
||||
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR 结果扫描失败');
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const monitorTone = useMemo(() => {
|
||||
if (!monitoredTask) return 'neutral';
|
||||
if (showingRecentTask) return 'info';
|
||||
return String(monitoredTask.status || '').toUpperCase() === 'RUNNING' ? 'warn' : 'neutral';
|
||||
}, [monitoredTask, showingRecentTask]);
|
||||
|
||||
return (
|
||||
<div className="dinsar-products-page">
|
||||
<div className="dinsar-products-hero">
|
||||
<div>
|
||||
<strong>D-InSAR 结果提取与标准目录</strong>
|
||||
<p>
|
||||
这里负责把生产目录中的位移结果提取为标准成果包,并触发统一扫描、发布和编目。
|
||||
生产运行与参数配置现已收口到“生产管理”工作台中的 “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>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-products-top-grid">
|
||||
<section className="dinsar-products-card">
|
||||
<div className="dinsar-products-card-head">
|
||||
<div>
|
||||
<strong>结果提取与重扫</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>
|
||||
|
||||
<div className="dinsar-products-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={handleExtract}
|
||||
disabled={extracting || !extractRootDir.trim()}
|
||||
>
|
||||
{extracting ? '提取中...' : '提取位移结果'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScan}
|
||||
disabled={readOnly || scanning}
|
||||
>
|
||||
{scanning ? '重扫中...' : '重扫结果'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionMessage && (
|
||||
<div className={`dinsar-products-message tone-${actionTone}`}>
|
||||
{actionMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extractResult && (
|
||||
<div className={`dinsar-products-result-card ${extractResult.error ? 'error' : 'success'}`}>
|
||||
{extractResult.error ? (
|
||||
<span>提取失败:{extractResult.error}</span>
|
||||
) : (
|
||||
<>
|
||||
<div>提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。</div>
|
||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'ok' && (
|
||||
<div>
|
||||
标准结果目录已同步:发布 {extractResult.catalog?.publish?.processed || 0} 项,
|
||||
重建登记 {extractResult.catalog?.rebuild?.registered || 0} 项。
|
||||
</div>
|
||||
)}
|
||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'error' && (
|
||||
<div>标准结果目录同步失败:{extractResult.catalog?.message}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`dinsar-products-card monitor tone-${monitorTone}`}>
|
||||
<div className="dinsar-products-card-head">
|
||||
<div>
|
||||
<strong>产物任务监控</strong>
|
||||
<span>当前不轮询,按需手动刷新</span>
|
||||
</div>
|
||||
<button type="button" onClick={refreshMonitor}>刷新</button>
|
||||
</div>
|
||||
|
||||
{!monitoredTask ? (
|
||||
<div className="dinsar-products-empty">当前没有正在执行的产物处理任务。</div>
|
||||
) : (
|
||||
<div className="dinsar-monitor-card">
|
||||
<div className="dinsar-monitor-top">
|
||||
<div>
|
||||
<strong>{showingRecentTask ? '最近一次任务' : '当前任务'}</strong>
|
||||
<span>{formatTaskType(monitoredTask.task_type)}</span>
|
||||
</div>
|
||||
<StatusSummary status={monitoredTask.status} />
|
||||
</div>
|
||||
|
||||
<div className="dinsar-monitor-task-id">{monitoredTask.task_id}</div>
|
||||
<div className="dinsar-monitor-message">{monitoredTask.message || '-'}</div>
|
||||
|
||||
{monitoredTask.progress != null && (
|
||||
<div className="dinsar-monitor-progress">
|
||||
<div className="dinsar-monitor-progress-track">
|
||||
<div
|
||||
className="dinsar-monitor-progress-bar"
|
||||
style={{ width: `${monitoredTask.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{monitoredTask.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dinsar-monitor-log-head">
|
||||
<strong>{showingRecentTask ? '最近一次任务日志' : '当前任务日志'}</strong>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearTaskLogs}
|
||||
disabled={taskLogActionLoading || taskLogs.length === 0}
|
||||
>
|
||||
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taskLogsLoading ? (
|
||||
<div className="dinsar-products-empty">日志加载中...</div>
|
||||
) : taskLogs.length === 0 ? (
|
||||
<div className="dinsar-products-empty">暂无日志。</div>
|
||||
) : (
|
||||
<div className="dinsar-monitor-log-list">
|
||||
{taskLogs.map((log, index) => {
|
||||
const tone = getLogTone(log.level);
|
||||
return (
|
||||
<div
|
||||
key={log.id || `${log.timestamp || 'log'}-${index}`}
|
||||
className={`dinsar-monitor-log-item tone-${tone}`}
|
||||
>
|
||||
<div className="dinsar-monitor-log-main">
|
||||
<div className="dinsar-monitor-log-time">
|
||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
||||
</div>
|
||||
<div className="dinsar-monitor-log-message">{log.message}</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => handleDeleteTaskLog(log.id)}
|
||||
disabled={taskLogActionLoading || !log.id}
|
||||
>
|
||||
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DinsarCatalogPanel
|
||||
readOnly={readOnly}
|
||||
initialSourceDir={extractRootDir}
|
||||
onTaskQueued={onJobQueued}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusSummary({ status }) {
|
||||
const normalized = String(status || '').toUpperCase();
|
||||
const tone = normalized === 'RUNNING'
|
||||
? 'warn'
|
||||
: normalized === 'FAILED'
|
||||
? 'error'
|
||||
: normalized === 'COMPLETED'
|
||||
? 'ready'
|
||||
: 'neutral';
|
||||
|
||||
return (
|
||||
<span className={`dinsar-status-pill tone-${tone}`}>
|
||||
{formatStatus(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
PRODUCTION_WORKSPACE_ENTRY_TO_VIEW,
|
||||
PRODUCTION_WORKSPACE_TAB,
|
||||
PRODUCTION_WORKSPACE_VIEWS,
|
||||
} from './config/appConstants';
|
||||
import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
|
||||
|
||||
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
|
||||
const LazyTimeseriesProductionPanel = lazy(() => import('./TimeseriesProductionPanel'));
|
||||
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel.rewrite'));
|
||||
const LazyPsinsarCatalogPanel = lazy(() => import('./components/PsinsarCatalogPanel'));
|
||||
|
||||
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)',
|
||||
};
|
||||
|
||||
function resolveView(entry) {
|
||||
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[entry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
|
||||
}
|
||||
|
||||
export default function ProductionWorkspace({
|
||||
activeEntry = PRODUCTION_WORKSPACE_TAB,
|
||||
readOnly = false,
|
||||
onTaskStart,
|
||||
}) {
|
||||
const [activeView, setActiveView] = useState(() => resolveView(activeEntry));
|
||||
|
||||
useEffect(() => {
|
||||
setActiveView(resolveView(activeEntry));
|
||||
}, [activeEntry]);
|
||||
|
||||
const activeViewMeta = useMemo(
|
||||
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView) || PRODUCTION_WORKSPACE_VIEWS[0],
|
||||
[activeView]
|
||||
);
|
||||
|
||||
const handleDinsarRunQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'D-InSAR 任务已入队,等待处理...');
|
||||
};
|
||||
|
||||
const handleTimeseriesRunQueued = taskId => {
|
||||
onTaskStart?.(taskId, '时序InSAR 运行已入队,当前默认执行 SBAS 流程...');
|
||||
};
|
||||
|
||||
const handleDinsarProductQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...');
|
||||
};
|
||||
|
||||
const handleTimeseriesProductQueued = taskId => {
|
||||
onTaskStart?.(taskId, '时序InSAR 产物目录任务已入队,等待处理...');
|
||||
};
|
||||
|
||||
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>
|
||||
<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 与时序InSAR的运行和产物工作台。当前时序入口默认接入 SBAS 实现,
|
||||
后续可在同一界面继续扩展 PS-InSAR、SBAS-InSAR 以及更多 WSL2 引擎实例。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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' }}>运行与产物同域编排</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
|
||||
生产运行、目录重建、产物编目全部收口到同一顶级工作区。
|
||||
</div>
|
||||
</div>
|
||||
<div style={summaryCardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>时序当前实现</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>SBAS 默认接入</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
|
||||
现阶段实际运行链路为 SBAS,界面命名已统一为时序InSAR。
|
||||
</div>
|
||||
</div>
|
||||
<div style={summaryCardStyle}>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>引擎预留</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>ISCE / Gamma 可扩</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
|
||||
现有 WSL2 嵌入保持不变,后续增加 Gamma 时可直接挂入当前工作台。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section
|
||||
style={{
|
||||
...heroCardStyle,
|
||||
padding: '14px',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{PRODUCTION_WORKSPACE_VIEWS.map(view => {
|
||||
const isActive = view.key === activeView;
|
||||
return (
|
||||
<button
|
||||
key={view.key}
|
||||
type="button"
|
||||
onClick={() => setActiveView(view.key)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 18,
|
||||
border: `1px solid ${isActive ? '#93c5fd' : '#d7e0eb'}`,
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, #eff6ff 0%, #f8fbff 100%)'
|
||||
: '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: 15, color: '#0f172a' }}>{view.label}</strong>
|
||||
<span
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: isActive ? '#1d4ed8' : '#64748b',
|
||||
background: isActive ? '#dbeafe' : '#f1f5f9',
|
||||
}}
|
||||
>
|
||||
{isActive ? '当前视图' : '切换'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#475569' }}>{view.description}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: '#64748b' }}>{activeViewMeta.label}</div>
|
||||
<Suspense fallback={<PanelLoadingBody message={`正在加载 ${activeViewMeta.label}...`} />}>
|
||||
{activeView === 'dinsar_runs' && (
|
||||
<LazyDinsarProductionPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleDinsarRunQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'timeseries_runs' && (
|
||||
<LazyTimeseriesProductionPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleTimeseriesRunQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'dinsar_products' && (
|
||||
<LazyDinsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleDinsarProductQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'timeseries_products' && (
|
||||
<LazyPsinsarCatalogPanel
|
||||
readOnly={readOnly}
|
||||
showActions
|
||||
onTaskQueued={handleTimeseriesProductQueued}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedBatchId) {
|
||||
setMessage('请先选择一个 PS 批次。');
|
||||
setMessage('请先选择一个时序批次。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -182,9 +182,9 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
const workflowSteps = selectedRunDetail?.workflow?.steps || [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1080 }}>
|
||||
<div style={{ padding: '16px 0', width: '100%' }}>
|
||||
<div style={card}>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>SBAS 生产入口</strong>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>时序InSAR 运行入口</strong>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@@ -196,18 +196,18 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
当前系统阶段已接入完整八步链路:prepare、stack_prep_initial、materialize、stack_prep_refresh、
|
||||
run_isce2_stack、run_mintpy_sbas、export_publish_bundle、register_psinsar_product。
|
||||
提交后系统会依次生成选栈 manifest、物化 LT-1 SLC、执行 ISCE2 stack、运行 MintPy SBAS、
|
||||
导出 publish bundle,并把结果注册进 PS-InSAR catalog。
|
||||
当前接入实现为 SBAS。现阶段已连通完整八步链路:prepare、stack_prep_initial、materialize、
|
||||
stack_prep_refresh、run_isce2_stack、run_mintpy_sbas、export_publish_bundle、
|
||||
register_psinsar_product。提交后系统会依次生成选栈 manifest、物化 LT-1 SLC、执行 ISCE2
|
||||
stack、运行 MintPy SBAS、导出 publish bundle,并把结果注册进时序InSAR catalog。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>新建运行</strong>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 4 }}>PS 批次</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 4 }}>时序批次</div>
|
||||
<select
|
||||
value={selectedBatchId}
|
||||
onChange={event => setSelectedBatchId(event.target.value)}
|
||||
@@ -298,7 +298,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
cursor: readOnly ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{submitting ? '提交中...' : '提交 SBAS 运行'}
|
||||
{submitting ? '提交中...' : '提交时序运行(SBAS)'}
|
||||
</button>
|
||||
{message && (
|
||||
<span style={{ fontSize: 12, color: message.includes('失败') ? '#dc2626' : '#166534' }}>
|
||||
@@ -321,13 +321,13 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 360px) 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 12 }}>
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
|
||||
运行列表 ({runs.length})
|
||||
</div>
|
||||
{runs.length === 0 ? (
|
||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>当前暂无 SBAS 运行记录。</div>
|
||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>当前暂无时序InSAR运行记录。</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 420, overflowY: 'auto' }}>
|
||||
{runs.map(item => (
|
||||
|
||||
@@ -18,3 +18,6 @@ export const submitRun = (payload) =>
|
||||
// 运行历史
|
||||
export const listRuns = (limit = 20) =>
|
||||
apiClient.get(`/dinsar-production/runs?limit=${encodeURIComponent(limit)}`).then(r => r.data);
|
||||
|
||||
export const previewPyintInputAssets = (payload) =>
|
||||
apiClient.post('/dinsar-production/engines/pyint/preview-input-assets', payload).then(r => r.data);
|
||||
|
||||
@@ -19,7 +19,7 @@ const getTaskTypeLabel = (taskType) => {
|
||||
case 'SCAN_HAZARD':
|
||||
return '灾害点同步';
|
||||
case 'UNPACK_ARCHIVES':
|
||||
return 'Archive unpack';
|
||||
return 'LT-1 解包';
|
||||
case 'IDL_IMPORT':
|
||||
return 'ENVI 数据导入';
|
||||
case 'IDL_DINSAR':
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import apiClient from '../api/client';
|
||||
import {
|
||||
getDinsarCatalogStatus,
|
||||
getDinsarProductDetail,
|
||||
listDinsarProducts,
|
||||
queueDinsarCatalogRebuild,
|
||||
queueDinsarProductPublish,
|
||||
} from '../api/dinsarProducts';
|
||||
import {
|
||||
DINSAR_ENGINE_ALL,
|
||||
buildDinsarEngineOptions,
|
||||
getDinsarEngineMeta,
|
||||
} from '../utils/dinsarEngines';
|
||||
|
||||
const STATUS_TONE_MAP = {
|
||||
READY: 'ready',
|
||||
PARTIAL: 'warn',
|
||||
QUARANTINED: 'error',
|
||||
WARN: 'warn',
|
||||
ERROR: 'error',
|
||||
REBUILDING: 'info',
|
||||
};
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function StatusPill({ label, tone = 'neutral' }) {
|
||||
return <span className={`dinsar-status-pill tone-${tone}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function MetaField({ label, value, multiline = false }) {
|
||||
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
|
||||
return (
|
||||
<div className="dinsar-catalog-meta-field">
|
||||
<span>{label}</span>
|
||||
<strong className={multiline ? 'break-all' : ''}>{displayValue}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DinsarCatalogPanel({
|
||||
readOnly = false,
|
||||
compact = false,
|
||||
initialSourceDir = '',
|
||||
onTaskQueued,
|
||||
}) {
|
||||
const [catalogStatus, setCatalogStatus] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [selectedProductId, setSelectedProductId] = useState(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
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 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(products, { includeKnown: true }),
|
||||
[products]
|
||||
);
|
||||
const selectedEngineMeta = useMemo(
|
||||
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
||||
[engineFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (engineFilter === DINSAR_ENGINE_ALL) return;
|
||||
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||
}
|
||||
}, [engineFilter, engineOptions]);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusData, productData] = await Promise.all([
|
||||
getDinsarCatalogStatus(),
|
||||
listDinsarProducts({
|
||||
limit: listLimit,
|
||||
offset: 0,
|
||||
engine_code: engineFilter === DINSAR_ENGINE_ALL ? undefined : engineFilter,
|
||||
query: queryApplied || undefined,
|
||||
}),
|
||||
]);
|
||||
setCatalogStatus(statusData);
|
||||
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
|
||||
setProducts(nextItems);
|
||||
setSelectedProductId((current) => {
|
||||
if (current && nextItems.some((item) => item.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextItems[0]?.id ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
setActionMessage(`结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
setCatalogStatus(null);
|
||||
setProducts([]);
|
||||
setSelectedProductId(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [engineFilter, listLimit, queryApplied]);
|
||||
|
||||
const loadProductDetail = useCallback(async (productId) => {
|
||||
if (!productId) {
|
||||
setSelectedProduct(null);
|
||||
return;
|
||||
}
|
||||
setSelectedProduct(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await getDinsarProductDetail(productId);
|
||||
setSelectedProduct(detail);
|
||||
} catch (error) {
|
||||
setSelectedProduct({
|
||||
error: error?.response?.data?.detail || error.message || '结果详情加载失败',
|
||||
});
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProductDetail(selectedProductId);
|
||||
}, [loadProductDetail, selectedProductId]);
|
||||
|
||||
const handleApplyFilters = useCallback(() => {
|
||||
setQueryApplied(queryDraft.trim());
|
||||
}, [queryDraft]);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||
setQueryDraft('');
|
||||
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 : [];
|
||||
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
|
||||
const selectedPairingTrace = selectedProduct?.pairing_trace || null;
|
||||
const selectedPairingNetwork = selectedProduct?.pairing_network || null;
|
||||
const selectedPairingRun = selectedPairingNetwork?.run || null;
|
||||
const selectedPairingEdge = selectedPairingNetwork?.edge || null;
|
||||
const selectedPairingMetric = selectedPairingNetwork?.metric || null;
|
||||
const selectedProductEngine = getDinsarEngineMeta(selectedProduct?.engine_code);
|
||||
const selectedStatusTone = STATUS_TONE_MAP[selectedProduct?.status] || 'neutral';
|
||||
|
||||
return (
|
||||
<div className={`dinsar-catalog-shell ${compact ? 'compact' : ''}`}>
|
||||
<div className="dinsar-catalog-header">
|
||||
<div className="dinsar-catalog-header-copy">
|
||||
<strong>{compact ? '结果目录状态' : '标准结果包目录'}</strong>
|
||||
<p>
|
||||
{compact
|
||||
? '查看结果包目录与数据库索引是否一致。'
|
||||
: '统一结果目录按 engine + pair + run 管理,便于同一对影像保留多套生产结果并行对比。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="dinsar-catalog-header-actions">
|
||||
{selectedEngineMeta && (
|
||||
<span className={`dinsar-engine-badge tone-${selectedEngineMeta.tone}`}>
|
||||
{selectedEngineMeta.shortLabel}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={loadCatalog} disabled={loading || actionLoading}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-summary">
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>目录状态</span>
|
||||
<strong>{catalogStatus?.status || '未知'}</strong>
|
||||
<StatusPill label={catalogStatus?.status || 'UNKNOWN'} tone={catalogTone} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>需要重建</span>
|
||||
<strong>{catalogStatus?.needs_rebuild ? '是' : '否'}</strong>
|
||||
<small>{catalogStatus?.needs_rebuild ? 'Manifest 与数据库存在漂移' : '目录登记正常'}</small>
|
||||
</div>
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>Manifest / 数据库</span>
|
||||
<strong>{catalogStatus?.manifest_count ?? 0} / {catalogStatus?.db_count ?? 0}</strong>
|
||||
<small>已登记结果包总量</small>
|
||||
</div>
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>问题数量</span>
|
||||
<strong>{catalogStatus?.issue_count ?? 0}</strong>
|
||||
<small>含缺失文件与健康异常</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-meta-strip">
|
||||
<div><strong>结果包根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
||||
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
||||
<div><strong>最近全量重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
||||
</div>
|
||||
|
||||
{actionMessage && (
|
||||
<div className={`dinsar-catalog-message tone-${actionTone}`}>
|
||||
{actionMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && (
|
||||
<div className="dinsar-catalog-manage">
|
||||
<div className="dinsar-catalog-manage-copy">
|
||||
<strong>手动发布与目录重建</strong>
|
||||
<p>
|
||||
这里用于把既有结果目录重新发布为标准结果包,并按最新规则重建目录索引。
|
||||
如果同一对影像存在 ENVI 与 ISCE2 两套结果,它们会依赖 `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">
|
||||
<div>
|
||||
<strong>结果包列表</strong>
|
||||
<span>
|
||||
{loading ? '加载中...' : `当前展示 ${products.length} 条`}
|
||||
</span>
|
||||
</div>
|
||||
{queryApplied && <StatusPill label={`检索: ${queryApplied}`} tone="info" />}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-filter-bar">
|
||||
<label className="dinsar-catalog-filter-field">
|
||||
<span>生产引擎</span>
|
||||
<select value={engineFilter} onChange={(event) => setEngineFilter(event.target.value)}>
|
||||
<option value={DINSAR_ENGINE_ALL}>全部引擎</option>
|
||||
{engineOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="dinsar-catalog-filter-field search">
|
||||
<span>检索</span>
|
||||
<input
|
||||
value={queryDraft}
|
||||
onChange={(event) => setQueryDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleApplyFilters();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索任务名 / pair / run / 引擎"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="dinsar-catalog-filter-actions">
|
||||
<button type="button" onClick={handleApplyFilters}>查询</button>
|
||||
<button type="button" onClick={handleResetFilters}>重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="dinsar-catalog-empty">
|
||||
{loading ? '正在加载结果包...' : '当前筛选条件下没有结果包。'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-list">
|
||||
{products.map((item) => {
|
||||
const tone = STATUS_TONE_MAP[item.status] || 'neutral';
|
||||
const engineMeta = getDinsarEngineMeta(item.engine_code);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`dinsar-catalog-list-item ${selectedProductId === item.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedProductId(item.id)}
|
||||
>
|
||||
<div className="dinsar-catalog-list-item-top">
|
||||
<strong>{item.display_name || item.product_id}</strong>
|
||||
<StatusPill label={item.status || 'UNKNOWN'} tone={tone} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-list-item-badges">
|
||||
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
|
||||
<span>{formatDateTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div className="dinsar-catalog-list-item-meta">
|
||||
{(item.task_alias || item.task_name || '-')}{item.run_key ? ` / ${item.run_key}` : ''}
|
||||
</div>
|
||||
<div className="dinsar-catalog-list-item-meta">
|
||||
{item.pair_key || '-'}
|
||||
</div>
|
||||
{(item.selection_strategy || item.network_run_id || item.network_edge_id != null) && (
|
||||
<div className="dinsar-catalog-list-item-trace">
|
||||
{(item.selection_strategy || 'trace')}
|
||||
{item.network_edge_id != null ? ` / edge ${item.network_edge_id}` : ''}
|
||||
{item.network_run_id ? ` / ${item.network_run_id}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="dinsar-catalog-detail-card">
|
||||
<div className="dinsar-catalog-card-head">
|
||||
<div>
|
||||
<strong>结果包详情</strong>
|
||||
<span>查看选中结果的发布信息、配对溯源与资产健康</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedProductId ? (
|
||||
<div className="dinsar-catalog-empty">请选择一个结果包查看详情。</div>
|
||||
) : detailLoading || !selectedProduct ? (
|
||||
<div className="dinsar-catalog-empty">正在加载详情...</div>
|
||||
) : selectedProduct?.error ? (
|
||||
<div className="dinsar-catalog-empty error">{selectedProduct.error}</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-detail-body">
|
||||
<div className="dinsar-catalog-hero">
|
||||
<div className="dinsar-catalog-preview-frame">
|
||||
<img
|
||||
src={`${previewBaseUrl}/dinsar-products/${selectedProduct.id}/preview`}
|
||||
alt={selectedProduct.display_name || selectedProduct.product_id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-hero-meta">
|
||||
<div className="dinsar-catalog-hero-title-row">
|
||||
<div>
|
||||
<h4>{selectedProduct.display_name || selectedProduct.product_id}</h4>
|
||||
<p>{selectedProduct.task_alias || selectedProduct.task_name || '未命名任务'}</p>
|
||||
</div>
|
||||
<div className="dinsar-catalog-hero-badges">
|
||||
<span className={`dinsar-engine-badge tone-${selectedProductEngine.tone}`}>
|
||||
{selectedProductEngine.shortLabel}
|
||||
</span>
|
||||
<StatusPill label={selectedProduct.status || 'UNKNOWN'} tone={selectedStatusTone} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-kv-grid">
|
||||
<MetaField label="产品编号" value={selectedProduct.product_id} multiline />
|
||||
<MetaField label="配对标识" value={selectedProduct.pair_key} multiline />
|
||||
<MetaField label="场景配对 UID" value={selectedProduct.pair_uid} multiline />
|
||||
<MetaField label="运行标识" value={selectedProduct.run_key} multiline />
|
||||
<MetaField label="生产配置" value={selectedProduct.profile_code} />
|
||||
<MetaField label="健康状态" value={selectedProduct.health_status} />
|
||||
<MetaField label="主文件" value={selectedProduct.primary_asset_path} multiline />
|
||||
<MetaField label="源文件" value={selectedProduct.source_primary_path} multiline />
|
||||
<MetaField label="结果包目录" value={selectedProduct.publish_dir} multiline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-detail-grid">
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">时空概览</div>
|
||||
<MetaField label="主影像日期" value={selectedProduct.profile?.master_imaging_date} />
|
||||
<MetaField label="从影像日期" value={selectedProduct.profile?.slave_imaging_date} />
|
||||
<MetaField label="时间基线" value={selectedProduct.profile?.time_baseline_days} />
|
||||
<MetaField label="空间基线" value={selectedProduct.profile?.spatial_baseline_meters} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">空间范围</div>
|
||||
<MetaField label="最小坐标" value={`${selectedProduct.min_lon ?? '-'}, ${selectedProduct.min_lat ?? '-'}`} />
|
||||
<MetaField label="最大坐标" value={`${selectedProduct.max_lon ?? '-'}, ${selectedProduct.max_lat ?? '-'}`} />
|
||||
<MetaField label="登记时间" value={formatDateTime(selectedProduct.registered_at)} />
|
||||
<MetaField label="发布时间" value={formatDateTime(selectedProduct.published_at)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">配对追踪</div>
|
||||
{!selectedPairingTrace?.network_run_id ? (
|
||||
<div className="dinsar-catalog-empty inline">当前结果未携带完整的配对网络追踪信息。</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-detail-grid">
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="network_run_id" value={selectedPairingTrace.network_run_id} multiline />
|
||||
<MetaField label="network_edge_id" value={selectedPairingTrace.network_edge_id} />
|
||||
<MetaField label="pair_uid" value={selectedPairingTrace.pair_uid} multiline />
|
||||
<MetaField label="选择策略" value={selectedPairingTrace.selection_strategy} />
|
||||
<MetaField label="策略版本" value={selectedPairingTrace.policy_version} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="网络记录" value={selectedPairingNetwork?.run_found ? '已找到' : '未找到'} />
|
||||
<MetaField label="边记录" value={selectedPairingNetwork?.edge_found ? '已找到' : '未找到'} />
|
||||
<MetaField label="运行状态" value={selectedPairingRun?.status} />
|
||||
<MetaField label="候选边数" value={selectedPairingRun?.candidate_count} />
|
||||
<MetaField label="入选边数" value={selectedPairingRun?.selected_edge_count} />
|
||||
<MetaField label="告警数" value={selectedPairingRun?.warning_count} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="edge_rank" value={selectedPairingEdge?.edge_rank} />
|
||||
<MetaField label="selection_reason" value={selectedPairingEdge?.selection_reason} multiline />
|
||||
<MetaField label="selection_score" value={selectedPairingEdge?.selection_score} />
|
||||
<MetaField label="reference_edge" value={selectedPairingEdge?.is_reference_edge ? '是' : '否'} />
|
||||
<MetaField label="metric_cache_ref_id" value={selectedPairingEdge?.metric_cache_ref_id} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="主从日期" value={`${selectedPairingMetric?.master_imaging_date || '-'} / ${selectedPairingMetric?.slave_imaging_date || '-'}`} />
|
||||
<MetaField label="主从卫星" value={`${selectedPairingMetric?.master_satellite || '-'} / ${selectedPairingMetric?.slave_satellite || '-'}`} />
|
||||
<MetaField label="主从模式" value={`${selectedPairingMetric?.master_imaging_mode || '-'} / ${selectedPairingMetric?.slave_imaging_mode || '-'}`} />
|
||||
<MetaField label="主从极化" value={`${selectedPairingMetric?.master_polarization || '-'} / ${selectedPairingMetric?.slave_polarization || '-'}`} />
|
||||
<MetaField label="时间基线" value={selectedPairingMetric?.time_baseline_days} />
|
||||
<MetaField label="空间基线" value={selectedPairingMetric?.spatial_baseline_meters} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-detail-grid">
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">资产列表 ({selectedAssets.length})</div>
|
||||
{selectedAssets.length === 0 ? (
|
||||
<div className="dinsar-catalog-empty inline">暂无资产记录。</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-asset-list">
|
||||
{selectedAssets.map((asset) => (
|
||||
<div key={asset.id} className={`dinsar-catalog-asset-item ${asset.exists_flag ? 'ok' : 'missing'}`}>
|
||||
<div className="dinsar-catalog-asset-top">
|
||||
<strong>{asset.asset_role}</strong>
|
||||
<span>{asset.exists_flag ? '文件存在' : '文件缺失'}</span>
|
||||
</div>
|
||||
<div>{asset.asset_name}</div>
|
||||
<div className="break-all">{asset.absolute_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">问题列表 ({selectedIssues.length})</div>
|
||||
{selectedIssues.length === 0 ? (
|
||||
<div className="dinsar-catalog-empty inline ok">当前没有登记问题。</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-issue-list">
|
||||
{selectedIssues.map((issue) => (
|
||||
<div key={issue.id} className={`dinsar-catalog-issue-item ${String(issue.severity || '').toUpperCase() === 'ERROR' ? 'error' : 'warn'}`}>
|
||||
<div className="dinsar-catalog-issue-top">
|
||||
<strong>{issue.issue_code}</strong>
|
||||
<span>{issue.severity}</span>
|
||||
</div>
|
||||
<div>{issue.message}</div>
|
||||
{issue.repair_action && (
|
||||
<div className="dinsar-catalog-issue-action">
|
||||
建议修复动作:{issue.repair_action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function PsStackModal({
|
||||
return (
|
||||
<div className="modal-overlay visible">
|
||||
<div className="modal-content">
|
||||
<h3>准备PS时序数据栈</h3>
|
||||
<h3>准备时序InSAR候选栈</h3>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<label>研究区域来源:</label>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function PsinsarCatalogPanel({
|
||||
return nextItems[0]?.id ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
setActionMessage(`PS-InSAR 结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionMessage(`时序InSAR产物目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
setCatalogStatus(null);
|
||||
setProducts([]);
|
||||
setSelectedProductId(null);
|
||||
@@ -146,11 +146,11 @@ export default function PsinsarCatalogPanel({
|
||||
publish_root: publishRoot.trim() || null,
|
||||
full_rebuild: true,
|
||||
});
|
||||
setActionMessage(`PS-InSAR 结果目录重建任务已入队:${result.task_id}`);
|
||||
setActionMessage(`时序InSAR产物目录重建任务已入队:${result.task_id}`);
|
||||
onTaskQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setActionMessage(`PS-InSAR 结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionMessage(`时序InSAR产物目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -164,9 +164,9 @@ export default function PsinsarCatalogPanel({
|
||||
<div style={panelCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 14 }}>PS-InSAR 结果目录</strong>
|
||||
<strong style={{ fontSize: 14 }}>时序InSAR 产物目录</strong>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
|
||||
结果目录以 `psinsar.publish.v1` bundle 为事实源,数据库仅保存索引与展示信息。
|
||||
当前默认登记 SBAS 流程产物。结果目录以 `psinsar.publish.v1` bundle 为事实源,数据库仅保存索引与展示信息,后续可继续兼容 PS-InSAR / SBAS-InSAR。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -224,7 +224,7 @@ export default function PsinsarCatalogPanel({
|
||||
<input
|
||||
value={publishRoot}
|
||||
onChange={event => setPublishRoot(event.target.value)}
|
||||
placeholder="可选:自定义 PS-InSAR 发布根目录,留空使用系统默认目录"
|
||||
placeholder="可选:自定义时序InSAR发布根目录,留空使用系统默认目录"
|
||||
disabled={readOnly || actionLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -249,7 +249,7 @@ export default function PsinsarCatalogPanel({
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{actionLoading ? '处理中...' : '重建 PS-InSAR 结果目录'}
|
||||
{actionLoading ? '处理中...' : '重建时序InSAR产物目录'}
|
||||
</button>
|
||||
{actionMessage && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
|
||||
@@ -259,14 +259,14 @@ export default function PsinsarCatalogPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(260px, 360px) 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 12 }}>
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
|
||||
产品列表 ({products.length})
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>
|
||||
{loading ? '正在加载结果...' : '当前没有已登记的 PS-InSAR 产品。'}
|
||||
{loading ? '正在加载结果...' : '当前没有已登记的时序InSAR产物。'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 420, overflowY: 'auto' }}>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LEFT_GROUP_TABS,
|
||||
LEFT_TAB_GROUP,
|
||||
LEFT_TAB_SECTION,
|
||||
PRODUCTION_WORKSPACE_ROUTE_TABS,
|
||||
} from '../../config/appConstants';
|
||||
import { getLeftTabLabel } from '../../utils/appUiHelpers';
|
||||
import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
|
||||
@@ -15,27 +16,26 @@ import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
|
||||
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
|
||||
const LazyDataCopierPanel = lazy(() => import('../../DataCopierPanel'));
|
||||
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
|
||||
const LazyDinsarProductionPanel = lazy(() => import('../../DinsarProductionPanel'));
|
||||
const LazyDinsarProductsPanel = lazy(() => import('../../DinsarProductsPanel'));
|
||||
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
|
||||
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
|
||||
const LazyTimeseriesProductionPanel = lazy(() => import('../../TimeseriesProductionPanel'));
|
||||
const LazyWaterMonitorPanel = lazy(() => import('../../WaterMonitorPanel'));
|
||||
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
|
||||
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
|
||||
const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel'));
|
||||
const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel'));
|
||||
const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel'));
|
||||
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'));
|
||||
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel.rewrite'));
|
||||
const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel'));
|
||||
const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel'));
|
||||
const LazyPsResultsPanel = lazy(() => import('../../panels/PsResultsPanel'));
|
||||
const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel'));
|
||||
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
|
||||
|
||||
export default function AppSidePanel({
|
||||
leftPanelWidth,
|
||||
leftPanelTab,
|
||||
setLeftPanelTab,
|
||||
isStandalone,
|
||||
isAdmin,
|
||||
isReadOnlyUser,
|
||||
currentUser,
|
||||
@@ -62,7 +62,13 @@ export default function AppSidePanel({
|
||||
pairsPanel,
|
||||
psPanel,
|
||||
}) {
|
||||
const isProductionWorkspace = PRODUCTION_WORKSPACE_ROUTE_TABS.has(leftPanelTab);
|
||||
const activeLeftGroup = LEFT_TAB_GROUP[leftPanelTab] || 'data';
|
||||
const leftTabLabelContext = {
|
||||
pairCount: foundPairs.length,
|
||||
psResultCount: psResults ? Object.keys(psResults).length : 0,
|
||||
dinsarTotal,
|
||||
};
|
||||
const getVisibleTabs = (tabs = []) => tabs.filter((tab) => isAdmin || !ADMIN_ONLY_TABS.has(tab));
|
||||
const getVisibleSections = (groupKey) => (
|
||||
(LEFT_GROUP_SECTIONS[groupKey] || [])
|
||||
@@ -79,6 +85,7 @@ export default function AppSidePanel({
|
||||
}
|
||||
return getVisibleTabs(LEFT_GROUP_TABS[groupKey] || [])[0] || '';
|
||||
};
|
||||
const mainWorkspaceTab = getDefaultGroupTab('data') || 'data';
|
||||
const activeGroupSections = getVisibleSections(activeLeftGroup);
|
||||
const hasSectionNav = activeGroupSections.length > 0;
|
||||
const preferredActiveSection = LEFT_TAB_SECTION[leftPanelTab];
|
||||
@@ -88,59 +95,105 @@ export default function AppSidePanel({
|
||||
const activeLeafTabs = hasSectionNav
|
||||
? (activeGroupSections.find((section) => section.key === activeLeftSection)?.tabs || [])
|
||||
: getVisibleTabs(LEFT_GROUP_TABS[activeLeftGroup] || []);
|
||||
const psResultCount = psResults ? Object.keys(psResults).length : 0;
|
||||
const standaloneSectionTabs = isProductionWorkspace
|
||||
? []
|
||||
: (
|
||||
hasSectionNav
|
||||
? (activeGroupSections.find((section) => section.key === activeLeftSection)?.tabs || activeLeafTabs)
|
||||
: activeLeafTabs
|
||||
);
|
||||
const standaloneEyebrow = [LEFT_GROUP_LABELS[activeLeftGroup], activeGroupSections.find((section) => section.key === activeLeftSection)?.label]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
const standaloneTitle = isProductionWorkspace
|
||||
? '生产管理'
|
||||
: getLeftTabLabel(leftPanelTab, leftTabLabelContext);
|
||||
const standaloneDescription = isProductionWorkspace
|
||||
? '这里统一承载 D-InSAR 与时序InSAR的运行和产物页面,当前时序流程默认接入 SBAS,后续可继续扩展 PS-InSAR / SBAS-InSAR。'
|
||||
: '当前模块已切换为独立工作区模式。';
|
||||
|
||||
return (
|
||||
<aside className="panel data-panel" style={{ display: 'flex', flexDirection: 'column', width: leftPanelWidth }}>
|
||||
<div className="panel-tabs">
|
||||
<div className="tabs-header group-tabs">
|
||||
{Object.entries(LEFT_GROUP_LABELS)
|
||||
.filter(([groupKey]) => {
|
||||
if (isAdmin) return true;
|
||||
return !!getDefaultGroupTab(groupKey);
|
||||
})
|
||||
.map(([groupKey, label]) => (
|
||||
<button
|
||||
key={groupKey}
|
||||
className={activeLeftGroup === groupKey ? 'active-tab' : ''}
|
||||
onClick={() => {
|
||||
const nextTab = getDefaultGroupTab(groupKey);
|
||||
if (nextTab) setLeftPanelTab(nextTab);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<aside
|
||||
className={`panel data-panel${isStandalone ? ' panel--standalone' : ''}`}
|
||||
style={{ display: 'flex', flexDirection: 'column', width: leftPanelWidth }}
|
||||
>
|
||||
{isStandalone ? (
|
||||
<div className="panel-standalone-header">
|
||||
<div className="panel-standalone-header-main">
|
||||
<span className="panel-standalone-eyebrow">{standaloneEyebrow}</span>
|
||||
<strong>{standaloneTitle}</strong>
|
||||
<p>{standaloneDescription}</p>
|
||||
</div>
|
||||
<div className="panel-standalone-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="panel-standalone-return"
|
||||
onClick={() => setLeftPanelTab(mainWorkspaceTab)}
|
||||
>
|
||||
返回主界面
|
||||
</button>
|
||||
{standaloneSectionTabs.length > 1 && (
|
||||
<>
|
||||
{standaloneSectionTabs.map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
className={leftPanelTab === tabKey ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(tabKey)}
|
||||
>
|
||||
{getLeftTabLabel(tabKey, leftTabLabelContext)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasSectionNav && (
|
||||
<div className="tabs-header section-tabs">
|
||||
{activeGroupSections.map((section) => (
|
||||
) : (
|
||||
<div className="panel-tabs">
|
||||
<div className="tabs-header group-tabs">
|
||||
{Object.entries(LEFT_GROUP_LABELS)
|
||||
.filter(([groupKey]) => {
|
||||
if (isAdmin) return true;
|
||||
return !!getDefaultGroupTab(groupKey);
|
||||
})
|
||||
.map(([groupKey, label]) => (
|
||||
<button
|
||||
key={groupKey}
|
||||
className={activeLeftGroup === groupKey ? 'active-tab' : ''}
|
||||
onClick={() => {
|
||||
const nextTab = getDefaultGroupTab(groupKey);
|
||||
if (nextTab) setLeftPanelTab(nextTab);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hasSectionNav && (
|
||||
<div className="tabs-header section-tabs">
|
||||
{activeGroupSections.map((section) => (
|
||||
<button
|
||||
key={section.key}
|
||||
className={activeLeftSection === section.key ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(section.tabs[0])}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tabs-header left-tabs sub-tabs">
|
||||
{activeLeafTabs.map((tabKey) => (
|
||||
<button
|
||||
key={section.key}
|
||||
className={activeLeftSection === section.key ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(section.tabs[0])}
|
||||
key={tabKey}
|
||||
className={leftPanelTab === tabKey ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(tabKey)}
|
||||
>
|
||||
{section.label}
|
||||
{getLeftTabLabel(tabKey, leftTabLabelContext)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tabs-header left-tabs sub-tabs">
|
||||
{activeLeafTabs.map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
className={leftPanelTab === tabKey ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(tabKey)}
|
||||
>
|
||||
{getLeftTabLabel(tabKey, {
|
||||
pairCount: foundPairs.length,
|
||||
psResultCount,
|
||||
dinsarTotal,
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'data' && (
|
||||
<RadarDataPanel
|
||||
@@ -227,48 +280,14 @@ export default function AppSidePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'dinsar_production' && (
|
||||
{isProductionWorkspace && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 D-InSAR 生产面板..." />}>
|
||||
<LazyDinsarProductionPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
currentUser={currentUser}
|
||||
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'D-InSAR 任务已入队,等待处理...')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'dinsar_products' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 D-InSAR 产物面板..." />}>
|
||||
<LazyDinsarProductsPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'D-InSAR 产物任务已入队,等待处理...')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'ps_production' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 生产面板..." />}>
|
||||
<LazyTimeseriesProductionPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'SBAS 运行已入队,正在执行 prepare...')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'ps_products' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 目录面板..." />}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<LazyPsinsarCatalogPanel
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载生产管理工作台..." />}>
|
||||
<div style={{ minHeight: '100%' }}>
|
||||
<LazyProductionWorkspace
|
||||
activeEntry={leftPanelTab}
|
||||
readOnly={isReadOnlyUser}
|
||||
showActions
|
||||
onTaskQueued={(taskId) => taskPanel.onTaskStart(taskId, 'PS-InSAR 结果目录任务已入队,等待处理...')}
|
||||
onTaskStart={taskPanel.onTaskStart}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
@@ -380,7 +399,7 @@ export default function AppSidePanel({
|
||||
|
||||
{leftPanelTab === 'psinsar_results' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 结果目录..." />}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载时序InSAR结果目录..." />}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<LazyPsinsarCatalogPanel
|
||||
readOnly
|
||||
@@ -395,7 +414,7 @@ export default function AppSidePanel({
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div className="empty-state">
|
||||
PS-InSAR 分析页已预留。
|
||||
时序InSAR 分析页已预留。
|
||||
<br />
|
||||
后续可以在这里放置时序分析、速率分级、热点识别和专题统计能力。
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { memo } from 'react';
|
||||
import { parseDatesFromName, formatYmd } from '../../utils/appUiHelpers';
|
||||
import { getDinsarEngineMeta } from '../../utils/dinsarEngines';
|
||||
|
||||
function truncateMiddle(value, maxLength = 28) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text || '-';
|
||||
}
|
||||
const sideLength = Math.max(6, Math.floor((maxLength - 3) / 2));
|
||||
return `${text.slice(0, sideLength)}...${text.slice(-sideLength)}`;
|
||||
}
|
||||
|
||||
function DinsarResultRow({
|
||||
result,
|
||||
language,
|
||||
showDates,
|
||||
isLoading,
|
||||
isReadOnlyUser,
|
||||
onLabel,
|
||||
onAnalyze,
|
||||
onToggleVisibility,
|
||||
}) {
|
||||
const dates = showDates ? parseDatesFromName(result.name, (value) => formatYmd(value, language)) : null;
|
||||
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
||||
const hasTrace = !!(
|
||||
result.selection_strategy ||
|
||||
result.network_run_id ||
|
||||
result.network_edge_id ||
|
||||
result.pair_uid ||
|
||||
result.pair_key ||
|
||||
result.run_key
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="data-item dinsar-item">
|
||||
<div className="dinsar-row-header">
|
||||
<span className="data-item-name" title={result.name}>
|
||||
{result.name}
|
||||
</span>
|
||||
<div className="dinsar-row-badges">
|
||||
<span
|
||||
className={`dinsar-engine-badge tone-${engineMeta.tone}`}
|
||||
title={`${language === 'en' ? 'Engine' : '生产引擎'}: ${engineMeta.label}`}
|
||||
>
|
||||
{engineMeta.shortLabel}
|
||||
</span>
|
||||
{result.ai_score !== null && (
|
||||
<span
|
||||
className={`ai-score ${result.ai_score > 0.7 ? 'good' : (result.ai_score < 0.4 ? 'bad' : 'medium')}`}
|
||||
title={language === 'en' ? 'AI quality score' : 'AI 质量评分'}
|
||||
>
|
||||
AI {Math.round(result.ai_score * 100)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasTrace && (
|
||||
<div className="dinsar-trace-info">
|
||||
<div className="dinsar-trace-line">
|
||||
<span
|
||||
className="dinsar-trace-pill"
|
||||
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
|
||||
>
|
||||
{result.selection_strategy || 'legacy'}
|
||||
</span>
|
||||
{result.run_key && (
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={`${language === 'en' ? 'Run key' : '运行标识'}: ${result.run_key}`}
|
||||
>
|
||||
<strong>{language === 'en' ? 'run' : '运行'}</strong>
|
||||
<span>{truncateMiddle(result.run_key, 24)}</span>
|
||||
</span>
|
||||
)}
|
||||
{result.network_edge_id != null && (
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={language === 'en' ? 'Network edge id' : '网络边编号'}
|
||||
>
|
||||
<strong>edge</strong>
|
||||
<span>{result.network_edge_id}</span>
|
||||
</span>
|
||||
)}
|
||||
{result.network_run_id && (
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={`${language === 'en' ? 'Network run id' : '网络运行编号'}: ${result.network_run_id}`}
|
||||
>
|
||||
<strong>{language === 'en' ? 'network' : '网络'}</strong>
|
||||
<span>{truncateMiddle(result.network_run_id, 22)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dinsar-trace-line">
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={result.pair_uid || result.pair_key || '-'}
|
||||
>
|
||||
<strong>{language === 'en' ? 'pair' : '配对'}</strong>
|
||||
<span>{truncateMiddle(result.pair_uid || result.pair_key, 36)}</span>
|
||||
</span>
|
||||
{result.policy_version && (
|
||||
<span className="dinsar-trace-stat">
|
||||
<strong>{language === 'en' ? 'policy' : '策略版本'}</strong>
|
||||
<span>{result.policy_version}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dates && (
|
||||
<div className="date-info">
|
||||
<span className="date-tag master" title={language === 'en' ? 'Master date' : '主影像日期'}>
|
||||
{dates.master}
|
||||
</span>
|
||||
<span className="date-arrow">-></span>
|
||||
<span className="date-tag slave" title={language === 'en' ? 'Slave date' : '从影像日期'}>
|
||||
{dates.slave}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="data-item-controls">
|
||||
<div className="label-buttons">
|
||||
<button
|
||||
className={`label-btn good ${result.user_label === 1 ? 'active' : ''}`}
|
||||
onClick={() => onLabel(result.id, result.user_label === 1 ? null : 1)}
|
||||
title={language === 'en' ? 'Mark as high quality' : '标记为高质量'}
|
||||
disabled={isReadOnlyUser}
|
||||
>
|
||||
{language === 'en' ? 'Good' : '良好'}
|
||||
</button>
|
||||
<button
|
||||
className={`label-btn bad ${result.user_label === 0 ? 'active' : ''}`}
|
||||
onClick={() => onLabel(result.id, result.user_label === 0 ? null : 0)}
|
||||
title={language === 'en' ? 'Mark as low quality' : '标记为低质量'}
|
||||
disabled={isReadOnlyUser}
|
||||
>
|
||||
{language === 'en' ? 'Poor' : '较差'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-control-cluster">
|
||||
<button
|
||||
className="ai-analyze-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAnalyze(result.id);
|
||||
}}
|
||||
disabled={isLoading || isReadOnlyUser}
|
||||
title={language === 'en' ? 'Use AI to analyze this result' : '使用 AI 分析该结果'}
|
||||
>
|
||||
{language === 'en' ? 'AI Diagnose' : 'AI 诊断'}
|
||||
</button>
|
||||
<label className="dinsar-toggle-label" title={language === 'en' ? 'Show or hide on map' : '在地图上显示或隐藏'}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!result.isVisible}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleVisibility(result.id);
|
||||
}}
|
||||
/>
|
||||
<span>{language === 'en' ? 'Map' : '地图'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DinsarResultRow);
|
||||
@@ -51,9 +51,54 @@ export const NATIONAL_BOUNDARY_GEOJSON_URL = buildTileServerUrl('/geojson/全国
|
||||
|
||||
export const getBaseLayerConfig = key => BASE_LAYERS[key] || BASE_LAYERS[TILE_LAYER_DEFAULT_KEY];
|
||||
|
||||
export const PRODUCTION_WORKSPACE_TAB = 'production_management';
|
||||
export const PRODUCTION_WORKSPACE_LEGACY_TABS = [
|
||||
'dinsar_production',
|
||||
'dinsar_products',
|
||||
'ps_production',
|
||||
'ps_products',
|
||||
];
|
||||
|
||||
export const PRODUCTION_WORKSPACE_VIEWS = [
|
||||
{
|
||||
key: 'dinsar_runs',
|
||||
label: 'D-InSAR 运行',
|
||||
description: '运行任务编排、引擎切换与过程监控',
|
||||
},
|
||||
{
|
||||
key: 'timeseries_runs',
|
||||
label: '时序InSAR 运行',
|
||||
description: '当前默认接入 SBAS 流程,后续可扩展 PS-InSAR / SBAS-InSAR',
|
||||
},
|
||||
{
|
||||
key: 'dinsar_products',
|
||||
label: 'D-InSAR 产物',
|
||||
description: '结果提取、标准目录发布与产物编目',
|
||||
},
|
||||
{
|
||||
key: 'timeseries_products',
|
||||
label: '时序InSAR 产物',
|
||||
description: '当前统一登记时序产物,后续兼容多类型时序成果',
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({
|
||||
[PRODUCTION_WORKSPACE_TAB]: 'dinsar_runs',
|
||||
dinsar_production: 'dinsar_runs',
|
||||
dinsar_products: 'dinsar_products',
|
||||
ps_production: 'timeseries_runs',
|
||||
ps_products: 'timeseries_products',
|
||||
});
|
||||
|
||||
export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
|
||||
PRODUCTION_WORKSPACE_TAB,
|
||||
...PRODUCTION_WORKSPACE_LEGACY_TABS,
|
||||
]);
|
||||
|
||||
export const LEFT_GROUP_LABELS = {
|
||||
data: '数据管理',
|
||||
production: '生产规划',
|
||||
production_planning: '生产规划',
|
||||
production_management: '生产管理',
|
||||
insar_analysis: 'InSAR形变分析',
|
||||
ai_analysis: 'AI分析',
|
||||
water: '水体监测',
|
||||
@@ -61,7 +106,7 @@ export const LEFT_GROUP_LABELS = {
|
||||
};
|
||||
|
||||
export const LEFT_GROUP_SECTIONS = {
|
||||
production: [
|
||||
production_planning: [
|
||||
{
|
||||
key: 'planning',
|
||||
label: '规划编组',
|
||||
@@ -72,16 +117,6 @@ export const LEFT_GROUP_SECTIONS = {
|
||||
label: '数据分发',
|
||||
tabs: ['copier'],
|
||||
},
|
||||
{
|
||||
key: 'dinsar',
|
||||
label: 'D-InSAR',
|
||||
tabs: ['dinsar_production', 'dinsar_products'],
|
||||
},
|
||||
{
|
||||
key: 'psinsar',
|
||||
label: 'PS-InSAR',
|
||||
tabs: ['ps_production', 'ps_products'],
|
||||
},
|
||||
],
|
||||
insar_analysis: [
|
||||
{
|
||||
@@ -91,7 +126,7 @@ export const LEFT_GROUP_SECTIONS = {
|
||||
},
|
||||
{
|
||||
key: 'psinsar',
|
||||
label: 'PS-InSAR',
|
||||
label: '时序InSAR',
|
||||
tabs: ['psinsar_results', 'psinsar_analysis'],
|
||||
},
|
||||
],
|
||||
@@ -111,7 +146,8 @@ export const LEFT_GROUP_SECTIONS = {
|
||||
|
||||
export const LEFT_GROUP_TABS = {
|
||||
data: ['ingest', 'data', 'hazard'],
|
||||
production: LEFT_GROUP_SECTIONS.production.flatMap(section => section.tabs),
|
||||
production_planning: LEFT_GROUP_SECTIONS.production_planning.flatMap(section => section.tabs),
|
||||
production_management: [PRODUCTION_WORKSPACE_TAB],
|
||||
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
|
||||
ai_analysis: LEFT_GROUP_SECTIONS.ai_analysis.flatMap(section => section.tabs),
|
||||
water: ['water'],
|
||||
@@ -125,6 +161,10 @@ export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [grou
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
PRODUCTION_WORKSPACE_LEGACY_TABS.forEach(tab => {
|
||||
LEFT_TAB_GROUP[tab] = 'production_management';
|
||||
});
|
||||
|
||||
export const LEFT_TAB_SECTION = Object.entries(LEFT_GROUP_SECTIONS).reduce((acc, [, sections]) => {
|
||||
sections.forEach(section => {
|
||||
section.tabs.forEach(tab => {
|
||||
@@ -134,6 +174,10 @@ export const LEFT_TAB_SECTION = Object.entries(LEFT_GROUP_SECTIONS).reduce((acc,
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const FULL_WIDTH_LEFT_TABS = new Set([
|
||||
...PRODUCTION_WORKSPACE_ROUTE_TABS,
|
||||
]);
|
||||
|
||||
export const ADMIN_ONLY_TABS = new Set([
|
||||
'ingest',
|
||||
'pairing',
|
||||
@@ -141,10 +185,8 @@ export const ADMIN_ONLY_TABS = new Set([
|
||||
'ps_results',
|
||||
'batches',
|
||||
'copier',
|
||||
'dinsar_production',
|
||||
'dinsar_products',
|
||||
'ps_production',
|
||||
'ps_products',
|
||||
PRODUCTION_WORKSPACE_TAB,
|
||||
...PRODUCTION_WORKSPACE_LEGACY_TABS,
|
||||
'water',
|
||||
'users',
|
||||
'audit',
|
||||
|
||||
@@ -11,6 +11,8 @@ import { normalizePagePayload } from '../utils/appHelpers';
|
||||
import { normalizeTaskStatus } from '../utils/appUiHelpers';
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '../config/appConstants';
|
||||
|
||||
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES']);
|
||||
|
||||
export default function useDinsarOperations({
|
||||
onCleanupDinsarLayers,
|
||||
fetchRadarImagingDates,
|
||||
@@ -24,7 +26,7 @@ export default function useDinsarOperations({
|
||||
setAiStatus, setActiveAiReport,
|
||||
} = useDinsarStore();
|
||||
const { setHazardPoints } = useHazardStore();
|
||||
const { setPendingTaskIds, setIsGlobalLocked } = useTaskStore();
|
||||
const { setPendingTaskIds, setNonBlockingTaskIds, setIsGlobalLocked } = useTaskStore();
|
||||
const { currentUser } = useAuthStore();
|
||||
const {
|
||||
hasRadarSearched, radarPagination,
|
||||
@@ -120,17 +122,49 @@ export default function useDinsarOperations({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskStart = (taskId, message) => {
|
||||
const handleTaskStart = (taskId, message, options = {}) => {
|
||||
const taskType = String(options?.taskType || '').trim().toUpperCase();
|
||||
const isNonBlocking = !!options?.nonBlocking || NON_BLOCKING_TASK_TYPES.has(taskType);
|
||||
if (taskId) {
|
||||
setPendingTaskIds(prev => [...prev, taskId]);
|
||||
if (isNonBlocking) {
|
||||
setNonBlockingTaskIds(prev => [...new Set([...prev, taskId])]);
|
||||
}
|
||||
}
|
||||
if (!isNonBlocking) {
|
||||
setIsGlobalLocked(true);
|
||||
}
|
||||
setIsGlobalLocked(true);
|
||||
if (message) addLog('info', message);
|
||||
};
|
||||
|
||||
const handleTaskCompletion = (taskInfo) => {
|
||||
console.log("收到任务完成通知:", taskInfo);
|
||||
const taskStatus = normalizeTaskStatus(taskInfo?.status);
|
||||
const syncRadarViewsAfterUnpack = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchRadarImagingDates(),
|
||||
fetchRadarSearchOptions(),
|
||||
]);
|
||||
if (hasRadarSearched) {
|
||||
const requestId = radarSearchRequestSeqRef.current + 1;
|
||||
radarSearchRequestSeqRef.current = requestId;
|
||||
await fetchAllData({
|
||||
limit: radarPagination.limit,
|
||||
offset: radarPagination.offset,
|
||||
criteria: radarSearchApplied,
|
||||
aoiMode: radarSearchAppliedAoiMode,
|
||||
regionTreeId: radarSearchAppliedRegionTreeId,
|
||||
aoiToken: radarSearchAoiToken,
|
||||
files: null,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解包完成后刷新 LT-1 视图失败:', error);
|
||||
addLog('warn', 'LT-1 解包已完成,但刷新数据视图时发生错误,请手动刷新。');
|
||||
}
|
||||
};
|
||||
|
||||
if (taskInfo.task_type === 'AI_ANALYZE') {
|
||||
if (taskInfo.message) {
|
||||
@@ -187,6 +221,14 @@ export default function useDinsarOperations({
|
||||
} else if (taskStatus === 'FAILED') {
|
||||
addLog('error', `灾害点同步失败: ${taskInfo.message || '未知错误'}`);
|
||||
}
|
||||
} else if (taskInfo.task_type === 'UNPACK_ARCHIVES') {
|
||||
if (taskStatus === 'COMPLETED') {
|
||||
addLog('success', taskInfo.message || 'LT-1 解包完成。');
|
||||
addLog('info', '正在同步 LT-1 解包后的数据视图...');
|
||||
void syncRadarViewsAfterUnpack();
|
||||
} else if (taskStatus === 'FAILED') {
|
||||
addLog('error', `LT-1 解包失败: ${taskInfo.message || '未知错误'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
import { normalizeTaskStatus } from '../utils/appUiHelpers';
|
||||
|
||||
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES']);
|
||||
|
||||
const isTaskNonBlocking = (taskId, taskType, nonBlockingTaskIds = []) => (
|
||||
NON_BLOCKING_TASK_TYPES.has(String(taskType || '').toUpperCase())
|
||||
|| nonBlockingTaskIds.includes(taskId)
|
||||
);
|
||||
|
||||
export default function useGlobalTaskControl({
|
||||
currentUser,
|
||||
licenseOk,
|
||||
@@ -9,6 +16,8 @@ export default function useGlobalTaskControl({
|
||||
setActiveTasks,
|
||||
pendingTaskIds,
|
||||
setPendingTaskIds,
|
||||
nonBlockingTaskIds,
|
||||
setNonBlockingTaskIds,
|
||||
isGlobalLocked,
|
||||
setIsGlobalLocked,
|
||||
setIsCheckingTasks,
|
||||
@@ -31,6 +40,8 @@ export default function useGlobalTaskControl({
|
||||
// Stable refs so SSE handler doesn't need to re-subscribe on every render
|
||||
const pendingTaskIdsRef = useRef(pendingTaskIds);
|
||||
useEffect(() => { pendingTaskIdsRef.current = pendingTaskIds; }, [pendingTaskIds]);
|
||||
const nonBlockingTaskIdsRef = useRef(nonBlockingTaskIds);
|
||||
useEffect(() => { nonBlockingTaskIdsRef.current = nonBlockingTaskIds; }, [nonBlockingTaskIds]);
|
||||
|
||||
const handleTasksUpdate = useCallback(async (tasks) => {
|
||||
setActiveTasks(tasks);
|
||||
@@ -41,14 +52,25 @@ export default function useGlobalTaskControl({
|
||||
|
||||
const currentPending = pendingTaskIdsRef.current;
|
||||
let updatedPending = currentPending;
|
||||
let effectiveNonBlockingTaskIds = Array.from(new Set([
|
||||
...nonBlockingTaskIdsRef.current,
|
||||
...tasks
|
||||
.filter((task) => NON_BLOCKING_TASK_TYPES.has(String(task.task_type || '').toUpperCase()))
|
||||
.map((task) => task.task_id),
|
||||
]));
|
||||
|
||||
// 如果 pendingTaskIds 为空,但 activeTasks 有任务,说明是刷新后的初始化
|
||||
// 需要将 activeTasks 中的任务添加到 pendingTaskIds
|
||||
if (currentPending.length === 0 && hasRunningTasks) {
|
||||
const activeTaskIds = tasks.map((t) => t.task_id);
|
||||
const nextNonBlockingIds = tasks
|
||||
.filter((task) => NON_BLOCKING_TASK_TYPES.has(String(task.task_type || '').toUpperCase()))
|
||||
.map((task) => task.task_id);
|
||||
console.log('初始化:将活跃任务添加到 pending 列表:', activeTaskIds);
|
||||
setPendingTaskIds(activeTaskIds);
|
||||
setNonBlockingTaskIds(nextNonBlockingIds);
|
||||
updatedPending = activeTaskIds;
|
||||
effectiveNonBlockingTaskIds = nextNonBlockingIds;
|
||||
}
|
||||
|
||||
if (currentPending.length > 0) {
|
||||
@@ -104,6 +126,8 @@ export default function useGlobalTaskControl({
|
||||
updatedPending = newPending;
|
||||
return newPending;
|
||||
});
|
||||
setNonBlockingTaskIds((prev) => prev.filter((id) => !reallyFinishedIds.includes(id)));
|
||||
effectiveNonBlockingTaskIds = effectiveNonBlockingTaskIds.filter((id) => !reallyFinishedIds.includes(id));
|
||||
} else {
|
||||
// 没有真正完成的任务,保持 updatedPending 不变
|
||||
updatedPending = currentPending;
|
||||
@@ -112,7 +136,13 @@ export default function useGlobalTaskControl({
|
||||
}
|
||||
|
||||
// 只有当没有运行中的任务且没有待处理的任务时,才解锁
|
||||
const shouldBeLocked = hasRunningTasks || updatedPending.length > 0;
|
||||
const blockingRunningTasks = tasks.filter((task) => (
|
||||
!isTaskNonBlocking(task.task_id, task.task_type, effectiveNonBlockingTaskIds)
|
||||
));
|
||||
const blockingPendingTaskIds = updatedPending.filter((taskId) => (
|
||||
!effectiveNonBlockingTaskIds.includes(taskId)
|
||||
));
|
||||
const shouldBeLocked = blockingRunningTasks.length > 0 || blockingPendingTaskIds.length > 0;
|
||||
|
||||
if (shouldBeLocked !== isGlobalLockedRef.current) {
|
||||
setIsGlobalLocked(shouldBeLocked);
|
||||
@@ -128,6 +158,7 @@ export default function useGlobalTaskControl({
|
||||
setIsCheckingTasks,
|
||||
handleTaskCompletionRef,
|
||||
setPendingTaskIds,
|
||||
setNonBlockingTaskIds,
|
||||
setIsGlobalLocked,
|
||||
addLog,
|
||||
initializeAppDataRef,
|
||||
|
||||
@@ -59,13 +59,13 @@ export default function usePairingLogic({
|
||||
name: `PS_${direction}_${new Date().toISOString().slice(0, 10)}`
|
||||
});
|
||||
const batchId = response.data?.batch_id || '';
|
||||
addLog('success', `已创建 PS 批次: ${batchId || direction}`);
|
||||
addLog('success', `已创建时序批次: ${batchId || direction}`);
|
||||
if (focusAfterCreate && batchId) {
|
||||
await focusBatchAfterCreate('ps', batchId);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
|
||||
addLog('error', `PS 批次创建失败: ${errorMessage}`);
|
||||
addLog('error', `时序批次创建失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function usePairingLogic({
|
||||
setPsResults(null);
|
||||
onClearAoiLayer();
|
||||
setAoiLayer(null);
|
||||
addLog('info', 'PS-InSAR 结果已清空。');
|
||||
addLog('info', '时序InSAR 候选栈结果已清空。');
|
||||
};
|
||||
|
||||
const findPairs = async (e, externalRequireOrbitRef) => {
|
||||
@@ -210,7 +210,7 @@ export default function usePairingLogic({
|
||||
|
||||
setShowPsModal(false);
|
||||
setIsLoading(true);
|
||||
addLog('info', '开始准备PS时序数据栈...');
|
||||
addLog('info', '开始准备时序InSAR候选栈...');
|
||||
|
||||
const formData = new FormData();
|
||||
for (const key in psParams) {
|
||||
@@ -258,7 +258,7 @@ export default function usePairingLogic({
|
||||
setPsResults(processedResults);
|
||||
|
||||
if (Object.keys(processedResults).length > 0) {
|
||||
addLog('success', `成功找到 ${Object.keys(processedResults).length} 个PS时序栈。`);
|
||||
addLog('success', `成功找到 ${Object.keys(processedResults).length} 个时序InSAR候选栈。`);
|
||||
setLeftPanelTab('ps_results');
|
||||
for (const [direction, stack] of Object.entries(processedResults)) {
|
||||
await createPsBatch(direction, stack, { focusAfterCreate: false });
|
||||
@@ -268,9 +268,9 @@ export default function usePairingLogic({
|
||||
setLeftPanelTab('ps_results');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("PS时序准备失败:", error);
|
||||
console.error('时序InSAR候选栈准备失败:', error);
|
||||
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
|
||||
addLog('error', `PS时序准备失败: ${errorMessage}`);
|
||||
addLog('error', `时序InSAR候选栈准备失败: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setPsFiles(null);
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
{ zh: '取消', en: 'Cancel' },
|
||||
|
||||
// --- App.jsx: PS modal ---
|
||||
{ zh: '准备PS时序数据栈', en: 'Prepare PS Time-series Stack' },
|
||||
{ zh: '准备时序InSAR候选栈', en: 'Prepare Time-series InSAR Stack' },
|
||||
{ zh: '研究区域来源:', en: 'Study Area Source:' },
|
||||
{ zh: '研究区域 (Shapefile):', en: 'Study Area (Shapefile):' },
|
||||
{ zh: '准备并导出', en: 'Prepare & Export' },
|
||||
|
||||
@@ -7,6 +7,11 @@ import DinsarResultRow from '../components/panels/DinsarResultRow';
|
||||
import ResultExportModal from '../components/ResultExportModal';
|
||||
import { PAGE_SIZE_OPTIONS } from '../config/appConstants';
|
||||
import { getPageHintText } from '../utils/appUiHelpers';
|
||||
import {
|
||||
DINSAR_ENGINE_ALL,
|
||||
buildDinsarEngineOptions,
|
||||
getDinsarEngineMeta,
|
||||
} from '../utils/dinsarEngines';
|
||||
import {
|
||||
DINSAR_STRATEGY_ALL,
|
||||
buildDinsarStrategyOptions,
|
||||
@@ -14,8 +19,8 @@ import {
|
||||
} from '../utils/dinsarResultFilters';
|
||||
|
||||
const DINSAR_ROW_HEIGHT = {
|
||||
compact: 110,
|
||||
expanded: 138,
|
||||
compact: 136,
|
||||
expanded: 166,
|
||||
};
|
||||
|
||||
export default function DinsarResultPanel({
|
||||
@@ -37,9 +42,11 @@ export default function DinsarResultPanel({
|
||||
dinsarResults,
|
||||
dinsarPagination,
|
||||
scoreFilter,
|
||||
engineFilter,
|
||||
traceSearch,
|
||||
strategyFilter,
|
||||
dinsarPageInput,
|
||||
setEngineFilter,
|
||||
setTraceSearch,
|
||||
setStrategyFilter,
|
||||
setDinsarPageInput,
|
||||
@@ -48,9 +55,11 @@ export default function DinsarResultPanel({
|
||||
dinsarResults: state.dinsarResults,
|
||||
dinsarPagination: state.dinsarPagination,
|
||||
scoreFilter: state.scoreFilter,
|
||||
engineFilter: state.engineFilter,
|
||||
traceSearch: state.traceSearch,
|
||||
strategyFilter: state.strategyFilter,
|
||||
dinsarPageInput: state.dinsarPageInput,
|
||||
setEngineFilter: state.setEngineFilter,
|
||||
setTraceSearch: state.setTraceSearch,
|
||||
setStrategyFilter: state.setStrategyFilter,
|
||||
setDinsarPageInput: state.setDinsarPageInput,
|
||||
@@ -73,6 +82,38 @@ export default function DinsarResultPanel({
|
||||
() => buildDinsarStrategyOptions(dinsarResults),
|
||||
[dinsarResults]
|
||||
);
|
||||
const engineOptions = useMemo(
|
||||
() => buildDinsarEngineOptions(dinsarResults),
|
||||
[dinsarResults]
|
||||
);
|
||||
const engineFilterOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: DINSAR_ENGINE_ALL,
|
||||
label: language === 'en' ? 'All engines' : '全部引擎',
|
||||
},
|
||||
...engineOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
],
|
||||
[engineOptions, language]
|
||||
);
|
||||
const filteredEngineMeta = useMemo(
|
||||
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
||||
[engineFilter]
|
||||
);
|
||||
const engineCounts = useMemo(() => {
|
||||
const counts = new Map();
|
||||
dinsarResults.forEach((result) => {
|
||||
const meta = getDinsarEngineMeta(result?.engine_code);
|
||||
counts.set(meta.code, (counts.get(meta.code) || 0) + 1);
|
||||
});
|
||||
return engineOptions.map((option) => ({
|
||||
...option,
|
||||
count: counts.get(option.code) || 0,
|
||||
}));
|
||||
}, [dinsarResults, engineOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (strategyFilter === DINSAR_STRATEGY_ALL) {
|
||||
@@ -83,17 +124,31 @@ export default function DinsarResultPanel({
|
||||
}
|
||||
}, [setStrategyFilter, strategyFilter, strategyOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (engineFilter === DINSAR_ENGINE_ALL) {
|
||||
return;
|
||||
}
|
||||
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||
}
|
||||
}, [engineFilter, engineOptions, setEngineFilter]);
|
||||
|
||||
const filteredResults = useMemo(
|
||||
() => filterDinsarResults(dinsarResults, {
|
||||
scoreFilter,
|
||||
engineFilter,
|
||||
strategyFilter,
|
||||
traceSearch,
|
||||
focusedHazardPoint,
|
||||
}),
|
||||
[dinsarResults, focusedHazardPoint, scoreFilter, strategyFilter, traceSearch]
|
||||
[dinsarResults, engineFilter, focusedHazardPoint, scoreFilter, strategyFilter, traceSearch]
|
||||
);
|
||||
|
||||
const scorePercent = Math.round(Number(scoreFilter || 0) * 100);
|
||||
const virtualRowHeight = showDates ? DINSAR_ROW_HEIGHT.expanded : DINSAR_ROW_HEIGHT.compact;
|
||||
const pageSummaryText = language === 'en'
|
||||
? `Page ${dinsarCurrentPage}/${dinsarTotalPages} · ${dinsarPagination.total} total`
|
||||
: `第 ${dinsarCurrentPage}/${dinsarTotalPages} 页 · 共 ${dinsarPagination.total} 条`;
|
||||
|
||||
return (
|
||||
<div className="panel-content">
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useEffect, useMemo, useState } 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.rewrite';
|
||||
import ResultExportModal from '../components/ResultExportModal.rewrite';
|
||||
import { PAGE_SIZE_OPTIONS } from '../config/appConstants';
|
||||
import { getPageHintText } from '../utils/appUiHelpers';
|
||||
import {
|
||||
DINSAR_ENGINE_ALL,
|
||||
buildDinsarEngineOptions,
|
||||
getDinsarEngineMeta,
|
||||
} from '../utils/dinsarEngines';
|
||||
import {
|
||||
DINSAR_STRATEGY_ALL,
|
||||
buildDinsarStrategyOptions,
|
||||
filterDinsarResults,
|
||||
} from '../utils/dinsarResultFilters';
|
||||
|
||||
const DINSAR_ROW_HEIGHT = {
|
||||
compact: 136,
|
||||
expanded: 166,
|
||||
};
|
||||
|
||||
export default function DinsarResultPanel({
|
||||
dinsarCurrentPage,
|
||||
dinsarTotalPages,
|
||||
showDinsarPageInputError,
|
||||
dinsarPageInputValidationError,
|
||||
onSetAllVisibility,
|
||||
onScoreFilterChange,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onGoToPage,
|
||||
onToggleVisibility,
|
||||
onLabel,
|
||||
onAnalyze,
|
||||
}) {
|
||||
const { language } = useI18n();
|
||||
const {
|
||||
dinsarResults,
|
||||
dinsarPagination,
|
||||
scoreFilter,
|
||||
engineFilter,
|
||||
traceSearch,
|
||||
strategyFilter,
|
||||
dinsarPageInput,
|
||||
setEngineFilter,
|
||||
setTraceSearch,
|
||||
setStrategyFilter,
|
||||
setDinsarPageInput,
|
||||
setDinsarPageInputTouched,
|
||||
} = useDinsarStore(useShallow((state) => ({
|
||||
dinsarResults: state.dinsarResults,
|
||||
dinsarPagination: state.dinsarPagination,
|
||||
scoreFilter: state.scoreFilter,
|
||||
engineFilter: state.engineFilter,
|
||||
traceSearch: state.traceSearch,
|
||||
strategyFilter: state.strategyFilter,
|
||||
dinsarPageInput: state.dinsarPageInput,
|
||||
setEngineFilter: state.setEngineFilter,
|
||||
setTraceSearch: state.setTraceSearch,
|
||||
setStrategyFilter: state.setStrategyFilter,
|
||||
setDinsarPageInput: state.setDinsarPageInput,
|
||||
setDinsarPageInputTouched: state.setDinsarPageInputTouched,
|
||||
})));
|
||||
const { focusedHazardPoint, setFocusedHazardPoint } = useHazardStore(useShallow((state) => ({
|
||||
focusedHazardPoint: state.focusedHazardPoint,
|
||||
setFocusedHazardPoint: state.setFocusedHazardPoint,
|
||||
})));
|
||||
const { isLoading, showDates, setShowDates } = useUiStore(useShallow((state) => ({
|
||||
isLoading: state.isLoading,
|
||||
showDates: state.showDates,
|
||||
setShowDates: state.setShowDates,
|
||||
})));
|
||||
const { currentUser } = useAuthStore();
|
||||
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const strategyOptions = useMemo(
|
||||
() => buildDinsarStrategyOptions(dinsarResults),
|
||||
[dinsarResults]
|
||||
);
|
||||
const engineOptions = useMemo(
|
||||
() => buildDinsarEngineOptions(dinsarResults),
|
||||
[dinsarResults]
|
||||
);
|
||||
const engineFilterOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: DINSAR_ENGINE_ALL,
|
||||
label: language === 'en' ? 'All engines' : '全部引擎',
|
||||
},
|
||||
...engineOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
],
|
||||
[engineOptions, language]
|
||||
);
|
||||
const filteredEngineMeta = useMemo(
|
||||
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
||||
[engineFilter]
|
||||
);
|
||||
const engineCounts = useMemo(() => {
|
||||
const counts = new Map();
|
||||
dinsarResults.forEach((result) => {
|
||||
const meta = getDinsarEngineMeta(result?.engine_code);
|
||||
counts.set(meta.code, (counts.get(meta.code) || 0) + 1);
|
||||
});
|
||||
return engineOptions.map((option) => ({
|
||||
...option,
|
||||
count: counts.get(option.code) || 0,
|
||||
}));
|
||||
}, [dinsarResults, engineOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (strategyFilter === DINSAR_STRATEGY_ALL) {
|
||||
return;
|
||||
}
|
||||
if (!strategyOptions.includes(strategyFilter)) {
|
||||
setStrategyFilter(DINSAR_STRATEGY_ALL);
|
||||
}
|
||||
}, [setStrategyFilter, strategyFilter, strategyOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (engineFilter === DINSAR_ENGINE_ALL) {
|
||||
return;
|
||||
}
|
||||
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||
}
|
||||
}, [engineFilter, engineOptions, setEngineFilter]);
|
||||
|
||||
const filteredResults = useMemo(
|
||||
() => filterDinsarResults(dinsarResults, {
|
||||
scoreFilter,
|
||||
engineFilter,
|
||||
strategyFilter,
|
||||
traceSearch,
|
||||
focusedHazardPoint,
|
||||
}),
|
||||
[dinsarResults, engineFilter, focusedHazardPoint, scoreFilter, strategyFilter, traceSearch]
|
||||
);
|
||||
|
||||
const scorePercent = Math.round(Number(scoreFilter || 0) * 100);
|
||||
const virtualRowHeight = showDates ? DINSAR_ROW_HEIGHT.expanded : DINSAR_ROW_HEIGHT.compact;
|
||||
const pageSummaryText = language === 'en'
|
||||
? `Page ${dinsarCurrentPage}/${dinsarTotalPages} · ${dinsarPagination.total} total`
|
||||
: `第 ${dinsarCurrentPage}/${dinsarTotalPages} 页 · 共 ${dinsarPagination.total} 条`;
|
||||
|
||||
return (
|
||||
<div className="panel-content">
|
||||
{dinsarPagination.total === 0 ? (
|
||||
<p className="empty-state">
|
||||
{language === 'en' ? 'No D-InSAR results found.' : '未找到 D-InSAR 结果。'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="list-toolbar column-layout dinsar-results-toolbar">
|
||||
{focusedHazardPoint && (
|
||||
<div className="filter-banner">
|
||||
<span>
|
||||
{language === 'en' ? (
|
||||
<>
|
||||
Viewing results covering <strong>{focusedHazardPoint.hazard_name}</strong>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
当前仅显示覆盖隐患点 <strong>{focusedHazardPoint.hazard_name}</strong> 的结果
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button className="clear-filter-btn" onClick={() => setFocusedHazardPoint(null)}>
|
||||
{language === 'en' ? 'Clear Filter' : '清除筛选'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dinsar-toolbar-grid">
|
||||
<section className="dinsar-toolbar-panel">
|
||||
<span className="dinsar-toolbar-kicker">
|
||||
{language === 'en' ? 'Current page' : '当前页'}
|
||||
</span>
|
||||
<strong className="dinsar-toolbar-value">
|
||||
{filteredResults.length} / {dinsarResults.length}
|
||||
</strong>
|
||||
<p className="dinsar-toolbar-note">
|
||||
{language === 'en'
|
||||
? 'Results after local filtering on the current page'
|
||||
: '当前页本地筛选后的结果数量'}
|
||||
</p>
|
||||
<div className="dinsar-toolbar-chip-row">
|
||||
<span className="dinsar-toolbar-chip">
|
||||
{language === 'en' ? 'AI score' : 'AI 分数'}
|
||||
{' >= '}
|
||||
{scorePercent}
|
||||
</span>
|
||||
<span className="dinsar-toolbar-chip">
|
||||
{language === 'en' ? 'Dates' : '日期'}
|
||||
{showDates
|
||||
? (language === 'en' ? ': visible' : ':已展开')
|
||||
: (language === 'en' ? ': hidden' : ':已收起')}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dinsar-toolbar-panel">
|
||||
<span className="dinsar-toolbar-kicker">
|
||||
{language === 'en' ? 'Engine focus' : '当前引擎'}
|
||||
</span>
|
||||
<strong className="dinsar-toolbar-value">
|
||||
{filteredEngineMeta
|
||||
? filteredEngineMeta.shortLabel
|
||||
: (language === 'en' ? 'All' : '全部')}
|
||||
</strong>
|
||||
<p className="dinsar-toolbar-note">
|
||||
{filteredEngineMeta
|
||||
? filteredEngineMeta.label
|
||||
: (language === 'en'
|
||||
? 'Compare outputs from all registered engines'
|
||||
: '同时查看所有登记引擎的结果')}
|
||||
</p>
|
||||
<div className="dinsar-toolbar-chip-row">
|
||||
<span className="dinsar-toolbar-chip">
|
||||
{language === 'en' ? 'Strategy' : '策略'}:
|
||||
{' '}
|
||||
{strategyFilter === DINSAR_STRATEGY_ALL
|
||||
? (language === 'en' ? 'All' : '全部')
|
||||
: strategyFilter}
|
||||
</span>
|
||||
<span className="dinsar-toolbar-chip">
|
||||
{language === 'en' ? 'Trace search' : '检索词'}:
|
||||
{' '}
|
||||
{traceSearch.trim() || (language === 'en' ? 'None' : '未设置')}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dinsar-toolbar-panel">
|
||||
<span className="dinsar-toolbar-kicker">
|
||||
{language === 'en' ? 'Page control' : '分页控制'}
|
||||
</span>
|
||||
<strong className="dinsar-toolbar-value">{pageSummaryText}</strong>
|
||||
<p className="dinsar-toolbar-note">
|
||||
{language === 'en'
|
||||
? 'Use page size and jump controls below for large catalogs'
|
||||
: '大规模结果集请结合页大小和跳页控制使用'}
|
||||
</p>
|
||||
<div className="dinsar-toolbar-actions">
|
||||
<button onClick={() => onSetAllVisibility(true)}>
|
||||
{language === 'en' ? 'Show All' : '全部显示'}
|
||||
</button>
|
||||
<button onClick={() => onSetAllVisibility(false)}>
|
||||
{language === 'en' ? 'Hide All' : '全部隐藏'}
|
||||
</button>
|
||||
<button onClick={() => setShowDates(!showDates)}>
|
||||
{showDates
|
||||
? (language === 'en' ? 'Hide Dates' : '收起日期')
|
||||
: (language === 'en' ? 'Show Dates' : '显示日期')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={isLoading || filteredResults.length === 0}
|
||||
title={language === 'en'
|
||||
? 'Export visible results in the current filter scope'
|
||||
: '按当前筛选范围导出结果文件'}
|
||||
>
|
||||
{language === 'en' ? 'Export...' : '提取结果...'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-filter-layout">
|
||||
<label className="dinsar-filter-field">
|
||||
<span>{language === 'en' ? 'AI score floor' : 'AI 分数下限'}</span>
|
||||
<div className="dinsar-score-filter">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={scoreFilter}
|
||||
onChange={onScoreFilterChange}
|
||||
/>
|
||||
<strong>{scorePercent}</strong>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="dinsar-filter-field">
|
||||
<span>{language === 'en' ? 'Pairing strategy' : '配对策略'}</span>
|
||||
<select
|
||||
value={strategyFilter}
|
||||
onChange={(event) => setStrategyFilter(event.target.value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value={DINSAR_STRATEGY_ALL}>
|
||||
{language === 'en' ? 'All strategies' : '全部策略'}
|
||||
</option>
|
||||
{strategyOptions
|
||||
.filter((value) => value !== DINSAR_STRATEGY_ALL)
|
||||
.map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="dinsar-filter-field">
|
||||
<span>{language === 'en' ? 'Production engine' : '生产引擎'}</span>
|
||||
<select
|
||||
value={engineFilter}
|
||||
onChange={(event) => setEngineFilter(event.target.value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{engineFilterOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="dinsar-filter-field dinsar-filter-field-wide">
|
||||
<span>{language === 'en' ? 'Trace search' : 'Trace 检索'}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={traceSearch}
|
||||
onChange={(event) => setTraceSearch(event.target.value)}
|
||||
placeholder={language === 'en'
|
||||
? 'Search pair / run / policy / engine'
|
||||
: '搜索 pair / run / policy / engine'}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-engine-filter-row">
|
||||
<button
|
||||
type="button"
|
||||
className={engineFilter === DINSAR_ENGINE_ALL ? 'active' : ''}
|
||||
onClick={() => setEngineFilter(DINSAR_ENGINE_ALL)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span>{language === 'en' ? 'All engines' : '全部引擎'}</span>
|
||||
<strong>{dinsarResults.length}</strong>
|
||||
</button>
|
||||
{engineCounts.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
className={engineFilter === option.code ? 'active' : ''}
|
||||
onClick={() => setEngineFilter(option.code)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span>{option.shortLabel}</span>
|
||||
<strong>{option.count}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-toolbar-footer">
|
||||
<div className="dinsar-toolbar-footer-main">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(-1)}
|
||||
disabled={isLoading || dinsarPagination.offset <= 0}
|
||||
>
|
||||
{language === 'en' ? 'Previous' : '上一页'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={isLoading || !dinsarPagination.hasMore}
|
||||
>
|
||||
{language === 'en' ? 'Next' : '下一页'}
|
||||
</button>
|
||||
|
||||
<label className="dinsar-pagination-field">
|
||||
<span>{language === 'en' ? 'Per page' : '每页条数'}</span>
|
||||
<select
|
||||
value={dinsarPagination.limit}
|
||||
onChange={onPageSizeChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="dinsar-pagination-field dinsar-pagination-field-jump">
|
||||
<span>{language === 'en' ? 'Jump to page' : '跳转页码'}</span>
|
||||
<div className="dinsar-page-jump-input">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={dinsarTotalPages}
|
||||
value={dinsarPageInput}
|
||||
onChange={(event) => {
|
||||
setDinsarPageInput(event.target.value);
|
||||
setDinsarPageInputTouched(false);
|
||||
}}
|
||||
onBlur={() => setDinsarPageInputTouched(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onGoToPage();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={showDinsarPageInputError ? 'has-error' : ''}
|
||||
/>
|
||||
<button type="button" onClick={onGoToPage} disabled={isLoading}>
|
||||
{language === 'en' ? 'Jump' : '跳转'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`dinsar-toolbar-hint ${showDinsarPageInputError ? 'error' : ''}`}>
|
||||
{showDinsarPageInputError
|
||||
? dinsarPageInputValidationError
|
||||
: getPageHintText(dinsarTotalPages, language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-scroll-shell">
|
||||
<VirtualizedList
|
||||
items={filteredResults}
|
||||
itemHeight={virtualRowHeight}
|
||||
getKey={(result) => result.id}
|
||||
renderItem={(result, index, key) => (
|
||||
<DinsarResultRow
|
||||
key={key || `${result.id}-${index}`}
|
||||
result={result}
|
||||
language={language}
|
||||
showDates={showDates}
|
||||
isLoading={isLoading}
|
||||
isReadOnlyUser={isReadOnlyUser}
|
||||
onLabel={onLabel}
|
||||
onAnalyze={onAnalyze}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showExportModal && (
|
||||
<ResultExportModal
|
||||
results={filteredResults}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ function PsResultsPanel({
|
||||
return (
|
||||
<div className="panel-content panel-scroll-shell">
|
||||
{psStacks.length === 0 ? (
|
||||
<p className="empty-state">未找到PS时序栈。</p>
|
||||
<p className="empty-state">未找到时序InSAR候选栈。</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="list-toolbar">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { DINSAR_ENGINE_ALL } from '../utils/dinsarEngines';
|
||||
|
||||
const s = (set, key) => (v) =>
|
||||
set((state) => ({ [key]: typeof v === 'function' ? v(state[key]) : v }));
|
||||
@@ -12,6 +13,7 @@ export const useDinsarStore = create((set) => ({
|
||||
dinsarPageInputTouched: false,
|
||||
aiStatus: null,
|
||||
scoreFilter: 0,
|
||||
engineFilter: DINSAR_ENGINE_ALL,
|
||||
traceSearch: '',
|
||||
strategyFilter: '__ALL__',
|
||||
activeAiReport: null,
|
||||
@@ -21,6 +23,7 @@ export const useDinsarStore = create((set) => ({
|
||||
setDinsarPageInputTouched: s(set, 'dinsarPageInputTouched'),
|
||||
setAiStatus: s(set, 'aiStatus'),
|
||||
setScoreFilter: s(set, 'scoreFilter'),
|
||||
setEngineFilter: s(set, 'engineFilter'),
|
||||
setTraceSearch: s(set, 'traceSearch'),
|
||||
setStrategyFilter: s(set, 'strategyFilter'),
|
||||
setActiveAiReport: s(set, 'activeAiReport'),
|
||||
|
||||
@@ -8,8 +8,10 @@ export const useTaskStore = create((set) => ({
|
||||
isGlobalLocked: false,
|
||||
isCheckingTasks: true, // 初始化时假设正在检查任务,避免闪烁
|
||||
pendingTaskIds: [],
|
||||
nonBlockingTaskIds: [],
|
||||
setActiveTasks: s(set, 'activeTasks'),
|
||||
setIsGlobalLocked: s(set, 'isGlobalLocked'),
|
||||
setIsCheckingTasks: s(set, 'isCheckingTasks'),
|
||||
setPendingTaskIds: s(set, 'pendingTaskIds'),
|
||||
setNonBlockingTaskIds: s(set, 'nonBlockingTaskIds'),
|
||||
}));
|
||||
|
||||
@@ -42,29 +42,31 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
|
||||
case 'pairs':
|
||||
return `任务规划 (${pairCount})`;
|
||||
case 'ps_results':
|
||||
return `PS时序栈 (${psResultCount})`;
|
||||
return `时序候选栈 (${psResultCount})`;
|
||||
case 'batches':
|
||||
return '任务批次';
|
||||
case 'copier':
|
||||
return '数据分发';
|
||||
case 'production_management':
|
||||
return '生产管理';
|
||||
case 'idl':
|
||||
return 'D-InSAR生产(旧)';
|
||||
case 'dinsar_production':
|
||||
return 'D-InSAR生产';
|
||||
return 'D-InSAR运行';
|
||||
case 'dinsar_products':
|
||||
return 'D-InSAR产物';
|
||||
case 'ps_production':
|
||||
return 'PS-InSAR生产';
|
||||
return '时序InSAR运行';
|
||||
case 'ps_products':
|
||||
return 'PS-InSAR产物';
|
||||
return '时序InSAR产物';
|
||||
case 'dinsar_results':
|
||||
return `D-InSAR结果 (${dinsarTotal})`;
|
||||
case 'dinsar_analysis':
|
||||
return 'D-InSAR分析';
|
||||
case 'psinsar_results':
|
||||
return 'PS-InSAR结果';
|
||||
return '时序InSAR结果';
|
||||
case 'psinsar_analysis':
|
||||
return 'PS-InSAR分析';
|
||||
return '时序InSAR分析';
|
||||
case 'ai_quality':
|
||||
return 'AI质量评估';
|
||||
case 'ai_diagnosis':
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export const DINSAR_ENGINE_ALL = '__ALL__';
|
||||
|
||||
export const KNOWN_DINSAR_ENGINE_CODES = ['sarscape', 'envi', 'isce2', 'pyint', 'landsar'];
|
||||
|
||||
const DINSAR_ENGINE_META = {
|
||||
sarscape: {
|
||||
label: 'ENVI / SARscape',
|
||||
shortLabel: 'ENVI',
|
||||
tone: 'envi',
|
||||
},
|
||||
envi: {
|
||||
label: 'Legacy ENVI',
|
||||
shortLabel: 'ENVI-L',
|
||||
tone: 'envi',
|
||||
},
|
||||
isce2: {
|
||||
label: 'ISCE2',
|
||||
shortLabel: 'ISCE2',
|
||||
tone: 'isce2',
|
||||
},
|
||||
pyint: {
|
||||
label: 'PyINT / Gamma',
|
||||
shortLabel: 'PyINT',
|
||||
tone: 'pyint',
|
||||
},
|
||||
landsar: {
|
||||
label: 'LandSAR',
|
||||
shortLabel: 'LandSAR',
|
||||
tone: 'landsar',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeEngineCode(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function getDinsarEngineMeta(engineCode) {
|
||||
const normalizedCode = normalizeEngineCode(engineCode);
|
||||
const matched = DINSAR_ENGINE_META[normalizedCode];
|
||||
if (matched) {
|
||||
return {
|
||||
code: normalizedCode,
|
||||
...matched,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: normalizedCode || 'unknown',
|
||||
label: normalizedCode || 'Unknown',
|
||||
shortLabel: normalizedCode || 'Unknown',
|
||||
tone: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDinsarEngineOptions(results = [], { includeKnown = false } = {}) {
|
||||
const codes = new Set(includeKnown ? KNOWN_DINSAR_ENGINE_CODES : []);
|
||||
|
||||
(Array.isArray(results) ? results : []).forEach((result) => {
|
||||
const normalizedCode = normalizeEngineCode(result?.engine_code);
|
||||
if (normalizedCode) {
|
||||
codes.add(normalizedCode);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(codes)
|
||||
.sort((left, right) => {
|
||||
const leftIndex = KNOWN_DINSAR_ENGINE_CODES.indexOf(left);
|
||||
const rightIndex = KNOWN_DINSAR_ENGINE_CODES.indexOf(right);
|
||||
if (leftIndex >= 0 && rightIndex >= 0) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
if (leftIndex >= 0) {
|
||||
return -1;
|
||||
}
|
||||
if (rightIndex >= 0) {
|
||||
return 1;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
})
|
||||
.map((code) => ({
|
||||
value: code,
|
||||
...getDinsarEngineMeta(code),
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DINSAR_ENGINE_ALL } from './dinsarEngines';
|
||||
|
||||
export const DINSAR_STRATEGY_ALL = '__ALL__';
|
||||
|
||||
function normalizeTraceSearch(value) {
|
||||
@@ -15,10 +17,12 @@ function buildTraceText(result = {}) {
|
||||
result.task_name,
|
||||
result.pair_key,
|
||||
result.pair_uid,
|
||||
result.run_key,
|
||||
result.network_run_id,
|
||||
result.network_edge_id,
|
||||
result.policy_version,
|
||||
result.selection_strategy,
|
||||
result.engine_code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -71,19 +75,22 @@ export function matchesDinsarResultFilters(
|
||||
result,
|
||||
{
|
||||
scoreFilter = 0,
|
||||
engineFilter = DINSAR_ENGINE_ALL,
|
||||
strategyFilter = DINSAR_STRATEGY_ALL,
|
||||
traceSearch = '',
|
||||
focusedHazardPoint = null,
|
||||
} = {}
|
||||
) {
|
||||
const matchesScore = !hasAiScore(result) || Number(result.ai_score) >= Number(scoreFilter || 0);
|
||||
const engineValue = String(result?.engine_code || '').trim().toLowerCase();
|
||||
const matchesEngine = engineFilter === DINSAR_ENGINE_ALL || engineValue === engineFilter;
|
||||
const strategyValue = String(result?.selection_strategy || '').trim();
|
||||
const matchesStrategy = strategyFilter === DINSAR_STRATEGY_ALL || strategyValue === strategyFilter;
|
||||
const normalizedTraceSearch = normalizeTraceSearch(traceSearch);
|
||||
const matchesTrace = !normalizedTraceSearch || buildTraceText(result).includes(normalizedTraceSearch);
|
||||
const matchesHazard = matchesFocusedHazardPoint(result, focusedHazardPoint);
|
||||
|
||||
return matchesScore && matchesStrategy && matchesTrace && matchesHazard;
|
||||
return matchesScore && matchesEngine && matchesStrategy && matchesTrace && matchesHazard;
|
||||
}
|
||||
|
||||
export function filterDinsarResults(results = [], filters = {}) {
|
||||
|
||||
Reference in New Issue
Block a user