Integrate SBAS workflows and redesign task center
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
} from './api/ai';
|
||||
import { getDinsarResults } from './api/dinsar';
|
||||
import AiDiagnosisModal from './components/AiDiagnosisModal';
|
||||
import TaskStatusPanel from './components/tasks/TaskStatusPanel';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const cardStyle = {
|
||||
background: '#fff',
|
||||
@@ -20,6 +22,12 @@ const cardStyle = {
|
||||
|
||||
export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
|
||||
const { en } = useI18n();
|
||||
const aiTaskMonitor = useTaskMonitor({
|
||||
taskTypes: ['AI_ANALYZE'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
|
||||
// 状态
|
||||
const [aiStatus, setAiStatus] = useState(null);
|
||||
@@ -209,6 +217,16 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
|
||||
{en ? 'Create Diagnosis' : '创建诊断'}
|
||||
</h3>
|
||||
|
||||
<TaskStatusPanel
|
||||
title={en ? 'AI Diagnosis Task' : 'AI 诊断任务'}
|
||||
activeTasks={aiTaskMonitor.activeTasks}
|
||||
recentTasks={aiTaskMonitor.recentTasks}
|
||||
latestTask={aiTaskMonitor.latestTask}
|
||||
isBusy={aiTaskMonitor.isBusy}
|
||||
idleText={en ? 'No AI diagnosis task is running.' : '当前没有正在执行的 AI 诊断任务。'}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* D-InSAR Result Selection */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
@@ -345,20 +363,20 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleCreateDiagnosis}
|
||||
disabled={loading || !selectedResultId || !aiStatus?.ollama_online}
|
||||
disabled={loading || aiTaskMonitor.isBusy || !selectedResultId || !aiStatus?.ollama_online}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
backgroundColor: loading || !selectedResultId || !aiStatus?.ollama_online ? '#cbd5e0' : '#3182ce',
|
||||
backgroundColor: loading || aiTaskMonitor.isBusy || !selectedResultId || !aiStatus?.ollama_online ? '#cbd5e0' : '#3182ce',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: loading || !selectedResultId || !aiStatus?.ollama_online ? 'not-allowed' : 'pointer',
|
||||
cursor: loading || aiTaskMonitor.isBusy || !selectedResultId || !aiStatus?.ollama_online ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? (en ? 'Creating...' : '创建中...') : (en ? 'Create Diagnosis' : '创建诊断')}
|
||||
{loading || aiTaskMonitor.isBusy ? (en ? 'Creating...' : '创建中...') : (en ? 'Create Diagnosis' : '创建诊断')}
|
||||
</button>
|
||||
|
||||
{/* Message */}
|
||||
|
||||
+91
-33
@@ -1795,31 +1795,62 @@ input[type="checkbox"] {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 全局任务锁定遮罩样式 */
|
||||
/* Global task center */
|
||||
.global-task-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(15, 23, 42, 0.75);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 1200;
|
||||
color: #0f172a;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task-center-button {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-center-button strong {
|
||||
color: #2563eb;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.task-center-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 40px rgba(15, 23, 42, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
pointer-events: auto;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
width: min(440px, calc(100vw - 36px));
|
||||
max-height: min(70vh, 620px);
|
||||
overflow: auto;
|
||||
text-align: left;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.24);
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
@@ -1832,25 +1863,52 @@ input[type="checkbox"] {
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.overlay-content h3 {
|
||||
margin: 0 0 25px 0;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 1px;
|
||||
.task-center-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-center-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.task-center-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-center-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.active-tasks-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-progress-item {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
padding: 15px;
|
||||
background: #f8fafc;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.task-info-row {
|
||||
@@ -1862,7 +1920,7 @@ input[type="checkbox"] {
|
||||
|
||||
.task-label {
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.task-percent {
|
||||
@@ -1872,8 +1930,8 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.task-progress-bar {
|
||||
height: 8px;
|
||||
background-color: rgba(148, 163, 184, 0.35);
|
||||
height: 7px;
|
||||
background-color: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
@@ -1889,7 +1947,7 @@ input[type="checkbox"] {
|
||||
.task-status-msg {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1897,7 +1955,7 @@ input[type="checkbox"] {
|
||||
|
||||
.overlay-footer-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
+377
-36
@@ -11,6 +11,7 @@ import AppSidePanel from './components/app/AppSidePanel';
|
||||
import AppStatusHeader from './components/app/AppStatusHeader';
|
||||
import { useI18n } from './i18n/I18nContext';
|
||||
import apiClient from './api/client';
|
||||
import { getSbasInsarProductAssetUrl } from './api/sbasInsarProducts';
|
||||
import {
|
||||
useAuthStore, useTaskStore, useUiStore, useRadarStore,
|
||||
useDinsarStore, useBatchStore, usePairingStore, useHazardStore, useMapStore,
|
||||
@@ -47,6 +48,138 @@ import { DINSAR_ENGINE_ALL, getDinsarEngineMeta } from './utils/dinsarEngines';
|
||||
|
||||
const NATIONAL_BOUNDARY_STATIC_URL = '/geojson/\u5168\u56fd\u884c\u653f\u533a.geojson';
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
};
|
||||
|
||||
const formatMapNumber = (value, digits = 2) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric.toFixed(digits) : '-';
|
||||
};
|
||||
|
||||
const getSbasProductBounds = (product) => {
|
||||
const coverage = product?.geographic_coverage || {};
|
||||
const bbox = coverage.bbox || {};
|
||||
const minLon = toFiniteNumber(product?.min_lon ?? bbox.min_lon);
|
||||
const minLat = toFiniteNumber(product?.min_lat ?? bbox.min_lat);
|
||||
const maxLon = toFiniteNumber(product?.max_lon ?? bbox.max_lon);
|
||||
const maxLat = toFiniteNumber(product?.max_lat ?? bbox.max_lat);
|
||||
if ([minLon, minLat, maxLon, maxLat].some(value => value === null)) return null;
|
||||
if (minLon >= maxLon || minLat >= maxLat) return null;
|
||||
return [[minLat, minLon], [maxLat, maxLon]];
|
||||
};
|
||||
|
||||
const findSbasAsset = (detail, roles) => {
|
||||
const roleSet = new Set(roles);
|
||||
return (detail?.assets || []).find(asset => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||||
};
|
||||
|
||||
const sbasAssetCacheKey = (asset) => (
|
||||
[asset?.id, asset?.file_size, asset?.updated_at || asset?.created_at || asset?.relative_path]
|
||||
.filter(Boolean)
|
||||
.join(':')
|
||||
);
|
||||
|
||||
const sbasRateColor = (rate) => {
|
||||
const numeric = Number(rate);
|
||||
if (!Number.isFinite(numeric)) return '#64748b';
|
||||
if (numeric <= -30) return '#1d4ed8';
|
||||
if (numeric < -5) return '#38bdf8';
|
||||
if (numeric <= 5) return '#16a34a';
|
||||
if (numeric < 30) return '#f59e0b';
|
||||
return '#dc2626';
|
||||
};
|
||||
|
||||
const SBAS_OVERVIEW_COLORS = ['#1d4ed8', '#dc2626', '#059669', '#7c3aed', '#d97706', '#0f766e'];
|
||||
|
||||
const normalizeSbasDisplacements = (rows) => (Array.isArray(rows) ? rows : [])
|
||||
.map((item) => {
|
||||
const date = String(item?.date || '').trim();
|
||||
const time = Date.parse(`${date}T00:00:00Z`);
|
||||
const displacement = Number(item?.displacement_mm ?? item?.displacement ?? item?.value);
|
||||
if (!date || !Number.isFinite(time) || !Number.isFinite(displacement)) return null;
|
||||
return { date, time, displacement };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.time - right.time);
|
||||
|
||||
const buildSbasSparklineSvg = (rows) => {
|
||||
const values = normalizeSbasDisplacements(rows);
|
||||
if (values.length < 2) return '';
|
||||
const width = 220;
|
||||
const height = 72;
|
||||
const padX = 12;
|
||||
const padY = 10;
|
||||
const minTime = Math.min(...values.map(item => item.time));
|
||||
const maxTime = Math.max(...values.map(item => item.time));
|
||||
const minValue = Math.min(...values.map(item => item.displacement), 0);
|
||||
const maxValue = Math.max(...values.map(item => item.displacement), 0);
|
||||
const timeSpan = Math.max(1, maxTime - minTime);
|
||||
const valueSpan = Math.max(1e-9, maxValue - minValue);
|
||||
const xScale = (time) => padX + ((time - minTime) / timeSpan) * (width - padX * 2);
|
||||
const yScale = (value) => height - padY - ((value - minValue) / valueSpan) * (height - padY * 2);
|
||||
const points = values.map(item => `${xScale(item.time).toFixed(1)},${yScale(item.displacement).toFixed(1)}`).join(' ');
|
||||
const zeroY = yScale(0).toFixed(1);
|
||||
const minLabel = escapeHtml(formatMapNumber(minValue, 1));
|
||||
const maxLabel = escapeHtml(formatMapNumber(maxValue, 1));
|
||||
return `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="SBAS displacement sparkline">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="#f8fafc" rx="6"></rect>
|
||||
<line x1="${padX}" x2="${width - padX}" y1="${zeroY}" y2="${zeroY}" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3 3"></line>
|
||||
<polyline points="${points}" fill="none" stroke="#1d4ed8" stroke-width="2"></polyline>
|
||||
${values.map(item => `<circle cx="${xScale(item.time).toFixed(1)}" cy="${yScale(item.displacement).toFixed(1)}" r="2.4" fill="#1d4ed8"></circle>`).join('')}
|
||||
<text x="${padX}" y="10" font-size="9" fill="#64748b">${maxLabel} mm</text>
|
||||
<text x="${padX}" y="${height - 4}" font-size="9" fill="#64748b">${minLabel} mm</text>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
const buildSbasPointPopupHtml = (point, options = {}) => {
|
||||
const matched = point?.matched || {};
|
||||
const pointId = escapeHtml(point?.point_id || options.pointId || 'SBAS point');
|
||||
const label = escapeHtml(point?.selection_label || options.label || pointId);
|
||||
const rate = point?.deformation_rate_mm_per_year ?? point?.los_rate_mm_per_year ?? matched.los_rate_mm_per_year;
|
||||
const lon = point?.lon ?? matched.lon;
|
||||
const lat = point?.lat ?? matched.lat;
|
||||
const nearestNote = matched.used_nearest
|
||||
? `<div><strong>匹配:</strong> 最近有效像元,距离 ${escapeHtml(formatMapNumber(matched.distance_m, 1))} m</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="sbas-popup" style="min-width:240px">
|
||||
<div style="font-weight:800;margin-bottom:6px">${label}</div>
|
||||
<div><strong>ID:</strong> <span class="mono">${pointId}</span></div>
|
||||
<div><strong>经纬度:</strong> ${escapeHtml(formatMapNumber(lon, 6))}, ${escapeHtml(formatMapNumber(lat, 6))}</div>
|
||||
<div><strong>LOS速率:</strong> ${escapeHtml(formatMapNumber(rate, 2))} mm/yr</div>
|
||||
${nearestNote}
|
||||
<div style="margin-top:8px">${buildSbasSparklineSvg(point?.displacements || options.displacements || [])}</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const buildSbasOverviewPopupHtml = (product) => {
|
||||
const title = escapeHtml(product?.display_name || product?.stack_key || product?.run_key || `SBAS #${product?.id ?? '-'}`);
|
||||
const dateStart = escapeHtml(String(product?.date_start || '-').slice(0, 10));
|
||||
const dateEnd = escapeHtml(String(product?.date_end || '-').slice(0, 10));
|
||||
const stackSize = escapeHtml(product?.stack_size ?? product?.stack_dates?.length ?? '-');
|
||||
const status = escapeHtml(product?.status || '-');
|
||||
const health = escapeHtml(product?.health_status || '-');
|
||||
const runKey = escapeHtml(product?.run_key || '-');
|
||||
const stackKey = escapeHtml(product?.stack_key || '-');
|
||||
const region = product?.admin_region?.display_name || product?.admin_region?.name || product?.admin_region?.tree_id || '-';
|
||||
return `
|
||||
<div class="sbas-popup" style="min-width:260px">
|
||||
<div style="font-weight:850;margin-bottom:7px">${title}</div>
|
||||
<div><strong>时间:</strong> ${dateStart} → ${dateEnd}</div>
|
||||
<div><strong>栈期数:</strong> ${stackSize}</div>
|
||||
<div><strong>状态:</strong> ${status} / ${health}</div>
|
||||
<div><strong>区域:</strong> ${escapeHtml(region)}</div>
|
||||
<div><strong>stack:</strong> <span class="mono">${stackKey}</span></div>
|
||||
<div><strong>run:</strong> <span class="mono">${runKey}</span></div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { language, setLanguage } = useI18n();
|
||||
|
||||
@@ -77,21 +210,16 @@ function App() {
|
||||
setHealthError: state.setHealthError,
|
||||
})));
|
||||
const {
|
||||
activeTasks, setActiveTasks, isGlobalLocked, setIsGlobalLocked,
|
||||
activeTasks, setActiveTasks,
|
||||
isCheckingTasks, setIsCheckingTasks,
|
||||
pendingTaskIds, setPendingTaskIds,
|
||||
nonBlockingTaskIds, setNonBlockingTaskIds,
|
||||
} = useTaskStore(useShallow((state) => ({
|
||||
activeTasks: state.activeTasks,
|
||||
setActiveTasks: state.setActiveTasks,
|
||||
isGlobalLocked: state.isGlobalLocked,
|
||||
setIsGlobalLocked: state.setIsGlobalLocked,
|
||||
isCheckingTasks: state.isCheckingTasks,
|
||||
setIsCheckingTasks: state.setIsCheckingTasks,
|
||||
pendingTaskIds: state.pendingTaskIds,
|
||||
setPendingTaskIds: state.setPendingTaskIds,
|
||||
nonBlockingTaskIds: state.nonBlockingTaskIds,
|
||||
setNonBlockingTaskIds: state.setNonBlockingTaskIds,
|
||||
})));
|
||||
const {
|
||||
leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth,
|
||||
@@ -295,6 +423,7 @@ function App() {
|
||||
const foundPairsRef = useRef(foundPairs);
|
||||
const hazardLayersRef = useRef({});
|
||||
const dinsarResultLayersRef = useRef({});
|
||||
const sbasAnalysisLayersRef = useRef({});
|
||||
const resizeStateRef = useRef({ side: null, startX: 0, startLeft: 0, startRight: 0 });
|
||||
const allDataRef = useRef(allData);
|
||||
const dinsarResultsRef = useRef(dinsarResults);
|
||||
@@ -324,6 +453,7 @@ function App() {
|
||||
activeLayersRef: activeLayersRef.current,
|
||||
hazardLayersGroupRef: hazardLayersGroupRef.current,
|
||||
dinsarResultLayersRef: dinsarResultLayersRef.current,
|
||||
sbasAnalysisLayersRef: sbasAnalysisLayersRef.current,
|
||||
waterSceneLayersRef: waterSceneLayersRef.current,
|
||||
radarPreviewLayersRef: radarPreviewLayersRef.current,
|
||||
pairLayersRef: pairLayersRef.current,
|
||||
@@ -386,12 +516,8 @@ function App() {
|
||||
addLog('warn', '当前账号为只读用户,无法执行写操作。');
|
||||
return false;
|
||||
}
|
||||
if (isCheckingTasks || isGlobalLocked) {
|
||||
addLog('warn', '系统正在处理任务,请稍候...');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [addLog, isAdmin, isCheckingTasks, isGlobalLocked]);
|
||||
}, [addLog, isAdmin]);
|
||||
|
||||
const clearRadarMapLayers = () => {
|
||||
cancelMapBatch();
|
||||
@@ -412,6 +538,16 @@ function App() {
|
||||
radarPreviewLayersRef.current = {};
|
||||
};
|
||||
|
||||
const clearSbasAnalysisLayers = useCallback(() => {
|
||||
Object.values(sbasAnalysisLayersRef.current).forEach((entry) => {
|
||||
const layer = entry?.layer || entry;
|
||||
if (layer?.remove) {
|
||||
layer.remove();
|
||||
}
|
||||
});
|
||||
sbasAnalysisLayersRef.current = {};
|
||||
}, []);
|
||||
|
||||
const clearRadarSearchResults = (options = {}) => {
|
||||
const nextLimit = Math.max(
|
||||
1,
|
||||
@@ -613,7 +749,6 @@ function App() {
|
||||
setHasRadarSearched,
|
||||
setCurrentUser,
|
||||
setAuthChecked,
|
||||
setIsGlobalLocked,
|
||||
setPendingTaskIds,
|
||||
setLicenseLoading,
|
||||
setLicenseStatus,
|
||||
@@ -798,11 +933,11 @@ function App() {
|
||||
handleTaskCompletionRef.current = handleTaskCompletion;
|
||||
|
||||
const {
|
||||
forceUnlockPwd,
|
||||
setForceUnlockPwd,
|
||||
showForceUnlock,
|
||||
setShowForceUnlock,
|
||||
handleForceUnlock,
|
||||
cancelTaskPwd,
|
||||
setCancelTaskPwd,
|
||||
showCancelTask,
|
||||
setShowCancelTask,
|
||||
handleCancelActiveTasks,
|
||||
} = useGlobalTaskControl({
|
||||
currentUser,
|
||||
licenseOk: !!licenseStatus?.ok,
|
||||
@@ -810,14 +945,8 @@ function App() {
|
||||
setActiveTasks,
|
||||
pendingTaskIds,
|
||||
setPendingTaskIds,
|
||||
nonBlockingTaskIds,
|
||||
setNonBlockingTaskIds,
|
||||
isGlobalLocked,
|
||||
setIsGlobalLocked,
|
||||
setIsCheckingTasks,
|
||||
handleTaskCompletionRef,
|
||||
initializeAppDataRef,
|
||||
addLog,
|
||||
});
|
||||
|
||||
const fetchRadarPreviewStatus = useCallback(async (itemId, options = {}) => {
|
||||
@@ -839,8 +968,7 @@ function App() {
|
||||
if (!ensureCanOperate()) return;
|
||||
if (rebuildingPreviewIds[itemId]) return;
|
||||
|
||||
// 立即锁定前端
|
||||
handleTaskStart(null, `正在生成影像 ${itemId} 的预览缓存...`);
|
||||
addLog('info', `正在生成影像 ${itemId} 的预览缓存...`);
|
||||
|
||||
setRebuildingPreviewIds(prev => ({ ...prev, [itemId]: true }));
|
||||
try {
|
||||
@@ -1197,6 +1325,219 @@ function App() {
|
||||
}
|
||||
}, [addLog, buildDinsarResultPopupHtml, showDates, updateLayerTooltip]);
|
||||
|
||||
const flyToSbasProduct = useCallback((product) => {
|
||||
if (!mapRef.current || !product) return false;
|
||||
const bounds = getSbasProductBounds(product);
|
||||
if (!bounds) {
|
||||
addLog('warn', '当前 SBAS 产品没有可定位的地理范围。');
|
||||
return false;
|
||||
}
|
||||
mapRef.current.flyToBounds(L.latLngBounds(bounds), { padding: [45, 45], maxZoom: 12 });
|
||||
return true;
|
||||
}, [addLog]);
|
||||
|
||||
const toggleSbasRateLayer = useCallback((detail, shouldBeVisible, opacity = 0.78) => {
|
||||
if (!mapRef.current || !detail) return false;
|
||||
const layerKey = `rate:${detail.id}`;
|
||||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||||
if (!shouldBeVisible) {
|
||||
if (existing) existing.remove();
|
||||
delete sbasAnalysisLayersRef.current[layerKey];
|
||||
return true;
|
||||
}
|
||||
|
||||
const asset = findSbasAsset(detail, ['primary_geocoded_preview', 'primary_rate_color_preview']);
|
||||
const bounds = getSbasProductBounds(detail);
|
||||
if (!asset || !bounds) {
|
||||
addLog('warn', 'SBAS 产品缺少 LOS 速率图或地理范围,无法叠加到地图。');
|
||||
return false;
|
||||
}
|
||||
if (existing) {
|
||||
if (!mapRef.current.hasLayer(existing)) existing.addTo(mapRef.current);
|
||||
existing.setOpacity(opacity);
|
||||
flyToSbasProduct(detail);
|
||||
return true;
|
||||
}
|
||||
|
||||
const imageUrl = getSbasInsarProductAssetUrl(detail.id, asset.id, sbasAssetCacheKey(asset));
|
||||
const layer = L.imageOverlay(imageUrl, bounds, {
|
||||
opacity,
|
||||
interactive: true,
|
||||
crossOrigin: true,
|
||||
}).addTo(mapRef.current);
|
||||
layer.bindPopup(
|
||||
`<div class="sbas-popup"><div style="font-weight:800;margin-bottom:6px">${escapeHtml(detail.display_name || detail.run_key || 'Gamma SBAS')}</div>` +
|
||||
`<div><strong>图层:</strong> LOS 速率图</div>` +
|
||||
`<div><strong>色表:</strong> ${escapeHtml(detail.color_policy?.colormap || 'Gamma hls.cm')}</div>` +
|
||||
`<div><strong>范围:</strong> ${escapeHtml((detail.color_policy?.display_range_mm_per_year || [-80, 80]).join(' 到 '))} mm/yr</div></div>`,
|
||||
{ maxWidth: 340 },
|
||||
);
|
||||
layer.on('load', () => addLog('success', `SBAS LOS 速率图已加载:${detail.display_name || detail.run_key || detail.id}`));
|
||||
layer.on('error', () => {
|
||||
addLog('error', 'SBAS LOS 速率图加载失败。');
|
||||
layer.remove();
|
||||
delete sbasAnalysisLayersRef.current[layerKey];
|
||||
});
|
||||
sbasAnalysisLayersRef.current[layerKey] = { layer, kind: 'rate', productId: detail.id };
|
||||
flyToSbasProduct(detail);
|
||||
return true;
|
||||
}, [addLog, flyToSbasProduct]);
|
||||
|
||||
const updateSbasRateOpacity = useCallback((opacity) => {
|
||||
Object.values(sbasAnalysisLayersRef.current).forEach((entry) => {
|
||||
if (entry?.kind === 'rate' && entry.layer?.setOpacity) {
|
||||
entry.layer.setOpacity(opacity);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSbasProductOverview = useCallback((products, shouldBeVisible) => {
|
||||
if (!mapRef.current) return false;
|
||||
const layerKey = 'overview';
|
||||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||||
if (!shouldBeVisible) {
|
||||
if (existing) existing.remove();
|
||||
delete sbasAnalysisLayersRef.current[layerKey];
|
||||
return true;
|
||||
}
|
||||
if (existing) {
|
||||
if (!mapRef.current.hasLayer(existing)) existing.addTo(mapRef.current);
|
||||
const existingBounds = sbasAnalysisLayersRef.current[layerKey]?.bounds;
|
||||
if (existingBounds) {
|
||||
mapRef.current.flyToBounds(existingBounds, { padding: [55, 55], maxZoom: 10 });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const validProducts = (Array.isArray(products) ? products : [])
|
||||
.map((product) => ({ product, bounds: getSbasProductBounds(product) }))
|
||||
.filter(item => item.bounds);
|
||||
if (!validProducts.length) {
|
||||
addLog('warn', '当前没有可绘制范围的 SBAS 产品。');
|
||||
return false;
|
||||
}
|
||||
|
||||
const group = L.layerGroup();
|
||||
let allBounds = null;
|
||||
validProducts.forEach(({ product, bounds }, index) => {
|
||||
const color = SBAS_OVERVIEW_COLORS[index % SBAS_OVERVIEW_COLORS.length] || '#1d4ed8';
|
||||
const rectangle = L.rectangle(bounds, {
|
||||
color,
|
||||
weight: 2,
|
||||
opacity: 0.95,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.08,
|
||||
dashArray: index % 2 === 0 ? undefined : '6 4',
|
||||
interactive: true,
|
||||
});
|
||||
rectangle.bindPopup(buildSbasOverviewPopupHtml(product), { maxWidth: 360 });
|
||||
rectangle.bindTooltip(
|
||||
`${product.display_name || product.stack_key || product.run_key || product.id}<br>${String(product.date_start || '-').slice(0, 10)} → ${String(product.date_end || '-').slice(0, 10)}`,
|
||||
{ sticky: true, direction: 'top', opacity: 0.92 },
|
||||
);
|
||||
rectangle.addTo(group);
|
||||
const nextBounds = L.latLngBounds(bounds);
|
||||
allBounds = allBounds ? allBounds.extend(nextBounds) : nextBounds;
|
||||
});
|
||||
group.addTo(mapRef.current);
|
||||
sbasAnalysisLayersRef.current[layerKey] = { layer: group, kind: 'overview', bounds: allBounds };
|
||||
if (allBounds) {
|
||||
mapRef.current.flyToBounds(allBounds, { padding: [55, 55], maxZoom: 10 });
|
||||
}
|
||||
addLog('info', `已显示 ${validProducts.length} 个 SBAS 产品范围和时间。`);
|
||||
return true;
|
||||
}, [addLog]);
|
||||
|
||||
const toggleSbasMonitorPoints = useCallback((detail, shouldBeVisible) => {
|
||||
if (!mapRef.current || !detail) return false;
|
||||
const layerKey = `points:${detail.id}`;
|
||||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||||
if (!shouldBeVisible) {
|
||||
if (existing) existing.remove();
|
||||
delete sbasAnalysisLayersRef.current[layerKey];
|
||||
return true;
|
||||
}
|
||||
if (existing) {
|
||||
if (!mapRef.current.hasLayer(existing)) existing.addTo(mapRef.current);
|
||||
flyToSbasProduct(detail);
|
||||
return true;
|
||||
}
|
||||
const points = (detail.monitor_points?.monitor_points || [])
|
||||
.filter(point => Number.isFinite(Number(point.lat)) && Number.isFinite(Number(point.lon)));
|
||||
if (!points.length) {
|
||||
addLog('warn', '当前 SBAS 监测点没有 WGS84 坐标,请重新注册资产后再显示。');
|
||||
return false;
|
||||
}
|
||||
const group = L.layerGroup();
|
||||
const latLngs = [];
|
||||
points.forEach((point) => {
|
||||
const lat = Number(point.lat);
|
||||
const lon = Number(point.lon);
|
||||
const rate = Number(point.deformation_rate_mm_per_year);
|
||||
const color = sbasRateColor(rate);
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 7,
|
||||
color: '#ffffff',
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.92,
|
||||
interactive: true,
|
||||
});
|
||||
marker.bindPopup(buildSbasPointPopupHtml(point), { maxWidth: 320 });
|
||||
marker.addTo(group);
|
||||
latLngs.push([lat, lon]);
|
||||
});
|
||||
group.addTo(mapRef.current);
|
||||
sbasAnalysisLayersRef.current[layerKey] = { layer: group, kind: 'points', productId: detail.id };
|
||||
if (latLngs.length) {
|
||||
mapRef.current.flyToBounds(L.latLngBounds(latLngs), { padding: [55, 55], maxZoom: 13 });
|
||||
}
|
||||
addLog('info', `已显示 ${points.length} 个 SBAS 监测点。`);
|
||||
return true;
|
||||
}, [addLog, flyToSbasProduct]);
|
||||
|
||||
const showSbasQueryPoint = useCallback((result, detail) => {
|
||||
if (!mapRef.current || !result?.matched) return false;
|
||||
const matched = result.matched;
|
||||
const lat = Number(matched.lat);
|
||||
const lon = Number(matched.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
const layerKey = 'query';
|
||||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||||
if (existing) existing.remove();
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
color: '#111827',
|
||||
weight: 2,
|
||||
fillColor: matched.used_nearest ? '#f59e0b' : '#22c55e',
|
||||
fillOpacity: 0.95,
|
||||
interactive: true,
|
||||
}).addTo(mapRef.current);
|
||||
const point = {
|
||||
point_id: matched.used_nearest ? 'query_nearest' : 'query_point',
|
||||
selection_label: matched.used_nearest ? '查询点最近有效像元' : '查询点',
|
||||
deformation_rate_mm_per_year: matched.los_rate_mm_per_year,
|
||||
displacements: result.displacements || [],
|
||||
matched,
|
||||
lon,
|
||||
lat,
|
||||
};
|
||||
marker.bindPopup(buildSbasPointPopupHtml(point), { maxWidth: 320 }).openPopup();
|
||||
sbasAnalysisLayersRef.current[layerKey] = { layer: marker, kind: 'query', productId: detail?.id };
|
||||
mapRef.current.flyTo([lat, lon], Math.max(mapRef.current.getZoom(), 12), { animate: true });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const sbasAnalysisPanel = {
|
||||
onToggleRateLayer: toggleSbasRateLayer,
|
||||
onRateOpacityChange: updateSbasRateOpacity,
|
||||
onToggleMonitorPoints: toggleSbasMonitorPoints,
|
||||
onToggleProductOverview: toggleSbasProductOverview,
|
||||
onFlyToProduct: flyToSbasProduct,
|
||||
onShowQueryPoint: showSbasQueryPoint,
|
||||
onClearLayers: clearSbasAnalysisLayers,
|
||||
};
|
||||
|
||||
const toggleDinsarResultVisibility = useCallback((resultId) => {
|
||||
cancelMapBatch();
|
||||
const currentResults = dinsarResultsRef.current;
|
||||
@@ -1702,10 +2043,10 @@ function App() {
|
||||
fetchDinsarResults({ offset: 0 });
|
||||
}, [fetchDinsarResults]);
|
||||
|
||||
const handleCancelForceUnlock = useCallback(() => {
|
||||
setShowForceUnlock(false);
|
||||
setForceUnlockPwd('');
|
||||
}, [setShowForceUnlock, setForceUnlockPwd]);
|
||||
const handleCloseCancelTask = useCallback(() => {
|
||||
setShowCancelTask(false);
|
||||
setCancelTaskPwd('');
|
||||
}, [setShowCancelTask, setCancelTaskPwd]);
|
||||
|
||||
const radarPanel = {
|
||||
radarCurrentPage,
|
||||
@@ -1852,6 +2193,7 @@ function App() {
|
||||
aiPanel={aiPanel}
|
||||
pairsPanel={pairsPanel}
|
||||
psPanel={psPanel}
|
||||
sbasAnalysisPanel={sbasAnalysisPanel}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -1916,14 +2258,13 @@ function App() {
|
||||
onRefreshLicenseStatus={fetchLicenseStatus}
|
||||
licenseFileName={licenseFileName}
|
||||
licenseUploadStatus={licenseUploadStatus}
|
||||
isGlobalLocked={isGlobalLocked}
|
||||
activeTasks={activeTasks}
|
||||
showForceUnlock={showForceUnlock}
|
||||
forceUnlockPwd={forceUnlockPwd}
|
||||
onShowForceUnlock={() => setShowForceUnlock(true)}
|
||||
onForceUnlockPwdChange={setForceUnlockPwd}
|
||||
onForceUnlockConfirm={handleForceUnlock}
|
||||
onCancelForceUnlock={handleCancelForceUnlock}
|
||||
showCancelTask={showCancelTask}
|
||||
cancelTaskPwd={cancelTaskPwd}
|
||||
onShowCancelTask={() => setShowCancelTask(true)}
|
||||
onCancelTaskPwdChange={setCancelTaskPwd}
|
||||
onCancelTaskConfirm={handleCancelActiveTasks}
|
||||
onCloseCancelTask={handleCloseCancelTask}
|
||||
mapExport={mapExport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
|
||||
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getActiveTasks, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getRecentTasks, getTaskLogs } from './api/tasks';
|
||||
import { formatSatelliteFamilyLabel, inferSatelliteFamilyFromResultLike } from './utils/satelliteFamily';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const card = {
|
||||
background: '#fff',
|
||||
@@ -45,8 +46,10 @@ const ENGINE_LABEL = {
|
||||
const TASK_TYPE_LABEL = {
|
||||
ISCE2_RUN: 'ISCE2生产',
|
||||
PYINT_RUN: 'PyINT/Gamma生产',
|
||||
LANDSAR_RUN: 'LandSAR生产',
|
||||
IDL_RUN_DINSAR: 'ENVI生产',
|
||||
};
|
||||
const DINSAR_PRODUCTION_TASK_TYPES = ['ISCE2_RUN', 'PYINT_RUN', 'LANDSAR_RUN', 'IDL_RUN_DINSAR'];
|
||||
|
||||
const STATUS_LABEL = {
|
||||
PENDING: '等待中',
|
||||
@@ -111,6 +114,7 @@ function formatStatus(status) {
|
||||
function taskTypeToEngine(taskType) {
|
||||
if (taskType === 'ISCE2_RUN') return 'isce2';
|
||||
if (taskType === 'PYINT_RUN') return 'pyint';
|
||||
if (taskType === 'LANDSAR_RUN') return 'landsar';
|
||||
if (taskType === 'IDL_RUN_DINSAR') return 'sarscape';
|
||||
return '';
|
||||
}
|
||||
@@ -191,7 +195,7 @@ function mergeRunRows(productionRuns, recentTasks, limit = null) {
|
||||
);
|
||||
|
||||
(recentTasks || []).forEach(task => {
|
||||
if (!['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'].includes(task?.task_type)) {
|
||||
if (!DINSAR_PRODUCTION_TASK_TYPES.includes(task?.task_type)) {
|
||||
return;
|
||||
}
|
||||
const taskId = String(task?.task_id || '').trim();
|
||||
@@ -428,6 +432,7 @@ function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
const description = schema.description || '';
|
||||
const recommendation = schema.recommendation || '';
|
||||
const isReadonly = !!schema.readonly;
|
||||
const readonlyLabel = schema.readonly_label || '固定值';
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
@@ -441,29 +446,43 @@ function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: '#0f172a' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
disabled={disabled || isReadonly}
|
||||
onChange={event => onChange(name, event.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{isReadonly && (
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: '#e2e8f0',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
固定值
|
||||
</span>
|
||||
)}
|
||||
{description && <span style={{ color: '#64748b' }}>{description}</span>}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
minWidth: 220,
|
||||
flex: '1 1 260px',
|
||||
fontSize: 12,
|
||||
color: isReadonly ? '#475569' : '#0f172a',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
disabled={disabled || isReadonly}
|
||||
onChange={event => onChange(name, event.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{isReadonly && (
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: '#e2e8f0',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{readonlyLabel}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{description && <div style={{ color: '#64748b', lineHeight: 1.45 }}>{description}</div>}
|
||||
{recommendation && <div style={{ color: '#2563eb', lineHeight: 1.45 }}>推荐:{recommendation}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,7 +502,7 @@ function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
固定值
|
||||
{readonlyLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -517,7 +536,7 @@ function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
固定值
|
||||
{readonlyLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -568,8 +587,6 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
loading: false,
|
||||
});
|
||||
const [runLogDeletingId, setRunLogDeletingId] = useState('');
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const [recentTask, setRecentTask] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
||||
@@ -583,12 +600,19 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const currentDefaultTimeoutSec = Number(currentEngineObj?.default_timeout_seconds || 0) || 0;
|
||||
const currentParamHelpText = selectedEngine === 'pyint'
|
||||
? 'PyINT/Gamma 会按目标网格尺寸自动换算多视;新增 Gamma 残余重去平在解缠后执行 rascc_mask/quad_fit/quad_sub,再导出 native 和标准 GeoTIFF。'
|
||||
: selectedEngine === 'isce2'
|
||||
: selectedEngine === 'landsar'
|
||||
? 'LandSAR 当前使用已跑通的稳定参数。GACOS 大气相位改正需要外部大气延迟文件,未配置文件前不可启用;垂直向形变为可选输出,默认关闭。'
|
||||
: selectedEngine === 'isce2'
|
||||
? '这些参数现在按执行、交付、增强分组展示。结果异常时,优先尝试关闭增强项,再回看基础几何和配对质量。'
|
||||
: '这些参数影响当前引擎的生产模板。建议先使用默认值,只有在结果边界、噪声或几何表现异常时再逐项调整。';
|
||||
const pyintPreviewBlocksSubmit = selectedEngine === 'pyint' && pyintPreview && pyintPreview.allow_submit === false;
|
||||
const taskMonitor = useTaskMonitor({
|
||||
taskTypes: DINSAR_PRODUCTION_TASK_TYPES,
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
});
|
||||
const latestRunWithTask = runs.find(run => run?.task_id) || null;
|
||||
const monitoredTask = activeTask || recentTask || (
|
||||
const monitoredTask = taskMonitor.latestTask || (
|
||||
latestRunWithTask
|
||||
? {
|
||||
task_id: latestRunWithTask.task_id,
|
||||
@@ -597,7 +621,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
? 'ISCE2_RUN'
|
||||
: latestRunWithTask.engine === 'pyint'
|
||||
? 'PYINT_RUN'
|
||||
: 'IDL_RUN_DINSAR',
|
||||
: latestRunWithTask.engine === 'landsar'
|
||||
? 'LANDSAR_RUN'
|
||||
: 'IDL_RUN_DINSAR',
|
||||
status: latestRunWithTask.raw_status || latestRunWithTask.status,
|
||||
progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null,
|
||||
message: latestRunWithTask.message || '最近一次任务',
|
||||
@@ -605,7 +631,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
: null
|
||||
);
|
||||
const logTaskId = monitoredTask?.task_id || '';
|
||||
const showingRecentTask = !activeTask && !!monitoredTask;
|
||||
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
|
||||
|
||||
const loadEngines = useCallback(async () => {
|
||||
setEnginesLoading(true);
|
||||
@@ -640,7 +666,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const allTasks = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const data = await getRecentTasks(['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'], [], TASK_HISTORY_PAGE_SIZE, offset);
|
||||
const data = await getRecentTasks(DINSAR_PRODUCTION_TASK_TYPES, [], TASK_HISTORY_PAGE_SIZE, offset);
|
||||
const pageTasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
allTasks.push(...pageTasks);
|
||||
if (pageTasks.length < TASK_HISTORY_PAGE_SIZE) break;
|
||||
@@ -663,32 +689,6 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadActiveTask = useCallback(async () => {
|
||||
try {
|
||||
const data = await getActiveTasks();
|
||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
const relevantTask = tasks.find(task => ['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null;
|
||||
setActiveTask(relevantTask);
|
||||
return relevantTask;
|
||||
} catch {
|
||||
setActiveTask(null);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecentTask = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRecentTasks(['ISCE2_RUN', 'PYINT_RUN', 'IDL_RUN_DINSAR'], [], 1, 0);
|
||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
const relevantTask = tasks[0] || null;
|
||||
setRecentTask(relevantTask);
|
||||
return relevantTask;
|
||||
} catch {
|
||||
setRecentTask(null);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTaskLogs = useCallback(async (taskId, options = {}) => {
|
||||
const silent = !!options.silent;
|
||||
if (!taskId) {
|
||||
@@ -744,18 +744,17 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
|
||||
const refreshMonitor = useCallback(async (options = {}) => {
|
||||
const silent = !!options.silent;
|
||||
const [nextRuns, nextActiveTask, nextRecentTask] = await Promise.all([
|
||||
const [nextRuns, nextRecentTasks] = await Promise.all([
|
||||
loadRuns({ silent }),
|
||||
loadActiveTask(),
|
||||
loadRecentTask(),
|
||||
taskMonitor.refreshRecentTasks(),
|
||||
]);
|
||||
const fallbackTaskId =
|
||||
nextActiveTask?.task_id
|
||||
|| nextRecentTask?.task_id
|
||||
taskMonitor.activeTasks[0]?.task_id
|
||||
|| nextRecentTasks[0]?.task_id
|
||||
|| nextRuns.find(run => run?.task_id)?.task_id
|
||||
|| '';
|
||||
await loadTaskLogs(fallbackTaskId, { silent });
|
||||
}, [loadActiveTask, loadRecentTask, loadRuns, loadTaskLogs]);
|
||||
}, [loadRuns, loadTaskLogs, taskMonitor]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEngines();
|
||||
@@ -763,12 +762,12 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}, [loadEngines, refreshMonitor]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalMs = activeTask ? 5000 : 15000;
|
||||
const intervalMs = taskMonitor.isBusy ? 5000 : 15000;
|
||||
const timer = window.setInterval(() => {
|
||||
refreshMonitor({ silent: true });
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activeTask, refreshMonitor]);
|
||||
}, [taskMonitor.isBusy, refreshMonitor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProfiles.length > 0) {
|
||||
|
||||
@@ -2,8 +2,9 @@ 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 { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks';
|
||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const PRODUCT_TASK_TYPES = [
|
||||
'SCAN_DINSAR',
|
||||
@@ -55,42 +56,20 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
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 taskMonitor = useTaskMonitor({
|
||||
taskTypes: PRODUCT_TASK_TYPES,
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
});
|
||||
const monitoredTask = taskMonitor.latestTask;
|
||||
const logTaskId = monitoredTask?.task_id || '';
|
||||
const showingRecentTask = !activeTask && !!recentTask;
|
||||
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
|
||||
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([]);
|
||||
@@ -108,17 +87,14 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
}, []);
|
||||
|
||||
const refreshMonitor = useCallback(async () => {
|
||||
const [nextActiveTask, nextRecentTask] = await Promise.all([
|
||||
loadActiveTask(),
|
||||
loadRecentTask(),
|
||||
]);
|
||||
const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || '';
|
||||
const nextRecentTasks = await taskMonitor.refreshRecentTasks();
|
||||
const nextTaskId = taskMonitor.activeTasks[0]?.task_id || nextRecentTasks[0]?.task_id || logTaskId;
|
||||
await loadTaskLogs(nextTaskId);
|
||||
}, [loadActiveTask, loadRecentTask, loadTaskLogs]);
|
||||
}, [loadTaskLogs, logTaskId, taskMonitor]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshMonitor();
|
||||
}, [refreshMonitor]);
|
||||
loadTaskLogs(logTaskId);
|
||||
}, [loadTaskLogs, logTaskId]);
|
||||
|
||||
const handleDeleteTaskLog = useCallback(async (logId) => {
|
||||
const taskId = logTaskId;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import apiClient from './api/client';
|
||||
import TaskStatusPanel from './components/tasks/TaskStatusPanel';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanComplete, onTaskStart, points: externalPoints, readOnly = false }) => {
|
||||
const [points, setPoints] = useState(Array.isArray(externalPoints) ? externalPoints : []);
|
||||
@@ -7,6 +9,13 @@ const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanC
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const fetchPointsRef = useRef(null);
|
||||
const scanTaskMonitor = useTaskMonitor({
|
||||
taskTypes: ['SCAN_HAZARD'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
const scanBusy = isLoading || scanTaskMonitor.isBusy;
|
||||
|
||||
const fetchPoints = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -84,12 +93,22 @@ const HazardPointPanel = ({ onPointClick, onToggleVisibility, isVisible, onScanC
|
||||
<button
|
||||
className="primary-btn"
|
||||
onClick={handleScan}
|
||||
disabled={isLoading || readOnly}
|
||||
disabled={scanBusy || readOnly}
|
||||
style={{ width: '100%', marginBottom: '10px' }}
|
||||
>
|
||||
{isLoading ? '同步中...' : '同步 Shapefile 数据'}
|
||||
{scanBusy ? '同步中...' : '同步 Shapefile 数据'}
|
||||
</button>
|
||||
|
||||
<TaskStatusPanel
|
||||
title="灾害点同步任务"
|
||||
activeTasks={scanTaskMonitor.activeTasks}
|
||||
recentTasks={scanTaskMonitor.recentTasks}
|
||||
latestTask={scanTaskMonitor.latestTask}
|
||||
isBusy={scanTaskMonitor.isBusy}
|
||||
idleText="当前没有正在执行的灾害点同步任务。"
|
||||
compact
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索编号、名称、市县..."
|
||||
|
||||
@@ -857,7 +857,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
|
||||
<span>{en ? 'Overall' : '总体状态'}</span>
|
||||
{renderBadge(
|
||||
productPackages.ok,
|
||||
`${toNumber(productPackages.canonical_count)} / ${toNumber(productPackages.total_count)}`
|
||||
`${toNumber(productPackages.valid_schema_count ?? productPackages.canonical_count)} / ${toNumber(productPackages.total_count)}`
|
||||
)}
|
||||
</div>
|
||||
<div className="health-card-row">
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
queueImportJob,
|
||||
queueDinsarJob,
|
||||
getRecentRuns,
|
||||
getActiveTasks,
|
||||
forceCancelTask,
|
||||
extractDispResults,
|
||||
getTaskOverview,
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
deleteRun,
|
||||
} from './api/idl';
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
import TaskStatusPanel from './components/tasks/TaskStatusPanel';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
import { getStatistics } from './api/stats';
|
||||
|
||||
@@ -34,9 +35,15 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
const [recentRuns, setRecentRuns] = useState([]);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [runningTask, setRunningTask] = useState(null); // active IDL task from backend
|
||||
const [showUnlockInput, setShowUnlockInput] = useState(false);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [showCancelInput, setShowCancelInput] = useState(false);
|
||||
const [cancelPassword, setCancelPassword] = useState('');
|
||||
const idlTaskMonitor = useTaskMonitor({
|
||||
taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
pollRecentMs: 10000,
|
||||
});
|
||||
const runningTask = idlTaskMonitor.activeTasks[0] || null;
|
||||
|
||||
const [importRootDir, setImportRootDir] = useState('');
|
||||
const [importNumToProcess, setImportNumToProcess] = useState(0);
|
||||
@@ -58,20 +65,12 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
const [dinsarInspect, setDinsarInspect] = useState(null);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
const [s, runs, tasks] = await Promise.all([
|
||||
const [s, runs] = await Promise.all([
|
||||
getEnviStatus(),
|
||||
getRecentRuns(20),
|
||||
getActiveTasks().catch(() => []),
|
||||
]);
|
||||
setStatus(s);
|
||||
setRecentRuns(Array.isArray(runs?.runs) ? runs.runs : []);
|
||||
// Find any running IDL task (IDL_IMPORT or IDL_DINSAR)
|
||||
const taskList = Array.isArray(tasks) ? tasks : [];
|
||||
const active = taskList.find(
|
||||
(t) => ['IDL_IMPORT', 'IDL_DINSAR'].includes(t.task_type) &&
|
||||
['PENDING', 'RUNNING'].includes(t.status)
|
||||
);
|
||||
setRunningTask(active || null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -186,17 +185,18 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceUnlock = async () => {
|
||||
if (!runningTask || !unlockPassword) return;
|
||||
const handleCancelRunningTask = async () => {
|
||||
if (!runningTask || !cancelPassword) return;
|
||||
try {
|
||||
await forceCancelTask(runningTask.task_id, unlockPassword);
|
||||
setMessage('任务已强制取消,前端已解锁。');
|
||||
setShowUnlockInput(false);
|
||||
setUnlockPassword('');
|
||||
await forceCancelTask(runningTask.task_id, cancelPassword);
|
||||
setMessage('任务取消请求已提交。');
|
||||
setShowCancelInput(false);
|
||||
setCancelPassword('');
|
||||
await idlTaskMonitor.refreshRecentTasks();
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
const detail = error?.response?.data?.detail || error?.message || '解锁失败';
|
||||
setMessage(`强制解锁失败: ${detail}`);
|
||||
const detail = error?.response?.data?.detail || error?.message || '取消失败';
|
||||
setMessage(`取消任务失败: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,7 +220,7 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
);
|
||||
};
|
||||
|
||||
// Buttons are locked when: submitting API call, readOnly user, or a backend task is running
|
||||
// Buttons are locally disabled when submitting API calls or an IDL task is already active.
|
||||
const isLocked = isBusy || !!runningTask;
|
||||
|
||||
const demDisplay = status?.dem_base_file || '-';
|
||||
@@ -245,71 +245,46 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Running task indicator */}
|
||||
{runningTask && (
|
||||
<div style={{
|
||||
...cardStyle,
|
||||
background: '#fffbeb',
|
||||
borderColor: '#f59e0b',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '16px' }}>⚙</span>
|
||||
<div style={{ flex: 1, fontSize: '13px' }}>
|
||||
<strong style={{ color: '#b45309' }}>
|
||||
{runningTask.task_type === 'IDL_IMPORT' ? 'Import' : 'D-InSAR'} 任务运行中
|
||||
</strong>
|
||||
{runningTask.progress > 0 && (
|
||||
<span style={{ color: '#92400e', marginLeft: '8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{runningTask.progress}%
|
||||
</span>
|
||||
)}
|
||||
<div style={{ color: '#92400e', marginTop: '3px', fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{runningTask.message || runningTask.status}
|
||||
</div>
|
||||
{runningTask.progress > 0 && (
|
||||
<div style={{ marginTop: '5px', height: '6px', background: '#fde68a', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${runningTask.progress}%`,
|
||||
background: '#f59e0b',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && !showUnlockInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUnlockInput(true)}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#fef2f2',
|
||||
color: '#dc2626',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
强制解锁
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showUnlockInput && (
|
||||
<TaskStatusPanel
|
||||
title="ENVI / SARscape 任务"
|
||||
activeTasks={idlTaskMonitor.activeTasks}
|
||||
recentTasks={idlTaskMonitor.recentTasks}
|
||||
latestTask={idlTaskMonitor.latestTask}
|
||||
isBusy={idlTaskMonitor.isBusy}
|
||||
idleText="当前没有正在执行的 ENVI / SARscape 任务。"
|
||||
action={runningTask && !readOnly && !showCancelInput ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCancelInput(true)}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#fef2f2',
|
||||
color: '#dc2626',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消任务
|
||||
</button>
|
||||
) : null}
|
||||
footer={runningTask ? (
|
||||
<>
|
||||
{showCancelInput && (
|
||||
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="输入管理员密码"
|
||||
value={unlockPassword}
|
||||
onChange={(e) => setUnlockPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleForceUnlock()}
|
||||
value={cancelPassword}
|
||||
onChange={(e) => setCancelPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCancelRunningTask()}
|
||||
style={{ padding: '4px 8px', fontSize: '12px', borderRadius: '4px', border: '1px solid #d1d5db', width: '160px' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForceUnlock}
|
||||
disabled={!unlockPassword}
|
||||
onClick={handleCancelRunningTask}
|
||||
disabled={!cancelPassword}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
@@ -317,28 +292,29 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
background: '#dc2626',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: unlockPassword ? 'pointer' : 'not-allowed',
|
||||
opacity: unlockPassword ? 1 : 0.5,
|
||||
cursor: cancelPassword ? 'pointer' : 'not-allowed',
|
||||
opacity: cancelPassword ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
确认取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUnlockInput(false); setUnlockPassword(''); }}
|
||||
onClick={() => { setShowCancelInput(false); setCancelPassword(''); }}
|
||||
style={{ padding: '4px 10px', borderRadius: '4px', border: '1px solid #d1d5db', background: '#fff', fontSize: '12px', cursor: 'pointer' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!showUnlockInput && (
|
||||
{!showCancelInput && (
|
||||
<div style={{ fontSize: '11px', color: '#92400e', marginTop: '4px', marginLeft: '26px' }}>
|
||||
按钮已锁定,等待任务完成
|
||||
同类 ENVI/SARscape 任务运行中,当前提交按钮暂不可用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{/* Task 状态总览 */}
|
||||
<div style={cardStyle}>
|
||||
|
||||
@@ -69,7 +69,10 @@ export default function ProductionWorkspace({
|
||||
};
|
||||
|
||||
const handleSbasProductQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.');
|
||||
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.', {
|
||||
taskType: 'REBUILD_SBAS_INSAR_CATALOG',
|
||||
nonBlocking: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -181,6 +184,7 @@ export default function ProductionWorkspace({
|
||||
{activeView === 'sbas_insar_production' && (
|
||||
<LazySbasInsarProductionPanel
|
||||
readOnly={readOnly}
|
||||
onTaskStart={onTaskStart}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'sbas_insar_products' && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,6 @@ export const queueDinsarJob = (payload) =>
|
||||
apiClient.post('/idl/jobs/dinsar', payload).then(r => r.data);
|
||||
export const getRecentRuns = (limit = 20) =>
|
||||
apiClient.get(`/idl/jobs/recent?limit=${encodeURIComponent(limit)}`).then(r => r.data);
|
||||
export const getActiveTasks = () =>
|
||||
apiClient.get('/tasks/active').then(r => r.data);
|
||||
export const getTaskLogs = (taskId, limit = 50, offset = 0) =>
|
||||
apiClient.get(`/tasks/${taskId}/logs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`).then(r => r.data);
|
||||
export const forceCancelTask = (taskId, password) =>
|
||||
|
||||
@@ -24,6 +24,9 @@ export const listSbasInsarRuns = () =>
|
||||
export const getSbasInsarRun = runId =>
|
||||
apiClient.get(`/sbas-insar-production/runs/${encodeURIComponent(runId)}`).then(r => r.data);
|
||||
|
||||
export const deleteSbasInsarRun = runId =>
|
||||
apiClient.delete(`/sbas-insar-production/runs/${encodeURIComponent(runId)}`).then(r => r.data);
|
||||
|
||||
export const prepareSbasInsarWorkflow = (runId, payload = {}) =>
|
||||
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/workflow`, payload).then(r => r.data);
|
||||
|
||||
@@ -62,3 +65,18 @@ export const submitSbasInsarIptaTimeseriesJob = (runId, payload = {}) =>
|
||||
|
||||
export const getSbasInsarRunArtifactUrl = (runId, relativePath) =>
|
||||
`/api/sbas-insar-production/runs/${encodeURIComponent(runId)}/artifacts/${encodeArtifactPath(relativePath)}`;
|
||||
|
||||
export const getLandsarSbasCapabilities = () =>
|
||||
apiClient.get('/sbas-insar-production/landsar/capabilities').then(r => r.data);
|
||||
|
||||
export const submitLandsarSbasAutoWorkflow = (payload = {}) =>
|
||||
apiClient.post('/sbas-insar-production/landsar/workflows/auto', payload).then(r => r.data);
|
||||
|
||||
export const listLandsarSbasRuns = () =>
|
||||
apiClient.get('/sbas-insar-production/landsar/runs').then(r => r.data);
|
||||
|
||||
export const getLandsarSbasRun = runId =>
|
||||
apiClient.get(`/sbas-insar-production/landsar/runs/${encodeURIComponent(runId)}`).then(r => r.data);
|
||||
|
||||
export const getLandsarSbasRunArtifactUrl = (runId, relativePath) =>
|
||||
`/api/sbas-insar-production/landsar/runs/${encodeURIComponent(runId)}/artifacts/${encodeArtifactPath(relativePath)}`;
|
||||
|
||||
@@ -12,8 +12,20 @@ export const listSbasInsarProducts = (params = {}) =>
|
||||
export const getSbasInsarProductDetail = productId =>
|
||||
apiClient.get(`/sbas-insar-products/${encodeURIComponent(productId)}`).then(r => r.data);
|
||||
|
||||
export const getSbasInsarProductPreviewUrl = productId =>
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/preview`;
|
||||
export const querySbasInsarPointTimeseries = (productId, payload) =>
|
||||
apiClient.post(`/sbas-insar-products/${encodeURIComponent(productId)}/point-timeseries`, payload).then(r => r.data);
|
||||
|
||||
export const getSbasInsarProductAssetUrl = (productId, assetId) =>
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/assets/${encodeURIComponent(assetId)}`;
|
||||
const appendCacheKey = (url, cacheKey) =>
|
||||
cacheKey ? `${url}?v=${encodeURIComponent(cacheKey)}` : url;
|
||||
|
||||
export const getSbasInsarProductPreviewUrl = (productId, cacheKey = '') =>
|
||||
appendCacheKey(
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/preview`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
export const getSbasInsarProductAssetUrl = (productId, assetId, cacheKey = '') =>
|
||||
appendCacheKey(
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/assets/${encodeURIComponent(assetId)}`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
const getTaskTypeLabel = (taskType) => {
|
||||
if (taskType?.startsWith('WATER_GEOCODE_')) return '水体地理编码';
|
||||
if (taskType?.startsWith('WATER_FLOOD_')) return '洪涝检测';
|
||||
switch (taskType) {
|
||||
case 'SCAN_DATA':
|
||||
return '同步源数据';
|
||||
case 'SCAN_DINSAR':
|
||||
return '扫描结果与自愈';
|
||||
case 'AI_TRAIN':
|
||||
return '训练AI模型';
|
||||
case 'AI_PREDICT':
|
||||
return '全量质量评估';
|
||||
case 'AI_ANALYZE':
|
||||
return 'AI 智能诊断';
|
||||
case 'AI_WARMUP':
|
||||
return 'AI 模型预热';
|
||||
case 'COPY_DATA':
|
||||
return '数据分发拷贝';
|
||||
case 'SCAN_HAZARD':
|
||||
return '灾害点同步';
|
||||
case 'UNPACK_ARCHIVES':
|
||||
return 'LT-1 解包';
|
||||
case 'UNPACK_SENTINEL1':
|
||||
return 'Sentinel-1 解包';
|
||||
case 'GF3_UNPACK':
|
||||
return 'GF3 解包';
|
||||
case 'GF3_BATCH_PROCESS':
|
||||
return 'GF3 预处理';
|
||||
case 'GF3_SARSCAPE_PRODUCE':
|
||||
return 'GF3 SARscape 生产';
|
||||
case 'GF3_SARSCAPE_SYNC':
|
||||
return 'GF3 SARscape 入库';
|
||||
case 'GF3_SARSCAPE_CLEAN':
|
||||
return 'GF3 中间清理';
|
||||
case 'SCAN_ASSET_INVENTORY':
|
||||
return '资产库存扫描';
|
||||
case 'IDL_IMPORT':
|
||||
return 'ENVI 数据导入';
|
||||
case 'IDL_DINSAR':
|
||||
return 'ENVI D-InSAR 生产';
|
||||
default:
|
||||
return taskType;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ActiveTasksOverlay({
|
||||
isVisible,
|
||||
activeTasks,
|
||||
t,
|
||||
isAdmin,
|
||||
showForceUnlock,
|
||||
forceUnlockPwd,
|
||||
onShowForceUnlock,
|
||||
onForceUnlockPwdChange,
|
||||
onForceUnlockConfirm,
|
||||
onCancelForceUnlock,
|
||||
}) {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="global-task-overlay">
|
||||
<div className="overlay-content">
|
||||
<div className="loading-spinner-large"></div>
|
||||
<h3>系统任务执行中</h3>
|
||||
<div className="active-tasks-container">
|
||||
{(() => {
|
||||
const waterTasks = activeTasks.filter(t =>
|
||||
t.task_type?.startsWith('WATER_GEOCODE_') || t.task_type?.startsWith('WATER_FLOOD_')
|
||||
);
|
||||
const otherTasks = activeTasks.filter(t =>
|
||||
!t.task_type?.startsWith('WATER_GEOCODE_') && !t.task_type?.startsWith('WATER_FLOOD_')
|
||||
);
|
||||
const waterDone = waterTasks.filter(t => t.progress >= 100).length;
|
||||
return (
|
||||
<>
|
||||
{otherTasks.map((task) => (
|
||||
<div key={task.task_id} className="task-progress-item">
|
||||
<div className="task-info-row">
|
||||
<span className="task-label">{getTaskTypeLabel(task.task_type)}</span>
|
||||
<span className="task-percent">{task.progress}%</span>
|
||||
</div>
|
||||
<div className="task-progress-bar">
|
||||
<div className="task-progress-fill" style={{ width: `${task.progress}%` }}></div>
|
||||
</div>
|
||||
<p className="task-status-msg">{t(task.message || '')}</p>
|
||||
</div>
|
||||
))}
|
||||
{waterTasks.length > 0 && (
|
||||
<div className="task-progress-item">
|
||||
<div className="task-info-row">
|
||||
<span className="task-label">水体处理(剩余 {waterTasks.length - waterDone} 景)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="overlay-footer-hint">为了保证数据一致性,耗时任务执行期间 UI 已锁定。任务完成后将自动刷新页面数据。</p>
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: '16px', textAlign: 'center' }}>
|
||||
{!showForceUnlock ? (
|
||||
<button
|
||||
onClick={onShowForceUnlock}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
管理员强制解锁
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="输入管理员密码"
|
||||
value={forceUnlockPwd}
|
||||
onChange={(e) => onForceUnlockPwdChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onForceUnlockConfirm();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
width: '160px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={!forceUnlockPwd}
|
||||
onClick={onForceUnlockConfirm}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#dc2626',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: forceUnlockPwd ? 'pointer' : 'not-allowed',
|
||||
opacity: forceUnlockPwd ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
确认解锁
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelForceUnlock}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
background: 'transparent',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { getTaskTypeLabel } from '../config/taskUiPolicies';
|
||||
|
||||
export default function GlobalTaskCenter({
|
||||
isVisible,
|
||||
activeTasks,
|
||||
t,
|
||||
isAdmin,
|
||||
showCancelTask,
|
||||
cancelTaskPwd,
|
||||
onShowCancelTask,
|
||||
onCancelTaskPwdChange,
|
||||
onCancelTaskConfirm,
|
||||
onCloseCancelTask,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!isVisible || activeTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeCount = activeTasks.length;
|
||||
const avgProgress = Math.round(
|
||||
activeTasks.reduce((sum, task) => sum + (Number(task.progress) || 0), 0) / Math.max(1, activeCount)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="global-task-overlay">
|
||||
{!expanded && (
|
||||
<button className="task-center-button" onClick={() => setExpanded(true)}>
|
||||
<span className="task-center-dot" />
|
||||
<span>后台任务 {activeCount}</span>
|
||||
<strong>{avgProgress}%</strong>
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="overlay-content">
|
||||
<div className="task-center-header">
|
||||
<div>
|
||||
<h3>后台任务</h3>
|
||||
<p>任务正在执行,你可以继续使用其他功能;同类重复提交由系统限制。</p>
|
||||
</div>
|
||||
<button className="task-center-close" onClick={() => setExpanded(false)} aria-label="关闭任务中心">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="active-tasks-container">
|
||||
{(() => {
|
||||
const waterTasks = activeTasks.filter(task =>
|
||||
task.task_type?.startsWith('WATER_GEOCODE_') || task.task_type?.startsWith('WATER_FLOOD_')
|
||||
);
|
||||
const otherTasks = activeTasks.filter(task =>
|
||||
!task.task_type?.startsWith('WATER_GEOCODE_') && !task.task_type?.startsWith('WATER_FLOOD_')
|
||||
);
|
||||
const waterDone = waterTasks.filter(task => task.progress >= 100).length;
|
||||
return (
|
||||
<>
|
||||
{otherTasks.map((task) => (
|
||||
<div key={task.task_id} className="task-progress-item">
|
||||
<div className="task-info-row">
|
||||
<span className="task-label">{getTaskTypeLabel(task.task_type)}</span>
|
||||
<span className="task-percent">{Number(task.progress) || 0}%</span>
|
||||
</div>
|
||||
<div className="task-progress-bar">
|
||||
<div className="task-progress-fill" style={{ width: `${Number(task.progress) || 0}%` }}></div>
|
||||
</div>
|
||||
<p className="task-status-msg">{t(task.message || '')}</p>
|
||||
</div>
|
||||
))}
|
||||
{waterTasks.length > 0 && (
|
||||
<div className="task-progress-item">
|
||||
<div className="task-info-row">
|
||||
<span className="task-label">水体处理(剩余 {waterTasks.length - waterDone} 景)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="overlay-footer-hint">任务中心只展示状态,不再锁定整个界面。需要互斥的操作由功能页按钮和后端任务冲突检查处理。</p>
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: '16px', textAlign: 'center' }}>
|
||||
{!showCancelTask ? (
|
||||
<button
|
||||
onClick={onShowCancelTask}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fecaca',
|
||||
background: '#fff1f2',
|
||||
color: '#b91c1c',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
管理员取消任务
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="输入管理员密码"
|
||||
value={cancelTaskPwd}
|
||||
onChange={(e) => onCancelTaskPwdChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCancelTaskConfirm();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
width: '160px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={!cancelTaskPwd}
|
||||
onClick={onCancelTaskConfirm}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#dc2626',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: cancelTaskPwd ? 'pointer' : 'not-allowed',
|
||||
opacity: cancelTaskPwd ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
确认取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onCloseCancelTask}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#ffffff',
|
||||
color: '#334155',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { getAvailableSatellites } from '../api/radar';
|
||||
// 配对策略说明
|
||||
const STRATEGY_DESCRIPTIONS = {
|
||||
all: {
|
||||
title: '全部配对(默认)',
|
||||
title: '全部配对',
|
||||
description: '列出所有满足约束条件的候选干涉对,由用户自行筛选。',
|
||||
details: [
|
||||
'• 系统遍历所有影像组合,保留满足时间基线和两景 footprint 最小重叠率的配对',
|
||||
@@ -19,7 +19,7 @@ const STRATEGY_DESCRIPTIONS = {
|
||||
params: '参数:时间基线范围、两景 footprint 最小重叠率、可选 footprint 中心距上限'
|
||||
},
|
||||
sbas: {
|
||||
title: 'SBAS (短基线子集)',
|
||||
title: 'SBAS (短基线子集,推荐)',
|
||||
description: '基于短基线原则的配对策略,通过覆盖优化算法自动筛选配对。',
|
||||
details: [
|
||||
'• 优先选择时间间隔较短、覆盖质量较好的配对;可按需启用 footprint 中心距限制',
|
||||
@@ -204,6 +204,11 @@ function PairingModal({
|
||||
Star (星型)
|
||||
</label>
|
||||
</div>
|
||||
{pairingParams.strategy === 'all' && (
|
||||
<div style={{ marginTop: 8, padding: '8px 10px', borderRadius: 8, background: '#fff7ed', border: '1px solid #fdba74', color: '#9a3412', fontSize: 12, lineHeight: 1.5 }}>
|
||||
全部配对会返回所有候选边;当前数据量较大时请先限定 AOI 或主/从影像时间范围。做 SBAS 生产建议使用“SBAS (短基线)”策略。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主影像时间范围 */}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ModalLoadingFallback } from './AppLoadingFallbacks';
|
||||
const LazyPairingModal = lazy(() => import('../PairingModal'));
|
||||
const LazyPsStackModal = lazy(() => import('../PsStackModal'));
|
||||
const LazyDataInfoModal = lazy(() => import('../DataInfoModal'));
|
||||
const LazyActiveTasksOverlay = lazy(() => import('../ActiveTasksOverlay'));
|
||||
const LazyGlobalTaskCenter = lazy(() => import('../GlobalTaskCenter'));
|
||||
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
|
||||
const LazyAiReportModal = lazy(() => import('../AiReportModal'));
|
||||
const LazyMapExportModal = lazy(() => import('../MapExportModal'));
|
||||
@@ -31,14 +31,13 @@ export default function AppOverlays({
|
||||
onRefreshLicenseStatus,
|
||||
licenseFileName,
|
||||
licenseUploadStatus,
|
||||
isGlobalLocked,
|
||||
activeTasks,
|
||||
showForceUnlock,
|
||||
forceUnlockPwd,
|
||||
onShowForceUnlock,
|
||||
onForceUnlockPwdChange,
|
||||
onForceUnlockConfirm,
|
||||
onCancelForceUnlock,
|
||||
showCancelTask,
|
||||
cancelTaskPwd,
|
||||
onShowCancelTask,
|
||||
onCancelTaskPwdChange,
|
||||
onCancelTaskConfirm,
|
||||
onCloseCancelTask,
|
||||
mapExport,
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
@@ -126,19 +125,19 @@ export default function AppOverlays({
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{isGlobalLocked && (
|
||||
<Suspense fallback={<ModalLoadingFallback message="正在加载任务控制面板..." />}>
|
||||
<LazyActiveTasksOverlay
|
||||
isVisible={isGlobalLocked}
|
||||
{activeTasks.length > 0 && (
|
||||
<Suspense fallback={<ModalLoadingFallback message="正在加载任务中心..." />}>
|
||||
<LazyGlobalTaskCenter
|
||||
isVisible={activeTasks.length > 0}
|
||||
activeTasks={activeTasks}
|
||||
t={t}
|
||||
isAdmin={isAdmin}
|
||||
showForceUnlock={showForceUnlock}
|
||||
forceUnlockPwd={forceUnlockPwd}
|
||||
onShowForceUnlock={onShowForceUnlock}
|
||||
onForceUnlockPwdChange={onForceUnlockPwdChange}
|
||||
onForceUnlockConfirm={onForceUnlockConfirm}
|
||||
onCancelForceUnlock={onCancelForceUnlock}
|
||||
showCancelTask={showCancelTask}
|
||||
cancelTaskPwd={cancelTaskPwd}
|
||||
onShowCancelTask={onShowCancelTask}
|
||||
onCancelTaskPwdChange={onCancelTaskPwdChange}
|
||||
onCancelTaskConfirm={onCancelTaskConfirm}
|
||||
onCloseCancelTask={onCloseCancelTask}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel'));
|
||||
const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel'));
|
||||
const LazyPsResultsPanel = lazy(() => import('../../panels/PsResultsPanel'));
|
||||
const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel'));
|
||||
const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarMapAnalysisPanel'));
|
||||
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
|
||||
|
||||
export default function AppSidePanel({
|
||||
@@ -62,6 +63,7 @@ export default function AppSidePanel({
|
||||
aiPanel,
|
||||
pairsPanel,
|
||||
psPanel,
|
||||
sbasAnalysisPanel,
|
||||
}) {
|
||||
const isProductionWorkspace = PRODUCTION_WORKSPACE_ROUTE_TABS.has(leftPanelTab);
|
||||
const activeLeftGroup = LEFT_TAB_GROUP[leftPanelTab] || 'data';
|
||||
@@ -425,13 +427,12 @@ export default function AppSidePanel({
|
||||
|
||||
{leftPanelTab === 'psinsar_analysis' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div className="empty-state">
|
||||
时序InSAR 分析页已预留。
|
||||
<br />
|
||||
后续可以在这里放置时序分析、速率分级、热点识别和专题统计能力。
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载时序InSAR地图分析..." />}>
|
||||
<LazySbasInsarMapAnalysisPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
{...sbasAnalysisPanel}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getTaskTypeLabel } from '../../config/taskUiPolicies';
|
||||
|
||||
const toneColor = {
|
||||
active: {
|
||||
border: '#f59e0b',
|
||||
bg: '#fffbeb',
|
||||
text: '#92400e',
|
||||
fill: '#f59e0b',
|
||||
},
|
||||
recent: {
|
||||
border: '#93c5fd',
|
||||
bg: '#eff6ff',
|
||||
text: '#1d4ed8',
|
||||
fill: '#3b82f6',
|
||||
},
|
||||
idle: {
|
||||
border: '#e2e8f0',
|
||||
bg: '#f8fafc',
|
||||
text: '#64748b',
|
||||
fill: '#94a3b8',
|
||||
},
|
||||
error: {
|
||||
border: '#fecaca',
|
||||
bg: '#fef2f2',
|
||||
text: '#b91c1c',
|
||||
fill: '#dc2626',
|
||||
},
|
||||
};
|
||||
|
||||
const normalizeProgress = (value) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return Math.max(0, Math.min(100, numeric));
|
||||
};
|
||||
|
||||
const isFailed = (task) => String(task?.status || '').toUpperCase() === 'FAILED';
|
||||
|
||||
export default function TaskStatusPanel({
|
||||
title = '任务状态',
|
||||
activeTasks = [],
|
||||
recentTasks = [],
|
||||
latestTask = null,
|
||||
isBusy = false,
|
||||
idleText = '当前没有正在执行的相关任务。',
|
||||
compact = false,
|
||||
action = null,
|
||||
footer = null,
|
||||
}) {
|
||||
const task = latestTask || activeTasks[0] || recentTasks[0] || null;
|
||||
const showingRecent = !isBusy && !!task;
|
||||
const tone = task ? (isFailed(task) ? 'error' : (showingRecent ? 'recent' : 'active')) : 'idle';
|
||||
const colors = toneColor[tone];
|
||||
const progress = normalizeProgress(task?.progress);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="task-status-panel"
|
||||
style={{
|
||||
padding: compact ? 10 : 12,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${colors.border}`,
|
||||
background: colors.bg,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
|
||||
<div>
|
||||
<strong style={{ color: colors.text, fontSize: compact ? 13 : 14 }}>{title}</strong>
|
||||
{task && (
|
||||
<span style={{ marginLeft: 8, color: colors.text, fontSize: 12 }}>
|
||||
{showingRecent ? '最近一次' : '运行中'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
{!task ? (
|
||||
<div style={{ marginTop: 7, color: colors.text, fontSize: 12 }}>{idleText}</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginTop: 8, color: colors.text, fontSize: 12, wordBreak: 'break-all' }}>
|
||||
<span style={{ fontWeight: 700 }}>{getTaskTypeLabel(task.task_type)}</span>
|
||||
<span style={{ margin: '0 6px' }}>·</span>
|
||||
<span>{String(task.status || '-').toUpperCase()}</span>
|
||||
{task.task_id && (
|
||||
<>
|
||||
<span style={{ margin: '0 6px' }}>·</span>
|
||||
<code style={{ fontSize: 11 }}>{task.task_id}</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 5, color: colors.text, fontSize: 12, wordBreak: 'break-word' }}>
|
||||
{task.message || '-'}
|
||||
</div>
|
||||
{progress !== null && (
|
||||
<div style={{ marginTop: 7, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flex: 1, height: 7, background: '#ffffff', borderRadius: 999, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progress}%`,
|
||||
background: colors.fill,
|
||||
transition: 'width 0.25s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ color: colors.text, fontSize: 11, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{footer && <div style={{ marginTop: 7 }}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export const LEFT_GROUP_SECTIONS = {
|
||||
{
|
||||
key: 'psinsar',
|
||||
label: '时序InSAR',
|
||||
tabs: ['psinsar_results', 'psinsar_analysis'],
|
||||
tabs: ['psinsar_analysis'],
|
||||
},
|
||||
],
|
||||
ai_analysis: [
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const TASK_UI_POLICIES = {
|
||||
SCAN_DATA: { label: '同步源数据', featureScope: 'data_monitor' },
|
||||
SCAN_DINSAR: { label: '扫描 D-InSAR 结果', featureScope: 'dinsar_products' },
|
||||
DINSAR_RESULT_SCAN: { label: 'D-InSAR 结果扫描', featureScope: 'dinsar_products' },
|
||||
AI_TRAIN: { label: '训练 AI 模型', featureScope: 'ai' },
|
||||
AI_PREDICT: { label: '全量质量评估', featureScope: 'ai' },
|
||||
AI_ANALYZE: { label: 'AI 智能诊断', featureScope: 'ai' },
|
||||
AI_WARMUP: { label: 'AI 模型预热', featureScope: 'ai' },
|
||||
COPY_DATA: { label: '数据分发拷贝', featureScope: 'data_monitor' },
|
||||
SCAN_HAZARD: { label: '灾害点同步', featureScope: 'hazard' },
|
||||
UNPACK_ARCHIVES: { label: 'LT-1 解包', featureScope: 'data_monitor' },
|
||||
UNPACK_SENTINEL1: { label: 'Sentinel-1 解包', featureScope: 'data_monitor' },
|
||||
GF3_UNPACK: { label: 'GF3 解包', featureScope: 'data_monitor' },
|
||||
GF3_BATCH_PROCESS: { label: 'GF3 预处理', featureScope: 'data_monitor' },
|
||||
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产', featureScope: 'data_monitor' },
|
||||
GF3_SARSCAPE_SYNC: { label: 'GF3 SARscape 入库', featureScope: 'data_monitor' },
|
||||
GF3_SARSCAPE_CLEAN: { label: 'GF3 中间清理', featureScope: 'data_monitor' },
|
||||
SCAN_ASSET_INVENTORY: { label: '资产库存扫描', featureScope: 'asset_inventory' },
|
||||
IDL_IMPORT: { label: 'ENVI 数据导入', featureScope: 'dinsar_production' },
|
||||
IDL_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' },
|
||||
IDL_RUN_DINSAR: { label: 'ENVI D-InSAR 生产', featureScope: 'dinsar_production' },
|
||||
ISCE2_RUN: { label: 'ISCE2 D-InSAR 生产', featureScope: 'dinsar_production' },
|
||||
PYINT_RUN: { label: 'PyINT D-InSAR 生产', featureScope: 'dinsar_production' },
|
||||
LANDSAR_RUN: { label: 'LandSAR D-InSAR 生产', featureScope: 'dinsar_production' },
|
||||
SBAS_GAMMA_WORKFLOW: { label: 'Gamma SBAS 工作流', featureScope: 'sbas_insar' },
|
||||
SBAS_LANDSAR_WORKFLOW: { label: 'LandSAR SBAS 工作流', featureScope: 'sbas_insar' },
|
||||
SBAS_COREGISTRATION: { label: 'SBAS 配准', featureScope: 'sbas_insar' },
|
||||
SBAS_RDC_DEM: { label: 'SBAS RDC DEM', featureScope: 'sbas_insar' },
|
||||
SBAS_INTERFEROGRAMS: { label: 'SBAS 干涉图', featureScope: 'sbas_insar' },
|
||||
SBAS_IPTA_TIMESERIES: { label: 'SBAS IPTA 时序', featureScope: 'sbas_insar' },
|
||||
REBUILD_SBAS_INSAR_CATALOG: { label: 'SBAS 结果目录重建', featureScope: 'sbas_products' },
|
||||
};
|
||||
|
||||
const PREFIX_POLICIES = [
|
||||
{ prefix: 'WATER_GEOCODE_', label: '水体地理编码', featureScope: 'water' },
|
||||
{ prefix: 'WATER_DETECT_', label: '水体检测', featureScope: 'water' },
|
||||
{ prefix: 'WATER_FLOOD_', label: '洪涝检测', featureScope: 'water' },
|
||||
{ prefix: 'FLOOD_SCENE_PREPROCESS_', label: '洪涝场景预处理', featureScope: 'flood' },
|
||||
{ prefix: 'FLOOD_WATER_EXTRACTION_', label: '洪涝水体提取', featureScope: 'flood' },
|
||||
{ prefix: 'FLOOD_DETECTION_', label: '洪涝检测', featureScope: 'flood' },
|
||||
{ prefix: 'GF3_PROCESS_', label: 'GF3 场景处理', featureScope: 'water' },
|
||||
];
|
||||
|
||||
export function getTaskUiPolicy(taskType) {
|
||||
const normalized = String(taskType || '').trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return {
|
||||
taskType: '',
|
||||
label: '后台任务',
|
||||
featureScope: 'unknown',
|
||||
globalVisible: true,
|
||||
globalBlocking: false,
|
||||
localBlocking: true,
|
||||
};
|
||||
}
|
||||
const exact = TASK_UI_POLICIES[normalized];
|
||||
const prefix = PREFIX_POLICIES.find((item) => normalized.startsWith(item.prefix));
|
||||
const policy = exact || prefix || {};
|
||||
return {
|
||||
taskType: normalized,
|
||||
label: policy.label || normalized,
|
||||
featureScope: policy.featureScope || 'unknown',
|
||||
globalVisible: policy.globalVisible ?? true,
|
||||
globalBlocking: policy.globalBlocking ?? false,
|
||||
localBlocking: policy.localBlocking ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isTaskGloballyBlocking(taskType) {
|
||||
return !!getTaskUiPolicy(taskType).globalBlocking;
|
||||
}
|
||||
|
||||
export function getTaskTypeLabel(taskType) {
|
||||
return getTaskUiPolicy(taskType).label;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export default function useAppAuthLifecycle({
|
||||
setHasRadarSearched,
|
||||
setCurrentUser,
|
||||
setAuthChecked,
|
||||
setIsGlobalLocked,
|
||||
setPendingTaskIds,
|
||||
setLicenseLoading,
|
||||
setLicenseStatus,
|
||||
@@ -72,7 +71,6 @@ export default function useAppAuthLifecycle({
|
||||
setHasRadarSearched(false);
|
||||
clearRadarSearchResults();
|
||||
setCurrentUser(null);
|
||||
setIsGlobalLocked(false);
|
||||
setPendingTaskIds([]);
|
||||
prevLicenseOkRef.current = false;
|
||||
setAuthChecked(true);
|
||||
@@ -82,7 +80,6 @@ export default function useAppAuthLifecycle({
|
||||
radarSearchRequestSeqRef,
|
||||
setHasRadarSearched,
|
||||
setCurrentUser,
|
||||
setIsGlobalLocked,
|
||||
setPendingTaskIds,
|
||||
prevLicenseOkRef,
|
||||
setAuthChecked,
|
||||
|
||||
@@ -11,8 +11,6 @@ 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', 'UNPACK_SENTINEL1', 'GF3_UNPACK', 'GF3_SARSCAPE_PRODUCE', 'GF3_SARSCAPE_SYNC', 'GF3_SARSCAPE_CLEAN', 'SCAN_ASSET_INVENTORY', 'COPY_DATA']);
|
||||
|
||||
export default function useDinsarOperations({
|
||||
onCleanupDinsarLayers,
|
||||
fetchRadarImagingDates,
|
||||
@@ -26,7 +24,7 @@ export default function useDinsarOperations({
|
||||
setAiStatus, setActiveAiReport,
|
||||
} = useDinsarStore();
|
||||
const { setHazardPoints } = useHazardStore();
|
||||
const { setPendingTaskIds, setNonBlockingTaskIds, setIsGlobalLocked } = useTaskStore();
|
||||
const { setPendingTaskIds } = useTaskStore();
|
||||
const { currentUser } = useAuthStore();
|
||||
const {
|
||||
hasRadarSearched, radarPagination,
|
||||
@@ -123,16 +121,8 @@ export default function useDinsarOperations({
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
if (message) addLog('info', message);
|
||||
};
|
||||
@@ -313,7 +303,6 @@ export default function useDinsarOperations({
|
||||
const handleAnalyzeResult = async (resultId) => {
|
||||
if (!ensureCanOperate()) return;
|
||||
addLog('info', `正在对结果 ID:${resultId} 发起 AI 智能诊断任务...`);
|
||||
setIsGlobalLocked(true);
|
||||
try {
|
||||
const response = await apiClient.post(`/ai/analyze-result/${resultId}`);
|
||||
const taskId = response.data.task_id;
|
||||
@@ -322,7 +311,6 @@ export default function useDinsarOperations({
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.detail || error.message;
|
||||
addLog('error', `发起 AI 诊断失败: ${msg}`);
|
||||
setIsGlobalLocked(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@ 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', 'UNPACK_SENTINEL1', 'GF3_UNPACK', 'GF3_SARSCAPE_PRODUCE', 'GF3_SARSCAPE_SYNC', 'GF3_SARSCAPE_CLEAN', 'SCAN_ASSET_INVENTORY', 'COPY_DATA']);
|
||||
|
||||
const isTaskNonBlocking = (taskId, taskType, nonBlockingTaskIds = []) => (
|
||||
NON_BLOCKING_TASK_TYPES.has(String(taskType || '').toUpperCase())
|
||||
|| nonBlockingTaskIds.includes(taskId)
|
||||
);
|
||||
|
||||
export default function useGlobalTaskControl({
|
||||
currentUser,
|
||||
licenseOk,
|
||||
@@ -16,32 +9,15 @@ export default function useGlobalTaskControl({
|
||||
setActiveTasks,
|
||||
pendingTaskIds,
|
||||
setPendingTaskIds,
|
||||
nonBlockingTaskIds,
|
||||
setNonBlockingTaskIds,
|
||||
isGlobalLocked,
|
||||
setIsGlobalLocked,
|
||||
setIsCheckingTasks,
|
||||
handleTaskCompletionRef,
|
||||
initializeAppDataRef,
|
||||
addLog,
|
||||
}) {
|
||||
const [forceUnlockPwd, setForceUnlockPwd] = useState('');
|
||||
const [showForceUnlock, setShowForceUnlock] = useState(false);
|
||||
const lastLockTimeRef = useRef(null);
|
||||
|
||||
const isGlobalLockedRef = useRef(isGlobalLocked);
|
||||
useEffect(() => {
|
||||
isGlobalLockedRef.current = isGlobalLocked;
|
||||
if (isGlobalLocked) {
|
||||
lastLockTimeRef.current = Date.now();
|
||||
}
|
||||
}, [isGlobalLocked]);
|
||||
const [cancelTaskPwd, setCancelTaskPwd] = useState('');
|
||||
const [showCancelTask, setShowCancelTask] = useState(false);
|
||||
|
||||
// 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);
|
||||
@@ -51,26 +27,13 @@ export default function useGlobalTaskControl({
|
||||
setIsCheckingTasks(false);
|
||||
|
||||
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) {
|
||||
@@ -121,47 +84,16 @@ export default function useGlobalTaskControl({
|
||||
|
||||
if (reallyFinishedIds.length > 0) {
|
||||
console.log('真正完成的任务:', reallyFinishedIds);
|
||||
setPendingTaskIds((prev) => {
|
||||
const newPending = prev.filter((id) => !reallyFinishedIds.includes(id));
|
||||
updatedPending = newPending;
|
||||
return newPending;
|
||||
});
|
||||
setNonBlockingTaskIds((prev) => prev.filter((id) => !reallyFinishedIds.includes(id)));
|
||||
effectiveNonBlockingTaskIds = effectiveNonBlockingTaskIds.filter((id) => !reallyFinishedIds.includes(id));
|
||||
} else {
|
||||
// 没有真正完成的任务,保持 updatedPending 不变
|
||||
updatedPending = currentPending;
|
||||
setPendingTaskIds((prev) => prev.filter((id) => !reallyFinishedIds.includes(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当没有运行中的任务且没有待处理的任务时,才解锁
|
||||
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);
|
||||
if (!shouldBeLocked) {
|
||||
addLog('success', '后台任务已完成,正在同步最新数据...');
|
||||
setTimeout(() => {
|
||||
initializeAppDataRef.current?.({ refreshRadarSearch: true });
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
setActiveTasks,
|
||||
setIsCheckingTasks,
|
||||
handleTaskCompletionRef,
|
||||
setPendingTaskIds,
|
||||
setNonBlockingTaskIds,
|
||||
setIsGlobalLocked,
|
||||
addLog,
|
||||
initializeAppDataRef,
|
||||
]);
|
||||
|
||||
// Fallback polling (used when SSE is unavailable)
|
||||
@@ -216,22 +148,22 @@ export default function useGlobalTaskControl({
|
||||
};
|
||||
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]);
|
||||
|
||||
const handleForceUnlock = useCallback(() => {
|
||||
if (!forceUnlockPwd || activeTasks.length === 0) return;
|
||||
const handleCancelActiveTasks = useCallback(() => {
|
||||
if (!cancelTaskPwd || activeTasks.length === 0) return;
|
||||
Promise.all(activeTasks.map((task) =>
|
||||
apiClient.post(`/tasks/${task.task_id}/force-cancel`, { password: forceUnlockPwd }).catch(() => {})
|
||||
apiClient.post(`/tasks/${task.task_id}/force-cancel`, { password: cancelTaskPwd }).catch(() => {})
|
||||
)).then(() => {
|
||||
setForceUnlockPwd('');
|
||||
setShowForceUnlock(false);
|
||||
setCancelTaskPwd('');
|
||||
setShowCancelTask(false);
|
||||
syncActiveTasks();
|
||||
});
|
||||
}, [activeTasks, forceUnlockPwd, syncActiveTasks]);
|
||||
}, [activeTasks, cancelTaskPwd, syncActiveTasks]);
|
||||
|
||||
return {
|
||||
forceUnlockPwd,
|
||||
setForceUnlockPwd,
|
||||
showForceUnlock,
|
||||
setShowForceUnlock,
|
||||
handleForceUnlock,
|
||||
cancelTaskPwd,
|
||||
setCancelTaskPwd,
|
||||
showCancelTask,
|
||||
setShowCancelTask,
|
||||
handleCancelActiveTasks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* focusBatchAfterCreate, clearPsResults
|
||||
*/
|
||||
import apiClient from '../api/client';
|
||||
import { getPairingHealth } from '../api/pairing';
|
||||
import {
|
||||
useUiStore, usePairingStore, useMapStore, useBatchStore, useAuthStore,
|
||||
} from '../store';
|
||||
@@ -171,8 +172,42 @@ export default function usePairingLogic({
|
||||
setPairingAlert({ warnings: [], fallbackUsed: false });
|
||||
|
||||
const formData = new FormData();
|
||||
for (const key in pairingParams) {
|
||||
const value = pairingParams[key];
|
||||
const effectivePairingParams = { ...pairingParams };
|
||||
if (!effectivePairingParams.strategy) {
|
||||
effectivePairingParams.strategy = 'sbas';
|
||||
}
|
||||
if (effectivePairingParams.strategy === 'all') {
|
||||
const hasDateWindow = Boolean(
|
||||
effectivePairingParams.master_date_from
|
||||
|| effectivePairingParams.master_date_to
|
||||
|| effectivePairingParams.slave_date_from
|
||||
|| effectivePairingParams.slave_date_to
|
||||
);
|
||||
if (!hasDateWindow && pairingAoiMode !== 'region' && !pairingFiles?.length) {
|
||||
addLog('warn', '全部配对可能返回大量结果。请先限定行政区、上传 AOI 或设置主/从影像时间范围。');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pairingHealth = await getPairingHealth();
|
||||
if (pairingHealth?.needs_rebuild || pairingHealth?.status !== 'READY') {
|
||||
addLog(
|
||||
'warn',
|
||||
`配对基础当前状态为 ${pairingHealth?.status || 'UNKNOWN'},dirty 场景 ${Number(pairingHealth?.dirty_scene_count || 0)}。请先在“配对规划”页执行“修复配对基础”。`
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
addLog('warn', `配对基础状态检查失败: ${error.response?.data?.detail || error.message}`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in effectivePairingParams) {
|
||||
const value = effectivePairingParams[key];
|
||||
// 跳过 null/undefined 值
|
||||
if (value === null || value === undefined) continue;
|
||||
// allowed_satellites 是数组,需要序列化为 JSON
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTaskStore } from '../store';
|
||||
import { getRecentTasks, getTaskLogs } from '../api/tasks';
|
||||
|
||||
const normalizeList = (value) => (Array.isArray(value) ? value.filter(Boolean) : []);
|
||||
|
||||
const matchesTask = (task, taskTypes, taskTypePrefixes, taskIds) => {
|
||||
const taskId = String(task?.task_id || '');
|
||||
const taskType = String(task?.task_type || '').toUpperCase();
|
||||
if (taskIds.length && taskIds.includes(taskId)) return true;
|
||||
if (taskTypes.length && taskTypes.includes(taskType)) return true;
|
||||
if (taskTypePrefixes.length && taskTypePrefixes.some(prefix => taskType.startsWith(prefix))) return true;
|
||||
return !taskTypes.length && !taskTypePrefixes.length && !taskIds.length;
|
||||
};
|
||||
|
||||
export default function useTaskMonitor({
|
||||
taskTypes = [],
|
||||
taskTypePrefixes = [],
|
||||
taskIds = [],
|
||||
showRecent = false,
|
||||
recentLimit = 5,
|
||||
pollRecentMs = 0,
|
||||
} = {}) {
|
||||
const activeTasks = useTaskStore((state) => state.activeTasks);
|
||||
const normalizedTaskTypes = useMemo(
|
||||
() => normalizeList(taskTypes).map(item => String(item).toUpperCase()),
|
||||
[taskTypes],
|
||||
);
|
||||
const normalizedPrefixes = useMemo(
|
||||
() => normalizeList(taskTypePrefixes).map(item => String(item).toUpperCase()),
|
||||
[taskTypePrefixes],
|
||||
);
|
||||
const normalizedTaskIds = useMemo(
|
||||
() => normalizeList(taskIds).map(item => String(item)),
|
||||
[taskIds],
|
||||
);
|
||||
const [recentTasks, setRecentTasks] = useState([]);
|
||||
const [recentLoading, setRecentLoading] = useState(false);
|
||||
const [recentError, setRecentError] = useState('');
|
||||
|
||||
const filteredActiveTasks = useMemo(
|
||||
() => activeTasks.filter(task => matchesTask(task, normalizedTaskTypes, normalizedPrefixes, normalizedTaskIds)),
|
||||
[activeTasks, normalizedTaskTypes, normalizedPrefixes, normalizedTaskIds],
|
||||
);
|
||||
|
||||
const refreshRecentTasks = useCallback(async () => {
|
||||
if (!showRecent) return [];
|
||||
setRecentLoading(true);
|
||||
setRecentError('');
|
||||
try {
|
||||
if (!normalizedTaskTypes.length) {
|
||||
setRecentTasks([]);
|
||||
return [];
|
||||
}
|
||||
const data = await getRecentTasks(normalizedTaskTypes, [], recentLimit, 0);
|
||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||
const filtered = tasks.filter(task => matchesTask(task, normalizedTaskTypes, normalizedPrefixes, normalizedTaskIds));
|
||||
setRecentTasks(filtered);
|
||||
return filtered;
|
||||
} catch (error) {
|
||||
setRecentError(error?.response?.data?.detail || error?.message || '任务记录加载失败');
|
||||
setRecentTasks([]);
|
||||
return [];
|
||||
} finally {
|
||||
setRecentLoading(false);
|
||||
}
|
||||
}, [normalizedTaskTypes, normalizedPrefixes, normalizedTaskIds, recentLimit, showRecent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRecent) {
|
||||
setRecentTasks([]);
|
||||
setRecentError('');
|
||||
return undefined;
|
||||
}
|
||||
void refreshRecentTasks();
|
||||
if (!pollRecentMs) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshRecentTasks();
|
||||
}, pollRecentMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [pollRecentMs, refreshRecentTasks, showRecent]);
|
||||
|
||||
const latestTask = filteredActiveTasks[0] || recentTasks[0] || null;
|
||||
const isBusy = filteredActiveTasks.length > 0;
|
||||
|
||||
const loadTaskLogs = useCallback((taskId, limit = 50, offset = 0) => (
|
||||
getTaskLogs(taskId, limit, offset)
|
||||
), []);
|
||||
|
||||
return useMemo(() => ({
|
||||
activeTasks: filteredActiveTasks,
|
||||
recentTasks,
|
||||
latestTask,
|
||||
isBusy,
|
||||
recentLoading,
|
||||
recentError,
|
||||
refreshRecentTasks,
|
||||
loadTaskLogs,
|
||||
}), [
|
||||
filteredActiveTasks,
|
||||
recentTasks,
|
||||
latestTask,
|
||||
isBusy,
|
||||
recentLoading,
|
||||
recentError,
|
||||
refreshRecentTasks,
|
||||
loadTaskLogs,
|
||||
]);
|
||||
}
|
||||
@@ -231,10 +231,10 @@
|
||||
{ zh: '运行中', en: 'Running' },
|
||||
{ zh: '引擎', en: 'Engine' },
|
||||
{ zh: '任务运行中', en: 'Task Running' },
|
||||
{ zh: '强制解锁', en: 'Force Unlock' },
|
||||
{ zh: '取消任务', en: 'Cancel Task' },
|
||||
{ zh: '输入管理员密码', en: 'Enter admin password' },
|
||||
{ zh: '确认取消', en: 'Confirm Cancel' },
|
||||
{ zh: '按钮已锁定,等待任务完成', en: 'Buttons locked, waiting for task completion' },
|
||||
{ zh: '同类任务运行中,当前提交按钮暂不可用。', en: 'A similar task is running. Submit buttons are temporarily unavailable.' },
|
||||
{ zh: 'Task 状态总览', en: 'Task Status Overview' },
|
||||
{ zh: '加载中...', en: 'Loading...' },
|
||||
{ zh: '刷新', en: 'Refresh' },
|
||||
@@ -300,8 +300,8 @@
|
||||
{ zh: '扫描入库触发失败', en: 'Result scan trigger failed' },
|
||||
{ zh: '提取完成', en: 'Extraction complete' },
|
||||
{ zh: '总览加载失败', en: 'Overview load failed' },
|
||||
{ zh: '任务已强制取消,前端已解锁。', en: 'Task force-cancelled, UI unlocked.' },
|
||||
{ zh: '强制解锁失败', en: 'Force unlock failed' },
|
||||
{ zh: '任务取消请求已提交。', en: 'Task cancellation requested.' },
|
||||
{ zh: '取消任务失败', en: 'Task cancellation failed' },
|
||||
{ zh: '任务已入队', en: 'Task queued' },
|
||||
|
||||
// --- HazardPointPanel ---
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LineChart } from 'echarts/charts';
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkLineComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
import {
|
||||
getSbasInsarProductAssetUrl,
|
||||
getSbasInsarProductDetail,
|
||||
listSbasInsarProducts,
|
||||
querySbasInsarPointTimeseries,
|
||||
} from '../api/sbasInsarProducts';
|
||||
|
||||
echarts.use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
MarkLineComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const panelStyle = { display: 'grid', gap: 12, padding: 16 };
|
||||
const cardStyle = {
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
const cardBodyStyle = { display: 'grid', gap: 10, padding: 12 };
|
||||
const mutedStyle = { color: '#64748b', fontSize: 12, lineHeight: 1.55 };
|
||||
const labelStyle = { color: '#475569', fontSize: 12, fontWeight: 750 };
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 6,
|
||||
padding: '7px 9px',
|
||||
fontSize: 12,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
const buttonStyle = {
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 6,
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
padding: '7px 11px',
|
||||
};
|
||||
const primaryButtonStyle = {
|
||||
...buttonStyle,
|
||||
borderColor: '#1d4ed8',
|
||||
background: '#1d4ed8',
|
||||
color: '#ffffff',
|
||||
};
|
||||
const chartColors = ['#1d4ed8', '#dc2626', '#059669', '#7c3aed', '#d97706', '#0f766e', '#111827'];
|
||||
|
||||
function formatNumber(value, digits = 2) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return numeric.toFixed(digits);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (/^\d{8}$/.test(text)) {
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseDateMs(value) {
|
||||
const date = normalizeDate(value);
|
||||
if (!date) return NaN;
|
||||
const time = Date.parse(`${date}T00:00:00Z`);
|
||||
return Number.isFinite(time) ? time : NaN;
|
||||
}
|
||||
|
||||
function normalizeDisplacements(rows) {
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.map((item) => {
|
||||
const date = normalizeDate(item?.date);
|
||||
const time = parseDateMs(date);
|
||||
const displacement = Number(item?.displacement_mm ?? item?.displacement ?? item?.value);
|
||||
if (!date || !Number.isFinite(time) || !Number.isFinite(displacement)) return null;
|
||||
return { date, time, displacement };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.time - right.time);
|
||||
}
|
||||
|
||||
function buildLinearAxis(values, targetTicks = 6) {
|
||||
const finite = values.map(Number).filter(Number.isFinite);
|
||||
if (!finite.length) return { min: -1, max: 1, step: 0.5 };
|
||||
let min = Math.min(...finite, 0);
|
||||
let max = Math.max(...finite, 0);
|
||||
if (min === max) {
|
||||
const pad = Math.max(Math.abs(min) * 0.2, 1);
|
||||
min -= pad;
|
||||
max += pad;
|
||||
}
|
||||
const rawStep = (max - min) / Math.max(1, targetTicks);
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
||||
const normalized = rawStep / magnitude;
|
||||
const niceFactor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
const step = niceFactor * magnitude;
|
||||
return {
|
||||
min: Math.floor(min / step) * step,
|
||||
max: Math.ceil(max / step) * step,
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
function formatAxisDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function Metric({ label, value, accent }) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: '8px 9px', background: '#f8fafc', minWidth: 0 }}>
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>{label}</div>
|
||||
<div style={{ color: accent || '#0f172a', fontSize: 14, fontWeight: 800, marginTop: 4, overflowWrap: 'anywhere' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ value }) {
|
||||
const color = value === 'READY' ? '#15803d' : value === 'INCOMPLETE' ? '#b45309' : value === 'ERROR' ? '#dc2626' : '#64748b';
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color, fontSize: 12, fontWeight: 800 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 999, background: color }} />
|
||||
{value || 'UNKNOWN'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function findAsset(assets, roles) {
|
||||
const roleSet = new Set(roles);
|
||||
return (Array.isArray(assets) ? assets : []).find((asset) => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||||
}
|
||||
|
||||
function assetCacheKey(asset) {
|
||||
return [asset?.id, asset?.file_size, asset?.updated_at || asset?.created_at || asset?.relative_path]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
}
|
||||
|
||||
function pointCardsFromDetail(detail, queryResult) {
|
||||
const points = detail?.monitor_points?.monitor_points || [];
|
||||
const cards = points.map((point, index) => ({
|
||||
id: point.point_id || `point_${index + 1}`,
|
||||
name: point.selection_label || point.point_id || `监测点 ${index + 1}`,
|
||||
subName: point.selection_key || '',
|
||||
rate: Number(point.deformation_rate_mm_per_year),
|
||||
values: normalizeDisplacements(point.displacements),
|
||||
color: chartColors[index % chartColors.length],
|
||||
point,
|
||||
}));
|
||||
if (queryResult) {
|
||||
const matched = queryResult.matched || {};
|
||||
cards.push({
|
||||
id: matched.used_nearest ? 'query_nearest' : 'query_exact',
|
||||
name: matched.used_nearest ? '查询点最近邻' : '查询点',
|
||||
subName: `${formatNumber(matched.lon, 6)}, ${formatNumber(matched.lat, 6)}`,
|
||||
rate: Number(matched.los_rate_mm_per_year),
|
||||
values: normalizeDisplacements(queryResult.displacements),
|
||||
color: '#111827',
|
||||
point: {
|
||||
point_id: matched.used_nearest ? 'query_nearest' : 'query_exact',
|
||||
selection_label: matched.used_nearest ? '查询点最近邻' : '查询点',
|
||||
selection_key: matched.used_nearest ? `最近邻 ${formatNumber(matched.distance_m, 1)} m` : '输入点有效像元',
|
||||
deformation_rate_mm_per_year: matched.los_rate_mm_per_year,
|
||||
displacements: queryResult.displacements || [],
|
||||
matched,
|
||||
lon: matched.lon,
|
||||
lat: matched.lat,
|
||||
},
|
||||
});
|
||||
}
|
||||
return cards.filter((card) => card.values.length > 0);
|
||||
}
|
||||
|
||||
function EchartsCanvas({ option }) {
|
||||
const containerRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return undefined;
|
||||
const chart = echarts.init(containerRef.current, null, { renderer: 'canvas' });
|
||||
chartRef.current = chart;
|
||||
let resizeObserver = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => chart.resize());
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
const onResize = () => chart.resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
resizeObserver?.disconnect();
|
||||
chart.dispose();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !option) return;
|
||||
chartRef.current.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', minWidth: 640, height: 360 }} />;
|
||||
}
|
||||
|
||||
function TimeseriesChart({ cards }) {
|
||||
const chart = useMemo(() => {
|
||||
const validCards = (Array.isArray(cards) ? cards : []).filter((card) => card.values.length > 0);
|
||||
const allValues = validCards.flatMap((card) => card.values.map((value) => value.displacement));
|
||||
const allTimes = validCards.flatMap((card) => card.values.map((value) => value.time));
|
||||
if (!validCards.length || !allTimes.length) return null;
|
||||
return {
|
||||
cards: validCards,
|
||||
yAxis: buildLinearAxis(allValues, 6),
|
||||
minTime: Math.min(...allTimes),
|
||||
maxTime: Math.max(...allTimes),
|
||||
};
|
||||
}, [cards]);
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!chart) return null;
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const xMin = chart.minTime === chart.maxTime ? chart.minTime - oneDay : chart.minTime;
|
||||
const xMax = chart.minTime === chart.maxTime ? chart.maxTime + oneDay : chart.maxTime;
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: '#ffffff',
|
||||
color: chart.cards.map((card) => card.color),
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
axisPointer: { type: 'line', snap: true },
|
||||
formatter: (params) => {
|
||||
const rows = (Array.isArray(params) ? params : [params]).filter((item) => Array.isArray(item?.value));
|
||||
if (!rows.length) return '';
|
||||
const date = rows[0]?.data?.date || formatAxisDate(rows[0].value[0]);
|
||||
const body = rows.map((item) => `${item.marker}<b>${item.seriesName}</b>: ${formatNumber(item.value[1], 2)} mm`).join('<br/>');
|
||||
return `<div style="font-weight:750;margin-bottom:4px">${date}</div>${body}`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
top: 6,
|
||||
left: 8,
|
||||
right: 8,
|
||||
itemWidth: 14,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#334155', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 72, right: 28, top: 58, bottom: 62 },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: xMin,
|
||||
max: xMax,
|
||||
name: 'SAR Date',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 38,
|
||||
axisLabel: { color: '#64748b', hideOverlap: true, formatter: formatAxisDate },
|
||||
splitLine: { show: true, lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: chart.yAxis.min,
|
||||
max: chart.yAxis.max,
|
||||
interval: chart.yAxis.step,
|
||||
name: '累计形变 (mm)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 50,
|
||||
axisLabel: { color: '#64748b' },
|
||||
splitLine: { show: true, lineStyle: { color: '#e2e8f0' } },
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: 0, filterMode: 'none', height: 22, bottom: 18 },
|
||||
],
|
||||
series: chart.cards.map((card, index) => ({
|
||||
name: card.name,
|
||||
type: 'line',
|
||||
data: card.values.map((value) => ({ value: [value.time, Number(value.displacement.toFixed(6))], date: value.date })),
|
||||
showSymbol: true,
|
||||
symbolSize: card.id.startsWith('query') ? 8 : 6,
|
||||
smooth: false,
|
||||
connectNulls: false,
|
||||
lineStyle: { width: card.id.startsWith('query') ? 3 : 2.2, color: card.color },
|
||||
itemStyle: { color: card.color, borderColor: '#ffffff', borderWidth: 1 },
|
||||
markLine: index === 0 ? {
|
||||
symbol: 'none',
|
||||
silent: true,
|
||||
data: [{ yAxis: 0, name: '0 mm' }],
|
||||
label: { formatter: '0 mm', color: '#475569' },
|
||||
lineStyle: { color: '#0f172a', opacity: 0.32, type: 'dashed', width: 1 },
|
||||
} : undefined,
|
||||
})),
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
if (!option) {
|
||||
return (
|
||||
<div style={{ border: '1px dashed #cbd5e1', borderRadius: 8, padding: 12, background: '#f8fafc', ...mutedStyle }}>
|
||||
暂无可绘制的监测点或查询点时序。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#ffffff' }}>
|
||||
<div style={{ padding: '9px 12px', background: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 850, color: '#0f172a' }}>监测点和查询点形变曲线</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>横坐标按真实 SAR 日期间隔缩放,纵坐标为累计形变 mm。</div>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', padding: '8px 10px 0' }}>
|
||||
<EchartsCanvas option={option} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SbasInsarMapAnalysisPanel({
|
||||
readOnly,
|
||||
onToggleRateLayer,
|
||||
onRateOpacityChange,
|
||||
onToggleMonitorPoints,
|
||||
onToggleProductOverview,
|
||||
onFlyToProduct,
|
||||
onShowQueryPoint,
|
||||
onClearLayers,
|
||||
}) {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [overviewVisible, setOverviewVisible] = useState(false);
|
||||
const [rateVisible, setRateVisible] = useState(false);
|
||||
const [monitorVisible, setMonitorVisible] = useState(false);
|
||||
const [opacity, setOpacity] = useState(0.78);
|
||||
const [lon, setLon] = useState('');
|
||||
const [lat, setLat] = useState('');
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
const [queryResult, setQueryResult] = useState(null);
|
||||
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const payload = await listSbasInsarProducts({ limit: 30, offset: 0, status: 'READY' });
|
||||
const nextItems = (payload?.items || []).filter((item) => (
|
||||
String(item.engine_code || '').toLowerCase() === 'gamma'
|
||||
|| String(item.processor_code || '').toLowerCase().includes('gamma')
|
||||
));
|
||||
setProducts(nextItems);
|
||||
setSelectedId((prev) => (prev && nextItems.some((item) => String(item.id) === String(prev)) ? prev : String(nextItems[0]?.id || '')));
|
||||
if (!nextItems.length) {
|
||||
setMessage('暂无 READY 的 Gamma SBAS 产品;请先完成结果注册。');
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(`加载 SBAS 产品失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProducts();
|
||||
}, [loadProducts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setDetail(null);
|
||||
return undefined;
|
||||
}
|
||||
let disposed = false;
|
||||
setDetailLoading(true);
|
||||
setMessage('');
|
||||
setOverviewVisible(false);
|
||||
setRateVisible(false);
|
||||
setMonitorVisible(false);
|
||||
setQueryResult(null);
|
||||
onClearLayers?.();
|
||||
getSbasInsarProductDetail(selectedId)
|
||||
.then((payload) => {
|
||||
if (!disposed) setDetail(payload);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) {
|
||||
setDetail(null);
|
||||
setMessage(`加载产品详情失败:${error?.response?.data?.detail || error.message}`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [selectedId, onClearLayers]);
|
||||
|
||||
const selectedProduct = useMemo(
|
||||
() => products.find((item) => String(item.id) === String(selectedId)) || detail,
|
||||
[detail, products, selectedId],
|
||||
);
|
||||
|
||||
const assets = detail?.assets || [];
|
||||
const rateAsset = useMemo(() => findAsset(assets, ['primary_geocoded_preview', 'primary_rate_color_preview']), [assets]);
|
||||
const colorbarAsset = useMemo(() => findAsset(assets, ['primary_colorbar']), [assets]);
|
||||
const monitorPoints = detail?.monitor_points?.monitor_points || [];
|
||||
const geocodedPointCount = monitorPoints.filter((point) => Number.isFinite(Number(point.lon)) && Number.isFinite(Number(point.lat))).length;
|
||||
const chartCards = useMemo(() => pointCardsFromDetail(detail, queryResult), [detail, queryResult]);
|
||||
const colorPolicy = detail?.color_policy || {};
|
||||
const range = colorPolicy?.display_range_mm_per_year || [];
|
||||
const rateRangeText = Array.isArray(range) && range.length >= 2
|
||||
? `${formatNumber(range[0], 0)} 到 ${formatNumber(range[1], 0)} mm/yr`
|
||||
: '-80 到 80 mm/yr';
|
||||
|
||||
const toggleRate = () => {
|
||||
if (!detail) return;
|
||||
const nextVisible = !rateVisible;
|
||||
const ok = onToggleRateLayer?.(detail, nextVisible, opacity);
|
||||
if (ok !== false) setRateVisible(nextVisible);
|
||||
};
|
||||
|
||||
const toggleMonitor = () => {
|
||||
if (!detail) return;
|
||||
const nextVisible = !monitorVisible;
|
||||
const ok = onToggleMonitorPoints?.(detail, nextVisible);
|
||||
if (ok !== false) setMonitorVisible(nextVisible);
|
||||
};
|
||||
|
||||
const toggleOverview = () => {
|
||||
const nextVisible = !overviewVisible;
|
||||
const ok = onToggleProductOverview?.(products, nextVisible);
|
||||
if (ok !== false) {
|
||||
setOverviewVisible(nextVisible);
|
||||
setMessage(nextVisible ? `已在地图显示 ${products.length} 个 SBAS 产品范围和时间。` : '已隐藏 SBAS 产品范围总览。');
|
||||
}
|
||||
};
|
||||
|
||||
const changeOpacity = (event) => {
|
||||
const next = Number(event.target.value);
|
||||
setOpacity(next);
|
||||
onRateOpacityChange?.(next);
|
||||
};
|
||||
|
||||
const queryPoint = async () => {
|
||||
if (!detail?.id) return;
|
||||
const numericLon = Number(lon);
|
||||
const numericLat = Number(lat);
|
||||
if (!Number.isFinite(numericLon) || !Number.isFinite(numericLat)) {
|
||||
setMessage('请输入有效 WGS84 经度和纬度。');
|
||||
return;
|
||||
}
|
||||
if (numericLon < -180 || numericLon > 180 || numericLat < -90 || numericLat > 90) {
|
||||
setMessage('经纬度超出 WGS84 范围。');
|
||||
return;
|
||||
}
|
||||
setQueryLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await querySbasInsarPointTimeseries(detail.id, { lon: numericLon, lat: numericLat });
|
||||
setQueryResult(result);
|
||||
onShowQueryPoint?.(result, detail);
|
||||
const matched = result?.matched || {};
|
||||
setMessage(matched.used_nearest ? `已使用最近有效像元,距离 ${formatNumber(matched.distance_m, 1)} m。` : '查询点位于有效像元,曲线已生成。');
|
||||
} catch (error) {
|
||||
setQueryResult(null);
|
||||
setMessage(`查询失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ color: '#0f172a', fontSize: 16, fontWeight: 900 }}>时序InSAR地图分析</div>
|
||||
<div style={mutedStyle}>只读检视 Gamma SBAS 成果:速率图叠加、自动监测点、WGS84 点查询和形变曲线。</div>
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
<div style={cardBodyStyle}>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={labelStyle} htmlFor="sbas-map-product">SBAS 产品</label>
|
||||
<select
|
||||
id="sbas-map-product"
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
disabled={loading || detailLoading || !products.length}
|
||||
style={inputStyle}
|
||||
>
|
||||
{products.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.display_name || item.run_key || item.id} / {formatDate(item.date_start)} → {formatDate(item.date_end)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={loadProducts} disabled={loading} style={buttonStyle}>{loading ? '刷新中...' : '刷新产品'}</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOverview}
|
||||
disabled={!products.length}
|
||||
style={overviewVisible ? primaryButtonStyle : buttonStyle}
|
||||
>
|
||||
{overviewVisible ? '隐藏全部范围' : `查看全部范围/时间 (${products.length})`}
|
||||
</button>
|
||||
<button type="button" onClick={() => onFlyToProduct?.(detail || selectedProduct)} disabled={!selectedProduct} style={buttonStyle}>定位成果范围</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOverviewVisible(false);
|
||||
setRateVisible(false);
|
||||
setMonitorVisible(false);
|
||||
setQueryResult(null);
|
||||
onClearLayers?.();
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
清除地图图层
|
||||
</button>
|
||||
</div>
|
||||
{message && (
|
||||
<div style={{ color: message.includes('失败') || message.includes('超出') || message.includes('暂无') ? '#dc2626' : '#166534', fontSize: 12 }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading && <div className="empty-state">正在加载 SBAS 产品详情...</div>}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 8 }}>
|
||||
<Metric label="状态" value={<StatusBadge value={detail.status} />} accent={detail.status === 'READY' ? '#15803d' : '#b45309'} />
|
||||
<Metric label="时间范围" value={`${formatDate(detail.date_start)} → ${formatDate(detail.date_end)}`} />
|
||||
<Metric label="栈期数" value={detail.stack_size ?? detail.stack_dates?.length ?? '-'} />
|
||||
<Metric label="监测点" value={`${geocodedPointCount}/${monitorPoints.length || 0} 有经纬度`} accent={geocodedPointCount ? '#15803d' : '#b45309'} />
|
||||
<Metric label="色表范围" value={rateRangeText} />
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
<div style={{ padding: '10px 12px', background: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 850, color: '#0f172a' }}>地图图层</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>速率图使用专家链路生成的 Gamma hls.cm 浏览图;监测点使用 `disp_prt_2d` 自动选点结果。</div>
|
||||
</div>
|
||||
<div style={cardBodyStyle}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" onClick={toggleRate} disabled={!rateAsset} style={rateVisible ? primaryButtonStyle : buttonStyle}>
|
||||
{rateVisible ? '隐藏 LOS 速率图' : '显示 LOS 速率图'}
|
||||
</button>
|
||||
<button type="button" onClick={toggleMonitor} disabled={!monitorPoints.length || !geocodedPointCount} style={monitorVisible ? primaryButtonStyle : buttonStyle}>
|
||||
{monitorVisible ? '隐藏监测点' : '显示监测点'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr 44px', gap: 8, alignItems: 'center' }}>
|
||||
<div style={labelStyle}>透明度</div>
|
||||
<input type="range" min="0.2" max="1" step="0.02" value={opacity} onChange={changeOpacity} disabled={!rateAsset} />
|
||||
<div style={{ color: '#334155', fontSize: 12, fontWeight: 800 }}>{Math.round(opacity * 100)}%</div>
|
||||
</div>
|
||||
{colorbarAsset && (
|
||||
<div style={{ display: 'grid', gap: 5 }}>
|
||||
<div style={labelStyle}>LOS 速率色卡</div>
|
||||
<img
|
||||
src={getSbasInsarProductAssetUrl(detail.id, colorbarAsset.id, assetCacheKey(colorbarAsset))}
|
||||
alt="Gamma hls.cm LOS velocity colorbar"
|
||||
style={{ width: '100%', maxHeight: 76, objectFit: 'contain', border: '1px solid #e2e8f0', borderRadius: 6, background: '#ffffff' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!rateAsset && <div style={mutedStyle}>未找到可叠加的 LOS 速率预览资产。</div>}
|
||||
{monitorPoints.length > 0 && geocodedPointCount === 0 && (
|
||||
<div style={{ color: '#b45309', fontSize: 12 }}>
|
||||
当前监测点摘要没有 WGS84 经纬度;重新注册资产后可在主地图落点。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
<div style={{ padding: '10px 12px', background: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 850, color: '#0f172a' }}>WGS84 点查询</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>输入覆盖区内经纬度;若不是有效像元,系统会取最近有效像元并标注距离。</div>
|
||||
</div>
|
||||
<div style={cardBodyStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr)) minmax(110px, auto)', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
value={lon}
|
||||
onChange={(event) => setLon(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') queryPoint(); }}
|
||||
placeholder="经度 lon"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
value={lat}
|
||||
onChange={(event) => setLat(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') queryPoint(); }}
|
||||
placeholder="纬度 lat"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button type="button" onClick={queryPoint} disabled={queryLoading || !detail?.id} style={primaryButtonStyle}>
|
||||
{queryLoading ? '查询中...' : '查询曲线'}
|
||||
</button>
|
||||
</div>
|
||||
{queryResult && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 8 }}>
|
||||
<Metric label="匹配方式" value={queryResult.matched?.used_nearest ? '最近有效像元' : '输入点有效像元'} accent={queryResult.matched?.used_nearest ? '#b45309' : '#15803d'} />
|
||||
<Metric label="匹配经纬度" value={`${formatNumber(queryResult.matched?.lon, 6)}, ${formatNumber(queryResult.matched?.lat, 6)}`} />
|
||||
<Metric label="距离" value={`${formatNumber(queryResult.matched?.distance_m, 1)} m`} />
|
||||
<Metric label="LOS 速率" value={`${formatNumber(queryResult.matched?.los_rate_mm_per_year, 2)} mm/yr`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeseriesChart cards={chartCards} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !detailLoading && !detail && (
|
||||
<div className="empty-state">
|
||||
{readOnly ? '暂无可检视的 Gamma SBAS 产品。' : '暂无可检视的 Gamma SBAS 产品。'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const usePairingStore = create((set) => ({
|
||||
master_date_to: '',
|
||||
slave_date_from: '',
|
||||
slave_date_to: '',
|
||||
strategy: 'all',
|
||||
strategy: 'sbas',
|
||||
num_connections: 1,
|
||||
reference_image_id: null,
|
||||
allowed_satellites: null,
|
||||
|
||||
@@ -5,13 +5,9 @@ const s = (set, key) => (v) =>
|
||||
|
||||
export const useTaskStore = create((set) => ({
|
||||
activeTasks: [],
|
||||
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'),
|
||||
}));
|
||||
|
||||
@@ -54,6 +54,27 @@ const LAYER_DEFS = [
|
||||
color: '#ff6b35', type: 'colorbar',
|
||||
label: { en: 'D-InSAR Displacement (m)', zh: 'D-InSAR 形变量 (m)' },
|
||||
},
|
||||
{
|
||||
id: 'sbas_rate',
|
||||
ref: 'sbasAnalysisLayersRef',
|
||||
detect: (layers) => layers && Object.values(layers).some(item => item?.kind === 'rate'),
|
||||
color: '#1d4ed8', type: 'colorbar',
|
||||
label: { en: 'SBAS LOS Velocity (mm/yr)', zh: 'SBAS LOS 速率 (mm/yr)' },
|
||||
},
|
||||
{
|
||||
id: 'sbas_overview',
|
||||
ref: 'sbasAnalysisLayersRef',
|
||||
detect: (layers) => layers && Object.values(layers).some(item => item?.kind === 'overview'),
|
||||
color: '#7c3aed', type: 'polygon',
|
||||
label: { en: 'SBAS Product Footprints', zh: 'SBAS 产品范围' },
|
||||
},
|
||||
{
|
||||
id: 'sbas_points',
|
||||
ref: 'sbasAnalysisLayersRef',
|
||||
detect: (layers) => layers && Object.values(layers).some(item => item?.kind === 'points' || item?.kind === 'query'),
|
||||
color: '#16a34a', type: 'circle',
|
||||
label: { en: 'SBAS Monitoring Points', zh: 'SBAS 监测点' },
|
||||
},
|
||||
{
|
||||
id: 'water_scene',
|
||||
ref: 'waterSceneLayersRef',
|
||||
|
||||
Reference in New Issue
Block a user