Refactor local InSAR asset and production workflows

This commit is contained in:
2026-06-21 12:30:21 +08:00
parent 65a8cc4eac
commit 71c524967c
88 changed files with 11165 additions and 3017 deletions
+37 -10
View File
@@ -23,7 +23,7 @@ const cardStyle = {
export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
const { en } = useI18n();
const aiTaskMonitor = useTaskMonitor({
taskTypes: ['AI_ANALYZE'],
taskTypes: ['AI_DIAGNOSIS'],
showRecent: true,
recentLimit: 1,
pollRecentMs: 10000,
@@ -82,6 +82,15 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
loadInitialData();
}, [loadInitialData]);
useEffect(() => {
const models = aiStatus?.ollama_vlm_models || [];
if (models.length > 0 && !models.includes(selectedModel)) {
setSelectedModel(aiStatus?.default_vlm_model && models.includes(aiStatus.default_vlm_model)
? aiStatus.default_vlm_model
: models[0]);
}
}, [aiStatus, selectedModel]);
// 加载诊断列表
const loadDiagnoses = useCallback(async () => {
setLoading(true);
@@ -118,6 +127,14 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
setMessage(en ? 'Please select a D-InSAR result' : '请选择 D-InSAR 结果');
return;
}
if (!aiStatus?.ollama_online) {
setMessage(en ? 'Failed: Ollama is offline' : '失败: Ollama 未在线');
return;
}
if (!aiStatus?.ollama_vlm_models?.length) {
setMessage(en ? 'Failed: no local Ollama vision model is installed' : '失败: 未检测到本机 Ollama 视觉模型');
return;
}
setLoading(true);
setMessage('');
@@ -175,6 +192,11 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
critical: '#c53030',
};
const ollamaVlmModels = aiStatus?.ollama_vlm_models || [];
const modelOptions = ollamaVlmModels.length > 0
? ollamaVlmModels
: [aiStatus?.default_vlm_model || selectedModel].filter(Boolean);
const canCreateDiagnosis = !!selectedResultId && !!aiStatus?.ollama_online && ollamaVlmModels.length > 0;
const totalPages = Math.ceil(totalDiagnoses / pageSize);
return (
@@ -182,7 +204,7 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
{/* Header */}
<div style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>
{en ? 'AI Analysis' : 'AI 分析'}
{en ? 'D-InSAR Diagnosis' : 'D-InSAR诊断'}
</h2>
</div>
@@ -218,12 +240,12 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
</h3>
<TaskStatusPanel
title={en ? 'AI Diagnosis Task' : 'AI 诊断任务'}
title={en ? 'D-InSAR Diagnosis Task' : 'D-InSAR诊断任务'}
activeTasks={aiTaskMonitor.activeTasks}
recentTasks={aiTaskMonitor.recentTasks}
latestTask={aiTaskMonitor.latestTask}
isBusy={aiTaskMonitor.isBusy}
idleText={en ? 'No AI diagnosis task is running.' : '当前没有正在执行的 AI 诊断任务。'}
idleText={en ? 'No D-InSAR diagnosis task is running.' : '当前没有正在执行的 D-InSAR 诊断任务。'}
compact
/>
@@ -294,10 +316,15 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
fontSize: '13px',
}}
>
<option value="llama3.2-vision">llama3.2-vision</option>
<option value="llava">llava</option>
<option value="qwen2-vl">qwen2-vl</option>
{modelOptions.map((modelName) => (
<option key={modelName} value={modelName}>{modelName}</option>
))}
</select>
{aiStatus?.ollama_online && ollamaVlmModels.length === 0 && (
<div style={{ marginTop: '6px', fontSize: '12px', color: '#e53e3e' }}>
{en ? 'Ollama is online, but no local vision model is installed.' : 'Ollama 已在线,但未检测到本机视觉模型。'}
</div>
)}
</div>
{/* Prompt Template Selection */}
@@ -363,17 +390,17 @@ export default function AiAnalysisPanel({ readOnly = false, onJobQueued }) {
{/* Submit Button */}
<button
onClick={handleCreateDiagnosis}
disabled={loading || aiTaskMonitor.isBusy || !selectedResultId || !aiStatus?.ollama_online}
disabled={loading || aiTaskMonitor.isBusy || !canCreateDiagnosis}
style={{
width: '100%',
padding: '8px',
backgroundColor: loading || aiTaskMonitor.isBusy || !selectedResultId || !aiStatus?.ollama_online ? '#cbd5e0' : '#3182ce',
backgroundColor: loading || aiTaskMonitor.isBusy || !canCreateDiagnosis ? '#cbd5e0' : '#3182ce',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 500,
cursor: loading || aiTaskMonitor.isBusy || !selectedResultId || !aiStatus?.ollama_online ? 'not-allowed' : 'pointer',
cursor: loading || aiTaskMonitor.isBusy || !canCreateDiagnosis ? 'not-allowed' : 'pointer',
}}
>
{loading || aiTaskMonitor.isBusy ? (en ? 'Creating...' : '创建中...') : (en ? 'Create Diagnosis' : '创建诊断')}
+231 -5
View File
@@ -516,6 +516,24 @@ button:focus-visible {
color: var(--color-text-primary);
}
.radar-data-item {
align-items: flex-start;
gap: 10px;
}
.radar-data-item .data-item-name {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.35;
}
.radar-data-item .data-item-controls {
padding-top: 1px;
}
.data-item-controls {
display: flex;
align-items: center;
@@ -799,6 +817,87 @@ button:focus-visible {
flex-direction: column;
gap: 10px;
}
.dinsar-analysis-panel {
padding: 0;
}
.dinsar-analysis-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-panel);
position: sticky;
top: 0;
z-index: 1;
}
.dinsar-analysis-toolbar h3 {
margin: 0;
color: var(--color-text-primary);
font-size: 16px;
font-weight: 700;
}
.dinsar-analysis-toolbar p {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.dinsar-analysis-tabs {
display: inline-flex;
flex-shrink: 0;
gap: 4px;
padding: 3px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-panel-muted);
}
.dinsar-analysis-tabs button {
border: 0;
background: transparent;
color: var(--color-text-secondary);
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.dinsar-analysis-tabs button.active-tab {
background: var(--color-panel);
color: var(--color-accent-strong);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
}
.dinsar-analysis-body {
padding: 0;
}
.dinsar-analysis-body > .panel-content,
.dinsar-analysis-body > div {
min-height: 0;
}
@media (max-width: 720px) {
.dinsar-analysis-toolbar {
flex-direction: column;
}
.dinsar-analysis-tabs {
width: 100%;
}
.dinsar-analysis-tabs button {
flex: 1;
}
}
.primary-btn {
background-color: var(--color-accent);
color: white;
@@ -1027,17 +1126,19 @@ input[type="checkbox"] {
}
.pair-item {
padding: 10px 15px;
min-height: 64px;
padding: 10px 14px;
min-height: 176px;
border-bottom: 1px solid var(--color-border);
gap: 10px; /* Add gap for checkbox */
gap: 10px;
align-items: flex-start;
}
.pair-item:hover .pair-info {
cursor: pointer;
}
.pair-info {
font-size: 13px;
flex-grow: 1; /* Allow info to take up space */
flex-grow: 1;
min-width: 0;
}
.visibility-toggle {
@@ -1067,13 +1168,112 @@ input[type="checkbox"] {
font-size: 12px;
display: block;
margin-bottom: 5px;
color: var(--color-text-primary);
overflow-wrap: anywhere;
}
.pair-scenes {
display: grid;
gap: 3px;
margin-bottom: 7px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-secondary);
line-height: 1.35;
}
.pair-scenes span {
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pair-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px 10px;
font-size: 12px;
color: var(--color-text-secondary);
}
.pair-status-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 7px;
font-size: 11px;
}
.pair-production-badge {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 2px 7px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-panel-muted);
color: var(--color-text-secondary);
font-weight: 600;
}
.pair-production-badge.ready {
color: #166534;
background: #dcfce7;
border-color: #bbf7d0;
}
.pair-production-badge.running {
color: #92400e;
background: #fef3c7;
border-color: #fde68a;
}
.pair-production-badge.failed {
color: #991b1b;
background: #fee2e2;
border-color: #fecaca;
}
.pair-production-badge.missing {
color: #475569;
background: #f1f5f9;
}
.pair-production-badge.unknown {
color: #64748b;
background: transparent;
}
.pair-engine-text {
color: var(--color-text-secondary);
font-family: var(--font-mono);
}
.pair-planning-layout {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
min-height: 0;
}
.pair-planning-layout.with-map {
grid-template-columns: minmax(360px, 1fr) minmax(320px, 0.9fr);
align-items: start;
}
.pair-planning-list-pane {
min-height: 0;
display: flex;
flex-direction: column;
}
.pair-planning-list-viewport {
height: min(420px, calc(100vh - 310px));
max-height: 420px;
min-height: 260px;
flex: 0 0 auto;
}
.pair-planning-map-pane {
position: sticky;
top: 0;
align-self: start;
max-height: 420px;
overflow: hidden;
}
.pair-planning-map-pane section {
max-height: 420px;
}
.pair-planning-map-pane .leaflet-container {
max-height: 372px;
}
.list-toolbar {
display: flex;
@@ -1384,6 +1584,26 @@ input[type="checkbox"] {
font-family: var(--font-sans);
}
.flatpickr-year-jump {
align-items: center;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
height: 34px;
justify-content: center;
line-height: 1;
padding: 0 7px;
}
.flatpickr-year-jump:hover {
color: var(--color-accent);
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
@@ -5306,6 +5526,12 @@ input[type="checkbox"] {
}
@media (max-width: 900px) {
.pair-planning-layout.with-map {
grid-template-columns: 1fr;
}
.pair-planning-map-pane {
position: static;
}
.dinsar-toolbar-grid,
.dinsar-filter-layout,
.dinsar-catalog-summary,
+1 -23
View File
@@ -333,7 +333,7 @@ function App() {
setBatchError: state.setBatchError,
})));
const {
foundPairs, setFoundPairs, psResults,
foundPairs, setFoundPairs,
setShowPairingModal,
pairingAoiMode, setPairingAoiMode,
pairingRegionOptions, setPairingRegionOptions,
@@ -347,7 +347,6 @@ function App() {
} = usePairingStore(useShallow((state) => ({
foundPairs: state.foundPairs,
setFoundPairs: state.setFoundPairs,
psResults: state.psResults,
setShowPairingModal: state.setShowPairingModal,
pairingAoiMode: state.pairingAoiMode,
setPairingAoiMode: state.setPairingAoiMode,
@@ -666,11 +665,8 @@ function App() {
const {
fetchRegionGeometry,
handlePairingAoiModeChange,
handlePsAoiModeChange,
handlePairingProvinceChange,
handlePairingCityChange,
handlePsProvinceChange,
handlePsCityChange,
toggleMapRegionLocator,
handleMapRegionProvinceChange,
handleMapRegionCityChange,
@@ -681,7 +677,6 @@ function App() {
locateSelectedRegionOnMap,
clearMapRegionHighlight,
openPairingModal,
openPsModal,
} = useRegionAoiHandlers({
setPairingRegionLoading,
setPairingRegionError,
@@ -1641,10 +1636,7 @@ function App() {
const {
findPairs,
handleFindPsStack,
createDinsarBatch,
createPsBatch,
clearPsResults,
} = usePairingLogic({
fetchRegionGeometry,
refreshBatchList,
@@ -2076,7 +2068,6 @@ function App() {
};
const pairingPanel = {
onOpenPairingModal: openPairingModal,
onOpenPsModal: openPsModal,
onRefreshRadarSearch: refreshCurrentRadarSearch,
onRefreshDinsar: refreshDinsarResults,
};
@@ -2122,13 +2113,6 @@ function App() {
onTogglePairVisibility: togglePairVisibility,
onCreateDinsarBatch: createDinsarBatch,
};
const psPanel = {
onPreviewPsStack: previewPsStack,
onClearPsStackPreview: clearPsStackPreview,
onCreatePsBatch: createPsBatch,
onSendToTimeseriesProduction: (direction, stack) => createPsBatch(direction, stack, { sendToProduction: true }),
onClearPsResults: clearPsResults,
};
if (!authChecked) {
return (
@@ -2175,7 +2159,6 @@ function App() {
apiEndpoint={apiClient.defaults.baseURL}
licenseOk={licenseOk}
foundPairs={foundPairs}
psResults={psResults}
dinsarTotal={dinsarPagination.total}
selectedPairsCount={selectedPairsCount}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
@@ -2192,7 +2175,6 @@ function App() {
dinsarPanel={dinsarPanel}
aiPanel={aiPanel}
pairsPanel={pairsPanel}
psPanel={psPanel}
sbasAnalysisPanel={sbasAnalysisPanel}
/>
@@ -2246,10 +2228,6 @@ function App() {
onPairingAoiModeChange={handlePairingAoiModeChange}
onPairingProvinceChange={handlePairingProvinceChange}
onPairingCityChange={handlePairingCityChange}
onPsSubmit={handleFindPsStack}
onPsAoiModeChange={handlePsAoiModeChange}
onPsProvinceChange={handlePsProvinceChange}
onPsCityChange={handlePsCityChange}
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
+67 -6
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
auditSourceArchiveIntegrity,
getAssetInventoryStatus,
listAssetIssues,
listOrbitAssets,
@@ -46,6 +47,9 @@ const Metric = ({ label, value, hint }) => (
</div>
);
const INVENTORY_FAMILIES = ['LT1', 'S1'];
const ACTIVE_ROOT_ROLES = new Set(['source_product_pool', 'orbit_asset_pool']);
export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
const [status, setStatus] = useState(null);
const [sources, setSources] = useState({ items: [], total: 0, offset: 0, has_more: false });
@@ -55,10 +59,11 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
const [family, setFamily] = useState('all');
const [loading, setLoading] = useState(false);
const [scanLoading, setScanLoading] = useState(false);
const [auditLoading, setAuditLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const familyParam = useMemo(() => (family === 'all' ? undefined : family), [family]);
const familyParam = useMemo(() => (family === 'all' ? INVENTORY_FAMILIES.join(',') : family), [family]);
const refresh = useCallback(async ({ sourceOffset = 0, orbitOffset = 0, issueOffset = 0 } = {}) => {
setLoading(true);
@@ -85,13 +90,23 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
refresh();
}, [refresh]);
const handleScan = async () => {
const handleScan = async (scanPayload = {}, label = '源数据/精轨资产扫描') => {
if (readOnly || scanLoading) return;
setScanLoading(true);
setMessage('');
setError('');
const requestPayload =
scanPayload && typeof scanPayload === 'object' && scanPayload.nativeEvent
? {}
: scanPayload;
try {
const result = await scanAssetInventory({ inventory_types: [], root_ids: [], bind_orbits: true });
const result = await scanAssetInventory({
inventory_types: [],
root_ids: [],
bind_orbits: true,
families: INVENTORY_FAMILIES,
...requestPayload,
});
setMessage(`资产扫描任务已入队: ${result.task_id}`);
onTaskStart?.(result.task_id, '源数据/精轨资产扫描已入队', {
taskType: 'SCAN_ASSET_INVENTORY',
@@ -104,7 +119,36 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
}
};
const states = status?.states || [];
const handleArchiveIntegrityAudit = async (auditPayload = {}, label = '压缩包完整性审计') => {
if (readOnly || auditLoading) return;
setAuditLoading(true);
setMessage('');
setError('');
try {
const result = await auditSourceArchiveIntegrity({
families: family === 'all' ? INVENTORY_FAMILIES : [family],
source_formats: [],
asset_ids: [],
force: false,
...auditPayload,
});
setMessage(`压缩包完整性审计任务已入队: ${result.task_id}`);
onTaskStart?.(result.task_id, `${label}已入队`, {
taskType: 'AUDIT_SOURCE_ARCHIVE_INTEGRITY',
nonBlocking: true,
});
} catch (err) {
setError(err?.response?.data?.detail || err.message || '启动压缩包完整性审计失败');
} finally {
setAuditLoading(false);
}
};
const states = (status?.states || []).filter((item) => (
item?.enabled !== false &&
ACTIVE_ROOT_ROLES.has(item?.root_role) &&
item?.status !== 'NEVER_SCANNED'
));
const sourceRoots = states.filter(item => item.inventory_type === 'source_product');
const orbitRoots = states.filter(item => item.inventory_type === 'orbit_asset');
@@ -132,10 +176,17 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<option value="all">全部卫星族</option>
<option value="S1">Sentinel-1</option>
<option value="LT1">LT-1</option>
<option value="GF3">GF3</option>
</select>
<button type="button" onClick={() => refresh()} disabled={loading}>刷新</button>
<button type="button" onClick={handleScan} disabled={readOnly || scanLoading}>扫描资产</button>
<button type="button" onClick={() => handleScan({ families: INVENTORY_FAMILIES }, '全部资产扫描')} disabled={readOnly || scanLoading}>全部扫描</button>
<button type="button" onClick={() => handleScan({ families: ['LT1'] }, 'LT-1资产扫描')} disabled={readOnly || scanLoading}>LT-1扫描</button>
<button type="button" onClick={() => handleScan({ families: ['S1'] }, 'Sentinel-1资产扫描')} disabled={readOnly || scanLoading}>S1扫描</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: INVENTORY_FAMILIES }, '全部精轨扫描')} disabled={readOnly || scanLoading}>全部精轨</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: ['LT1'] }, 'LT-1精轨扫描')} disabled={readOnly || scanLoading}>LT-1精轨</button>
<button type="button" onClick={() => handleScan({ inventory_types: ['orbit_asset'], families: ['S1'] }, 'Sentinel-1精轨扫描')} disabled={readOnly || scanLoading}>S1精轨</button>
<button type="button" onClick={() => handleArchiveIntegrityAudit()} disabled={readOnly || auditLoading}>
{auditLoading ? '审计启动中' : '压缩包完整性审计'}
</button>
</div>
</div>
@@ -147,6 +198,11 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<Metric label="精轨资产" value={status?.orbit_asset_count} hint={`${orbitRoots.length} 个精轨根`} />
<Metric label="已绑定场景" value={status?.selected_binding_count} />
<Metric label="开放问题" value={status?.open_issue_count} />
<Metric
label="压缩包完整性"
value={status?.archive_integrity_counts?.OK || 0}
hint={`未审计 ${status?.archive_integrity_counts?.NOT_CHECKED || 0} / 失败 ${status?.archive_integrity_counts?.FAILED || 0}`}
/>
</div>
<div className="asset-root-strip">
@@ -183,6 +239,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<th>产品</th>
<th>轨道</th>
<th>状态</th>
<th>完整性</th>
<th>动作</th>
<th>文件</th>
</tr>
@@ -196,6 +253,10 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
<td>{item.source_format}<small>{item.imaging_mode} / {item.polarization}</small></td>
<td>{item.relative_orbit || '-'}<small>abs {item.absolute_orbit || '-'}</small></td>
<td><StatusBadge value={item.parse_status} /></td>
<td title={item.archive_integrity_error || ''}>
<StatusBadge value={item.archive_integrity_status || 'NOT_CHECKED'} />
<small>{item.archive_integrity_member_count != null ? `${item.archive_integrity_member_count} files` : item.archive_integrity_method || '-'}</small>
</td>
<td>
<span className="asset-action-placeholder">-</span>
</td>
+135 -125
View File
@@ -10,14 +10,19 @@ const COPY_STATUS_OPTIONS = [
];
const BATCH_API_PAGE_LIMIT = 500;
const BATCH_API_MAX_PAGES = 200;
const DINSAR_PURPOSE_PRODUCTION = 'production_prepare';
const DINSAR_PURPOSE_DISTRIBUTION = 'source_distribution';
const FALLBACK_DINSAR_TASK_POOL_ROOT = 'D:\\Task_Pool\\DInSAR';
const FALLBACK_DATA_DISTRIBUTION_ROOT = 'D:\\Task_Pool\\Data_Distribution';
const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState('dinsar');
const [destDir, setDestDir] = useState('');
const [targetName, setTargetName] = useState('');
const [dinsarTaskPoolRoot, setDinsarTaskPoolRoot] = useState('');
const [dataDistributionRoot, setDataDistributionRoot] = useState('');
const [dinsarPurpose, setDinsarPurpose] = useState(DINSAR_PURPOSE_PRODUCTION);
const [copyStatuses, setCopyStatuses] = useState(['COMPLETED']);
const [includeDinsarOrbitFiles, setIncludeDinsarOrbitFiles] = useState(true);
const [dinsarPackageMode, setDinsarPackageMode] = useState('task_folder');
const [skipExistingDinsarTasks, setSkipExistingDinsarTasks] = useState(true);
const [dinsarMaxItems, setDinsarMaxItems] = useState('200');
const [batches, setBatches] = useState([]);
@@ -57,13 +62,28 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
useEffect(() => {
fetchBatchesRef.current?.();
}, [activeTab]);
}, []);
useEffect(() => {
axios.get(`${apiEndpoint}/monitor/status`, { withCredentials: true })
.then((response) => {
const taskRoot = (response.data?.dinsar_task_pool_root || '').toString().trim();
const distributionRoot = (response.data?.data_distribution_root || '').toString().trim();
if (taskRoot) {
setDinsarTaskPoolRoot(taskRoot);
}
if (distributionRoot) {
setDataDistributionRoot(distributionRoot);
}
})
.catch((error) => {
console.error('Failed to load monitor status:', error);
});
}, [apiEndpoint]);
const fetchBatches = async () => {
try {
const endpoint = activeTab === 'ps'
? `${apiEndpoint}/task-batches/ps`
: `${apiEndpoint}/task-batches/dinsar`;
const endpoint = `${apiEndpoint}/task-batches/dinsar`;
const allBatches = [];
for (let page = 0; page < BATCH_API_MAX_PAGES; page += 1) {
const offset = page * BATCH_API_PAGE_LIMIT;
@@ -101,13 +121,21 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
};
fetchLogsRef.current = fetchLogs;
const handleDinsarPurposeChange = (nextPurpose) => {
setDinsarPurpose(nextPurpose);
setTaskId(null);
setLogs([]);
setStatus('IDLE');
setTargetName('');
};
const handleStartCopy = async () => {
if (readOnly) {
alert('当前账号为只读模式,无法执行复制任务。');
return;
}
if (!selectedBatchId || !destDir) {
alert('请选择批次并设置目标目录。');
if (!selectedBatchId || !targetName.trim()) {
alert('请选择批次并填写任务名。');
return;
}
if (!copyStatuses.length) {
@@ -119,25 +147,21 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
setLogs([]);
setStatus('RUNNING');
const endpoint = activeTab === 'ps'
? `${apiEndpoint}/tools/copy-ps-stack`
: `${apiEndpoint}/tools/copy-dinsar-pairs`;
const endpoint = `${apiEndpoint}/tools/copy-dinsar-pairs`;
try {
const payload = {
batch_id: selectedBatchId,
dest_dir: destDir,
target_name: targetName.trim(),
copy_statuses: copyStatuses,
};
if (activeTab === 'dinsar') {
payload.include_orbit_files = includeDinsarOrbitFiles;
payload.package_mode = dinsarPackageMode;
payload.export_zip = dinsarPackageMode === 'task_zip';
payload.skip_existing = skipExistingDinsarTasks;
const parsedMaxItems = Number.parseInt(dinsarMaxItems, 10);
if (Number.isFinite(parsedMaxItems) && parsedMaxItems > 0) {
payload.max_items = parsedMaxItems;
}
payload.include_orbit_files = includeDinsarOrbitFiles;
payload.package_mode = dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? 'task_folder' : 'source_bundle';
payload.export_zip = false;
payload.skip_existing = skipExistingDinsarTasks;
const parsedMaxItems = Number.parseInt(dinsarMaxItems, 10);
if (Number.isFinite(parsedMaxItems) && parsedMaxItems > 0) {
payload.max_items = parsedMaxItems;
}
const response = await axios.post(endpoint, payload, { withCredentials: true });
const taskId = response.data.task_id;
@@ -175,110 +199,85 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
return (
<div className="data-copier-panel" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="tabs-header">
<button
className={activeTab === 'ps' ? 'active-tab' : ''}
onClick={() => setActiveTab('ps')}
>
PS 分发
</button>
<button
className={activeTab === 'dinsar' ? 'active-tab' : ''}
onClick={() => setActiveTab('dinsar')}
>
D-InSAR 分发
</button>
</div>
<div className="panel-content" style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '15px', gap: '15px' }}>
{readOnly && (
<div style={{ fontSize: '12px', color: '#92400e', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '6px', padding: '8px 10px' }}>
当前账号为只读模式无法发起复制任务
</div>
)}
{activeTab === 'dinsar' && (
<div
className="input-group"
style={{
border: '1px solid #c7d2fe',
background: '#eef2ff',
borderRadius: '8px',
padding: '10px 12px',
}}
>
<label>D-InSAR 分发设置</label>
<div style={{ display: 'grid', gap: '6px', marginTop: '8px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="dinsar-package-mode"
value="task_folder"
checked={dinsarPackageMode === 'task_folder'}
onChange={(event) => setDinsarPackageMode(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>生产 Task 文件夹</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="dinsar-package-mode"
value="task_zip"
checked={dinsarPackageMode === 'task_zip'}
onChange={(event) => setDinsarPackageMode(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>生产 Task ZIP</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="dinsar-package-mode"
value="source_bundle"
checked={dinsarPackageMode === 'source_bundle'}
onChange={(event) => setDinsarPackageMode(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>去重源数据包data / orbit / pairs.json</span>
</label>
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={includeDinsarOrbitFiles}
onChange={(event) => setIncludeDinsarOrbitFiles(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>{dinsarPackageMode === 'source_bundle' ? '复制精密轨道到 orbit/' : '复制精密轨道到 Task/orbit'}</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={skipExistingDinsarTasks}
onChange={(event) => setSkipExistingDinsarTasks(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>{dinsarPackageMode === 'source_bundle' ? '复用已存在的 data/orbit' : '跳过目标目录中已存在的 Task'}</span>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ fontSize: '13px' }}>{dinsarPackageMode === 'source_bundle' ? '每次最多追加新配对:' : '每次最多分发新 Task:'}</label>
<input
type="number"
min="0"
value={dinsarMaxItems}
onChange={(event) => setDinsarMaxItems(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
style={{ width: '110px', padding: '5px 7px' }}
/>
<span style={{ fontSize: '12px', color: '#64748b' }}>0 或留空表示不限制</span>
</div>
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
去重源数据包只复制唯一影像和精轨并写出 pairs.json再次分发到同一目录时会接着追加未导出的配对
</div>
<div
className="input-group"
style={{
border: '1px solid #c7d2fe',
background: '#eef2ff',
borderRadius: '8px',
padding: '10px 12px',
}}
>
<label>D-InSAR 任务用途</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<button
type="button"
className={dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? 'primary-btn' : 'secondary-btn'}
onClick={() => handleDinsarPurposeChange(DINSAR_PURPOSE_PRODUCTION)}
disabled={status === 'RUNNING' || readOnly}
>
生产数据准备
</button>
<button
type="button"
className={dinsarPurpose === DINSAR_PURPOSE_DISTRIBUTION ? 'primary-btn' : 'secondary-btn'}
onClick={() => handleDinsarPurposeChange(DINSAR_PURPOSE_DISTRIBUTION)}
disabled={status === 'RUNNING' || readOnly}
>
数据分发
</button>
</div>
)}
<div style={{ fontSize: '13px', color: '#1e3a8a', marginTop: '8px', fontWeight: 600 }}>
{dinsarPurpose === DINSAR_PURPOSE_PRODUCTION
? '生成可直接运行的 Task_Pool 任务目录(Task_YYYYMMDD_YYYYMMDD / master / slave / orbit'
: '导出源压缩包去重包(data / orbit / pairs.json / manifest.json'}
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={includeDinsarOrbitFiles}
onChange={(event) => setIncludeDinsarOrbitFiles(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>{dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? '复制精密轨道到 Task/orbit' : '复制精密轨道到 orbit/'}</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={skipExistingDinsarTasks}
onChange={(event) => setSkipExistingDinsarTasks(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>{dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? '跳过已存在的完整 Task' : '复用已存在的 data/orbit'}</span>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ fontSize: '13px' }}>
{dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? '每次最多准备新 Task:' : '每次最多追加新配对:'}
</label>
<input
type="number"
min="0"
value={dinsarMaxItems}
onChange={(event) => setDinsarMaxItems(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
style={{ width: '110px', padding: '5px 7px' }}
/>
<span style={{ fontSize: '12px', color: '#64748b' }}>0 或留空表示不限制</span>
</div>
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
{dinsarPurpose === DINSAR_PURPOSE_PRODUCTION
? '源池仍管理压缩包;这里按任务解包到本机 Task_Pool,供 D-InSAR 引擎直接使用。'
: '该归口用于跨目录/跨机器下发源压缩包,不作为生产运行入口。'}
</div>
</div>
<div className="input-group">
<label>1. 选择批次</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
@@ -320,25 +319,36 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
</div>
<div className="input-group">
<label>3. 目标目录</label>
<label>3. {dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? '生产任务名' : '分发任务名'}</label>
<input
type="text"
value={destDir}
onChange={(e) => setDestDir(e.target.value)}
placeholder="例如:D:/Data/Project_X/PS_Stack"
value={targetName}
onChange={(e) => setTargetName(e.target.value)}
placeholder={
dinsarPurpose === DINSAR_PURPOSE_PRODUCTION
? '例如:MDJ_20240422_20240520'
: '例如:Project_X_DInSAR_Source_Bundle'
}
disabled={status === 'RUNNING' || readOnly}
style={{ width: '100%', padding: '8px' }}
/>
<div style={{ fontSize: '12px', color: '#64748b', marginTop: '4px' }}>
{dinsarPurpose === DINSAR_PURPOSE_PRODUCTION
? `服务器写入目录:${dinsarTaskPoolRoot || FALLBACK_DINSAR_TASK_POOL_ROOT}\\${targetName || '<任务名>'}`
: `服务器写入目录:${dataDistributionRoot || FALLBACK_DATA_DISTRIBUTION_ROOT}\\${targetName || '<任务名>'}`}
</div>
</div>
<div className="actions" style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleStartCopy}
disabled={status === 'RUNNING' || isUploading || !selectedBatchId || !destDir || readOnly}
disabled={status === 'RUNNING' || isUploading || !selectedBatchId || !targetName.trim() || readOnly}
className="primary-btn"
style={{ flex: 1 }}
>
{status === 'RUNNING' ? '复制中...' : (readOnly ? '只读模式' : '开始复制')}
{status === 'RUNNING'
? '处理中...'
: (readOnly ? '只读模式' : (dinsarPurpose === DINSAR_PURPOSE_PRODUCTION ? '生成生产任务' : '开始分发'))}
</button>
{status !== 'IDLE' && status !== 'RUNNING' && (
<button onClick={handleReset} className="secondary-btn">
File diff suppressed because it is too large Load Diff
+149 -62
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
import { deleteRunLog, deleteRunRecord, getRunLog, listEngines, listRuns, listTaskRoots, previewPyintInputAssets, submitRun } from './api/dinsarProduction';
import { clearTaskLogs, deleteTaskLog, deleteTaskRecord, getRecentTasks, getTaskLogs } from './api/tasks';
import { formatSatelliteFamilyLabel, inferSatelliteFamilyFromResultLike } from './utils/satelliteFamily';
import useTaskMonitor from './hooks/useTaskMonitor';
@@ -16,8 +16,9 @@ const card = {
const EMPTY_ARRAY = [];
const EMPTY_OBJECT = {};
const RUN_HISTORY_PAGE_SIZE = 200;
const TASK_HISTORY_PAGE_SIZE = 500;
const TASK_HISTORY_PAGE_SIZE = 200;
const TASK_LOG_PAGE_SIZE = 1000;
const INLINE_TASK_LOG_LIMIT = 200;
const TERMINAL_STATUS_VALUES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'CANCELED', 'success', 'failed', 'cancelled', 'canceled']);
const ENGINE_STATUS_COLOR = {
@@ -96,6 +97,8 @@ const RERUN_MODE_OPTIONS = [
description: '忽略已有结果,对本次选中的任务全部重新执行。',
},
];
const MANUAL_TASK_ROOT_VALUE = '__manual__';
const NO_TASK_ROOT_VALUE = '';
function formatEngineLabel(engineCode, engineLabel = '') {
return engineLabel || ENGINE_LABEL[engineCode] || engineCode || '-';
@@ -255,6 +258,15 @@ function formatPathValue(value) {
return text || '-';
}
function formatTaskRootUpdatedAt(value) {
if (!value) return '';
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}
function RunPathBlock({ run }) {
const items = Array.isArray(run?.items) ? run.items : [];
const item = items.find(entry => entry?.status === 'RUNNING') || items[0] || null;
@@ -560,6 +572,12 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [selectedEngine, setSelectedEngine] = useState('sarscape');
const [selectedProfile, setSelectedProfile] = useState('custom6');
const [rootDir, setRootDir] = useState('');
const [taskRoots, setTaskRoots] = useState([]);
const [taskRootsLoading, setTaskRootsLoading] = useState(false);
const [taskRootsError, setTaskRootsError] = useState('');
const [taskPoolRoot, setTaskPoolRoot] = useState('');
const [selectedTaskRootPath, setSelectedTaskRootPath] = useState('');
const [manualRootDir, setManualRootDir] = useState('');
const [numToProcess, setNumToProcess] = useState(0);
const [timeoutSec, setTimeoutSec] = useState('');
const [engineExtraParams, setEngineExtraParams] = useState({});
@@ -588,6 +606,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
const [monitorLoaded, setMonitorLoaded] = useState(false);
const currentEngineObj = engines.find(engine => engine.engine_code === selectedEngine) || null;
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
@@ -601,9 +620,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
? 'LandSAR 当前使用已跑通的稳定参数。GACOS 大气相位改正需要外部大气延迟文件,未配置文件前不可启用;垂直向形变为可选输出,默认关闭。'
: '这些参数影响当前引擎的生产模板。建议先使用默认值,只有在结果边界、噪声或几何表现异常时再逐项调整。';
const pyintPreviewBlocksSubmit = selectedEngine === 'pyint' && pyintPreview && pyintPreview.allow_submit === false;
const selectedTaskRoot = taskRoots.find(item => item.path === selectedTaskRootPath) || null;
const taskMonitor = useTaskMonitor({
taskTypes: DINSAR_PRODUCTION_TASK_TYPES,
showRecent: true,
showRecent: false,
recentLimit: 1,
});
const latestRunWithTask = runs.find(run => run?.task_id) || null;
@@ -638,40 +658,38 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}
}, []);
const loadTaskRoots = useCallback(async () => {
setTaskRootsLoading(true);
setTaskRootsError('');
try {
const data = await listTaskRoots();
const items = Array.isArray(data?.items) ? data.items : [];
setTaskPoolRoot(String(data?.root || ''));
setTaskRoots(items);
setSelectedTaskRootPath(current => {
if (current === MANUAL_TASK_ROOT_VALUE) return current;
if (current && items.some(item => item.path === current)) return current;
return NO_TASK_ROOT_VALUE;
});
} catch (err) {
setTaskRoots([]);
setTaskRootsError(err?.response?.data?.detail || err.message || '生产任务根目录加载失败');
} finally {
setTaskRootsLoading(false);
}
}, []);
const loadRuns = useCallback(async (options = {}) => {
const silent = !!options.silent;
if (!silent) setRunsLoading(true);
try {
const loadProductionRuns = async () => {
const allRuns = [];
let offset = 0;
while (true) {
const data = await listRuns(RUN_HISTORY_PAGE_SIZE, offset);
const pageRuns = data?.runs || [];
allRuns.push(...pageRuns);
const total = Number(data?.total || 0);
if (pageRuns.length < RUN_HISTORY_PAGE_SIZE || allRuns.length >= total) break;
offset += pageRuns.length;
}
return allRuns;
};
const loadRecentTasks = async () => {
const allTasks = [];
let offset = 0;
while (true) {
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;
offset += pageTasks.length;
}
return allTasks;
};
const [productionRuns, recentTasks] = await Promise.all([
loadProductionRuns(),
loadRecentTasks(),
const [productionRunData, recentTaskData] = await Promise.all([
listRuns(RUN_HISTORY_PAGE_SIZE, 0),
getRecentTasks(DINSAR_PRODUCTION_TASK_TYPES, [], TASK_HISTORY_PAGE_SIZE, 0),
]);
const nextRuns = mergeRunRows(productionRuns, recentTasks);
const productionRuns = productionRunData?.runs || [];
const recentTasks = Array.isArray(recentTaskData) ? recentTaskData : (recentTaskData?.tasks || []);
const nextRuns = mergeRunRows(productionRuns, recentTasks, RUN_HISTORY_PAGE_SIZE);
setRuns(nextRuns);
return nextRuns;
} catch {
@@ -690,7 +708,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}
if (!silent) setTaskLogsLoading(true);
try {
const logs = await fetchAllTaskLogs(taskId);
const data = await getTaskLogs(taskId, INLINE_TASK_LOG_LIMIT, 0);
const logs = data?.logs || [];
setTaskLogs(logs);
} catch {
setTaskLogs([]);
@@ -737,30 +756,27 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
const refreshMonitor = useCallback(async (options = {}) => {
const silent = !!options.silent;
const [nextRuns, nextRecentTasks] = await Promise.all([
loadRuns({ silent }),
taskMonitor.refreshRecentTasks(),
]);
const nextRuns = await loadRuns({ silent });
setMonitorLoaded(true);
const fallbackTaskId =
taskMonitor.activeTasks[0]?.task_id
|| nextRecentTasks[0]?.task_id
|| nextRuns.find(run => run?.task_id)?.task_id
|| '';
await loadTaskLogs(fallbackTaskId, { silent });
}, [loadRuns, loadTaskLogs, taskMonitor]);
}, [loadRuns, loadTaskLogs, taskMonitor.activeTasks]);
useEffect(() => {
loadEngines();
refreshMonitor();
}, [loadEngines, refreshMonitor]);
loadTaskRoots();
}, [loadEngines, loadTaskRoots]);
useEffect(() => {
const intervalMs = taskMonitor.isBusy ? 5000 : 15000;
const timer = window.setInterval(() => {
refreshMonitor({ silent: true });
}, intervalMs);
return () => window.clearInterval(timer);
}, [taskMonitor.isBusy, refreshMonitor]);
if (selectedTaskRootPath === MANUAL_TASK_ROOT_VALUE) {
setRootDir(manualRootDir);
return;
}
setRootDir(selectedTaskRootPath);
}, [manualRootDir, selectedTaskRootPath]);
useEffect(() => {
if (currentProfiles.length > 0) {
@@ -984,7 +1000,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
}
}, [logModal.open, logModal.runId, logTaskId, readOnly, refreshMonitor, runLogDeletingId]);
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || pyintPreviewBlocksSubmit;
const isSubmitDisabled = readOnly || submitting || !currentEngineObj?.available || !rootDir.trim() || pyintPreviewBlocksSubmit;
return (
<div style={{ padding: '16px 0', width: '100%' }}>
@@ -1153,6 +1169,17 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<div style={{ fontSize: 12, color: '#475569' }}>模板{currentProfileObj?.label || selectedProfile}</div>
<div style={{ fontSize: 12, color: '#475569' }}>任务数量{Number(numToProcess) > 0 ? Number(numToProcess) : '全部'}</div>
<div style={{ fontSize: 12, color: '#475569' }}>执行策略{RERUN_MODE_LABEL[rerunMode] || rerunMode}</div>
<div style={{ fontSize: 12, color: '#475569' }}>
根目录{selectedTaskRoot?.name || (selectedTaskRootPath === MANUAL_TASK_ROOT_VALUE ? '手动路径' : '-')}
</div>
{selectedTaskRoot && (
<div style={{ fontSize: 12, color: '#475569' }}>
可识别 Task{Number(selectedTaskRoot.task_count || 0)}
</div>
)}
<div style={{ fontSize: 12, color: '#475569', gridColumn: '1 / -1', wordBreak: 'break-all' }}>
路径{rootDir || '-'}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
@@ -1252,13 +1279,30 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
</div>
<div style={{ flex: 2, minWidth: 280 }}>
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>根目录</label>
<input
value={rootDir}
onChange={event => setRootDir(event.target.value)}
placeholder="批处理根目录或单个任务目录"
disabled={readOnly}
<div style={{ flex: 2, minWidth: 320 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<label style={{ fontSize: 12, color: '#64748b' }}>生产任务根目录</label>
<button
type="button"
onClick={loadTaskRoots}
disabled={readOnly || taskRootsLoading}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
border: '1px solid #e2e8f0',
background: '#fff',
color: '#475569',
cursor: readOnly || taskRootsLoading ? 'not-allowed' : 'pointer',
}}
>
{taskRootsLoading ? '刷新中' : '刷新'}
</button>
</div>
<select
value={selectedTaskRootPath}
onChange={event => setSelectedTaskRootPath(event.target.value)}
disabled={readOnly || taskRootsLoading}
style={{
width: '100%',
padding: '5px 8px',
@@ -1267,7 +1311,47 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
fontSize: 13,
boxSizing: 'border-box',
}}
/>
>
<option value={NO_TASK_ROOT_VALUE}>请选择生产任务根目录</option>
{taskRoots.length === 0 && (
<option value="" disabled>暂无已准备的生产任务</option>
)}
{taskRoots.map(item => (
<option key={item.path} value={item.path}>
{item.name} ({Number(item.task_count || 0)} Task{item.valid === false ? ',不可运行' : ''})
</option>
))}
<option value={MANUAL_TASK_ROOT_VALUE}>手动输入其他路径</option>
</select>
{selectedTaskRootPath === MANUAL_TASK_ROOT_VALUE && (
<input
value={manualRootDir}
onChange={event => setManualRootDir(event.target.value)}
placeholder="批处理根目录或单个任务目录"
disabled={readOnly}
style={{
width: '100%',
marginTop: 6,
padding: '5px 8px',
borderRadius: 4,
border: '1px solid #e2e8f0',
fontSize: 13,
boxSizing: 'border-box',
}}
/>
)}
<div style={{ fontSize: 11, color: taskRootsError ? '#b91c1c' : '#94a3b8', marginTop: 4, wordBreak: 'break-all' }}>
{taskRootsError || (
rootDir
? `服务器路径:${rootDir}`
: `扫描目录:${taskPoolRoot || '未配置'};请选择其中一个一级生产任务目录。`
)}
</div>
{selectedTaskRoot && (
<div style={{ fontSize: 11, color: '#64748b', marginTop: 3 }}>
最近更新{formatTaskRootUpdatedAt(selectedTaskRoot.updated_at)}不可识别 Task{Number(selectedTaskRoot.invalid_child_count || 0)}
</div>
)}
</div>
</div>
@@ -1544,7 +1628,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
border: 'none',
background: currentEngineObj?.available ? '#3b82f6' : '#94a3b8',
color: '#fff',
cursor: currentEngineObj?.available ? 'pointer' : 'not-allowed',
cursor: isSubmitDisabled ? 'not-allowed' : 'pointer',
fontSize: 13,
fontWeight: 600,
}}
@@ -1564,20 +1648,21 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<strong style={{ fontSize: 14 }}>运行监控</strong>
<button
onClick={refreshMonitor}
disabled={runsLoading || taskLogsLoading}
style={{
fontSize: 12,
padding: '3px 10px',
borderRadius: 4,
border: '1px solid #e2e8f0',
cursor: 'pointer',
background: '#f8fafc',
cursor: runsLoading || taskLogsLoading ? 'not-allowed' : 'pointer',
background: runsLoading || taskLogsLoading ? '#f1f5f9' : '#f8fafc',
}}
>
刷新
{runsLoading || taskLogsLoading ? '刷新中...' : '手动刷新'}
</button>
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 10 }}>
监控与日志改为手动刷新避免界面持续轮询请求
监控与日志不会自动轮询点击手动刷新时只加载最近记录和最近日志
</div>
{monitoredTask && (
@@ -1714,7 +1799,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>运行记录已加载 {runs.length} </div>
{runsLoading ? (
{!monitorLoaded && !runsLoading ? (
<div style={{ fontSize: 12, color: '#94a3b8' }}>尚未加载监控记录请点击手动刷新</div>
) : runsLoading ? (
<div style={{ fontSize: 12, color: '#94a3b8' }}>加载中...</div>
) : runs.length === 0 ? (
<div style={{ fontSize: 12, color: '#94a3b8' }}>暂无记录</div>
+91 -66
View File
@@ -374,9 +374,10 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const orbitPools = orbitStatus?.pools || {};
const orbitConsistency = orbitStatus?.consistency || {};
const orbitDatabase = orbitStatus?.database || {};
const orbitIsce2Enabled = Boolean(orbitPools.isce2?.enabled || orbitConsistency.isce2?.enabled || orbitDatabase.isce2_enabled);
const orbitMismatchCount = toNumber(orbitConsistency.mismatch_count);
const orbitDbMissingEnviCount = toNumber(orbitDatabase.stems_missing_in_envi_count);
const orbitDbMissingIsce2Count = toNumber(orbitDatabase.stems_missing_in_isce2_count);
const orbitDbMissingIsce2Count = orbitIsce2Enabled ? toNumber(orbitDatabase.stems_missing_in_isce2_count) : 0;
const orbitDbMissingPathCount = toNumber(orbitDatabase.db_missing_path_count);
const orbitDbFlagIssueCount =
toNumber(orbitDatabase.has_orbit_but_missing_path_count) +
@@ -384,15 +385,15 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
const orbitScanErrorCount =
(orbitSource.errors?.length || 0) +
(orbitPools.envi?.errors?.length || 0) +
(orbitPools.isce2?.errors?.length || 0);
(orbitIsce2Enabled ? (orbitPools.isce2?.errors?.length || 0) : 0);
const orbitDuplicateCount =
toNumber(orbitSource.duplicate_count) +
toNumber(orbitPools.envi?.duplicate_count) +
toNumber(orbitPools.isce2?.duplicate_count);
(orbitIsce2Enabled ? toNumber(orbitPools.isce2?.duplicate_count) : 0);
const orbitSuspectBadCount = toNumber(orbitSource.suspect_bad_count);
const orbitSourceWithoutEnviCount = toNumber(orbitSource.source_without_envi_count);
const orbitEnviWithoutSourceCount = toNumber(orbitSource.envi_without_source_count);
const orbitIsce2WithoutSourceCount = toNumber(orbitSource.isce2_without_source_count);
const orbitIsce2WithoutSourceCount = orbitIsce2Enabled ? toNumber(orbitSource.isce2_without_source_count) : 0;
const orbitQuarantinePath = orbitSource.quarantine_path || orbitStatus?.source_gaps?.quarantine_path;
const orbitBadSourceSamples = asArray(orbitSource.bad_source_samples).filter(hasOrbitCorruptionSignal);
const orbitSuspectBadSamples = asArray(orbitSource.suspect_bad_samples);
@@ -1118,9 +1119,15 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
)}
</div>
<div className="health-card-row">
<span>{en ? 'Source / ENVI / ISCE2' : '源目录 / ENVI / ISCE2'}</span>
<span>
{toNumber(orbitSource.total_source)} / {toNumber(orbitPools.envi?.total)} / {toNumber(orbitPools.isce2?.total)}
{orbitIsce2Enabled
? (en ? 'Source / ENVI-Gamma TXT / ISCE2 XML' : '源目录 / ENVI-Gamma TXT / ISCE2 XML')
: (en ? 'Source / ENVI-Gamma TXT' : '源目录 / ENVI-Gamma TXT')}
</span>
<span>
{orbitIsce2Enabled
? `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)} / ${toNumber(orbitPools.isce2?.total)}`
: `${toNumber(orbitSource.total_source)} / ${toNumber(orbitPools.envi?.total)}`}
</span>
</div>
<div className="health-card-row">
@@ -1133,17 +1140,24 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<span>{en ? 'Pool mismatches' : '池不一致'}</span>
<span>{orbitMismatchCount}</span>
</div>
{orbitIsce2Enabled ? (
<div className="health-card-row">
<span>{en ? 'Suspect bad TXT / source-only' : '疑似坏 TXT / 仅源存在'}</span>
<span>{orbitSuspectBadCount} / {orbitSourceWithoutEnviCount}</span>
</div>
) : (
<div className="health-card-row">
<span>{en ? 'Source-only' : '仅源存在'}</span>
<span>{orbitSourceWithoutEnviCount}</span>
</div>
)}
<div className="health-card-row">
<span>{en ? 'Suspect bad TXT / source-only' : '疑似坏 TXT / 仅源存在'}</span>
<span>{orbitSuspectBadCount} / {orbitSourceWithoutEnviCount}</span>
<span>{orbitIsce2Enabled ? (en ? 'TXT-only / ISCE2-only' : ' TXT / 仅 ISCE2') : (en ? 'TXT-only' : '仅 TXT')}</span>
<span>{orbitIsce2Enabled ? `${orbitEnviWithoutSourceCount} / ${orbitIsce2WithoutSourceCount}` : orbitEnviWithoutSourceCount}</span>
</div>
<div className="health-card-row">
<span>{en ? 'ENVI-only / ISCE2-only' : '仅 ENVI / ISCE2'}</span>
<span>{orbitEnviWithoutSourceCount} / {orbitIsce2WithoutSourceCount}</span>
</div>
<div className="health-card-row">
<span>{en ? 'DB missing in ENVI / ISCE2' : '数据库在 ENVI / ISCE2 缺失'}</span>
<span>{orbitDbMissingEnviCount} / {orbitDbMissingIsce2Count}</span>
<span>{orbitIsce2Enabled ? (en ? 'DB missing in TXT / ISCE2' : '数据库在 TXT / ISCE2 缺失') : (en ? 'DB missing in TXT' : '数据库在 TXT 缺失')}</span>
<span>{orbitIsce2Enabled ? `${orbitDbMissingEnviCount} / ${orbitDbMissingIsce2Count}` : orbitDbMissingEnviCount}</span>
</div>
<div className="health-card-row">
<span>{en ? 'Duplicate stems / scan errors' : '重复 stem / 扫描异常'}</span>
@@ -1152,14 +1166,21 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
<div className="health-card-note" style={{ marginTop: 4 }}>
{en
? 'Orbit scan writes ENVI TXT and ISCE2 XML pools automatically. This panel now shows source, pool, and database consistency together.'
: '“扫描精轨”会自动同步 ENVI TXT 和 ISCE2 XML。本卡片同时展示源目录、本地池和数据库三侧的一致性。'}
? (orbitIsce2Enabled
? 'LT-1 orbit scans synchronize the production TXT pool and the legacy ISCE2 XML pool. S1 EOF files remain registered as source orbit assets.'
: 'LT-1 orbit scans synchronize the production TXT pool for ENVI/SARscape and Gamma. S1 EOF files remain registered as source orbit assets; ISCE2 XML is disabled.')
: (orbitIsce2Enabled
? 'LT-1 精轨扫描会同步生产 TXT 池和 legacy ISCE2 XML 池;S1 EOF 只登记为源精轨资产。'
: 'LT-1 精轨扫描会同步 ENVI/SARscape 与 Gamma 共用的生产 TXT 池;S1 EOF 只登记为源精轨资产,ISCE2 XML 已停用。')}
</div>
<div className="health-card-note">{en ? 'Source path: ' : '源目录路径:'}{formatPathText(orbitSource.path)}</div>
<div className="health-card-note">{en ? 'ENVI pool: ' : 'ENVI 池:'}{formatPathText(orbitPools.envi?.path)}</div>
<div className="health-card-note">{en ? 'ISCE2 pool: ' : 'ISCE2 池:'}{formatPathText(orbitPools.isce2?.path)}</div>
<div className="health-card-note">{en ? 'LANDSAR pool: ' : 'LANDSAR 池:'}{formatPathText(orbitPools.landsar?.path)}</div>
<div className="health-card-note">{en ? 'Quarantine path: ' : '隔离目录'}{formatPathText(orbitQuarantinePath)}</div>
<div className="health-card-note">{en ? 'Production TXT pool: ' : '生产 TXT 池:'}{formatPathText(orbitPools.envi?.path)}</div>
{orbitIsce2Enabled && (
<>
<div className="health-card-note">{en ? 'Legacy ISCE2 pool: ' : 'Legacy ISCE2 池'}{formatPathText(orbitPools.isce2?.path)}</div>
<div className="health-card-note">{en ? 'Quarantine path: ' : '隔离目录:'}{formatPathText(orbitQuarantinePath)}</div>
</>
)}
{orbitDuplicateCount > 0 && (
<div className="health-card-note warn">
@@ -1182,7 +1203,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
: `数据库 orbit_file_path 指向不存在文件:${orbitDbMissingPathCount} / ${toNumber(orbitDatabase.distinct_orbit_path_count)}`}
</div>
)}
{orbitSuspectBadCount > 0 && (
{orbitIsce2Enabled && orbitSuspectBadCount > 0 && (
<div className="health-card-note warn">
{en
? `Suspect bad source TXT (source exists but ISCE2 XML missing): ${orbitSuspectBadCount}`
@@ -1202,7 +1223,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{orbitDatabase.sample_missing_in_envi.slice(0, 5).join(', ')}
</div>
)}
{orbitDatabase.sample_missing_in_isce2?.length > 0 && (
{orbitIsce2Enabled && orbitDatabase.sample_missing_in_isce2?.length > 0 && (
<div className="health-card-note error">
{en ? 'DB expected but ISCE2 pool missing: ' : '数据库期望但 ISCE2 池缺失:'}
{orbitDatabase.sample_missing_in_isce2.slice(0, 5).join(', ')}
@@ -1214,7 +1235,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
</div>
))}
{orbitSuspectWithoutCorruptionSamples.slice(0, 5).map((item) => (
{orbitIsce2Enabled && orbitSuspectWithoutCorruptionSamples.slice(0, 5).map((item) => (
<div key={`orbit-suspect-bad-${item.name}`} className="health-card-note warn">
{item.name}
{renderOrbitSourceIssueDetails(item, en, formatPathText)}
@@ -1257,7 +1278,7 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
{en ? 'ENVI pool scan error: ' : 'ENVI 池扫描异常:'}{item}
</div>
))}
{(orbitPools.isce2?.errors || []).slice(0, 3).map((item, index) => (
{orbitIsce2Enabled && (orbitPools.isce2?.errors || []).slice(0, 3).map((item, index) => (
<div key={`orbit-isce2-error-${index}`} className="health-card-note error">
{en ? 'ISCE2 pool scan error: ' : 'ISCE2 池扫描异常:'}{item}
</div>
@@ -1283,44 +1304,48 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
>
{orbitSyncing ? (en ? 'Checking...' : '检查中...') : (en ? 'Check Consistency' : '精轨一致性检查')}
</button>
<button
onClick={async () => {
setOrbitRepairing(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ repair: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitRepairing(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#0f766e', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitRepairing ? (en ? 'Repairing...' : '修复中...') : (en ? 'Repair Missing XML' : '修复缺失 XML')}
</button>
<button
onClick={async () => {
setOrbitQuarantining(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ quarantine_bad: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitQuarantining(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#b45309', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitQuarantining ? (en ? 'Quarantining...' : '隔离中...') : (en ? 'Quarantine Bad TXT' : '隔离坏精轨')}
</button>
{orbitIsce2Enabled && (
<>
<button
onClick={async () => {
setOrbitRepairing(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ repair: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitRepairing(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#0f766e', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitRepairing ? (en ? 'Repairing...' : '修复中...') : (en ? 'Repair Missing XML' : '修复缺失 XML')}
</button>
<button
onClick={async () => {
setOrbitQuarantining(true);
setOrbitSyncResult(null);
try {
const result = await syncOrbitPools({ quarantine_bad: true });
setOrbitSyncResult(result);
await refreshOrbitStatus();
} catch (e) {
setOrbitSyncResult({ error: e.response?.data?.detail || e.message });
} finally {
setOrbitQuarantining(false);
}
}}
disabled={orbitSyncing || orbitRepairing || orbitQuarantining}
style={{ padding: '4px 12px', background: '#b45309', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}
>
{orbitQuarantining ? (en ? 'Quarantining...' : '隔离中...') : (en ? 'Quarantine Bad TXT' : '隔离坏精轨')}
</button>
</>
)}
</div>
{orbitSyncResult && (
@@ -1387,8 +1412,8 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
<div className="health-card-note">
{en
? `Source scan ${toNumber(orbitSyncResult.sync_result?.total_source)}, ENVI copied ${(orbitSyncResult.sync_result?.envi?.copied || []).length}, ENVI refreshed ${(orbitSyncResult.sync_result?.envi?.updated || []).length}, ISCE2 converted ${(orbitSyncResult.sync_result?.isce2?.converted || []).length}, ISCE2 refreshed ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}`
: `源目录扫描 ${toNumber(orbitSyncResult.sync_result?.total_source)} 项,ENVI 新增 ${(orbitSyncResult.sync_result?.envi?.copied || []).length} 项、刷新 ${(orbitSyncResult.sync_result?.envi?.updated || []).length} 项,ISCE2 新增转换 ${(orbitSyncResult.sync_result?.isce2?.converted || []).length} 项、重转 ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}`}
? `Source scan ${toNumber(orbitSyncResult.sync_result?.total_source)}, TXT copied ${(orbitSyncResult.sync_result?.envi?.copied || []).length}, TXT refreshed ${(orbitSyncResult.sync_result?.envi?.updated || []).length}${orbitSyncResult.isce2_enabled ? `, ISCE2 converted ${(orbitSyncResult.sync_result?.isce2?.converted || []).length}, ISCE2 refreshed ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}` : ', ISCE2 disabled'}`
: `源目录扫描 ${toNumber(orbitSyncResult.sync_result?.total_source)} 项,TXT 新增 ${(orbitSyncResult.sync_result?.envi?.copied || []).length} 项、刷新 ${(orbitSyncResult.sync_result?.envi?.updated || []).length}${orbitSyncResult.isce2_enabled ? `ISCE2 新增转换 ${(orbitSyncResult.sync_result?.isce2?.converted || []).length} 项、重转 ${(orbitSyncResult.sync_result?.isce2?.reconverted || []).length}` : 'ISCE2 已停用'}`}
</div>
{(orbitSyncResult.repaired_from_envi || []).slice(0, 5).length > 0 && (
<div className="health-card-note ok">
@@ -1428,8 +1453,8 @@ const HealthCheckPanel = ({ language = 'zh', currentUser }) => {
</div>
<div className="health-card-note">
{en
? `ENVI ${toNumber(orbitSyncResult.envi?.total)}, ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}, scan errors ${toNumber(orbitSyncResult.error_count)}`
: `ENVI ${toNumber(orbitSyncResult.envi?.total)} 项,ISCE2 ${toNumber(orbitSyncResult.isce2?.total)} 项,扫描异常 ${toNumber(orbitSyncResult.error_count)}`}
? `TXT ${toNumber(orbitSyncResult.envi?.total)}${orbitSyncResult.isce2?.enabled ? `, ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}` : ', ISCE2 disabled'}, scan errors ${toNumber(orbitSyncResult.error_count)}`
: `TXT ${toNumber(orbitSyncResult.envi?.total)}${orbitSyncResult.isce2?.enabled ? `ISCE2 ${toNumber(orbitSyncResult.isce2?.total)}` : 'ISCE2 已停用'},扫描异常 ${toNumber(orbitSyncResult.error_count)}`}
</div>
{(orbitSyncResult.mismatches || []).slice(0, 5).map((item, index) => (
<div key={`orbit-check-mismatch-${index}`} className="health-card-note error">
+218 -24
View File
@@ -4,6 +4,7 @@ import {
PRODUCTION_WORKSPACE_ENTRY_TO_VIEW,
PRODUCTION_WORKSPACE_TAB,
PRODUCTION_WORKSPACE_VIEWS,
PRODUCTION_WORKSPACE_WORKBENCHES,
} from './config/appConstants';
import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
@@ -11,6 +12,10 @@ const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel'));
const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel'));
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
const LazyPairPlanningPanel = lazy(() => import('./panels/PairPlanningPanel'));
const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel'));
const LazyBatchPanel = lazy(() => import('./panels/BatchPanel'));
const LazyDataCopierPanel = lazy(() => import('./DataCopierPanel'));
const shellStyle = {
minHeight: '100%',
@@ -40,25 +45,128 @@ const summaryCardStyle = {
background: 'rgba(255, 255, 255, 0.82)',
};
const SENSOR_PRODUCTION_PLACEHOLDERS = {
lt1_production: {
title: '陆探一号生产模块',
subtitle: '当前先占位纳入生产管理,执行链路保留 LandSAR、ENVI+SARscape、Gamma/PyINT。',
rows: [
['源压缩包', 'D:\\LuTan1_Image_Pool_Zip,只索引包内 XML/元数据,不做全量解包。'],
['精密轨道', 'D:\\LT1_data_lsarorbit,本机部署并绑定到源资产。'],
['按需解包', '生产任务需要时才 materialize 到 D:\\Task_Pool\\DInSAR 或 D:\\Task_Pool\\SBAS。'],
['生产边界', 'D-InSAR 与 SBAS-InSAR 均使用本机 Task_Pool,不允许 UNC 参与运行。'],
['结果管理', '生成结果进入 D-InSAR/SBAS 产物目录,由生产管理结果页统一重建 catalog。'],
],
},
sentinel1_production: {
title: 'Sentinel-1 生产模块',
subtitle: '当前先占位纳入生产管理,D-InSAR 保留 Gamma/PyINT 路径,SBAS 仍为规划态。',
rows: [
['源压缩包', 'D:\\Sentinel1_Image_Pool_ZIP,本机登记 ZIP/SAFE 元数据。'],
['精密轨道', 'D:\\Sentinel1_EOF_Pool,本机保存 AUX_POEORB/RESORB。'],
['按需解包', '需要运行时才将 ZIP 解包到本机 Task_Pool,界面不提供全量解包按钮。'],
['D-InSAR', 'Gamma/PyINT 可作为生产方向,运行材料必须来自本机路径。'],
['SBAS', '当前仅做堆栈发现和规划,执行链路未启用。'],
],
},
gf3_native_registration: {
title: '高分三结果登记',
subtitle: 'GF3 不在本机生产;另一台 SARscape 服务器完成 _geo 后复制到本机登记。',
rows: [
['外部生产', '外部机器按 YYYYMMDD_geo/场景目录输出 SARscape 原生 _geo 二进制。'],
['本机落盘', '复制到 D:\\GaoFen3_Pool\\native_geo 后递归扫描登记。'],
['预览生成', 'WebP 从 *_geo 主二进制读取生成,不使用 *_geo_ql.tif 作为正式预览源。'],
['精轨', 'GF3 本链路无精密轨道管理。'],
['结果管理', '登记后的 GF3 资产进入数据管理,后续需要全影像时再提取/标准化。'],
],
},
};
function SensorProductionPlaceholder({ viewKey }) {
const data = SENSOR_PRODUCTION_PLACEHOLDERS[viewKey];
if (!data) {
return null;
}
return (
<section style={{ ...heroCardStyle, padding: '18px 20px' }}>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>当前设计约定</div>
<h3 style={{ margin: '0 0 8px', fontSize: 20, color: '#0f172a' }}>{data.title}</h3>
<p style={{ margin: '0 0 16px', color: '#475569', fontSize: 13, lineHeight: 1.7 }}>
{data.subtitle}
</p>
<div style={{ display: 'grid', gap: 10 }}>
{data.rows.map(([label, value]) => (
<div
key={label}
style={{
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: 12,
padding: '10px 12px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
}}
>
<strong style={{ color: '#0f172a', fontSize: 13 }}>{label}</strong>
<span style={{ color: '#475569', fontSize: 13, lineHeight: 1.7, wordBreak: 'break-word' }}>{value}</span>
</div>
))}
</div>
</section>
);
}
function resolveView(entry) {
return PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[entry] || PRODUCTION_WORKSPACE_ENTRY_TO_VIEW[PRODUCTION_WORKSPACE_TAB];
}
function resolveWorkbenchKey(viewKey) {
const workbench = PRODUCTION_WORKSPACE_WORKBENCHES.find(item => (
item.views.some(view => view.key === viewKey)
));
return workbench?.key || PRODUCTION_WORKSPACE_WORKBENCHES[0]?.key || 'dinsar_workbench';
}
export default function ProductionWorkspace({
activeEntry = PRODUCTION_WORKSPACE_TAB,
readOnly = false,
onTaskStart,
apiEndpoint,
language,
foundPairs = [],
selectedPairsCount = 0,
isLoading = false,
hasEnoughRadarScenesForPlanning = false,
hasRadarSearched = false,
pairingPanel = {},
radarPanel = {},
pairsPanel = {},
}) {
const [activeView, setActiveView] = useState(() => resolveView(activeEntry));
const [activeWorkbench, setActiveWorkbench] = useState(() => resolveWorkbenchKey(resolveView(activeEntry)));
useEffect(() => {
setActiveView(resolveView(activeEntry));
const nextView = resolveView(activeEntry);
setActiveView(nextView);
setActiveWorkbench(resolveWorkbenchKey(nextView));
}, [activeEntry]);
const activeViewMeta = useMemo(
() => PRODUCTION_WORKSPACE_VIEWS.find(view => view.key === activeView) || PRODUCTION_WORKSPACE_VIEWS[0],
[activeView]
);
const activeWorkbenchMeta = useMemo(
() => PRODUCTION_WORKSPACE_WORKBENCHES.find(item => item.key === activeWorkbench) || PRODUCTION_WORKSPACE_WORKBENCHES[0],
[activeWorkbench]
);
const activeSubViews = activeWorkbenchMeta?.views || [];
const switchWorkbench = (workbench) => {
setActiveWorkbench(workbench.key);
if (!workbench.views.some(view => view.key === activeView)) {
setActiveView(workbench.defaultView);
}
};
const handleDinsarRunQueued = taskId => {
onTaskStart?.(taskId, 'D-InSAR 任务已入队,等待处理...');
@@ -68,6 +176,13 @@ export default function ProductionWorkspace({
onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...');
};
const handleDinsarPrepareQueued = taskId => {
onTaskStart?.(taskId, 'D-InSAR生产准备任务已入队,正在处理...', {
taskType: 'COPY_DATA',
nonBlocking: true,
});
};
const handleSbasProductQueued = taskId => {
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.', {
taskType: 'REBUILD_SBAS_INSAR_CATALOG',
@@ -84,8 +199,8 @@ export default function ProductionWorkspace({
</div>
<h2 style={{ margin: '10px 0 12px', fontSize: 32, lineHeight: 1.1, color: '#0f172a' }}>生产管理</h2>
<p style={{ margin: 0, maxWidth: 900, fontSize: 14, lineHeight: 1.8, color: '#475569' }}>
这里统一承载 D-InSAR Gamma SBAS-InSAR 生产工作台 ISCE2/MintPy 时序入口已停用
SBAS 生产速率图质量指标与监测点曲线统一进入独立 SBAS-InSAR 页面
这里统一承载 D-InSAR 配对批次生产准备运行和产物管理以及 Gamma SBAS-InSAR 生产链
陆探与哨兵源数据按压缩包登记生产时再解包到本机 Task_Pool高分三只登记外部 SARscape 服务器复制回来的 _geo 结果
</p>
</section>
@@ -99,24 +214,24 @@ export default function ProductionWorkspace({
}}
>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>统一入口</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>运行与产物同域编排</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>主生产链</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>D-InSAR / SBAS</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
生产运行目录重建产物编目全部收口到同一顶级工作区
D-InSAR 使用配对批次驱动SBAS 使用 Gamma IPTA 工作流驱动PS/旧时序入口不再作为主流程展示
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>SBAS 当前实现</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>Gamma 独立入口</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>运行边界</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>本机 Task_Pool</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
生产链路绕开旧时序配对层 SBAS 页面管理栈发现基线审核配准与产物发布
源压缩包先登记元数据生产需要时再按需解包D-InSAR/SBAS 不走 UNC
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>旧链路状态</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>ISCE2/MintPy 停用</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>结果管理</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>产物 catalog</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
历史代码暂时保留兼容生产管理不再暴露旧时序运行/产物页面
生产结果进入 D-InSARSBAS GF3 数据目录后续分析从结果 catalog 读取
</div>
</div>
</section>
@@ -130,20 +245,20 @@ export default function ProductionWorkspace({
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
{PRODUCTION_WORKSPACE_VIEWS.map(view => {
const isActive = view.key === activeView;
{PRODUCTION_WORKSPACE_WORKBENCHES.map(workbench => {
const isActive = workbench.key === activeWorkbench;
return (
<button
key={view.key}
key={workbench.key}
type="button"
onClick={() => setActiveView(view.key)}
onClick={() => switchWorkbench(workbench)}
style={{
textAlign: 'left',
padding: '14px 16px',
borderRadius: 18,
padding: '16px 18px',
borderRadius: 10,
border: `1px solid ${isActive ? '#93c5fd' : '#d7e0eb'}`,
background: isActive
? 'linear-gradient(135deg, #eff6ff 0%, #f8fbff 100%)'
? '#eff6ff'
: 'rgba(255, 255, 255, 0.88)',
boxShadow: isActive ? '0 10px 24px rgba(37, 99, 235, 0.12)' : 'none',
cursor: 'pointer',
@@ -151,11 +266,11 @@ export default function ProductionWorkspace({
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
<strong style={{ fontSize: 15, color: '#0f172a' }}>{view.label}</strong>
<strong style={{ fontSize: 16, color: '#0f172a' }}>{workbench.label}</strong>
<span
style={{
padding: '4px 8px',
borderRadius: 999,
borderRadius: 8,
fontSize: 11,
fontWeight: 700,
color: isActive ? '#1d4ed8' : '#64748b',
@@ -165,7 +280,43 @@ export default function ProductionWorkspace({
{isActive ? '当前视图' : '切换'}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#475569' }}>{view.description}</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#475569' }}>{workbench.description}</div>
</button>
);
})}
</div>
</section>
<section
style={{
...heroCardStyle,
padding: '12px',
marginBottom: 18,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{activeSubViews.map(view => {
const isActive = view.key === activeView;
return (
<button
key={view.key}
type="button"
onClick={() => setActiveView(view.key)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 8,
border: `1px solid ${isActive ? '#2563eb' : '#d7e0eb'}`,
background: isActive ? '#ffffff' : '#f8fafc',
cursor: 'pointer',
}}
>
<strong style={{ display: 'block', marginBottom: 6, fontSize: 14, color: isActive ? '#1d4ed8' : '#0f172a' }}>
{view.label}
</strong>
<span style={{ display: 'block', fontSize: 12, lineHeight: 1.6, color: '#475569' }}>
{view.description}
</span>
</button>
);
})}
@@ -173,18 +324,61 @@ export default function ProductionWorkspace({
</section>
<section>
<div style={{ marginBottom: 12, fontSize: 12, color: '#64748b' }}>{activeViewMeta.label}</div>
<div style={{ marginBottom: 12, fontSize: 12, color: '#64748b' }}>
{activeWorkbenchMeta?.label} / {activeViewMeta.label}
</div>
<Suspense fallback={<PanelLoadingBody message={`正在加载 ${activeViewMeta.label}...`} />}>
{SENSOR_PRODUCTION_PLACEHOLDERS[activeView] && (
<SensorProductionPlaceholder viewKey={activeView} />
)}
{activeView === 'dinsar_pairing' && (
<LazyPairPlanningPanel
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
isReadOnlyUser={readOnly}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
onOpenPairingModal={pairingPanel.onOpenPairingModal}
hasRadarSearched={hasRadarSearched}
onRefreshRadarSearch={pairingPanel.onRefreshRadarSearch}
onSearchAll={radarPanel.onSearchAll}
onRefreshDinsar={pairingPanel.onRefreshDinsar}
language={language}
/>
)}
{activeView === 'dinsar_pairs' && (
<div style={{ display: 'grid', gap: 16 }}>
<LazyPairsListPanel
onVisualizePair={pairsPanel.onVisualizePair}
onTogglePairVisibility={pairsPanel.onTogglePairVisibility}
onCreateDinsarBatch={pairsPanel.onCreateDinsarBatch}
/>
<LazyBatchPanel />
</div>
)}
{activeView === 'dinsar_prepare' && (
<LazyDataCopierPanel
apiEndpoint={apiEndpoint}
readOnly={readOnly}
onJobQueued={handleDinsarPrepareQueued}
/>
)}
{activeView === 'dinsar_runs' && (
<LazyDinsarProductionPanel
readOnly={readOnly}
onJobQueued={handleDinsarRunQueued}
/>
)}
{activeView === 'sbas_insar_production' && (
{['sbas_insar_planning', 'sbas_insar_batches', 'sbas_insar_prepare', 'sbas_insar_runs'].includes(activeView) && (
<LazySbasInsarProductionPanel
readOnly={readOnly}
onTaskStart={onTaskStart}
initialFocus={{
sbas_insar_planning: 'planning',
sbas_insar_batches: 'batches',
sbas_insar_prepare: 'prepare',
sbas_insar_runs: 'runs',
}[activeView]}
/>
)}
{activeView === 'sbas_insar_products' && (
+84 -8
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import MiniCoverageMap from './components/MiniCoverageMap';
import {
auditSbasInsarStack,
@@ -387,6 +388,40 @@ function LocationSummaryPanel({ coverage }) {
);
}
function StackCoverageMiniMap({ stack, coverage, title = 'SBAS序列范围预览' }) {
const source = coverage || stack || {};
const bbox = source.bbox || source.stack_bbox || stack?.bbox || stack?.bbox_intersection;
const intersection = source.bbox_intersection || stack?.bbox_intersection;
const sceneFootprints = source.scene_footprints_geojson || stack?.scene_footprints_geojson;
const coverageGeojson = source.geojson || stack?.geojson || sceneFootprints;
const bboxes = [
bbox && {
bbox,
label: 'stack bbox',
color: '#2563eb',
fillOpacity: 0.05,
},
intersection && {
bbox: intersection,
label: 'common overlap',
color: '#16a34a',
fillOpacity: 0.12,
dashArray: null,
},
].filter(Boolean);
const sceneCount = (sceneFootprints?.features || []).length || source.scene_bbox_count || stack?.usable_scene_count || stack?.scene_count || 0;
return (
<MiniCoverageMap
title={title}
subtitle={sceneCount ? `${sceneCount}` : ''}
bboxes={bboxes}
geojson={coverageGeojson}
height={280}
emptyText="当前序列缺少可绘制范围。"
/>
);
}
/*
function UnusedSceneFootprintGeographicCoverageMap({ coverage }) {
const mapElementRef = useRef(null);
@@ -628,7 +663,15 @@ function UnusedGeographicCoveragePanel({ coverage }) {
}
*/
export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart }) {
const SBAS_FOCUS_TO_SECTION = {
planning: 'sbas-planning-section',
batches: 'sbas-run-section',
prepare: 'sbas-prepare-section',
runs: 'sbas-run-section',
};
export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart, initialFocus = 'planning' }) {
const lastAppliedFocusRef = useRef('');
const [processorMode, setProcessorMode] = useState('landsar');
const [capabilities, setCapabilities] = useState(null);
const [runs, setRuns] = useState([]);
@@ -686,6 +729,34 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
const [workflowJob, setWorkflowJob] = useState(null);
const [runDeleteLoading, setRunDeleteLoading] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const sectionId = SBAS_FOCUS_TO_SECTION[initialFocus] || SBAS_FOCUS_TO_SECTION.planning;
const focusToken = [
processorMode,
initialFocus,
selectedRunId,
selectedLandsarRunId,
stackCandidates.length,
runs.length,
landsarRuns.length,
].join(':');
if (lastAppliedFocusRef.current === focusToken) return undefined;
lastAppliedFocusRef.current = focusToken;
const timer = window.setTimeout(() => {
document.getElementById(sectionId)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, 80);
return () => window.clearTimeout(timer);
}, [
initialFocus,
landsarRuns.length,
processorMode,
runs.length,
selectedLandsarRunId,
selectedRunId,
stackCandidates.length,
]);
const stackDiscoveryPayload = useMemo(() => {
const adminRegion = stackAdminRegionQuery.trim();
const isLandsar = processorMode === 'landsar';
@@ -1387,7 +1458,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
)}
</section>
<section style={sectionStyle}>
<section id="sbas-planning-section" style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>SBAS 生产区域</h3>
@@ -1456,7 +1527,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10, marginTop: 12 }}>
<div id="sbas-prepare-section" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10, marginTop: 12 }}>
<label style={{ display: 'grid', gap: 5, gridColumn: 'span 2' }}>
<span style={labelStyle}>DEM 文件</span>
<input value={landsarDemPath} onChange={event => setLandsarDemPath(event.target.value)} placeholder="D:\\DEM\\HeiLongJiang10M_DEM.tif" style={{ border: '1px solid #cbd5e1', borderRadius: 8, padding: '8px 10px', fontSize: 12 }} />
@@ -1554,6 +1625,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
)}
<StackIdentityNotice stack={selectedStack} />
<SceneNamePanel stack={selectedStack} />
<StackCoverageMiniMap stack={selectedStack} title="LandSAR SBAS序列范围预览" />
<LocationSummaryPanel
coverage={{
bbox: selectedStack.bbox || selectedStack.bbox_intersection,
@@ -1602,7 +1674,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
)}
</section>
<section style={sectionStyle}>
<section id="sbas-run-section" style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>LandSAR Run</h3>
@@ -1673,7 +1745,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
return (
<div style={shellStyle}>
<section style={sectionStyle}>
<section id="sbas-prepare-section" style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div>
<h2 style={{ margin: 0, fontSize: 20, color: '#0f172a' }}>SBAS-InSAR 生产</h2>
@@ -1731,7 +1803,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
{activeGammaRunNotice}
</section>
<section style={sectionStyle}>
<section id="sbas-planning-section" style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>SBAS 生产区域</h3>
@@ -1898,6 +1970,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
)}
<StackIdentityNotice stack={selectedStack} />
<SceneNamePanel stack={selectedStack} />
<StackCoverageMiniMap stack={selectedStack} title="Gamma SBAS序列范围预览" />
<LocationSummaryPanel
coverage={{
bbox: selectedStack.bbox || selectedStack.bbox_intersection,
@@ -1944,7 +2017,7 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
)}
</section>
<section style={sectionStyle}>
<section id="sbas-run-section" style={sectionStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>生产 Run 计划</h3>
@@ -2059,6 +2132,9 @@ export default function SbasInsarProductionPanel({ readOnly = false, onTaskStart
<details style={compactDetailsStyle}>
<summary style={compactSummaryStyle}>空间覆盖</summary>
<div style={{ marginTop: 10 }}>
<StackCoverageMiniMap coverage={runGeographicCoverage} title="Run覆盖范围预览" />
</div>
<div style={{ marginTop: 10 }}>
<LocationSummaryPanel coverage={runGeographicCoverage} />
</div>
+3
View File
@@ -6,6 +6,9 @@ export const getAssetInventoryStatus = () =>
export const scanAssetInventory = (payload = {}) =>
apiClient.post('/assets/inventory/scan', payload).then(r => r.data);
export const auditSourceArchiveIntegrity = (payload = {}) =>
apiClient.post('/assets/inventory/archive-integrity-audit', payload).then(r => r.data);
export const listSourceAssets = (params = {}) =>
apiClient.get('/assets/sources', { params }).then(r => r.data);
+3
View File
@@ -4,6 +4,9 @@ import apiClient from './client';
export const listEngines = () =>
apiClient.get('/dinsar-production/engines').then(r => r.data);
export const listTaskRoots = () =>
apiClient.get('/dinsar-production/task-roots').then(r => r.data);
export const getEngineDetail = (engineCode) =>
apiClient.get(`/dinsar-production/engines/${encodeURIComponent(engineCode)}`).then(r => r.data);
+219
View File
@@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef } from 'react';
import L from 'leaflet';
import { getBaseLayerConfig, TILE_LAYER_DEFAULT_KEY, TILE_LAYER_OPTIONS } from '../config/appConstants';
const DEFAULT_HEIGHT = 260;
const palette = ['#2563eb', '#16a34a', '#dc2626', '#7c3aed', '#0891b2', '#d97706'];
function normalizeBbox(bbox) {
if (!bbox || typeof bbox !== 'object') return null;
const minLon = Number(bbox.min_lon);
const minLat = Number(bbox.min_lat);
const maxLon = Number(bbox.max_lon);
const maxLat = Number(bbox.max_lat);
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
if (minLon >= maxLon || minLat >= maxLat) return null;
return { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat };
}
function normalizePolygon(points) {
if (!Array.isArray(points) || points.length < 3) return null;
const latLngs = points
.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));
return latLngs.length >= 3 ? latLngs : null;
}
function normalizeFeatureCollection(value) {
if (!value || typeof value !== 'object') {
return { type: 'FeatureCollection', features: [] };
}
if (value.type === 'FeatureCollection' && Array.isArray(value.features)) {
return value;
}
if (value.type === 'Feature') {
return { type: 'FeatureCollection', features: [value] };
}
if (value.type && value.coordinates) {
return {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: {}, geometry: value }],
};
}
return { type: 'FeatureCollection', features: [] };
}
function bboxToBounds(bbox) {
const normalized = normalizeBbox(bbox);
if (!normalized) return null;
return L.latLngBounds(
[normalized.min_lat, normalized.min_lon],
[normalized.max_lat, normalized.max_lon],
);
}
function featureLabel(feature) {
const props = feature?.properties || {};
return props.label || props.scene_name || props.date || props.imaging_date || props.name || '';
}
export default function MiniCoverageMap({
title = '范围预览',
subtitle = '',
polygons = [],
bboxes = [],
geojson,
height = DEFAULT_HEIGHT,
emptyText = '暂无可绘制范围',
}) {
const mapElementRef = useRef(null);
const mapRef = useRef(null);
const layerGroupRef = useRef(null);
const tileLayerRef = useRef(null);
const safePolygons = useMemo(() => (
(polygons || [])
.map((item, index) => ({
...item,
latLngs: normalizePolygon(item?.points || item?.polygon || item?.coverage_polygon),
color: item?.color || palette[index % palette.length],
}))
.filter(item => item.latLngs)
), [polygons]);
const safeBboxes = useMemo(() => (
(bboxes || [])
.map((item, index) => ({
...item,
bounds: bboxToBounds(item?.bbox || item),
color: item?.color || palette[(index + safePolygons.length) % palette.length],
}))
.filter(item => item.bounds?.isValid?.())
), [bboxes, safePolygons.length]);
const safeGeojson = useMemo(() => normalizeFeatureCollection(geojson), [geojson]);
const hasDrawable = safePolygons.length > 0 || safeBboxes.length > 0 || safeGeojson.features.length > 0;
useEffect(() => {
if (hasDrawable || !mapRef.current) return;
mapRef.current.remove();
mapRef.current = null;
layerGroupRef.current = null;
tileLayerRef.current = null;
}, [hasDrawable]);
useEffect(() => {
if (!mapElementRef.current || !hasDrawable) return undefined;
if (!mapRef.current) {
mapRef.current = L.map(mapElementRef.current, {
attributionControl: false,
zoomControl: true,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
dragging: true,
});
const baseLayer = getBaseLayerConfig(TILE_LAYER_DEFAULT_KEY);
tileLayerRef.current = L.tileLayer(baseLayer.url, {
...TILE_LAYER_OPTIONS,
attribution: baseLayer.attribution,
}).addTo(mapRef.current);
layerGroupRef.current = L.layerGroup().addTo(mapRef.current);
}
const map = mapRef.current;
const layerGroup = layerGroupRef.current;
layerGroup.clearLayers();
let fitBounds = null;
safeBboxes.forEach((item) => {
L.rectangle(item.bounds, {
color: item.color,
weight: item.weight || 2,
dashArray: item.dashArray || '5 5',
fillColor: item.fillColor || item.color,
fillOpacity: item.fillOpacity ?? 0.05,
})
.bindTooltip(item.label || 'bbox', { sticky: true })
.addTo(layerGroup);
fitBounds = fitBounds ? fitBounds.extend(item.bounds) : item.bounds;
});
safePolygons.forEach((item) => {
const layer = L.polygon(item.latLngs, {
color: item.color,
weight: item.weight || 2,
opacity: 0.9,
fillColor: item.fillColor || item.color,
fillOpacity: item.fillOpacity ?? 0.12,
})
.bindTooltip(item.label || 'footprint', { sticky: true })
.addTo(layerGroup);
const bounds = layer.getBounds();
if (bounds.isValid()) {
fitBounds = fitBounds ? fitBounds.extend(bounds) : bounds;
}
});
if (safeGeojson.features.length > 0) {
const geoLayer = L.geoJSON(safeGeojson, {
style: feature => ({
color: feature?.properties?.color || '#0f766e',
weight: 1.8,
opacity: 0.95,
fillColor: feature?.properties?.fillColor || feature?.properties?.color || '#14b8a6',
fillOpacity: 0.1,
}),
onEachFeature: (feature, layer) => {
const label = featureLabel(feature);
if (label) layer.bindTooltip(label, { sticky: true });
},
}).addTo(layerGroup);
const bounds = geoLayer.getBounds();
if (bounds.isValid()) {
fitBounds = fitBounds ? fitBounds.extend(bounds) : bounds;
}
}
if (fitBounds?.isValid?.()) {
map.fitBounds(fitBounds.pad(0.12), { animate: false, maxZoom: 12 });
}
window.setTimeout(() => map.invalidateSize(), 0);
return undefined;
}, [hasDrawable, safeBboxes, safeGeojson, safePolygons]);
useEffect(() => () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
layerGroupRef.current = null;
tileLayerRef.current = null;
}
}, []);
return (
<section
style={{
border: '1px solid #d8dee8',
borderRadius: 8,
overflow: 'hidden',
background: '#ffffff',
}}
>
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', gap: 10 }}>
<strong style={{ color: '#0f172a', fontSize: 13 }}>{title}</strong>
{subtitle && <span style={{ color: '#64748b', fontSize: 12 }}>{subtitle}</span>}
</div>
{hasDrawable ? (
<div ref={mapElementRef} style={{ height, minHeight: 180 }} />
) : (
<div style={{ height, minHeight: 180, display: 'grid', placeItems: 'center', color: '#64748b', fontSize: 13, background: '#f8fafc' }}>
{emptyText}
</div>
)}
</section>
);
}
+277 -434
View File
@@ -1,56 +1,62 @@
import { useRef, useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { usePairingStore, useRadarStore, useAuthStore } from '../store';
import { useI18n } from '../i18n/I18nContext';
import UnifiedDatePicker from './UnifiedDatePicker';
import { getSelectedRegionTreeId } from '../utils/appUiHelpers';
import { getAvailableSatellites } from '../api/radar';
// 配对策略说明
const STRATEGY_DESCRIPTIONS = {
all: {
title: '全部配对',
description: '列出所有满足约束条件的候选干涉对,由用户自行筛选。',
details: [
'• 系统遍历所有影像组合,保留满足时间基线和两景 footprint 最小重叠率的配对',
'• 结果按时间排序,用户可在配对列表中逐一勾选或取消',
'• 适用于研究型场景,需要精确控制每一对干涉组合',
'• 配对数量可能较多,建议配合 AOI 和日期范围缩小结果'
],
params: '参数:时间基线范围、两景 footprint 最小重叠率、可选 footprint 中心距上限'
},
sbas: {
title: 'SBAS (短基线子集,推荐)',
description: '基于短基线原则的配对策略,通过覆盖优化算法自动筛选配对。',
details: [
'• 优先选择时间间隔较短、覆盖质量较好的配对;可按需启用 footprint 中心距限制',
'• 通过覆盖优化算法,去除冗余配对,确保时间序列连续性',
'• 适用于大范围、长时间序列的形变监测',
'• 配对数量会比"全部配对"少,但覆盖更均匀'
],
params: '参数:时间基线、两景 footprint 最小重叠率、覆盖多样性惩罚、可选 footprint 中心距上限'
},
sequential: {
title: 'Sequential (顺序配对)',
description: '每个影像与后续 N 个影像配对,形成时间序列链。',
details: [
'• 按时间顺序连接影像,形成连续的干涉链',
'• 连接数可调(1-10),数值越大配对越密集',
'• 适用于快速形变监测和时序分析',
'• 计算效率高,配对数量可控'
],
params: '参数:连接数(每个影像连接的后续影像数)'
},
star: {
title: 'Star (星型配对)',
description: '所有影像与一个参考影像配对,形成星型结构。',
details: [
'• 选择一个高质量影像作为参考(通常选时间居中的影像)',
'• 所有其他影像都与参考影像配对',
'• 适用于单次事件监测(如地震、滑坡)',
'• 便于差分结果的直接对比'
],
params: '参数:参考影像(不指定则自动选择时间居中的影像)'
const SENSOR_FAMILIES = [
{ value: 'LT1', label: 'LT-1' },
{ value: 'S1', label: 'Sentinel-1' },
];
const PAIRING_CENTER_DISTANCE_MAX_METERS = 20000000;
const inputValue = (value) => value ?? '';
const parseNumericField = (params, key, label, { integer = false, min = -Infinity, max = Infinity } = {}) => {
const rawValue = String(params[key] ?? '').trim();
if (rawValue === '') {
return { error: `${label}不能为空。` };
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || (integer && !Number.isInteger(parsed))) {
return { error: `${label}必须是${integer ? '整数' : '数字'}` };
}
if (parsed < min || parsed > max) {
return { error: `${label}必须在 ${min}${max} 之间。` };
}
return { value: parsed };
};
const normalizePairingParamsForSubmit = (params) => {
const timeMin = parseNumericField(params, 'time_baseline_min', '最小时间基线', { integer: true, min: 0, max: 3650 });
if (timeMin.error) return timeMin;
const timeMax = parseNumericField(params, 'time_baseline_max', '最大时间基线', { integer: true, min: 1, max: 3650 });
if (timeMax.error) return timeMax;
if (timeMin.value > timeMax.value) {
return { error: '最小时间基线不能大于最大时间基线。' };
}
const overlap = parseNumericField(params, 'overlap_threshold', '两景最小重叠率', { min: 0, max: 1 });
if (overlap.error) return overlap;
const centerDistance = parseNumericField(params, 'spatial_baseline_max_meters', 'footprint 中心距离上限', {
integer: true,
min: 0,
max: PAIRING_CENTER_DISTANCE_MAX_METERS,
});
if (centerDistance.error) return centerDistance;
const aoiOverlap = parseNumericField(params, 'aoi_overlap_threshold', 'AOI 覆盖率阈值', { min: 0, max: 1 });
if (aoiOverlap.error) return aoiOverlap;
return {
value: {
...params,
time_baseline_min: timeMin.value,
time_baseline_max: timeMax.value,
overlap_threshold: overlap.value,
spatial_baseline_max_meters: centerDistance.value,
aoi_overlap_threshold: aoiOverlap.value,
},
};
};
function PairingModal({
@@ -60,7 +66,6 @@ function PairingModal({
onCityChange,
}) {
const { language } = useI18n();
const {
pairingParams, setPairingParams,
pairingAoiMode,
@@ -71,470 +76,308 @@ function PairingModal({
pairingRegionLoading,
pairingRegionError, setPairingRegionError,
} = usePairingStore();
const { radarImagingDates, allData } = useRadarStore();
const { currentUser } = useAuthStore();
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
const [selectedFamilies, setSelectedFamilies] = useState(pairingParams.allowed_satellites || []);
const requireOrbitRef = useRef(null);
const [availableSatellites, setAvailableSatellites] = useState([]);
const [selectedSatellites, setSelectedSatellites] = useState(pairingParams.allowed_satellites || []);
const [referenceImageOptions, setReferenceImageOptions] = useState([]);
// 获取可用日期列表(用于日期选择器)
const availableDates = (
radarImagingDates.length > 0
? radarImagingDates
: [...new Set(allData.map(item => item.imaging_date))].sort()
).filter(Boolean);
// 加载可用卫星列表
useEffect(() => {
if (showPairingModal) {
getAvailableSatellites()
.then(data => {
setAvailableSatellites(data.satellites || []);
})
.catch(err => {
console.error('Failed to load satellites:', err);
});
}
}, [showPairingModal]);
setPairingParams(prev => ({
...prev,
strategy: 'dinsar_production',
time_baseline_max: prev.time_baseline_max ?? 30,
spatial_baseline_max_meters: prev.spatial_baseline_max_meters ?? 5000,
limit_footprint_center_distance: true,
cross_satellite_pairing: false,
allowed_satellites: selectedFamilies.length > 0 ? selectedFamilies : null,
}));
}, [selectedFamilies, setPairingParams]);
useEffect(() => {
if (!showPairingModal || availableSatellites.length === 0) {
return;
}
setSelectedSatellites((prev) => {
const normalized = prev.filter((satellite) => availableSatellites.includes(satellite));
return normalized.length === prev.length ? prev : normalized;
});
}, [availableSatellites, showPairingModal]);
const updateParam = (patch) => setPairingParams({
...pairingParams,
...patch,
strategy: 'dinsar_production',
cross_satellite_pairing: false,
});
// 同步 selectedSatellites 到 pairingParams
useEffect(() => {
if (selectedSatellites.length > 0) {
setPairingParams(prev => ({ ...prev, allowed_satellites: selectedSatellites }));
} else {
setPairingParams(prev => ({ ...prev, allowed_satellites: null }));
}
}, [selectedSatellites, setPairingParams]);
// 生成参考影像选项(用于 Star 策略)
useEffect(() => {
if (showPairingModal && allData.length > 0) {
const options = allData.map(item => ({
id: item.id,
label: (item.file_path || '').split(/[\\/]/).pop() || `ID_${item.id}`,
date: item.imaging_date
})).sort((a, b) => a.date.localeCompare(b.date));
setReferenceImageOptions(options);
}
}, [showPairingModal, allData]);
const handleSatelliteToggle = (satellite) => {
setSelectedSatellites(prev => {
if (prev.includes(satellite)) {
return prev.filter(s => s !== satellite);
} else {
return [...prev, satellite];
}
});
const toggleFamily = (family) => {
setSelectedFamilies(prev => (
prev.includes(family)
? prev.filter(item => item !== family)
: [...prev, family]
));
};
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(e, requireOrbitRef);
const handleSubmit = (event) => {
event.preventDefault();
if (isReadOnlyUser) {
setPairingRegionError('当前账号为只读用户,不能执行配对。');
return;
}
setPairingRegionError('');
const normalized = normalizePairingParamsForSubmit(pairingParams);
if (normalized.error) {
setPairingRegionError(normalized.error);
return;
}
const submitParams = {
...normalized.value,
strategy: 'dinsar_production',
limit_footprint_center_distance: true,
cross_satellite_pairing: false,
allowed_satellites: selectedFamilies.length > 0 ? selectedFamilies : null,
};
setPairingParams(submitParams);
const requireOrbitRef = { current: { checked: true } };
onSubmit(event, requireOrbitRef, submitParams);
};
if (!showPairingModal) return null;
const currentStrategy = STRATEGY_DESCRIPTIONS[pairingParams.strategy] || STRATEGY_DESCRIPTIONS.sbas;
return (
<div className="modal-overlay visible">
<div className="modal-content pairing-modal-wide">
<div className="pairing-modal-layout">
{/* 左侧:参数表单 */}
<div className="pairing-modal-form">
<h3>D-InSAR 配对参数</h3>
<form onSubmit={handleSubmit}>
{/* 配对策略选择 */}
<h3>D-InSAR 生产配对</h3>
<form onSubmit={handleSubmit} noValidate>
<div className="form-group">
<label>配对策略:</label>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="strategy"
value="all"
checked={pairingParams.strategy === 'all'}
onChange={(e) => setPairingParams({ ...pairingParams, strategy: e.target.value })}
/>
全部配对
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="strategy"
value="sbas"
checked={pairingParams.strategy === 'sbas'}
onChange={(e) => setPairingParams({ ...pairingParams, strategy: e.target.value })}
/>
SBAS (短基线)
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="strategy"
value="sequential"
checked={pairingParams.strategy === 'sequential'}
onChange={(e) => setPairingParams({ ...pairingParams, strategy: e.target.value })}
/>
Sequential (顺序)
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="strategy"
value="star"
checked={pairingParams.strategy === 'star'}
onChange={(e) => setPairingParams({ ...pairingParams, strategy: e.target.value })}
/>
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>
{/* 主影像时间范围 */}
<div className="form-group">
<label>主影像时间范围:</label>
<label>主影像时间范围</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<UnifiedDatePicker
value={pairingParams.master_date_from || ''}
onChange={(value) => setPairingParams({ ...pairingParams, master_date_from: value ? value.replace(/-/g, '') : null })}
onChange={(value) => updateParam({ master_date_from: value ? value.replace(/-/g, '') : null })}
language={language}
placeholder="始日期"
placeholder="始日期"
enabledDates={availableDates}
allowClear={true}
allowClear
/>
<span></span>
<UnifiedDatePicker
value={pairingParams.master_date_to || ''}
onChange={(value) => setPairingParams({ ...pairingParams, master_date_to: value ? value.replace(/-/g, '') : null })}
onChange={(value) => updateParam({ master_date_to: value ? value.replace(/-/g, '') : null })}
language={language}
placeholder="结束日期"
enabledDates={availableDates}
allowClear={true}
allowClear
/>
</div>
</div>
{/* 从影像时间范围 */}
<div className="form-group">
<label>从影像时间范围:</label>
<label>从影像时间范围</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<UnifiedDatePicker
value={pairingParams.slave_date_from || ''}
onChange={(value) => setPairingParams({ ...pairingParams, slave_date_from: value ? value.replace(/-/g, '') : null })}
onChange={(value) => updateParam({ slave_date_from: value ? value.replace(/-/g, '') : null })}
language={language}
placeholder="始日期"
placeholder="始日期"
enabledDates={availableDates}
allowClear={true}
allowClear
/>
<span></span>
<UnifiedDatePicker
value={pairingParams.slave_date_to || ''}
onChange={(value) => setPairingParams({ ...pairingParams, slave_date_to: value ? value.replace(/-/g, '') : null })}
onChange={(value) => updateParam({ slave_date_to: value ? value.replace(/-/g, '') : null })}
language={language}
placeholder="结束日期"
enabledDates={availableDates}
allowClear={true}
allowClear
/>
</div>
</div>
{/* Star 策略专用:参考影像 */}
{pairingParams.strategy === 'star' && (
<div className="form-group">
<label>参考影像 (Star 策略中心影像):</label>
<select
value={pairingParams.reference_image_id || ''}
onChange={(e) => setPairingParams({ ...pairingParams, reference_image_id: e.target.value ? parseInt(e.target.value) : null })}
style={{ width: '100%' }}
>
<option value="">-- 自动选择时间居中的影像 --</option>
{referenceImageOptions.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
<div className="form-group">
<label>限定数据体系</label>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{SENSOR_FAMILIES.map(item => (
<label key={item.value} style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={selectedFamilies.includes(item.value)}
onChange={() => toggleFamily(item.value)}
/>
{item.label}
</label>
))}
</div>
)}
</div>
{/* Sequential 策略专用:连接数 */}
{pairingParams.strategy === 'sequential' && (
<div className="form-group">
<label>连接数 (1-10):</label>
<input
type="number"
min="1"
max="10"
value={pairingParams.num_connections || 1}
onChange={(e) => setPairingParams({ ...pairingParams, num_connections: e.target.value ? parseInt(e.target.value) : 1 })}
placeholder="每个影像连接的后续影像数"
/>
</div>
)}
<div className="form-group">
<label>最小时间基线</label>
<input
type="number"
min="0"
max="3650"
value={inputValue(pairingParams.time_baseline_min)}
onChange={e => updateParam({ time_baseline_min: e.target.value })}
/>
</div>
<div className="form-group">
<label>最大时间基线默认 30可修改</label>
<input
type="number"
min="1"
max="3650"
value={inputValue(pairingParams.time_baseline_max)}
onChange={e => updateParam({ time_baseline_max: e.target.value })}
/>
</div>
<div className="form-group">
<label>两景最小重叠率</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={inputValue(pairingParams.overlap_threshold)}
onChange={e => updateParam({ overlap_threshold: e.target.value })}
/>
</div>
<div className="form-group">
<label>footprint 中心距离上限</label>
<input
type="number"
min="0"
max={PAIRING_CENTER_DISTANCE_MAX_METERS}
value={inputValue(pairingParams.spatial_baseline_max_meters)}
onChange={e => updateParam({ spatial_baseline_max_meters: e.target.value })}
/>
</div>
{/* 卫星选择器 */}
{availableSatellites.length > 0 && (
<div className="form-group">
<label>限定卫星 (不选则不限制):</label>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{availableSatellites.map(sat => (
<label key={sat} style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<div className="form-group">
<label>AOI 来源可选</label>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={selectedSatellites.includes(sat)}
onChange={() => handleSatelliteToggle(sat)}
type="radio"
name="pairing-aoi-mode"
value="shp"
checked={pairingAoiMode === 'shp'}
onChange={() => onAoiModeChange('shp')}
/>
{sat}
上传 SHP
</label>
))}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="pairing-aoi-mode"
value="region"
checked={pairingAoiMode === 'region'}
onChange={() => onAoiModeChange('region')}
/>
行政区
</label>
</div>
</div>
</div>
)}
{/* 跨卫星配对选项 */}
{selectedSatellites.length > 1 && (
<div className="form-group checkbox-group">
<input
type="checkbox"
id="cross-satellite-pairing"
checked={pairingParams.cross_satellite_pairing}
onChange={e => setPairingParams({
...pairingParams,
cross_satellite_pairing: e.target.checked
})}
/>
<label htmlFor="cross-satellite-pairing">允许跨卫星配对</label>
</div>
)}
{/* 时间、中心距和重叠率约束 */}
<div className="form-group">
<label>时间基线最小值 ():</label>
<input type="number" min="0" value={pairingParams.time_baseline_min}
onChange={e => setPairingParams({...pairingParams, time_baseline_min: parseInt(e.target.value) || 0})} />
</div>
<div className="form-group">
<label>最大时间间隔 ():</label>
<input type="number" min="1" value={pairingParams.time_baseline_max}
onChange={e => setPairingParams({...pairingParams, time_baseline_max: parseInt(e.target.value) || 90})} />
</div>
<div className="form-group">
<label>两景 footprint 最小重叠率 (0-1):</label>
<input type="number" step="0.1" min="0" max="1" value={pairingParams.overlap_threshold}
onChange={e => setPairingParams({...pairingParams, overlap_threshold: parseFloat(e.target.value) || 0})} />
</div>
<div className="form-group checkbox-group">
<input
type="checkbox"
id="limit-footprint-center-distance"
checked={Boolean(pairingParams.limit_footprint_center_distance)}
onChange={e => setPairingParams({
...pairingParams,
limit_footprint_center_distance: e.target.checked
})}
/>
<label htmlFor="limit-footprint-center-distance">限制 footprint 中心距</label>
</div>
<div className="form-group">
<label>footprint 中心距上限 ():</label>
<input
type="number"
min="0"
value={pairingParams.spatial_baseline_max_meters}
disabled={!pairingParams.limit_footprint_center_distance}
onChange={e => setPairingParams({...pairingParams, spatial_baseline_max_meters: parseInt(e.target.value) || 3000})}
/>
{!pairingParams.limit_footprint_center_distance && (
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>默认不按中心距过滤勾选后使用上方数值</div>
)}
</div>
<div className="form-group">
<label>覆盖多样性惩罚 (0-1):</label>
<input type="number" step="0.1" min="0" max="1"
value={pairingParams.coverage_diversity_penalty}
onChange={e => setPairingParams({...pairingParams, coverage_diversity_penalty: parseFloat(e.target.value) || 0})}
disabled={pairingParams.strategy !== 'sbas'}
/>
{pairingParams.strategy !== 'sbas' && (
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}> SBAS 策略使用此参数</div>
)}
</div>
<div className="form-group">
<label>AOI 来源:</label>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="pairing-aoi-mode"
value="shp"
checked={pairingAoiMode === 'shp'}
onChange={() => onAoiModeChange('shp')}
/>
上传SHP
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="pairing-aoi-mode"
value="region"
checked={pairingAoiMode === 'region'}
onChange={() => onAoiModeChange('region')}
/>
行政区选择
</label>
</div>
</div>
{pairingAoiMode === 'shp' ? (
<div className="form-group">
<label>限定范围 (Shapefile可选):</label>
<input
type="file"
multiple
onChange={e => setPairingFiles(e.target.files)}
style={{ display: 'none' }}
id="shp-upload"
/>
<label htmlFor="shp-upload" className="file-upload-button">
选择文件...
</label>
{pairingFiles && pairingFiles.length > 0 && (
<div className="file-list">
{Array.from(pairingFiles).map(f => f.name).join(', ')}
{pairingAoiMode === 'shp' ? (
<div className="form-group">
<label>限定范围可选</label>
<input
type="file"
multiple
onChange={e => setPairingFiles(e.target.files)}
style={{ display: 'none' }}
id="shp-upload"
/>
<label htmlFor="shp-upload" className="file-upload-button">选择文件...</label>
{pairingFiles && pairingFiles.length > 0 && (
<div className="file-list">
{Array.from(pairingFiles).map(file => file.name).join(', ')}
</div>
)}
</div>
) : (
<div className="form-group">
<label>行政区范围</label>
<div className="aoi-region-select-grid">
<select
value={pairingRegionSelection.province}
onChange={(event) => onProvinceChange(event.target.value)}
disabled={pairingRegionLoading}
>
<option value="">-- 省级 --</option>
{pairingRegionOptions.provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
<select
value={pairingRegionSelection.city}
onChange={(event) => onCityChange(event.target.value)}
disabled={pairingRegionLoading || !pairingRegionSelection.province}
>
<option value="">-- 地市 --</option>
{pairingRegionOptions.cities.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</div>
</div>
)}
</div>
) : (
<div className="form-group">
<label>行政区范围:</label>
<div className="aoi-region-select-grid">
<select
value={pairingRegionSelection.province}
onChange={(e) => onProvinceChange(e.target.value)}
disabled={pairingRegionLoading}
>
<option value="">-- 省级 --</option>
{pairingRegionOptions.provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
<select
value={pairingRegionSelection.city}
onChange={(e) => onCityChange(e.target.value)}
disabled={pairingRegionLoading || !pairingRegionSelection.province}
>
<option value="">-- 地市 --</option>
{pairingRegionOptions.cities.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</div>
<div style={{ marginTop: '6px', fontSize: '12px', color: '#6b7280' }}>
可只选到省/市级系统将自动使用当前选中层级边界
</div>
{pairingRegionError && (
<div style={{ marginTop: '6px', color: '#b91c1c', fontSize: '12px' }}>
<div style={{ marginBottom: '10px', color: '#b91c1c', fontSize: '12px', whiteSpace: 'pre-line' }}>
{pairingRegionError}
</div>
)}
</div>
)}
<div className="form-group">
<label>AOI 覆盖率阈值 (0 表示不限制):</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={pairingParams.aoi_overlap_threshold}
onChange={e => setPairingParams({
...pairingParams,
aoi_overlap_threshold: parseFloat(e.target.value) || 0
})}
/>
</div>
<div className="form-group checkbox-group">
<input
type="checkbox"
id="require-imaging-mode"
checked={pairingParams.require_same_imaging_mode}
onChange={e => setPairingParams({
...pairingParams,
require_same_imaging_mode: e.target.checked
})}
/>
<label htmlFor="require-imaging-mode">成像模式一致</label>
</div>
<div className="form-group checkbox-group">
<input
type="checkbox"
id="require-polarization"
checked={pairingParams.require_same_polarization}
onChange={e => setPairingParams({
...pairingParams,
require_same_polarization: e.target.checked
})}
/>
<label htmlFor="require-polarization">极化一致</label>
</div>
<div className="form-group checkbox-group">
<input
type="checkbox"
id="require-orbit"
ref={requireOrbitRef}
defaultChecked={true}
/>
<label htmlFor="require-orbit">仅使用有精轨数据的影像</label>
</div>
<div className="modal-actions">
<button type="button" onClick={() => { setShowPairingModal(false); setPairingFiles(null); setPairingRegionError(''); }}>取消</button>
<button
type="submit"
disabled={isReadOnlyUser || (pairingAoiMode === 'region' && !getSelectedRegionTreeId(pairingRegionSelection))}
>
开始配对
</button>
</div>
</form>
</div>
{/* 右侧:策略介绍面板 */}
<div className="pairing-modal-info">
<div className="strategy-info-panel">
<h4>{currentStrategy.title}</h4>
<p className="strategy-description">{currentStrategy.description}</p>
<div className="strategy-details">
{currentStrategy.details.map((detail, idx) => (
<p key={idx}>{detail}</p>
))}
<div className="form-group">
<label>AOI 覆盖率阈值未选 AOI 时不生效</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={inputValue(pairingParams.aoi_overlap_threshold)}
onChange={e => updateParam({ aoi_overlap_threshold: e.target.value })}
/>
</div>
<div className="modal-actions">
<button
type="button"
onClick={() => {
setShowPairingModal(false);
setPairingFiles(null);
setPairingRegionError('');
}}
>
取消
</button>
<button type="submit">
开始配对
</button>
</div>
</form>
</div>
<div className="strategy-params">
<strong>配置参数</strong>
<p>{currentStrategy.params}</p>
<div className="pairing-modal-info">
<div className="strategy-info-panel">
<h4>单一生产配对规则</h4>
<p className="strategy-description">
GF3 不参与 D-InSAR 配对LT-1 只和 LT-1 配对LT1A/LT1B 可互配Sentinel-1 只和 Sentinel-1 配对
</p>
<div className="strategy-details">
<p>A 相对轨道一致且中心距离较小优先生产</p>
<p>B 相对轨道缺失但其他几何条件满足可生产但需关注配准质量</p>
<p>C 仅作为候选不建议直接批量生产</p>
</div>
<div className="strategy-params">
<strong>AOI 规则</strong>
<p>未选择 AOI 时按全库条件配对选择 AOI 后使用两景重叠区与 AOI 的交集筛选</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -45,6 +45,44 @@ const parseDateLikeValue = (value) => {
return parsed;
};
const installYearNavigation = (instance) => {
const container = instance?.calendarContainer;
if (!container || container.dataset.yearNavigationReady === '1') return;
const monthsBar = container.querySelector('.flatpickr-months');
if (!monthsBar) return;
const changeYear = (delta) => {
instance.changeYear(instance.currentYear + delta);
instance.redraw();
};
const createButton = (delta, className, label, title) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `flatpickr-year-jump ${className}`;
button.textContent = label;
button.title = title;
button.setAttribute('aria-label', title);
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
changeYear(delta);
});
return button;
};
const prevYearButton = createButton(-1, 'flatpickr-prev-year', '<<', '上一年');
const nextYearButton = createButton(1, 'flatpickr-next-year', '>>', '下一年');
const prevMonthButton = monthsBar.querySelector('.flatpickr-prev-month');
const nextMonthButton = monthsBar.querySelector('.flatpickr-next-month');
monthsBar.insertBefore(prevYearButton, prevMonthButton || monthsBar.firstChild);
if (nextMonthButton?.nextSibling) {
monthsBar.insertBefore(nextYearButton, nextMonthButton.nextSibling);
} else {
monthsBar.appendChild(nextYearButton);
}
container.dataset.yearNavigationReady = '1';
};
export default function UnifiedDatePicker({
value,
onChange,
@@ -123,9 +161,11 @@ export default function UnifiedDatePicker({
maxDate: parsedMaxDate || undefined,
onReady: (_, __, fp) => {
applyAltInputAttrs(fp);
installYearNavigation(fp);
},
onOpen: (_, __, fp) => {
applyAltInputAttrs(fp);
installYearNavigation(fp);
},
onChange: (selectedDates) => {
const nextDate = Array.isArray(selectedDates) && selectedDates.length > 0
+1 -18
View File
@@ -7,7 +7,6 @@ import { formatYmd } from '../../utils/appUiHelpers';
import { ModalLoadingFallback } from './AppLoadingFallbacks';
const LazyPairingModal = lazy(() => import('../PairingModal'));
const LazyPsStackModal = lazy(() => import('../PsStackModal'));
const LazyDataInfoModal = lazy(() => import('../DataInfoModal'));
const LazyGlobalTaskCenter = lazy(() => import('../GlobalTaskCenter'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
@@ -19,10 +18,6 @@ export default function AppOverlays({
onPairingAoiModeChange,
onPairingProvinceChange,
onPairingCityChange,
onPsSubmit,
onPsAoiModeChange,
onPsProvinceChange,
onPsCityChange,
licenseLoading,
licenseStatus,
isAdmin,
@@ -41,9 +36,8 @@ export default function AppOverlays({
mapExport,
}) {
const { language, t } = useI18n();
const { showPairingModal, showPsModal } = usePairingStore(useShallow((state) => ({
const { showPairingModal } = usePairingStore(useShallow((state) => ({
showPairingModal: state.showPairingModal,
showPsModal: state.showPsModal,
})));
const {
showStats,
@@ -76,17 +70,6 @@ export default function AppOverlays({
</Suspense>
)}
{showPsModal && (
<Suspense fallback={<ModalLoadingFallback message="正在加载 PS 参数弹窗..." />}>
<LazyPsStackModal
onSubmit={onPsSubmit}
onAoiModeChange={onPsAoiModeChange}
onProvinceChange={onPsProvinceChange}
onCityChange={onPsCityChange}
/>
</Suspense>
)}
{activeAiReport && (
<Suspense fallback={<ModalLoadingFallback message="正在加载 AI 报告..." />}>
<LazyAiReportModal
+21 -79
View File
@@ -15,7 +15,6 @@ import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel'));
const LazyDataCopierPanel = lazy(() => import('../../DataCopierPanel'));
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
@@ -24,11 +23,8 @@ const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel'));
const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel'));
const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel'));
const LazyDinsarAnalysisPanel = lazy(() => import('../../panels/DinsarAnalysisPanel'));
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'));
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'));
@@ -45,7 +41,6 @@ export default function AppSidePanel({
apiEndpoint,
licenseOk,
foundPairs,
psResults,
dinsarTotal,
selectedPairsCount,
hasEnoughRadarScenesForPlanning,
@@ -62,14 +57,12 @@ export default function AppSidePanel({
dinsarPanel,
aiPanel,
pairsPanel,
psPanel,
sbasAnalysisPanel,
}) {
const isProductionWorkspace = PRODUCTION_WORKSPACE_ROUTE_TABS.has(leftPanelTab);
const activeLeftGroup = LEFT_TAB_GROUP[leftPanelTab] || 'data';
const leftTabLabelContext = {
pairCount: foundPairs.length,
psResultCount: psResults ? Object.keys(psResults).length : 0,
dinsarTotal,
};
const getVisibleTabs = (tabs = []) => tabs.filter((tab) => isAdmin || !ADMIN_ONLY_TABS.has(tab));
@@ -251,41 +244,6 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'pairing' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载组网规划面板..." />}>
<LazyPairingPanel
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
isReadOnlyUser={isReadOnlyUser}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
onOpenPairingModal={pairingPanel.onOpenPairingModal}
onOpenPsModal={pairingPanel.onOpenPsModal}
hasRadarSearched={hasRadarSearched}
onRefreshRadarSearch={pairingPanel.onRefreshRadarSearch}
onSearchAll={radarPanel.onSearchAll}
onRefreshDinsar={pairingPanel.onRefreshDinsar}
language={language}
/>
</Suspense>
)}
{leftPanelTab === 'copier' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据分发面板..." />}>
<LazyDataCopierPanel
apiEndpoint={apiEndpoint}
readOnly={isReadOnlyUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(
taskId,
'数据分发任务已入队,正在处理...',
{ taskType: 'COPY_DATA', nonBlocking: true },
)}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'idl' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 IDL 面板..." />}>
@@ -306,6 +264,16 @@ export default function AppSidePanel({
activeEntry={leftPanelTab}
readOnly={isReadOnlyUser}
onTaskStart={taskPanel.onTaskStart}
apiEndpoint={apiEndpoint}
language={language}
foundPairs={foundPairs}
selectedPairsCount={selectedPairsCount}
isLoading={isLoading}
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
hasRadarSearched={hasRadarSearched}
pairingPanel={pairingPanel}
radarPanel={radarPanel}
pairsPanel={pairsPanel}
/>
</div>
</Suspense>
@@ -401,15 +369,16 @@ export default function AppSidePanel({
)}
{leftPanelTab === 'dinsar_analysis' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<div style={{ padding: '16px' }}>
<div className="empty-state">
D-InSAR 分析页已预留
<br />
后续可在这里承接专题筛选人工判读统计汇总和分析报告能力
</div>
</div>
</div>
<Suspense fallback={<PanelLoadingPanel message="正在加载 D-InSAR 分析面板..." />}>
<LazyDinsarAnalysisPanel
aiStatus={aiStatus}
isLoading={isLoading}
isReadOnlyUser={isReadOnlyUser}
aiPanel={aiPanel}
language={language}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, '任务已入队,等待处理...')}
/>
</Suspense>
)}
{leftPanelTab === 'psinsar_results' && (
@@ -482,33 +451,6 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'pairs' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载配对结果面板..." />}>
<LazyPairsListPanel
onVisualizePair={pairsPanel.onVisualizePair}
onTogglePairVisibility={pairsPanel.onTogglePairVisibility}
onCreateDinsarBatch={pairsPanel.onCreateDinsarBatch}
/>
</Suspense>
)}
{leftPanelTab === 'ps_results' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载 PS 候选结果面板..." />}>
<LazyPsResultsPanel
onPreviewPsStack={psPanel.onPreviewPsStack}
onClearPsStackPreview={psPanel.onClearPsStackPreview}
onCreatePsBatch={psPanel.onCreatePsBatch}
onSendToTimeseriesProduction={psPanel.onSendToTimeseriesProduction}
onClearPsResults={psPanel.onClearPsResults}
/>
</Suspense>
)}
{leftPanelTab === 'batches' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载批处理面板..." />}>
<LazyBatchPanel />
</Suspense>
)}
</aside>
);
}
+88 -1
View File
@@ -1,5 +1,75 @@
import { memo } from 'react';
function basename(path) {
const text = String(path || '').trim();
if (!text) return '';
const normalized = text.replace(/\\/g, '/');
return normalized.split('/').filter(Boolean).pop() || text;
}
function formatScene(scene) {
if (!scene) return '未识别影像';
const name = basename(scene.file_path) || scene.source_product_token || scene.product_unique_id || `#${scene.id || '-'}`;
const meta = [
scene.satellite_family || scene.satellite,
scene.imaging_date,
scene.imaging_mode,
scene.polarization,
].filter(Boolean).join(' / ');
return meta ? `${name} (${meta})` : name;
}
function formatPercent(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return `${(number * 100).toFixed(number >= 0.995 ? 0 : 1)}%`;
}
function formatDistance(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
if (Math.abs(number) >= 1000) return `${(number / 1000).toFixed(2)}km`;
return `${number.toFixed(1)}m`;
}
function qualityLabel(pair) {
const tier = String(pair?.dinsar_quality_tier || '').trim().toUpperCase();
const readiness = String(pair?.dinsar_readiness || '').trim().toUpperCase();
if (!tier && !readiness) return null;
const tierLabel = { A: 'A级', B: 'B级', C: 'C级', REJECT: '不推荐' }[tier] || tier;
const readinessLabel = {
RECOMMENDED: '推荐',
CANDIDATE: '候选',
NOT_RECOMMENDED: '不推荐',
}[readiness] || readiness;
return readinessLabel ? `${tierLabel} ${readinessLabel}` : tierLabel;
}
function productionLabel(summary) {
if (!summary) {
return { text: '生产状态未知', tone: 'unknown' };
}
const aliasOnly = summary.match_level === 'task_alias';
if (summary.is_produced) {
const readyCount = Number(summary.ready_product_count || 0);
return {
text: aliasOnly
? '别名记录 已生产'
: (readyCount > 0 ? `已生产 ${readyCount} 个结果` : '已生产'),
tone: aliasOnly ? 'running' : 'ready',
};
}
if (summary.has_record) {
const status = String(summary.status || summary.latest_item_status || summary.latest_run_status || 'RUNNING').toUpperCase();
const failed = Number(summary.failed_run_count || 0) > 0 || ['FAILED', 'ERROR', 'CANCELLED', 'CANCELED'].includes(status);
return {
text: aliasOnly ? `别名记录 ${status}` : `有记录 ${status}`,
tone: failed ? 'failed' : 'running',
};
}
return { text: '未生产', tone: 'missing' };
}
function PairListRow({
pair,
index,
@@ -8,6 +78,13 @@ function PairListRow({
onTogglePairVisibility,
}) {
const centerDistance = pair.scene_center_distance_meters ?? pair.spatial_baseline_meters;
const overlap = pair.pair_aoi_overlap_ratio ?? pair.scene_overlap_ratio ?? pair.overlap_ratio;
const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠';
const production = productionLabel(pair.production_summary);
const quality = qualityLabel(pair);
const engines = Array.isArray(pair.production_summary?.engine_codes)
? pair.production_summary.engine_codes.filter(Boolean).join('/')
: '';
return (
<li className="pair-item">
<input
@@ -19,9 +96,19 @@ function PairListRow({
/>
<div className="pair-info" onClick={() => onVisualizePair(pair)}>
<strong>{pair.task_name}</strong>
<div className="pair-scenes">
<span title={pair.master?.file_path}>: {formatScene(pair.master)}</span>
<span title={pair.slave?.file_path}>: {formatScene(pair.slave)}</span>
</div>
<div className="pair-details">
<span>时基: {pair.time_baseline_days}d</span>
<span>中心距: {Number(centerDistance || 0).toFixed(2)}m</span>
<span>中心距: {formatDistance(centerDistance)}</span>
<span>{overlapLabel}: {formatPercent(overlap)}</span>
{quality && <span>D-InSAR: {quality}</span>}
</div>
<div className="pair-status-line">
<span className={`pair-production-badge ${production.tone}`}>{production.text}</span>
{engines && <span className="pair-engine-text">引擎: {engines}</span>}
</div>
</div>
<button
@@ -13,7 +13,7 @@ function RadarDataRow({
onToggleLayer,
}) {
return (
<li className="data-item" onClick={() => onFlyTo(item)}>
<li className="data-item radar-data-item" onClick={() => onFlyTo(item)}>
<span className="data-item-name" title={item.displayName}>
{item.displayName}
</span>
+96 -44
View File
@@ -56,40 +56,120 @@ export const getBaseLayerConfig = key => BASE_LAYERS[key] || BASE_LAYERS[TILE_LA
export const PRODUCTION_WORKSPACE_TAB = 'production_management';
export const PRODUCTION_WORKSPACE_LEGACY_TABS = [
'pairing',
'pairs',
'ps_results',
'batches',
'copier',
'dinsar_production',
'dinsar_products',
'ps_production',
'ps_products',
];
export const PRODUCTION_WORKSPACE_VIEWS = [
export const PRODUCTION_WORKSPACE_DINSAR_VIEWS = [
{
key: 'dinsar_pairing',
label: '配对规划',
description: '按时间基线、空间关系、覆盖重叠率和 AOI 生成 D-InSAR 候选干涉对。',
},
{
key: 'dinsar_pairs',
label: '候选对与批次',
description: '审查候选干涉对,选择可进入生产的任务,并在同一流程中管理 D-InSAR 批次。',
},
{
key: 'dinsar_prepare',
label: '生产准备',
description: '按批次解包源压缩包到本机 Task_Pool,或导出源压缩包分发包。',
},
{
key: 'dinsar_runs',
label: 'D-InSAR 运行',
label: '生产运行',
description: '运行任务编排、引擎切换与过程监控',
},
{
key: 'sbas_insar_production',
label: 'SBAS-InSAR Production',
description: 'Gamma IPTA SBAS stack production, velocity maps, quality metrics, and monitor-point curves',
},
{
key: 'sbas_insar_products',
label: 'SBAS-InSAR 结果',
description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog',
},
{
key: 'dinsar_products',
label: 'D-InSAR 产物',
label: '结果管理',
description: '结果提取、标准目录发布与产物编目',
},
];
export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
{
key: 'sbas_insar_planning',
label: '序列规划',
description: '按生产区域发现 SBAS 候选序列,审计覆盖、时序密度、精轨和公共重叠范围。',
},
{
key: 'sbas_insar_batches',
label: '候选栈与Run',
description: '查看候选序列、Manifest 与已创建的 SBAS 生产 Run。',
},
{
key: 'sbas_insar_prepare',
label: '生产准备',
description: '配置 DEM、处理器参数、Workflow Manifest 与生产前检查。',
},
{
key: 'sbas_insar_runs',
label: '生产运行',
description: '跟踪 LandSAR/Gamma SBAS Run 状态、任务执行和阶段产物。',
},
{
key: 'sbas_insar_products',
label: '结果管理',
description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog',
},
];
export const PRODUCTION_WORKSPACE_WORKBENCHES = [
{
key: 'dinsar_workbench',
label: 'D-InSAR工作台',
description: '配对规划、候选对与批次、生产准备、生产运行和结果管理集中到一条 D-InSAR 工作流。',
defaultView: 'dinsar_pairing',
views: PRODUCTION_WORKSPACE_DINSAR_VIEWS,
},
{
key: 'sbas_workbench',
label: 'SBAS-InSAR工作台',
description: '围绕 SBAS 序列发现、生产执行和结果 catalog 管理组织时序 InSAR 生产。',
defaultView: 'sbas_insar_planning',
views: PRODUCTION_WORKSPACE_SBAS_VIEWS,
},
];
export const PRODUCTION_WORKSPACE_VIEWS = [
...PRODUCTION_WORKSPACE_DINSAR_VIEWS,
...PRODUCTION_WORKSPACE_SBAS_VIEWS,
{
key: 'lt1_production',
label: '陆探生产占位',
description: 'LT-1 源压缩包本机登记,按需 materialize 到 Task_PoolD-InSAR/SBAS 生产不走 UNC。',
},
{
key: 'sentinel1_production',
label: '哨兵生产占位',
description: 'Sentinel-1 ZIP/SAFE 与 EOF 精轨本机管理,按需解包;当前 SBAS 仅保留规划能力。',
},
{
key: 'gf3_native_registration',
label: '高分三结果登记',
description: 'GF3 在外部 SARscape 服务器生产,本机只登记复制回来的 _geo 二进制并生成 WebP。',
},
];
export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({
[PRODUCTION_WORKSPACE_TAB]: 'dinsar_runs',
[PRODUCTION_WORKSPACE_TAB]: 'dinsar_pairing',
pairing: 'dinsar_pairing',
pairs: 'dinsar_pairs',
ps_results: 'sbas_insar_planning',
batches: 'dinsar_pairs',
copier: 'dinsar_prepare',
dinsar_production: 'dinsar_runs',
dinsar_products: 'dinsar_products',
ps_production: 'sbas_insar_production',
ps_production: 'sbas_insar_runs',
ps_products: 'sbas_insar_products',
});
@@ -100,27 +180,13 @@ export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
export const LEFT_GROUP_LABELS = {
data: '数据管理',
production_planning: '生产规划',
production_management: '生产管理',
insar_analysis: 'InSAR形变分析',
ai_analysis: 'AI分析',
flood_analysis: '洪涝灾害分析',
ops: '运行维护',
};
export const LEFT_GROUP_SECTIONS = {
production_planning: [
{
key: 'planning',
label: '规划编组',
tabs: ['pairing', 'pairs', 'ps_results', 'batches'],
},
{
key: 'dispatch',
label: '数据分发',
tabs: ['copier'],
},
],
insar_analysis: [
{
key: 'dinsar',
@@ -129,30 +195,16 @@ export const LEFT_GROUP_SECTIONS = {
},
{
key: 'psinsar',
label: '时序InSAR',
label: 'SBAS',
tabs: ['psinsar_analysis'],
},
],
ai_analysis: [
{
key: 'deformation_ai',
label: '形变智能分析',
tabs: ['ai_quality', 'ai_diagnosis'],
},
{
key: 'vision_ai',
label: '遥感视觉分析',
tabs: ['landslide_segmentation', 'uav_image_analysis'],
},
],
};
export const LEFT_GROUP_TABS = {
data: ['ingest', 'asset_inventory', 'data', 'hazard'],
production_planning: LEFT_GROUP_SECTIONS.production_planning.flatMap(section => section.tabs),
production_management: [PRODUCTION_WORKSPACE_TAB],
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
ai_analysis: LEFT_GROUP_SECTIONS.ai_analysis.flatMap(section => section.tabs),
flood_analysis: ['flood_analysis'],
ops: ['health', 'users', 'audit'],
};
+11 -8
View File
@@ -2,20 +2,23 @@ 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' },
AI_TRAIN: { label: '训练 AI 模型', featureScope: 'insar_analysis' },
AI_PREDICT: { label: '全量质量评估', featureScope: 'insar_analysis' },
AI_ANALYZE: { label: 'AI 智能诊断(旧)', featureScope: 'insar_analysis' },
AI_DIAGNOSIS: { label: 'D-InSAR诊断', featureScope: 'insar_analysis' },
AI_WARMUP: { label: 'AI 模型预热', featureScope: 'insar_analysis' },
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' },
UNPACK_ARCHIVES: { label: 'LT-1 批量解包(旧)', featureScope: 'data_monitor' },
UNPACK_SENTINEL1: { label: 'Sentinel-1 批量解包(旧)', featureScope: 'data_monitor' },
GF3_UNPACK: { label: 'GF3 legacy 解包', featureScope: 'data_monitor' },
GF3_BATCH_PROCESS: { label: 'GF3 legacy 预处理', featureScope: 'data_monitor' },
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产', featureScope: 'data_monitor' },
GF3_SARSCAPE_SYNC: { label: 'GF3 SARscape 入库', featureScope: 'data_monitor' },
GF3_SARSCAPE_PRODUCE: { label: 'GF3 SARscape 生产(停用)', featureScope: 'data_monitor' },
GF3_SARSCAPE_SYNC: { label: 'GF3 _geo 原生入库', featureScope: 'data_monitor' },
GF3_QUICKLOOK_WEBP: { label: 'GF3 _geo WebP', featureScope: 'data_monitor' },
GF3_SARSCAPE_CLEAN: { label: 'GF3 中间清理', featureScope: 'data_monitor' },
SCAN_ASSET_INVENTORY: { label: '资产库存扫描', featureScope: 'asset_inventory' },
AUDIT_SOURCE_ARCHIVE_INTEGRITY: { 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' },
+7 -2
View File
@@ -21,6 +21,7 @@ export default function useBatchOperations({
setBatchItems,
setBatchLoading,
setBatchError,
includePsBatches = true,
}) {
const fetchDinsarBatches = useCallback(async () => {
try {
@@ -62,13 +63,17 @@ export default function useBatchOperations({
setBatchLoading(true);
setBatchError('');
try {
await Promise.all([fetchDinsarBatches(), fetchPsBatches()]);
if (includePsBatches) {
await Promise.all([fetchDinsarBatches(), fetchPsBatches()]);
} else {
await fetchDinsarBatches();
}
} catch {
setBatchError('加载批次失败');
} finally {
setBatchLoading(false);
}
}, [fetchDinsarBatches, fetchPsBatches, setBatchLoading, setBatchError]);
}, [fetchDinsarBatches, fetchPsBatches, includePsBatches, setBatchLoading, setBatchError]);
const fetchBatchItems = useCallback(async (type, batchId) => {
if (!batchId) {
+29 -5
View File
@@ -156,7 +156,13 @@ export default function useDinsarOperations({
}
};
if (taskInfo.task_type === 'AI_ANALYZE') {
if (taskInfo.task_type === 'AI_DIAGNOSIS') {
if (taskStatus === 'COMPLETED') {
addLog('success', taskInfo.message || 'D-InSAR 诊断完成,请在 D-InSAR分析 / D-InSAR诊断 中查看记录。');
} else if (taskStatus === 'FAILED') {
addLog('error', `D-InSAR 诊断失败: ${taskInfo.message || '未知错误'}`);
}
} else if (taskInfo.task_type === 'AI_ANALYZE') {
if (taskInfo.message) {
try {
const result = JSON.parse(taskInfo.message);
@@ -302,15 +308,33 @@ export default function useDinsarOperations({
const handleAnalyzeResult = async (resultId) => {
if (!ensureCanOperate()) return;
addLog('info', `正在对结果 ID:${resultId} 发起 AI 智能诊断任务...`);
addLog('info', `正在对结果 ID:${resultId} 发起 D-InSAR 诊断任务...`);
try {
const response = await apiClient.post(`/ai/analyze-result/${resultId}`);
const statusResponse = await apiClient.get('/ai/status');
const models = statusResponse.data?.ollama_vlm_models || [];
if (!statusResponse.data?.ollama_online) {
addLog('error', '发起 D-InSAR 诊断失败: Ollama 未在线。');
return;
}
if (models.length === 0) {
addLog('error', '发起 D-InSAR 诊断失败: 未检测到本机 Ollama 视觉模型。');
return;
}
const defaultModel = statusResponse.data?.default_vlm_model;
const modelName = (defaultModel && models.includes(defaultModel))
? defaultModel
: models[0];
const response = await apiClient.post('/ai/diagnosis', {
result_id: resultId,
model_name: modelName,
prompt_template: 'standard',
});
const taskId = response.data.task_id;
handleTaskStart(taskId);
addLog('info', `AI 诊断任务已启动 (ID: ${taskId}),请稍候...`);
addLog('info', `D-InSAR 诊断任务已启动 (ID: ${taskId}),请稍候...`);
} catch (error) {
const msg = error.response?.data?.detail || error.message;
addLog('error', `发起 AI 诊断失败: ${msg}`);
addLog('error', `发起 D-InSAR 诊断失败: ${msg}`);
}
};
+146 -60
View File
@@ -1,8 +1,5 @@
/**
* usePairingLogic pairing and PS stack business logic extracted from App.jsx
*
* Contains: findPairs, handleFindPsStack, createDinsarBatch, createPsBatch,
* focusBatchAfterCreate, clearPsResults
* usePairingLogic - pairing and time-series stack business logic extracted from App.jsx.
*/
import apiClient from '../api/client';
import { getPairingHealth } from '../api/pairing';
@@ -14,9 +11,15 @@ import { getSelectedRegionTreeId } from '../utils/appUiHelpers';
const compactDinsarBatchScene = (scene = {}) => ({
file_path: scene.file_path || '',
satellite: scene.satellite || null,
satellite_family: scene.satellite_family || null,
imaging_date: scene.imaging_date || null,
imaging_mode: scene.imaging_mode || null,
polarization: scene.polarization || null,
orbit_direction: scene.orbit_direction || null,
relative_orbit: scene.relative_orbit || null,
absolute_orbit: scene.absolute_orbit || null,
has_orbit_data: scene.has_orbit_data ?? null,
orbit_file_path: scene.orbit_file_path || null,
});
const compactDinsarBatchPair = (pair = {}) => ({
@@ -31,10 +34,90 @@ const compactDinsarBatchPair = (pair = {}) => ({
time_baseline_days: pair.time_baseline_days ?? null,
spatial_baseline_meters: pair.spatial_baseline_meters ?? null,
scene_center_distance_meters: pair.scene_center_distance_meters ?? pair.spatial_baseline_meters ?? null,
dinsar_quality_tier: pair.dinsar_quality_tier || null,
dinsar_quality_score: pair.dinsar_quality_score ?? null,
dinsar_readiness: pair.dinsar_readiness || null,
master: compactDinsarBatchScene(pair.master),
slave: compactDinsarBatchScene(pair.slave),
});
const normalizePairingFamilies = (values) => {
if (!Array.isArray(values)) return null;
const normalized = [];
values.forEach((value) => {
const compact = String(value || '').trim().toUpperCase().replace(/[-_\s]/g, '');
if (['LT1', 'LT1A', 'LT1B', 'LUTAN1', 'LUTAN1A', 'LUTAN1B'].includes(compact)) {
normalized.push('LT1');
} else if (['S1', 'S1A', 'S1B', 'S1C', 'SENTINEL1', 'SENTINEL1A', 'SENTINEL1B', 'SENTINEL1C'].includes(compact)) {
normalized.push('S1');
}
});
return [...new Set(normalized)];
};
const readPairingNumber = (params, key, label, { integer = false, min = -Infinity, max = Infinity, defaultValue = null } = {}) => {
const rawValue = params[key];
const text = String(rawValue ?? '').trim();
const parsed = text === '' && defaultValue !== null ? defaultValue : Number(text);
if (!Number.isFinite(parsed) || (integer && !Number.isInteger(parsed))) {
return { error: `${label}必须是${integer ? '整数' : '数字'}` };
}
if (parsed < min || parsed > max) {
return { error: `${label}必须在 ${min}${max} 之间。` };
}
return { value: parsed };
};
const normalizePairingParamsForRequest = (params = {}) => {
const timeMin = readPairingNumber(params, 'time_baseline_min', '最小时间基线', {
integer: true,
min: 0,
max: 3650,
defaultValue: 1,
});
if (timeMin.error) return timeMin;
const timeMax = readPairingNumber(params, 'time_baseline_max', '最大时间基线', {
integer: true,
min: 1,
max: 3650,
defaultValue: 30,
});
if (timeMax.error) return timeMax;
if (timeMin.value > timeMax.value) {
return { error: '最小时间基线不能大于最大时间基线。' };
}
const overlap = readPairingNumber(params, 'overlap_threshold', '两景最小重叠率', {
min: 0,
max: 1,
defaultValue: 0.5,
});
if (overlap.error) return overlap;
const centerDistance = readPairingNumber(params, 'spatial_baseline_max_meters', 'footprint 中心距离上限', {
integer: true,
min: 0,
max: 20000000,
defaultValue: 5000,
});
if (centerDistance.error) return centerDistance;
const aoiOverlap = readPairingNumber(params, 'aoi_overlap_threshold', 'AOI 覆盖率阈值', {
min: 0,
max: 1,
defaultValue: 0,
});
if (aoiOverlap.error) return aoiOverlap;
return {
value: {
...params,
time_baseline_min: timeMin.value,
time_baseline_max: timeMax.value,
overlap_threshold: overlap.value,
spatial_baseline_max_meters: centerDistance.value,
aoi_overlap_threshold: aoiOverlap.value,
},
};
};
export default function usePairingLogic({
fetchRegionGeometry,
refreshBatchList,
@@ -46,9 +129,10 @@ export default function usePairingLogic({
pairingParams, pairingAoiMode,
pairingFiles, setPairingFiles,
pairingRegionSelection,
setPairingRegionError,
setShowPairingModal, setFoundPairs, setPairingAlert,
psAoiMode, psFiles, setPsFiles, psRegionSelection,
psParams, setShowPsModal, setPsResults,
psParams, setPsResults,
} = usePairingStore();
const { setAoiLayer } = useMapStore();
const { setBatchTab, setSelectedBatchId, setBatchItems, setPendingTimeseriesBatchId } = useBatchStore();
@@ -144,7 +228,7 @@ export default function usePairingLogic({
const batchPairs = selectedPairs.map(compactDinsarBatchPair);
const response = await apiClient.post('/task-batches/dinsar', {
name: `DINSAR_${new Date().toISOString().slice(0, 10)}`,
pairs: batchPairs
pairs: batchPairs,
});
const batchId = response.data?.batch_id || '';
addLog('success', `已创建 D-InSAR 批次: ${batchId || 'OK'}`);
@@ -161,41 +245,38 @@ export default function usePairingLogic({
setPsResults(null);
onClearAoiLayer();
setAoiLayer(null);
addLog('info', '时序InSAR 候选栈结果已清空。');
addLog('info', '时序 InSAR 候选栈结果已清空。');
};
const findPairs = async (e, externalRequireOrbitRef) => {
e.preventDefault();
const findPairs = async (e, externalRequireOrbitRef, overridePairingParams = null) => {
e?.preventDefault?.();
if (!ensureCanOperate()) return;
setIsLoading(true);
addLog('info', '开始寻找干涉对...');
addLog('info', '开始查找 D-InSAR 生产配对...');
setPairingAlert({ warnings: [], fallbackUsed: false });
const formData = new FormData();
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;
}
const sourcePairingParams = overridePairingParams || pairingParams;
const normalizedParams = normalizePairingParamsForRequest(sourcePairingParams);
if (normalizedParams.error) {
addLog('error', normalizedParams.error);
setIsLoading(false);
return;
}
const effectivePairingParams = {
...normalizedParams.value,
strategy: 'dinsar_production',
limit_footprint_center_distance: true,
cross_satellite_pairing: false,
};
effectivePairingParams.allowed_satellites = normalizePairingFamilies(sourcePairingParams.allowed_satellites);
try {
const pairingHealth = await getPairingHealth();
if (pairingHealth?.needs_rebuild || pairingHealth?.status !== 'READY') {
addLog(
'warn',
`配对基础当前状态为 ${pairingHealth?.status || 'UNKNOWN'}dirty 场景 ${Number(pairingHealth?.dirty_scene_count || 0)}。请先在“配对规划页执行“修复配对基础”。`
`配对基础当前状态为 ${pairingHealth?.status || 'UNKNOWN'}dirty 场景 ${Number(pairingHealth?.dirty_scene_count || 0)}。请先在生产规划页执行“修复配对基础”。`
);
setIsLoading(false);
return;
@@ -208,10 +289,10 @@ export default function usePairingLogic({
for (const key in effectivePairingParams) {
const value = effectivePairingParams[key];
// 跳过 null/undefined 值
if (value === null || value === undefined) continue;
// allowed_satellites 是数组,需要序列化为 JSON
if (typeof value === 'string' && value.trim() === '') continue;
if (key === 'allowed_satellites' && Array.isArray(value)) {
if (value.length === 0) continue;
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value);
@@ -229,25 +310,22 @@ export default function usePairingLogic({
}
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(pairingRegionSelection);
if (!selectedRegionTreeId) {
addLog('warn', '请选择行政区后再执行配对。');
setIsLoading(false);
return;
}
try {
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
addLog('error', '未获取到行政区边界,请检查后端行政区边界数据。');
if (selectedRegionTreeId) {
try {
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
addLog('error', '未获取到行政区边界,请检查后端行政区边界数据。');
setIsLoading(false);
return;
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeoJson));
setAoiLayer(selectedAoiGeoJson);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '行政区边界加载失败';
addLog('error', `加载行政区边界失败: ${errorMessage}`);
setIsLoading(false);
return;
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeoJson));
setAoiLayer(selectedAoiGeoJson);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '行政区边界加载失败';
addLog('error', `加载行政区边界失败: ${errorMessage}`);
setIsLoading(false);
return;
}
}
@@ -261,30 +339,39 @@ export default function usePairingLogic({
const policyVersion = response.data?.policy_version || response.data?.policyVersion;
const candidateCount = Number(response.data?.candidate_count ?? response.data?.candidateCount ?? pairs.length ?? 0);
const selectedEdgeCount = Number(response.data?.selected_edge_count ?? response.data?.selectedEdgeCount ?? pairs.length ?? 0);
setFoundPairs(pairs.map(p => ({ ...p, isSelected: true, isVis: false })));
setFoundPairs(Array.isArray(pairs) ? pairs.map(p => ({ ...p, isSelected: true, isVis: false })) : []);
setPairingAlert({ warnings, fallbackUsed });
if (!Array.isArray(pairs) || pairs.length === 0) {
const emptyMessage = `当前参数下没有满足条件的 D-InSAR 配对。候选 ${candidateCount},入选 ${selectedEdgeCount}`;
const detailText = warnings.length > 0
? warnings.join('\n')
: '可以放宽时间基线、footprint 中心距离、重叠率、AOI,或检查配对缓存/精轨状态。';
addLog('warn', emptyMessage);
warnings.forEach(msg => addLog('warn', msg));
setPairingRegionError(`${emptyMessage}\n${detailText}`);
return;
}
if (aoi_geojson) {
setAoiLayer(aoi_geojson);
}
setPairingAlert({ warnings, fallbackUsed });
if (warnings.length > 0) {
warnings.forEach(msg => addLog('warn', msg));
}
warnings.forEach(msg => addLog('warn', msg));
if (fallbackUsed && warnings.length === 0) {
addLog('warn', '配对进入回退路径,请检查数据库函数或收紧筛选条件。');
}
if (networkRunId) {
addLog('info', `配对网络已生成: ${networkRunId} (${policyVersion || 'unknown policy'})`);
addLog('info', `配对网络已生成 ${networkRunId} (${policyVersion || 'unknown policy'})`);
}
if (degraded) {
addLog('warn', '当前配对结果来自降级缓存状态,建议尽快执行缓存修复。');
}
addLog('success', `成功找到 ${pairs.length}干涉对(候选 ${candidateCount},入选 ${selectedEdgeCount})。`);
addLog('success', `成功找到 ${pairs.length} D-InSAR 生产配对(候选 ${candidateCount},入选 ${selectedEdgeCount})。`);
setShowPairingModal(false);
setPairingFiles(null);
setLeftPanelTab('pairs');
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
addLog('error', `寻找干涉对失败: ${errorMessage}`);
addLog('error', `查找 D-InSAR 配对失败: ${errorMessage}`);
} finally {
setIsLoading(false);
}
@@ -295,7 +382,7 @@ export default function usePairingLogic({
if (!ensureCanOperate()) return;
if (psAoiMode === 'shp') {
if (!psFiles || psFiles.length === 0) {
addLog('warn', '请先选择有效的Shapefile文件。');
addLog('warn', '请先选择有效的 Shapefile 文件。');
return;
}
} else {
@@ -306,9 +393,8 @@ export default function usePairingLogic({
}
}
setShowPsModal(false);
setIsLoading(true);
addLog('info', '开始准备时序InSAR候选栈...');
addLog('info', '开始准备时序 InSAR 候选栈...');
const formData = new FormData();
for (const key in psParams) {
@@ -356,17 +442,17 @@ export default function usePairingLogic({
setPsResults(processedResults);
if (Object.keys(processedResults).length > 0) {
addLog('success', `成功找到 ${Object.keys(processedResults).length} 个时序InSAR候选栈。`);
addLog('success', `成功找到 ${Object.keys(processedResults).length} 个时序 InSAR 候选栈。`);
setLeftPanelTab('ps_results');
addLog('info', '候选栈仅作为预览结果保留;需要生产时请手动保存批次或送入生产。');
} else {
addLog('info', '在给定的AOI和阈值下,未找到满足 SBAS 至少 3 景要求的时序影像栈。');
addLog('info', '在给定的 AOI 和阈值下,未找到满足 SBAS 至少 3 景要求的时序影像栈。');
setLeftPanelTab('ps_results');
}
} catch (error) {
console.error('时序InSAR候选栈准备失败:', error);
console.error('时序 InSAR 候选栈准备失败:', error);
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `时序InSAR候选栈准备失败: ${errorMessage}`);
addLog('error', `时序 InSAR 候选栈准备失败: ${errorMessage}`);
} finally {
setIsLoading(false);
setPsFiles(null);
+8 -5
View File
@@ -138,12 +138,15 @@ export default function useRadarSearch({
}, [setRadarSearchOptions, setRadarSearchOptionsLoading]);
const processAndSetAllData = useCallback((data) => {
const nameCounts = {};
const dataWithDisplayNames = data.map(item => {
const baseName = `${item.satellite}_${item.imaging_mode}_${item.imaging_date}`;
nameCounts[baseName] = (nameCounts[baseName] || 0) + 1;
const count = nameCounts[baseName];
const displayName = count > 1 ? `${baseName}_${count - 1}` : baseName;
const filePath = String(item.file_path || '').trim().replace(/[\\/]+$/, '');
const fileName = filePath ? filePath.split(/[\\/]/).pop() : '';
const displayName =
fileName
|| item.product_unique_id
|| item.unique_id
|| [item.satellite, item.imaging_mode, item.imaging_date].filter(Boolean).join('_')
|| `SAR_${item.id}`;
const previewStatus = normalizePreviewStatus(item.preview_cache_status);
return {
...item,
+32 -58
View File
@@ -16,12 +16,12 @@ function engineResultTone(status) {
export default function BatchPanel() {
const { language } = useI18n();
const {
batchTab, setBatchTab,
setBatchTab,
selectedBatchId, setSelectedBatchId,
batchLoading,
batchError,
batchItems, setBatchItems,
dinsarBatches, psBatches,
dinsarBatches,
} = useBatchStore();
const { addLog } = useUiStore();
const { currentUser } = useAuthStore();
@@ -37,7 +37,7 @@ export default function BatchPanel() {
};
const {
refreshBatchList,
fetchDinsarBatches,
fetchBatchItems,
updateBatchItemLocal,
saveBatchItem,
@@ -45,49 +45,30 @@ export default function BatchPanel() {
} = useBatchOperations({
addLog,
ensureCanOperate,
batchTab,
batchTab: 'dinsar',
selectedBatchId,
setDinsarBatches: useBatchStore.getState().setDinsarBatches,
setPsBatches: useBatchStore.getState().setPsBatches,
setBatchItems,
setBatchLoading: useBatchStore.getState().setBatchLoading,
setBatchError: useBatchStore.getState().setBatchError,
includePsBatches: false,
});
useEffect(() => {
refreshBatchList();
}, [refreshBatchList]);
setBatchTab('dinsar');
fetchDinsarBatches();
}, [fetchDinsarBatches, setBatchTab]);
const currentBatches = batchTab === 'ps' ? psBatches : dinsarBatches;
const currentBatches = dinsarBatches;
const en = language === 'en';
return (
<div className="panel-content">
<div className="list-toolbar column-layout">
<div className="toolbar-row">
<button
className={batchTab === 'dinsar' ? 'active-tool' : ''}
onClick={() => {
setBatchTab('dinsar');
setSelectedBatchId('');
setBatchItems([]);
refreshBatchList();
}}
>
D-InSAR
</button>
<button
className={batchTab === 'ps' ? 'active-tool' : ''}
onClick={() => {
setBatchTab('ps');
setSelectedBatchId('');
setBatchItems([]);
refreshBatchList();
}}
>
PS
</button>
<button onClick={refreshBatchList} disabled={batchLoading}>
<strong style={{ alignSelf: 'center', color: '#0f172a' }}>D-InSAR 批次</strong>
<button onClick={fetchDinsarBatches} disabled={batchLoading}>
{batchLoading ? (en ? 'Refreshing...' : '刷新中...') : (en ? 'Refresh Batches' : '刷新批次')}
</button>
</div>
@@ -97,7 +78,7 @@ export default function BatchPanel() {
onChange={(e) => {
const nextId = e.target.value;
setSelectedBatchId(nextId);
fetchBatchItems(batchTab, nextId);
fetchBatchItems('dinsar', nextId);
}}
style={{ flex: 1, padding: '6px 8px' }}
>
@@ -125,33 +106,26 @@ export default function BatchPanel() {
{batchItems.map(item => (
<li key={item.id} className="batch-item">
<div className="batch-item-main">
<strong>{batchTab === 'ps'
? (item.file_path || '').split(/[\\/]/).pop()
: (item.task_name || `${item.master_imaging_date || ''}_${item.slave_imaging_date || ''}`)
}</strong>
{batchTab === 'dinsar' && (
<div className="batch-item-meta">
M: {item.master_imaging_date || '-'} / S: {item.slave_imaging_date || '-'}
</div>
)}
{batchTab === 'dinsar' && (
<div className="batch-engine-results">
{['sarscape', 'landsar', 'pyint'].map((engineCode) => {
const engineMeta = getDinsarEngineMeta(engineCode);
const result = item.engine_results?.[engineCode] || {};
const status = result.status || 'missing';
return (
<span
key={engineCode}
className={`batch-engine-chip tone-${engineResultTone(status)}`}
title={result.skip_reason || result.run_key || ''}
>
{engineMeta.shortLabel}: {status}
</span>
);
})}
</div>
)}
<strong>{item.task_name || `${item.master_imaging_date || ''}_${item.slave_imaging_date || ''}`}</strong>
<div className="batch-item-meta">
M: {item.master_imaging_date || '-'} / S: {item.slave_imaging_date || '-'}
</div>
<div className="batch-engine-results">
{['sarscape', 'landsar', 'pyint'].map((engineCode) => {
const engineMeta = getDinsarEngineMeta(engineCode);
const result = item.engine_results?.[engineCode] || {};
const status = result.status || 'missing';
return (
<span
key={engineCode}
className={`batch-engine-chip tone-${engineResultTone(status)}`}
title={result.skip_reason || result.run_key || ''}
>
{engineMeta.shortLabel}: {status}
</span>
);
})}
</div>
</div>
<select
value={item.status || 'PENDING'}
@@ -0,0 +1,78 @@
import { useState } from 'react';
import AiQualityPanel from './AiQualityPanel';
import AiAnalysisPanel from '../AiAnalysisPanel';
const MODES = {
quality: 'quality',
diagnosis: 'diagnosis',
};
export default function DinsarAnalysisPanel({
aiStatus,
isLoading,
isReadOnlyUser,
aiPanel,
language,
onJobQueued,
}) {
const [activeMode, setActiveMode] = useState(MODES.quality);
const en = language === 'en';
const tabs = [
{
key: MODES.quality,
label: en ? 'AI Quality Assessment' : 'AI质量评估',
},
{
key: MODES.diagnosis,
label: en ? 'D-InSAR Diagnosis' : 'D-InSAR诊断',
},
];
return (
<div className="panel-content dinsar-analysis-panel">
<div className="dinsar-analysis-toolbar">
<div>
<h3>{en ? 'D-InSAR Analysis' : 'D-InSAR分析'}</h3>
<p>
{en
? 'Quality assessment and diagnosis are managed under the D-InSAR workflow.'
: '质量评估和智能诊断统一归口到 D-InSAR 分析。'}
</p>
</div>
<div className="dinsar-analysis-tabs" role="tablist" aria-label={en ? 'D-InSAR analysis mode' : 'D-InSAR分析模式'}>
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={activeMode === tab.key ? 'active-tab' : ''}
onClick={() => setActiveMode(tab.key)}
role="tab"
aria-selected={activeMode === tab.key}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="dinsar-analysis-body">
{activeMode === MODES.quality ? (
<AiQualityPanel
aiStatus={aiStatus}
isLoading={isLoading}
isReadOnlyUser={isReadOnlyUser}
onTrain={aiPanel.onTrain}
onPredictAll={aiPanel.onPredictAll}
language={language}
/>
) : (
<AiAnalysisPanel
readOnly={isReadOnlyUser}
onJobQueued={onJobQueued}
/>
)}
</div>
</div>
);
}
+37 -6
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getPairingHealth,
rebuildPairingCache,
reconcileDirtyPairingCache,
} from '../api/pairing';
import MiniCoverageMap from '../components/MiniCoverageMap';
const formatIso = (value, en = false) => {
if (!value) return en ? 'Never' : '未执行';
@@ -34,7 +35,6 @@ export default function PairPlanningPanel({
isReadOnlyUser,
hasEnoughRadarScenesForPlanning,
onOpenPairingModal,
onOpenPsModal,
hasRadarSearched,
onRefreshRadarSearch,
onSearchAll,
@@ -48,6 +48,26 @@ export default function PairPlanningPanel({
const [pairingRepairing, setPairingRepairing] = useState(false);
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null);
const previewPairs = useMemo(() => foundPairs.slice(0, 20), [foundPairs]);
const previewPolygons = useMemo(() => (
previewPairs.flatMap((pair, index) => {
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
return [
{
label: `${taskLabel} / master`,
points: pair.master?.coverage_polygon,
color: '#2563eb',
fillOpacity: 0.08,
},
{
label: `${taskLabel} / slave`,
points: pair.slave?.coverage_polygon,
color: '#16a34a',
fillOpacity: 0.08,
},
];
})
), [previewPairs]);
const refreshPairingStatus = useCallback(async () => {
if (isReadOnlyUser) {
@@ -112,14 +132,25 @@ export default function PairPlanningPanel({
</p>
<div className="header-buttons" style={{ marginTop: '10px' }}>
<button onClick={onOpenPairingModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'Pair' : '配对'}
</button>
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'Timeseries Prep' : '时序准备'}
{en ? 'Plan D-InSAR Pairs' : '生成 D-InSAR 配对'}
</button>
</div>
</div>
<div style={{ marginTop: '12px' }}>
<MiniCoverageMap
title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'}
subtitle={
foundPairs.length > previewPairs.length
? `${previewPairs.length}/${foundPairs.length}`
: `${foundPairs.length}`
}
polygons={previewPolygons}
height={260}
emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'}
/>
</div>
<div className="panel-card" style={{ marginTop: '12px' }}>
<div className="panel-card-title">{en ? 'Pairing Foundation' : '配对基础'}</div>
{isReadOnlyUser ? (
+1 -5
View File
@@ -5,7 +5,6 @@ export default function PairingPanel({
isReadOnlyUser,
hasEnoughRadarScenesForPlanning,
onOpenPairingModal,
onOpenPsModal,
hasRadarSearched,
onRefreshRadarSearch,
onSearchAll,
@@ -25,10 +24,7 @@ export default function PairingPanel({
</p>
<div className="header-buttons" style={{ marginTop: '10px' }}>
<button onClick={onOpenPairingModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'Pair' : '配对'}
</button>
<button onClick={onOpenPsModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
{en ? 'Timeseries Prep' : '时序准备'}
{en ? 'Plan D-InSAR Pairs' : '生成 D-InSAR 配对'}
</button>
</div>
</div>
+72 -32
View File
@@ -3,8 +3,9 @@ import { useShallow } from 'zustand/react/shallow';
import { usePairingStore, useAuthStore } from '../store';
import VirtualizedList from '../components/common/VirtualizedList';
import PairListRow from '../components/panels/PairListRow';
import MiniCoverageMap from '../components/MiniCoverageMap';
const PAIR_ROW_HEIGHT = 64;
const PAIR_ROW_HEIGHT = 176;
function PairsListPanel({
onVisualizePair,
@@ -44,6 +45,29 @@ function PairsListPanel({
() => foundPairs.filter((pair) => pair.isSelected).length,
[foundPairs]
);
const mapPreviewPairs = useMemo(() => {
const visible = foundPairs.filter((pair) => pair.isVis);
return visible.slice(0, 24);
}, [foundPairs]);
const previewPolygons = useMemo(() => (
mapPreviewPairs.flatMap((pair, index) => {
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
return [
{
label: `${taskLabel} / master`,
points: pair.master?.coverage_polygon,
color: '#2563eb',
fillOpacity: 0.08,
},
{
label: `${taskLabel} / slave`,
points: pair.slave?.coverage_polygon,
color: '#16a34a',
fillOpacity: 0.08,
},
];
})
), [mapPreviewPairs]);
return (
<>
@@ -67,38 +91,54 @@ function PairsListPanel({
)}
</div>
)}
{foundPairs.length === 0 ? (
<p className="empty-state">未找到配对</p>
) : (
<>
<div className="list-toolbar">
<input
type="checkbox"
checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
<label htmlFor="select-all-pairs">
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
</label>
</div>
<VirtualizedList
items={foundPairs}
itemHeight={PAIR_ROW_HEIGHT}
getKey={(pair, index) => pair.task_name || `${pair.master?.id || 'm'}-${pair.slave?.id || 's'}-${index}`}
renderItem={(pair, index, key) => (
<PairListRow
key={key || `${pair.task_name}-${index}`}
pair={pair}
index={index}
onToggleSelected={handlePairSelectionChange}
onVisualizePair={onVisualizePair}
onTogglePairVisibility={onTogglePairVisibility}
<div className={`pair-planning-layout ${foundPairs.length ? 'with-map' : ''}`}>
<div className="pair-planning-list-pane">
{foundPairs.length === 0 ? (
<p className="empty-state">未找到配对</p>
) : (
<>
<div className="list-toolbar">
<input
type="checkbox"
checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
<label htmlFor="select-all-pairs">
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
</label>
</div>
<VirtualizedList
items={foundPairs}
itemHeight={PAIR_ROW_HEIGHT}
viewportClassName="pair-planning-list-viewport"
getKey={(pair, index) => pair.task_name || `${pair.master?.id || 'm'}-${pair.slave?.id || 's'}-${index}`}
renderItem={(pair, index, key) => (
<PairListRow
key={key || `${pair.task_name}-${index}`}
pair={pair}
index={index}
onToggleSelected={handlePairSelectionChange}
onVisualizePair={onVisualizePair}
onTogglePairVisibility={onTogglePairVisibility}
/>
)}
/>
)}
/>
</>
)}
</>
)}
</div>
{foundPairs.length > 0 && (
<div className="pair-planning-map-pane">
<MiniCoverageMap
title="候选配对范围"
subtitle={`${mapPreviewPairs.length}/${foundPairs.length} 对显示`}
polygons={previewPolygons}
height={372}
emptyText="点击左侧“显示”后在这里查看配对范围。"
/>
</div>
)}
</div>
</div>
<footer className="panel-footer">
<button
+1 -1
View File
@@ -8,7 +8,7 @@ import UnifiedDatePicker from '../components/UnifiedDatePicker';
import { PAGE_SIZE_OPTIONS, SATELLITE_GROUPS } from '../config/appConstants';
import { getPageHintText } from '../utils/appUiHelpers';
const RADAR_ROW_HEIGHT = 48;
const RADAR_ROW_HEIGHT = 72;
export default function RadarDataPanel({
radarCurrentPage,
+4 -4
View File
@@ -15,10 +15,10 @@ export const usePairingStore = create((set) => ({
pairingRegionError: '',
pairingParams: {
time_baseline_min: 1,
time_baseline_max: 90,
time_baseline_max: 30,
overlap_threshold: 0.5,
spatial_baseline_max_meters: 3000,
limit_footprint_center_distance: false,
spatial_baseline_max_meters: 5000,
limit_footprint_center_distance: true,
coverage_diversity_penalty: 0.3,
require_same_imaging_mode: true,
require_same_polarization: true,
@@ -29,7 +29,7 @@ export const usePairingStore = create((set) => ({
master_date_to: '',
slave_date_from: '',
slave_date_to: '',
strategy: 'sbas',
strategy: 'dinsar_production',
num_connections: 1,
reference_image_id: null,
allowed_satellites: null,
+11 -11
View File
@@ -40,35 +40,35 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
case 'hazard':
return '灾害点';
case 'pairing':
return '配对规划';
return 'D-InSAR配对规划';
case 'pairs':
return `任务规划 (${pairCount})`;
return `D-InSAR候选对与批次 (${pairCount})`;
case 'ps_results':
return `时序候选栈 (${psResultCount})`;
return `SBAS序列规划 (${psResultCount})`;
case 'batches':
return '任务批次';
return 'D-InSAR候选对与批次';
case 'copier':
return '数据分发';
return 'D-InSAR生产准备';
case 'production_management':
return '生产管理';
case 'idl':
return 'D-InSAR生产(旧)';
case 'dinsar_production':
return 'D-InSAR运行';
return 'D-InSAR生产运行';
case 'dinsar_products':
return 'D-InSAR产物';
return 'D-InSAR结果管理';
case 'ps_production':
return '时序InSAR运行';
return 'SBAS-InSAR生产工作流';
case 'ps_products':
return '时序InSAR产物';
return 'SBAS-InSAR结果管理';
case 'dinsar_results':
return `D-InSAR结果 (${dinsarTotal})`;
case 'dinsar_analysis':
return 'D-InSAR分析';
case 'psinsar_results':
return '时序InSAR结果';
return 'SBAS-InSAR结果';
case 'psinsar_analysis':
return '时序InSAR分析';
return 'SBAS-InSAR分析';
case 'ai_quality':
return 'AI质量评估';
case 'ai_diagnosis':