feat: engineer SBAS timeseries production workflow

This commit is contained in:
2026-04-29 14:43:31 +08:00
parent dace8b20f6
commit 4c0d1f2c2b
54 changed files with 5843 additions and 201 deletions
+10
View File
@@ -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
View File
@@ -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,
};
+71 -10
View File
@@ -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>
+409 -6
View File
@@ -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>
))}
+9
View File
@@ -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);
+20 -6
View File
@@ -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>
);
+39 -6
View File
@@ -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) {
+1 -1
View File
@@ -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' },
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 -1
View File
@@ -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>
+2
View File
@@ -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'),