Integrate SBAS workflows and redesign task center

This commit is contained in:
2026-06-13 13:10:12 +08:00
parent b931e8db53
commit 58a87706ef
275 changed files with 18750 additions and 42524 deletions
+22 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+67 -68
View File
@@ -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) {
+14 -38
View File
@@ -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;
+21 -2
View File
@@ -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="搜索编号、名称、市县..."
+1 -1
View File
@@ -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">
+62 -86
View File
@@ -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' }}>&#9881;</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}>
+5 -1
View File
@@ -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
-2
View File
@@ -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) =>
+18
View File
@@ -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)}`;
+16 -4
View File
@@ -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>
);
}
+7 -2
View File
@@ -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>
{/* 主影像时间范围 */}
+17 -18
View File
@@ -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>
)}
+8 -7
View File
@@ -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>
);
}
+1 -1
View File
@@ -127,7 +127,7 @@ export const LEFT_GROUP_SECTIONS = {
{
key: 'psinsar',
label: '时序InSAR',
tabs: ['psinsar_results', 'psinsar_analysis'],
tabs: ['psinsar_analysis'],
},
],
ai_analysis: [
+76
View File
@@ -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,
+1 -13
View File
@@ -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);
}
};
+14 -82
View File
@@ -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,
};
}
+37 -2
View File
@@ -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
+109
View File
@@ -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,
]);
}
+4 -4
View File
@@ -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>
);
}
+1 -1
View File
@@ -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,
-4
View File
@@ -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'),
}));
+21
View File
@@ -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',