Refactor local InSAR asset and production workflows
This commit is contained in:
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
+456
-816
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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-InSAR、SBAS 或 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' && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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_Pool;D-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'],
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user