feat: engineer SBAS timeseries production workflow
This commit is contained in:
@@ -917,6 +917,16 @@ input[type="checkbox"] {
|
||||
font-size: 0.8em;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.ps-stack-preview-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.preview-button-secondary {
|
||||
background: var(--color-panel-muted);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.ps-stack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+115
-1
@@ -276,6 +276,8 @@ function App() {
|
||||
const activeLayersRef = useRef({});
|
||||
const radarPreviewLayersRef = useRef({});
|
||||
const pairLayersRef = useRef({});
|
||||
const psStackPreviewLayerRef = useRef(null);
|
||||
const psStackPreviewStateRef = useRef({ previousVisibilityById: new Map() });
|
||||
const hazardLayersGroupRef = useRef(null);
|
||||
const aoeLayerRef = useRef(null);
|
||||
const waterSceneLayersRef = useRef({});
|
||||
@@ -394,6 +396,11 @@ function App() {
|
||||
aoeLayerRef.current.remove();
|
||||
aoeLayerRef.current = null;
|
||||
}
|
||||
if (psStackPreviewLayerRef.current) {
|
||||
psStackPreviewLayerRef.current.remove();
|
||||
psStackPreviewLayerRef.current = null;
|
||||
}
|
||||
psStackPreviewStateRef.current = { previousVisibilityById: new Map() };
|
||||
setAoiLayer(null);
|
||||
|
||||
Object.values(activeLayersRef.current).forEach(layer => layer.remove());
|
||||
@@ -938,10 +945,59 @@ function App() {
|
||||
}
|
||||
}, [aoiLayer]);
|
||||
|
||||
const restorePsStackPreviewVisibility = () => {
|
||||
const previousVisibilityById = psStackPreviewStateRef.current?.previousVisibilityById;
|
||||
if (!(previousVisibilityById instanceof Map) || previousVisibilityById.size === 0) {
|
||||
psStackPreviewStateRef.current = { previousVisibilityById: new Map() };
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const currentData = allDataRef.current;
|
||||
const restoredData = currentData.map(item => {
|
||||
if (!previousVisibilityById.has(item.id)) {
|
||||
return item;
|
||||
}
|
||||
const shouldBeVisible = previousVisibilityById.get(item.id);
|
||||
if (item.isVisible !== shouldBeVisible) {
|
||||
updateLayerVisibility(item, shouldBeVisible);
|
||||
changed = true;
|
||||
return { ...item, isVisible: shouldBeVisible };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
allDataRef.current = restoredData;
|
||||
setAllData(restoredData);
|
||||
}
|
||||
psStackPreviewStateRef.current = { previousVisibilityById: new Map() };
|
||||
return changed;
|
||||
};
|
||||
|
||||
const clearPsStackPreview = ({ silent = false } = {}) => {
|
||||
const hadLayer = !!psStackPreviewLayerRef.current;
|
||||
if (psStackPreviewLayerRef.current) {
|
||||
psStackPreviewLayerRef.current.remove();
|
||||
psStackPreviewLayerRef.current = null;
|
||||
}
|
||||
const restoredVisibility = restorePsStackPreviewVisibility();
|
||||
if (silent) {
|
||||
return;
|
||||
}
|
||||
if (!hadLayer && !restoredVisibility) {
|
||||
addLog('info', '当前没有打开的时序候选栈预览范围。');
|
||||
return;
|
||||
}
|
||||
addLog('info', '已关闭时序候选栈预览范围。');
|
||||
};
|
||||
|
||||
const previewPsStack = (stack) => {
|
||||
cancelMapBatch();
|
||||
clearPsStackPreview({ silent: true });
|
||||
const stackIds = new Set(stack.map(img => img.id));
|
||||
const currentData = allDataRef.current;
|
||||
const previousVisibilityById = new Map(currentData.map(item => [item.id, item.isVisible]));
|
||||
const newAllData = currentData.map(item => {
|
||||
const shouldBeVisible = stackIds.has(item.id);
|
||||
if (item.isVisible !== shouldBeVisible) {
|
||||
@@ -952,7 +1008,63 @@ function App() {
|
||||
});
|
||||
allDataRef.current = newAllData;
|
||||
setAllData(newAllData);
|
||||
addLog('info', `正在预览包含 ${stack.length} 个场景的时序InSAR候选栈。`);
|
||||
psStackPreviewStateRef.current = { previousVisibilityById };
|
||||
|
||||
if (!mapRef.current) {
|
||||
clearPsStackPreview({ silent: true });
|
||||
addLog('warn', '地图尚未就绪,无法预览时序候选栈范围。');
|
||||
return;
|
||||
}
|
||||
|
||||
const previewGroup = L.layerGroup();
|
||||
const allLatLngs = [];
|
||||
const palette = ['#f59e0b', '#06b6d4', '#a855f7', '#22c55e', '#ef4444', '#3b82f6'];
|
||||
let validSceneCount = 0;
|
||||
|
||||
stack.forEach((scene, index) => {
|
||||
const polygon = scene.coverage_polygon;
|
||||
if (!Array.isArray(polygon) || polygon.length < 3) {
|
||||
return;
|
||||
}
|
||||
const latLngs = polygon
|
||||
.filter((point) => Array.isArray(point) && point.length >= 2)
|
||||
.map((point) => [Number(point[1]), Number(point[0])])
|
||||
.filter(([lat, lon]) => Number.isFinite(lat) && Number.isFinite(lon));
|
||||
if (latLngs.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
validSceneCount += 1;
|
||||
allLatLngs.push(...latLngs);
|
||||
const color = palette[index % palette.length];
|
||||
const scenePolygon = L.polygon(latLngs, {
|
||||
color,
|
||||
weight: 3,
|
||||
opacity: 0.95,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.08,
|
||||
dashArray: scene.stack_selection_mode === 'pairwise_sbas_network' ? '8, 5' : null,
|
||||
});
|
||||
scenePolygon.bindPopup(
|
||||
`<strong>时序候选场景</strong><br>` +
|
||||
`ID: ${escapeHtml(scene.id)}<br>` +
|
||||
`日期: ${escapeHtml(scene.imaging_date || '-')}<br>` +
|
||||
`卫星: ${escapeHtml(scene.satellite || '-')}<br>` +
|
||||
`模式: ${escapeHtml(scene.imaging_mode || '-')} / ${escapeHtml(scene.polarization || '-')}`
|
||||
);
|
||||
previewGroup.addLayer(scenePolygon);
|
||||
});
|
||||
|
||||
if (validSceneCount === 0 || allLatLngs.length < 3) {
|
||||
clearPsStackPreview({ silent: true });
|
||||
addLog('warn', `时序候选栈包含 ${stack.length} 个场景,但没有可绘制的覆盖范围。`);
|
||||
return;
|
||||
}
|
||||
|
||||
previewGroup.addTo(mapRef.current);
|
||||
psStackPreviewLayerRef.current = previewGroup;
|
||||
mapRef.current.fitBounds(L.latLngBounds(allLatLngs), { padding: [50, 50], maxZoom: 10 });
|
||||
addLog('info', `正在预览包含 ${stack.length} 个场景的时序InSAR候选栈,已绘制 ${validSceneCount} 个覆盖范围。`);
|
||||
};
|
||||
|
||||
const updateLayerTooltip = useCallback((layer, result, show) => {
|
||||
@@ -1560,7 +1672,9 @@ function App() {
|
||||
};
|
||||
const psPanel = {
|
||||
onPreviewPsStack: previewPsStack,
|
||||
onClearPsStackPreview: clearPsStackPreview,
|
||||
onCreatePsBatch: createPsBatch,
|
||||
onSendToTimeseriesProduction: (direction, stack) => createPsBatch(direction, stack, { sendToProduction: true }),
|
||||
onClearPsResults: clearPsResults,
|
||||
};
|
||||
|
||||
|
||||
@@ -239,6 +239,20 @@ function buildExtraPayload(schema, values) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildParamSections(schema) {
|
||||
const sections = [];
|
||||
const indexByTitle = new Map();
|
||||
Object.entries(schema || {}).forEach(([name, item]) => {
|
||||
const title = item.section || '处理参数';
|
||||
if (!indexByTitle.has(title)) {
|
||||
indexByTitle.set(title, sections.length);
|
||||
sections.push({ title, items: [] });
|
||||
}
|
||||
sections[indexByTitle.get(title)].items.push([name, item]);
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
const label = schema.label || name;
|
||||
const description = schema.description || '';
|
||||
@@ -283,6 +297,41 @@ function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
||||
return (
|
||||
<div style={{ minWidth: 180, flex: '0 1 220px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block' }}>{label}</label>
|
||||
{isReadonly && (
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: '#e2e8f0',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
固定值
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={value ?? schema.default ?? schema.enum[0]}
|
||||
disabled={disabled || isReadonly}
|
||||
onChange={event => onChange(name, event.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{schema.enum.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
{description && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{description}</div>}
|
||||
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>推荐:{recommendation}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 180, flex: schema.type === 'string' ? '1 1 280px' : '0 1 180px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
@@ -350,10 +399,13 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
|
||||
const currentProfileObj = currentProfiles.find(profile => profile.code === selectedProfile) || null;
|
||||
const currentParamSchema = currentProfileObj?.params_schema || EMPTY_OBJECT;
|
||||
const currentParamSections = buildParamSections(currentParamSchema);
|
||||
const currentDefaultTimeoutSec = Number(currentEngineObj?.default_timeout_seconds || 0) || 0;
|
||||
const currentParamHelpText = selectedEngine === 'pyint'
|
||||
? '这些参数影响 PyINT 的多视、并行度以及是否执行解缠/地理编码。建议先直接使用默认值,优先确认当前任务目录里的 LT-1 原始压缩包是否能被正常识别。'
|
||||
: '这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。';
|
||||
: selectedEngine === 'isce2'
|
||||
? '这些参数现在按执行、交付、增强分组展示。结果异常时,优先尝试关闭增强项,再回看基础几何和配对质量。'
|
||||
: '这些参数影响当前引擎的生产模板。建议先使用默认值,只有在结果边界、噪声或几何表现异常时再逐项调整。';
|
||||
const pyintPreviewBlocksSubmit = selectedEngine === 'pyint' && pyintPreview && pyintPreview.allow_submit === false;
|
||||
const latestRunWithTask = runs.find(run => run?.task_id) || null;
|
||||
const monitoredTask = activeTask || (
|
||||
@@ -935,15 +987,24 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
{currentParamHelpText}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{Object.entries(currentParamSchema).map(([name, schema]) => (
|
||||
<ParamField
|
||||
key={name}
|
||||
name={name}
|
||||
schema={schema}
|
||||
value={engineExtraParams[name]}
|
||||
disabled={readOnly}
|
||||
onChange={handleParamChange}
|
||||
/>
|
||||
{currentParamSections.map(section => (
|
||||
<div key={section.title} style={{ width: '100%' }}>
|
||||
<div style={{ fontSize: 12, color: '#0f172a', fontWeight: 600, marginBottom: 8 }}>
|
||||
{section.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{section.items.map(([name, schema]) => (
|
||||
<ParamField
|
||||
key={name}
|
||||
name={name}
|
||||
schema={schema}
|
||||
value={engineExtraParams[name]}
|
||||
disabled={readOnly}
|
||||
onChange={handleParamChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getPsBatches } from './api/taskBatches';
|
||||
import { useBatchStore } from './store';
|
||||
import {
|
||||
createTimeseriesRun,
|
||||
getTimeseriesRunDetail,
|
||||
listTimeseriesRuns,
|
||||
retryTimeseriesStep,
|
||||
runTimeseriesPreflight,
|
||||
runTimeseriesWslCheck,
|
||||
} from './api/timeseriesProduction';
|
||||
|
||||
const card = {
|
||||
@@ -75,7 +79,75 @@ function StatusPill({ value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return '-';
|
||||
if (size < 1024) return `${size} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let current = size / 1024;
|
||||
let unitIndex = 0;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function QualityBadge({ ok, okLabel = '通过', failLabel = '失败' }) {
|
||||
const color = ok ? '#166534' : '#991b1b';
|
||||
const background = ok ? '#f0fdf4' : '#fef2f2';
|
||||
const border = ok ? '#bbf7d0' : '#fecaca';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${border}`,
|
||||
background,
|
||||
color,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
{ok ? okLabel : failLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonBlock({ value }) {
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '8px 10px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 11,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value || {}, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TimeseriesProductionPanel({ readOnly = false, onJobQueued }) {
|
||||
const pendingTimeseriesBatchId = useBatchStore(state => state.pendingTimeseriesBatchId);
|
||||
const setPendingTimeseriesBatchId = useBatchStore(state => state.setPendingTimeseriesBatchId);
|
||||
const [batches, setBatches] = useState([]);
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [selectedBatchId, setSelectedBatchId] = useState('');
|
||||
@@ -89,6 +161,11 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
const [referenceDate, setReferenceDate] = useState('');
|
||||
const [waterMaskMode, setWaterMaskMode] = useState('synthetic_fallback');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [wslChecking, setWslChecking] = useState(false);
|
||||
const [wslReport, setWslReport] = useState(null);
|
||||
const [preflightLoading, setPreflightLoading] = useState(false);
|
||||
const [preflightReport, setPreflightReport] = useState(null);
|
||||
const [retryingStepId, setRetryingStepId] = useState('');
|
||||
|
||||
const selectedBatch = useMemo(
|
||||
() => batches.find(item => item.batch_id === selectedBatchId) || null,
|
||||
@@ -100,11 +177,16 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
const data = await getPsBatches();
|
||||
const nextItems = Array.isArray(data) ? data : [];
|
||||
setBatches(nextItems);
|
||||
setSelectedBatchId(current => current || nextItems[0]?.batch_id || '');
|
||||
setSelectedBatchId(current => {
|
||||
if (pendingTimeseriesBatchId && nextItems.some(item => item.batch_id === pendingTimeseriesBatchId)) {
|
||||
return pendingTimeseriesBatchId;
|
||||
}
|
||||
return current || nextItems[0]?.batch_id || '';
|
||||
});
|
||||
} catch {
|
||||
setBatches([]);
|
||||
}
|
||||
}, []);
|
||||
}, [pendingTimeseriesBatchId]);
|
||||
|
||||
const loadRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -138,6 +220,72 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWslCheck = useCallback(async () => {
|
||||
setWslChecking(true);
|
||||
try {
|
||||
const report = await runTimeseriesWslCheck();
|
||||
setWslReport(report);
|
||||
} catch (error) {
|
||||
setWslReport({
|
||||
overall_ok: false,
|
||||
message: error?.response?.data?.detail || error.message || 'WSL 检查失败',
|
||||
checks: [],
|
||||
});
|
||||
} finally {
|
||||
setWslChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRetryStep = useCallback(async stepId => {
|
||||
if (!selectedRunId || !stepId) return;
|
||||
setRetryingStepId(stepId);
|
||||
setMessage('');
|
||||
try {
|
||||
await retryTimeseriesStep(selectedRunId, { step_id: stepId });
|
||||
setMessage(`已重新入队:${selectedRunId} / ${stepId}`);
|
||||
await loadRuns();
|
||||
await loadRunDetail(selectedRunId);
|
||||
} catch (error) {
|
||||
setMessage(error?.response?.data?.detail || error.message || '重试失败');
|
||||
} finally {
|
||||
setRetryingStepId('');
|
||||
}
|
||||
}, [loadRunDetail, loadRuns, selectedRunId]);
|
||||
|
||||
const handlePreflight = useCallback(async () => {
|
||||
if (!selectedBatchId) {
|
||||
setPreflightReport({
|
||||
overall_ok: false,
|
||||
errors: ['请先选择一个时序批次。'],
|
||||
warnings: [],
|
||||
checks: [],
|
||||
summary: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPreflightLoading(true);
|
||||
try {
|
||||
const report = await runTimeseriesPreflight({
|
||||
batch_id: selectedBatchId,
|
||||
reference_date: referenceDate.trim() || null,
|
||||
water_mask_mode: waterMaskMode,
|
||||
});
|
||||
setPreflightReport(report);
|
||||
} catch (error) {
|
||||
const detail = error?.response?.data?.detail || error.message || '预检失败';
|
||||
setPreflightReport({
|
||||
overall_ok: false,
|
||||
batch_id: selectedBatchId,
|
||||
errors: [detail],
|
||||
warnings: [],
|
||||
checks: [],
|
||||
summary: {},
|
||||
});
|
||||
} finally {
|
||||
setPreflightLoading(false);
|
||||
}
|
||||
}, [referenceDate, selectedBatchId, waterMaskMode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBatches();
|
||||
loadRuns();
|
||||
@@ -152,6 +300,20 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
loadRunDetail(selectedRunId);
|
||||
}, [loadRunDetail, selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pendingTimeseriesBatchId &&
|
||||
batches.some(item => item.batch_id === pendingTimeseriesBatchId) &&
|
||||
selectedBatchId === pendingTimeseriesBatchId
|
||||
) {
|
||||
setPendingTimeseriesBatchId('');
|
||||
}
|
||||
}, [batches, pendingTimeseriesBatchId, selectedBatchId, setPendingTimeseriesBatchId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreflightReport(null);
|
||||
}, [selectedBatchId, referenceDate, waterMaskMode]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedBatchId) {
|
||||
setMessage('请先选择一个时序批次。');
|
||||
@@ -168,6 +330,7 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
notes: notes.trim() || null,
|
||||
});
|
||||
setMessage(`运行已入队:${result.run_id} / task=${result.task_id}`);
|
||||
setSelectedRunId(result.run_id);
|
||||
onJobQueued?.(result.task_id);
|
||||
await loadRuns();
|
||||
} catch (error) {
|
||||
@@ -180,11 +343,34 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
const runData = selectedRunDetail?.run || null;
|
||||
const linkedProduct = selectedRunDetail?.product || null;
|
||||
const workflowSteps = selectedRunDetail?.workflow?.steps || [];
|
||||
const preflightChecks = Array.isArray(preflightReport?.checks) ? preflightReport.checks : [];
|
||||
const preflightErrors = Array.isArray(preflightReport?.errors) ? preflightReport.errors : [];
|
||||
const preflightWarnings = Array.isArray(preflightReport?.warnings) ? preflightReport.warnings : [];
|
||||
const preflightSummary = preflightReport?.summary || {};
|
||||
const runPreflightQuality = runData?.quality_summary_json?.preflight || null;
|
||||
const runPublishValidation = runData?.quality_summary_json?.publish_validation || null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 0', width: '100%' }}>
|
||||
<div style={card}>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>时序InSAR 运行入口</strong>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 10 }}>
|
||||
<strong style={{ fontSize: 14, display: 'block' }}>时序InSAR 运行入口</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWslCheck}
|
||||
disabled={readOnly || wslChecking}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#fff',
|
||||
cursor: readOnly ? 'not-allowed' : 'pointer',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{wslChecking ? '检查中...' : 'WSL检查'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@@ -201,6 +387,32 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
register_psinsar_product。提交后系统会依次生成选栈 manifest、物化 LT-1 SLC、执行 ISCE2
|
||||
stack、运行 MintPy SBAS、导出 publish bundle,并把结果注册进时序InSAR catalog。
|
||||
</div>
|
||||
{wslReport && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${wslReport.overall_ok ? '#bbf7d0' : '#fecaca'}`,
|
||||
background: wslReport.overall_ok ? '#f0fdf4' : '#fef2f2',
|
||||
fontSize: 12,
|
||||
color: wslReport.overall_ok ? '#166534' : '#991b1b',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
||||
{wslReport.overall_ok ? 'WSL运行时正常' : 'WSL运行时存在问题'}
|
||||
</div>
|
||||
<div style={{ marginBottom: 6 }}>{wslReport.message || '-'}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{(Array.isArray(wslReport.checks) ? wslReport.checks : []).slice(0, 8).map(check => (
|
||||
<div key={check.name}>
|
||||
<strong>{check.ok ? 'OK' : 'FAIL'}</strong> {check.name}
|
||||
{check.detail ? `: ${check.detail}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
@@ -277,6 +489,8 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
|
||||
{selectedBatch && (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: '#334155', lineHeight: 1.7 }}>
|
||||
<div><strong>Stack Plan:</strong>{selectedBatch.plan_id || '-'}</div>
|
||||
<div><strong>Plan Strategy:</strong>{selectedBatch.plan_strategy || '-'}</div>
|
||||
<div><strong>方向:</strong>{selectedBatch.direction || '-'}</div>
|
||||
<div><strong>影像数:</strong>{selectedBatch.total_items || 0}</div>
|
||||
<div><strong>批次状态:</strong>{selectedBatch.status || '-'}</div>
|
||||
@@ -284,7 +498,22 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreflight}
|
||||
disabled={readOnly || preflightLoading || !selectedBatchId}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#fff',
|
||||
color: '#0f172a',
|
||||
cursor: readOnly ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{preflightLoading ? '预检中...' : '运行预检'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
@@ -306,6 +535,114 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preflightReport && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${preflightReport.overall_ok ? '#bbf7d0' : '#fecaca'}`,
|
||||
background: preflightReport.overall_ok ? '#f0fdf4' : '#fef2f2',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 13, color: preflightReport.overall_ok ? '#166534' : '#991b1b' }}>
|
||||
{preflightReport.overall_ok ? '预检通过' : '预检发现问题'}
|
||||
</strong>
|
||||
<QualityBadge ok={!!preflightReport.overall_ok} okLabel="可提交" failLabel="需处理" />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#475569' }}>
|
||||
错误 {preflightErrors.length} / 告警 {preflightWarnings.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
|
||||
<div style={{ color: '#64748b', marginBottom: 4 }}>有效参考日期</div>
|
||||
<strong>{preflightReport.reference_date_effective || '-'}</strong>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
|
||||
<div style={{ color: '#64748b', marginBottom: 4 }}>场景规模</div>
|
||||
<strong>{preflightSummary.scene_count || 0} 景</strong>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
|
||||
<div style={{ color: '#64748b', marginBottom: 4 }}>Stack Key</div>
|
||||
<strong style={{ wordBreak: 'break-all' }}>{preflightSummary.stack_key || '-'}</strong>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #e2e8f0', fontSize: 12 }}>
|
||||
<div style={{ color: '#64748b', marginBottom: 4 }}>数据量</div>
|
||||
<strong>{formatBytes(preflightSummary.total_scene_bytes)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: '#334155', lineHeight: 1.7 }}>
|
||||
<div><strong>Stack Plan:</strong>{preflightReport.plan_id || preflightSummary.plan_id || '-'}</div>
|
||||
<div><strong>Plan Strategy:</strong>{preflightReport.plan_strategy || preflightSummary.plan_strategy || '-'}</div>
|
||||
<div><strong>批次:</strong>{preflightReport.batch_name || preflightReport.batch_id || '-'}</div>
|
||||
<div><strong>批次状态:</strong>{preflightReport.batch_status || '-'}</div>
|
||||
<div><strong>水体掩膜:</strong>{preflightReport.water_mask_mode || '-'}</div>
|
||||
<div><strong>分组:</strong>{preflightSummary.group_key || '-'}</div>
|
||||
<div><strong>源目录:</strong>{preflightSummary.source_root_windows || '-'}</div>
|
||||
<div><strong>日期列表:</strong>{(preflightSummary.stack_dates || []).join(', ') || '-'}</div>
|
||||
</div>
|
||||
|
||||
{preflightErrors.length > 0 && (
|
||||
<div style={{ marginTop: 8, padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #fecaca', fontSize: 12, color: '#991b1b' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>错误</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{preflightErrors.map((item, index) => (
|
||||
<div key={`preflight-error-${index}`}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preflightWarnings.length > 0 && (
|
||||
<div style={{ marginTop: 8, padding: '8px 10px', borderRadius: 6, background: '#fff', border: '1px solid #fde68a', fontSize: 12, color: '#92400e' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>告警</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{preflightWarnings.map((item, index) => (
|
||||
<div key={`preflight-warning-${index}`}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preflightChecks.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#0f172a', marginBottom: 6 }}>检查项</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{preflightChecks.map(check => {
|
||||
const accent = check.ok ? '#166534' : check.severity === 'warn' ? '#92400e' : '#991b1b';
|
||||
return (
|
||||
<div
|
||||
key={check.name}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#fff',
|
||||
fontSize: 12,
|
||||
color: '#334155',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: accent }}>
|
||||
{check.ok ? 'OK' : check.severity === 'warn' ? 'WARN' : 'FAIL'} / {check.name}
|
||||
</strong>
|
||||
{check.skipped ? <span style={{ color: '#64748b' }}>skipped</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, wordBreak: 'break-word' }}>{check.detail || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
@@ -377,6 +714,8 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
<strong>{runData?.run_name || '-'}</strong>
|
||||
<StatusPill value={runData?.status} />
|
||||
</div>
|
||||
<div><strong>Stack Plan:</strong>{runData?.plan_id || '-'}</div>
|
||||
<div><strong>Plan Strategy:</strong>{runData?.plan_strategy || '-'}</div>
|
||||
<div><strong>运行标识:</strong>{runData?.run_id || '-'}</div>
|
||||
<div><strong>批次标识:</strong>{runData?.batch_id || '-'}</div>
|
||||
<div><strong>参考日期:</strong>{runData?.reference_date || '-'}</div>
|
||||
@@ -392,7 +731,54 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
<div><strong>创建时间:</strong>{formatDateTime(runData?.created_at)}</div>
|
||||
<div><strong>结束时间:</strong>{formatDateTime(runData?.ended_at)}</div>
|
||||
<div><strong>输入日期:</strong>{(runData?.input_snapshot_json?.stack_dates || []).join(', ') || '-'}</div>
|
||||
<div><strong>轨道摘要:</strong>{JSON.stringify(runData?.orbit_summary_json || {}, null, 2)}</div>
|
||||
<div>
|
||||
<strong>轨道摘要:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<JsonBlock value={runData?.orbit_summary_json || {}} />
|
||||
</div>
|
||||
</div>
|
||||
{(runPreflightQuality || runPublishValidation) && (
|
||||
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px dashed #cbd5e1' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>运行质量</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
|
||||
{runPreflightQuality && (
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<strong>预检记录</strong>
|
||||
<QualityBadge ok={!!runPreflightQuality.overall_ok} okLabel="通过" failLabel="失败" />
|
||||
</div>
|
||||
<div><strong>有效参考日期:</strong>{runPreflightQuality.reference_date_effective || '-'}</div>
|
||||
<div><strong>错误数:</strong>{(runPreflightQuality.errors || []).length}</div>
|
||||
<div><strong>告警数:</strong>{(runPreflightQuality.warnings || []).length}</div>
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary style={{ cursor: 'pointer', color: '#2563eb' }}>查看预检详情</summary>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<JsonBlock value={runPreflightQuality} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{runPublishValidation && (
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<strong>发布校验</strong>
|
||||
<QualityBadge ok={!!runPublishValidation.ok} okLabel="通过" failLabel="失败" />
|
||||
</div>
|
||||
<div><strong>主资产角色:</strong>{runPublishValidation.primary_role || '-'}</div>
|
||||
<div><strong>预览角色:</strong>{runPublishValidation.preview_role || '-'}</div>
|
||||
<div><strong>缺失角色:</strong>{(runPublishValidation.missing_roles || []).length}</div>
|
||||
<div><strong>缺失文件:</strong>{(runPublishValidation.missing_paths || []).length}</div>
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary style={{ cursor: 'pointer', color: '#2563eb' }}>查看发布校验详情</summary>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<JsonBlock value={runPublishValidation} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{workflowSteps.length > 0 && (
|
||||
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px dashed #cbd5e1' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>工作流步骤</div>
|
||||
@@ -423,8 +809,25 @@ export default function TimeseriesProductionPanel({ readOnly = false, onJobQueue
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
|
||||
<StatusPill value={step.status} />
|
||||
{!readOnly && step.status === 'FAILED' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetryStep(step.step_id)}
|
||||
disabled={retryingStepId === step.step_id}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{retryingStepId === step.step_id ? '重试中...' : '重试该步'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,8 +3,17 @@ import apiClient from './client';
|
||||
export const createTimeseriesRun = payload =>
|
||||
apiClient.post('/timeseries-production/runs', payload).then(r => r.data);
|
||||
|
||||
export const runTimeseriesWslCheck = (payload = {}) =>
|
||||
apiClient.post('/timeseries-production/wsl-check', payload).then(r => r.data);
|
||||
|
||||
export const runTimeseriesPreflight = payload =>
|
||||
apiClient.post('/timeseries-production/preflight', payload).then(r => r.data);
|
||||
|
||||
export const listTimeseriesRuns = (params = {}) =>
|
||||
apiClient.get('/timeseries-production/runs', { params }).then(r => r.data);
|
||||
|
||||
export const getTimeseriesRunDetail = runId =>
|
||||
apiClient.get(`/timeseries-production/runs/${encodeURIComponent(runId)}`).then(r => r.data);
|
||||
|
||||
export const retryTimeseriesStep = (runId, payload) =>
|
||||
apiClient.post(`/timeseries-production/runs/${encodeURIComponent(runId)}/retry-step`, payload).then(r => r.data);
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { usePairingStore, useUiStore, useAuthStore } from '../store';
|
||||
import { getSelectedRegionTreeId } from '../utils/appUiHelpers';
|
||||
|
||||
const PARAM_METADATA = {
|
||||
initial_overlap_threshold: {
|
||||
label: '单景 AOI 覆盖率',
|
||||
title: '单景影像覆盖 AOI 的最低比例。0.30 表示影像至少覆盖 AOI 面积的 30%。',
|
||||
hint: '先过滤明显不覆盖研究区的影像;AOI 很大时可适当降低。',
|
||||
},
|
||||
final_overlap_threshold: {
|
||||
label: '栈覆盖一致性',
|
||||
title: '最终候选栈的公共覆盖区 / 栈内最小单景 AOI 覆盖区。0.95 表示栈内场景覆盖范围基本一致,不要求覆盖整个行政区。',
|
||||
hint: '控制时序栈内部覆盖稳定性;若同轨同模式影像仍被过滤,可尝试 0.85-0.90。',
|
||||
},
|
||||
};
|
||||
|
||||
function PsStackModal({
|
||||
onSubmit,
|
||||
onAoiModeChange,
|
||||
@@ -110,12 +123,8 @@ function PsStackModal({
|
||||
)}
|
||||
{Object.entries(psParams).map(([key, value]) => (
|
||||
<div className="form-group" key={key}>
|
||||
<label title={
|
||||
key === 'initial_overlap_threshold'
|
||||
? '影像与AOI重叠面积 / AOI面积'
|
||||
: '影像与公共重叠区面积 / 公共重叠区面积'
|
||||
}>
|
||||
{key.replace(/_/g, ' ')}:
|
||||
<label title={PARAM_METADATA[key]?.title || key}>
|
||||
{PARAM_METADATA[key]?.label || key.replace(/_/g, ' ')}:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -125,6 +134,11 @@ function PsStackModal({
|
||||
value={value}
|
||||
onChange={e => setPsParams({...psParams, [key]: parseFloat(e.target.value)})}
|
||||
/>
|
||||
{PARAM_METADATA[key]?.hint && (
|
||||
<div style={{ marginTop: '4px', fontSize: '12px', color: '#6b7280', lineHeight: 1.4 }}>
|
||||
{PARAM_METADATA[key].hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -329,9 +329,14 @@ export default function PsinsarCatalogPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: '#334155' }}>
|
||||
<div><strong>Stack Plan:</strong>{selectedProduct.plan_id || '-'}</div>
|
||||
<div><strong>Plan Strategy:</strong>{selectedProduct.plan_strategy || '-'}</div>
|
||||
<div><strong>名称:</strong>{selectedProduct.display_name || '-'}</div>
|
||||
<div><strong>产品编号:</strong>{selectedProduct.product_id || '-'}</div>
|
||||
<div><strong>运行标识:</strong>{selectedProduct.run_key || '-'}</div>
|
||||
<div><strong>运行标识:</strong>{selectedProduct.run_id || selectedProduct.run_key || '-'}</div>
|
||||
<div><strong>来源批次:</strong>{selectedProduct.batch_id || '-'}</div>
|
||||
<div><strong>任务标识:</strong>{selectedProduct.task_id || '-'}</div>
|
||||
<div><strong>工作流标识:</strong>{selectedProduct.workflow_run_id || '-'}</div>
|
||||
<div><strong>参考日期:</strong>{selectedProduct.reference_date || '-'}</div>
|
||||
<div><strong>影像数:</strong>{selectedProduct.stack_size || 0}</div>
|
||||
<div><strong>引擎:</strong>{selectedProduct.engine_code || '-'}</div>
|
||||
@@ -349,6 +354,21 @@ export default function PsinsarCatalogPanel({
|
||||
<div><strong>主展示产物:</strong>{selectedProduct.primary_asset_path || '-'}</div>
|
||||
</div>
|
||||
|
||||
{selectedProduct.source_summary && (
|
||||
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>来源追踪</div>
|
||||
<div style={{ fontSize: 12, color: '#334155', lineHeight: 1.7 }}>
|
||||
<div><strong>Stack Plan:</strong>{selectedProduct.source_summary?.plan_id || selectedProduct.plan_id || '-'}</div>
|
||||
<div><strong>Plan Strategy:</strong>{selectedProduct.source_summary?.plan_strategy || selectedProduct.plan_strategy || '-'}</div>
|
||||
<div><strong>规划来源:</strong>{selectedProduct.source_summary?.planning_context?.source || '-'}</div>
|
||||
<div><strong>规划策略:</strong>{selectedProduct.source_summary?.planning_context?.strategy || '-'}</div>
|
||||
<div><strong>场景数:</strong>{selectedProduct.source_summary?.planning_context?.scene_count || '-'}</div>
|
||||
<div><strong>工作目录:</strong>{selectedProduct.source_summary?.work_dir || '-'}</div>
|
||||
<div><strong>原生输出:</strong>{selectedProduct.source_summary?.native_output_dir || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>质量摘要</div>
|
||||
<pre
|
||||
|
||||
@@ -482,7 +482,9 @@ export default function AppSidePanel({
|
||||
<Suspense fallback={<PanelLoadingPanel message="正在加载 PS 候选结果面板..." />}>
|
||||
<LazyPsResultsPanel
|
||||
onPreviewPsStack={psPanel.onPreviewPsStack}
|
||||
onClearPsStackPreview={psPanel.onClearPsStackPreview}
|
||||
onCreatePsBatch={psPanel.onCreatePsBatch}
|
||||
onSendToTimeseriesProduction={psPanel.onSendToTimeseriesProduction}
|
||||
onClearPsResults={psPanel.onClearPsResults}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -17,8 +17,20 @@ function PsStackSection({
|
||||
stack,
|
||||
isReadOnlyUser,
|
||||
onPreviewPsStack,
|
||||
onClearPsStackPreview,
|
||||
onCreatePsBatch,
|
||||
onSendToTimeseriesProduction,
|
||||
}) {
|
||||
const planMeta = stack[0] || {};
|
||||
const commonAoiRatio = planMeta.stack_common_aoi_coverage_ratio == null
|
||||
? NaN
|
||||
: Number(planMeta.stack_common_aoi_coverage_ratio);
|
||||
const consistencyRatio = planMeta.stack_coverage_consistency_ratio == null
|
||||
? NaN
|
||||
: Number(planMeta.stack_coverage_consistency_ratio);
|
||||
const selectionModeLabel = planMeta.stack_selection_mode === 'pairwise_sbas_network'
|
||||
? 'SBAS网络'
|
||||
: (planMeta.stack_selection_mode === 'common_overlap' ? '公共栈' : '');
|
||||
const viewportHeight = useMemo(
|
||||
() => Math.min(Math.max(stack.length * PS_STACK_ROW_HEIGHT, PS_STACK_ROW_HEIGHT), PS_STACK_MAX_HEIGHT),
|
||||
[stack.length]
|
||||
@@ -27,8 +39,28 @@ function PsStackSection({
|
||||
return (
|
||||
<div className="ps-stack">
|
||||
<div className="ps-stack-header">
|
||||
<h4>{direction} ({stack.length} scenes)</h4>
|
||||
<button className="preview-button" onClick={() => onPreviewPsStack(stack)}>预览</button>
|
||||
<div>
|
||||
<h4>{direction} ({stack.length} scenes)</h4>
|
||||
{(planMeta.stack_plan_id || planMeta.stack_key) && (
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
|
||||
{planMeta.stack_plan_id ? `plan=${planMeta.stack_plan_id}` : ''}
|
||||
{planMeta.stack_plan_id && planMeta.stack_key ? ' / ' : ''}
|
||||
{planMeta.stack_key ? `stack=${planMeta.stack_key}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{(Number.isFinite(consistencyRatio) || Number.isFinite(commonAoiRatio)) && (
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
|
||||
{selectionModeLabel ? `${selectionModeLabel} / ` : ''}
|
||||
{Number.isFinite(consistencyRatio) ? `一致性 ${(consistencyRatio * 100).toFixed(1)}%` : ''}
|
||||
{Number.isFinite(consistencyRatio) && Number.isFinite(commonAoiRatio) ? ' / ' : ''}
|
||||
{Number.isFinite(commonAoiRatio) ? `公共AOI ${(commonAoiRatio * 100).toFixed(1)}%` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ps-stack-preview-actions">
|
||||
<button className="preview-button" onClick={() => onPreviewPsStack(stack)}>预览开</button>
|
||||
<button className="preview-button preview-button-secondary" onClick={onClearPsStackPreview}>预览关</button>
|
||||
</div>
|
||||
</div>
|
||||
<VirtualizedList
|
||||
items={stack}
|
||||
@@ -45,6 +77,9 @@ function PsStackSection({
|
||||
<button onClick={() => onCreatePsBatch(direction, stack)} disabled={isReadOnlyUser}>
|
||||
保存批次
|
||||
</button>
|
||||
<button onClick={() => onSendToTimeseriesProduction(direction, stack)} disabled={isReadOnlyUser}>
|
||||
送入生产
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function usePairingLogic({
|
||||
psParams, setShowPsModal, setPsResults,
|
||||
} = usePairingStore();
|
||||
const { setAoiLayer } = useMapStore();
|
||||
const { setBatchTab, setSelectedBatchId, setBatchItems } = useBatchStore();
|
||||
const { setBatchTab, setSelectedBatchId, setBatchItems, setPendingTimeseriesBatchId } = useBatchStore();
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
@@ -51,15 +51,48 @@ export default function usePairingLogic({
|
||||
|
||||
const createPsBatch = async (direction, stack, options = {}) => {
|
||||
if (!ensureCanOperate()) return;
|
||||
const { focusAfterCreate = true } = options;
|
||||
const { focusAfterCreate = true, sendToProduction = false } = options;
|
||||
if (!Array.isArray(stack) || stack.length < 3) {
|
||||
addLog('warn', `当前候选栈仅 ${Array.isArray(stack) ? stack.length : 0} 景,SBAS 至少需要 3 景。`);
|
||||
return;
|
||||
}
|
||||
const firstScene = stack[0] || {};
|
||||
const planId = firstScene.stack_plan_id || null;
|
||||
const batchDirection = firstScene.orbit_direction || direction;
|
||||
const planningContext = {
|
||||
source: planId ? 'timeseries_stack_plan' : 'find_ps_timeseries',
|
||||
plan_id: planId,
|
||||
strategy: 'sbas_stack',
|
||||
direction: batchDirection,
|
||||
display_group: direction,
|
||||
scene_count: stack.length,
|
||||
group_key: firstScene.stack_group_key || null,
|
||||
stack_key: firstScene.stack_key || null,
|
||||
initial_overlap_threshold: psParams?.initial_overlap_threshold ?? null,
|
||||
final_overlap_threshold: psParams?.final_overlap_threshold ?? null,
|
||||
stack_dates: stack.map(item => item.imaging_date).filter(Boolean),
|
||||
};
|
||||
try {
|
||||
const response = await apiClient.post('/task-batches/ps', {
|
||||
direction,
|
||||
direction: batchDirection,
|
||||
plan_id: planId,
|
||||
stack,
|
||||
name: `PS_${direction}_${new Date().toISOString().slice(0, 10)}`
|
||||
name: `TS_${batchDirection}_${new Date().toISOString().slice(0, 10)}`,
|
||||
planning_context: planningContext,
|
||||
});
|
||||
const batchId = response.data?.batch_id || '';
|
||||
addLog('success', `已创建时序批次: ${batchId || direction}`);
|
||||
if (planId && batchId) {
|
||||
addLog('info', `时序批次已关联候选栈计划 ${planId}`);
|
||||
}
|
||||
addLog('success', `已创建时序批次: ${batchId || batchDirection}`);
|
||||
if (batchId && sendToProduction) {
|
||||
setBatchTab('ps');
|
||||
setSelectedBatchId(batchId);
|
||||
setPendingTimeseriesBatchId(batchId);
|
||||
setLeftPanelTab('ps_production');
|
||||
addLog('info', `已将批次 ${batchId} 送入时序生产入口。`);
|
||||
return;
|
||||
}
|
||||
if (focusAfterCreate && batchId) {
|
||||
await focusBatchAfterCreate('ps', batchId);
|
||||
}
|
||||
@@ -264,7 +297,7 @@ export default function usePairingLogic({
|
||||
await createPsBatch(direction, stack, { focusAfterCreate: false });
|
||||
}
|
||||
} else {
|
||||
addLog('info', '在给定的AOI和阈值下,未找到合适的时序影像栈。');
|
||||
addLog('info', '在给定的AOI和阈值下,未找到满足 SBAS 至少 3 景要求的时序影像栈。');
|
||||
setLeftPanelTab('ps_results');
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
// PairingPanel
|
||||
{ zh: '基于时间基线、空间基线与重叠率筛选干涉对,可选 AOI 限定范围。', en: 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.' },
|
||||
{ zh: '配对', en: 'Pair' },
|
||||
{ zh: 'PS准备', en: 'PS Prep' },
|
||||
{ zh: '时序准备', en: 'Timeseries Prep' },
|
||||
{ zh: '结果与刷新', en: 'Results & Refresh' },
|
||||
{ zh: '已生成配对', en: 'Generated Pairs' },
|
||||
{ zh: '已选中', en: 'Selected' },
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function PairPlanningPanel({
|
||||
{en ? 'Pair' : '配对'}
|
||||
</button>
|
||||
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
|
||||
{en ? 'PS Prep' : 'PS 准备'}
|
||||
{en ? 'Timeseries Prep' : '时序准备'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function PairingPanel({
|
||||
{en ? 'Pair' : '配对'}
|
||||
</button>
|
||||
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
|
||||
{en ? 'PS Prep' : 'PS准备'}
|
||||
{en ? 'Timeseries Prep' : '时序准备'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ import PsStackSection from '../components/panels/PsStackSection';
|
||||
|
||||
function PsResultsPanel({
|
||||
onPreviewPsStack,
|
||||
onClearPsStackPreview,
|
||||
onCreatePsBatch,
|
||||
onSendToTimeseriesProduction,
|
||||
onClearPsResults,
|
||||
}) {
|
||||
const { psResults } = usePairingStore(useShallow((state) => ({
|
||||
@@ -18,7 +20,7 @@ function PsResultsPanel({
|
||||
return (
|
||||
<div className="panel-content panel-scroll-shell">
|
||||
{psStacks.length === 0 ? (
|
||||
<p className="empty-state">未找到时序InSAR候选栈。</p>
|
||||
<p className="empty-state">未找到满足 SBAS 至少 3 景要求的时序InSAR候选栈。</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="list-toolbar">
|
||||
@@ -32,7 +34,9 @@ function PsResultsPanel({
|
||||
stack={stack}
|
||||
isReadOnlyUser={isReadOnlyUser}
|
||||
onPreviewPsStack={onPreviewPsStack}
|
||||
onClearPsStackPreview={onClearPsStackPreview}
|
||||
onCreatePsBatch={onCreatePsBatch}
|
||||
onSendToTimeseriesProduction={onSendToTimeseriesProduction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const useBatchStore = create((set) => ({
|
||||
dinsarBatches: [],
|
||||
psBatches: [],
|
||||
selectedBatchId: '',
|
||||
pendingTimeseriesBatchId: '',
|
||||
batchItems: [],
|
||||
batchLoading: false,
|
||||
batchError: '',
|
||||
@@ -15,6 +16,7 @@ export const useBatchStore = create((set) => ({
|
||||
setDinsarBatches: s(set, 'dinsarBatches'),
|
||||
setPsBatches: s(set, 'psBatches'),
|
||||
setSelectedBatchId: s(set, 'selectedBatchId'),
|
||||
setPendingTimeseriesBatchId: s(set, 'pendingTimeseriesBatchId'),
|
||||
setBatchItems: s(set, 'batchItems'),
|
||||
setBatchLoading: s(set, 'batchLoading'),
|
||||
setBatchError: s(set, 'batchError'),
|
||||
|
||||
Reference in New Issue
Block a user