chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
@@ -0,0 +1,165 @@
const getTaskTypeLabel = (taskType) => {
if (taskType?.startsWith('WATER_GEOCODE_')) return '水体地理编码';
if (taskType?.startsWith('WATER_FLOOD_')) return '洪涝检测';
switch (taskType) {
case 'SCAN_DATA':
return '同步源数据';
case 'SCAN_DINSAR':
return '扫描结果与自愈';
case 'AI_TRAIN':
return '训练AI模型';
case 'AI_PREDICT':
return '全量质量评估';
case 'AI_ANALYZE':
return 'AI 智能诊断';
case 'AI_WARMUP':
return 'AI 模型预热';
case 'COPY_DATA':
return '数据分发拷贝';
case 'SCAN_HAZARD':
return '灾害点同步';
case 'UNPACK_ARCHIVES':
return 'Archive unpack';
case 'IDL_IMPORT':
return 'ENVI 数据导入';
case 'IDL_DINSAR':
return 'ENVI D-InSAR 生产';
default:
return taskType;
}
};
export default function ActiveTasksOverlay({
isVisible,
activeTasks,
t,
isAdmin,
showForceUnlock,
forceUnlockPwd,
onShowForceUnlock,
onForceUnlockPwdChange,
onForceUnlockConfirm,
onCancelForceUnlock,
}) {
if (!isVisible) {
return null;
}
return (
<div className="global-task-overlay">
<div className="overlay-content">
<div className="loading-spinner-large"></div>
<h3>系统任务执行中</h3>
<div className="active-tasks-container">
{(() => {
const waterTasks = activeTasks.filter(t =>
t.task_type?.startsWith('WATER_GEOCODE_') || t.task_type?.startsWith('WATER_FLOOD_')
);
const otherTasks = activeTasks.filter(t =>
!t.task_type?.startsWith('WATER_GEOCODE_') && !t.task_type?.startsWith('WATER_FLOOD_')
);
const waterDone = waterTasks.filter(t => t.progress >= 100).length;
return (
<>
{otherTasks.map((task) => (
<div key={task.task_id} className="task-progress-item">
<div className="task-info-row">
<span className="task-label">{getTaskTypeLabel(task.task_type)}</span>
<span className="task-percent">{task.progress}%</span>
</div>
<div className="task-progress-bar">
<div className="task-progress-fill" style={{ width: `${task.progress}%` }}></div>
</div>
<p className="task-status-msg">{t(task.message || '')}</p>
</div>
))}
{waterTasks.length > 0 && (
<div className="task-progress-item">
<div className="task-info-row">
<span className="task-label">水体处理剩余 {waterTasks.length - waterDone} </span>
</div>
</div>
)}
</>
);
})()}
</div>
<p className="overlay-footer-hint">为了保证数据一致性耗时任务执行期间 UI 已锁定任务完成后将自动刷新页面数据</p>
{isAdmin && (
<div style={{ marginTop: '16px', textAlign: 'center' }}>
{!showForceUnlock ? (
<button
onClick={onShowForceUnlock}
style={{
padding: '6px 16px',
borderRadius: '6px',
border: '1px solid rgba(255,255,255,0.4)',
background: 'rgba(255,255,255,0.1)',
color: '#fff',
fontSize: '12px',
cursor: 'pointer',
}}
>
管理员强制解锁
</button>
) : (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<input
type="password"
placeholder="输入管理员密码"
value={forceUnlockPwd}
onChange={(e) => onForceUnlockPwdChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onForceUnlockConfirm();
}
}}
style={{
padding: '5px 10px',
fontSize: '12px',
borderRadius: '4px',
border: '1px solid rgba(255,255,255,0.4)',
background: 'rgba(255,255,255,0.15)',
color: '#fff',
width: '160px',
outline: 'none',
}}
/>
<button
disabled={!forceUnlockPwd}
onClick={onForceUnlockConfirm}
style={{
padding: '5px 14px',
borderRadius: '4px',
border: '1px solid #dc2626',
background: '#dc2626',
color: '#fff',
fontSize: '12px',
cursor: forceUnlockPwd ? 'pointer' : 'not-allowed',
opacity: forceUnlockPwd ? 1 : 0.5,
}}
>
确认解锁
</button>
<button
onClick={onCancelForceUnlock}
style={{
padding: '5px 10px',
borderRadius: '4px',
border: '1px solid rgba(255,255,255,0.4)',
background: 'transparent',
color: '#fff',
fontSize: '12px',
cursor: 'pointer',
}}
>
取消
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,136 @@
import { useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { useI18n } from '../i18n/I18nContext';
/**
* AI 诊断报告查看 Modal
* @param {Object} props
* @param {Object|null} props.diagnosis - 诊断记录对象
* @param {Function} props.onClose - 关闭回调
*/
export default function AiDiagnosisModal({ diagnosis, onClose }) {
const { en } = useI18n();
useEffect(() => {
const handleEsc = (e) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
if (!diagnosis) return null;
const riskLevelMap = {
low: { label: en ? 'Low' : '低', color: '#48bb78' },
medium: { label: en ? 'Medium' : '中', color: '#ed8936' },
high: { label: en ? 'High' : '高', color: '#f56565' },
critical: { label: en ? 'Critical' : '极高', color: '#c53030' },
};
const riskInfo = riskLevelMap[diagnosis.risk_level] || { label: en ? 'Unknown' : '未知', color: '#a0aec0' };
return (
<div className="modal-overlay visible" onClick={onClose}>
<div className="ai-diagnosis-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="ai-diagnosis-modal-header">
<div className="ai-diagnosis-modal-title">
<span className="ai-diagnosis-icon">🔍</span>
<span>{en ? 'AI Diagnosis Report' : 'AI 诊断报告'}</span>
</div>
<button className="modal-close-btn" onClick={onClose}>×</button>
</div>
{/* Meta Info */}
<div className="ai-diagnosis-meta">
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Result' : '结果名称'}:</span>
<span className="ai-diagnosis-meta-value">{diagnosis.result_name || 'N/A'}</span>
</div>
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Date Range' : '监测周期'}:</span>
<span className="ai-diagnosis-meta-value">{diagnosis.date_range || 'N/A'}</span>
</div>
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Model' : '模型'}:</span>
<span className="ai-diagnosis-meta-value">{diagnosis.model_name}</span>
</div>
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Template' : '模板'}:</span>
<span className="ai-diagnosis-meta-value">{diagnosis.prompt_template}</span>
</div>
{diagnosis.risk_level && (
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Risk Level' : '风险等级'}:</span>
<span
className="ai-diagnosis-risk-badge"
style={{ backgroundColor: riskInfo.color }}
>
{riskInfo.label}
</span>
</div>
)}
{diagnosis.confidence_score !== null && (
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Confidence' : '置信度'}:</span>
<span className="ai-diagnosis-meta-value">
{(diagnosis.confidence_score * 100).toFixed(0)}%
</span>
</div>
)}
{diagnosis.quality_score !== null && (
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Quality Score' : '质量评分'}:</span>
<span className="ai-diagnosis-meta-value">
{diagnosis.quality_score.toFixed(1)} / 10
</span>
</div>
)}
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Hazards Found' : '隐患点数'}:</span>
<span className="ai-diagnosis-meta-value">{diagnosis.hazards_found}</span>
</div>
{diagnosis.duration_seconds !== null && (
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Duration' : '耗时'}:</span>
<span className="ai-diagnosis-meta-value">
{diagnosis.duration_seconds.toFixed(1)}s
</span>
</div>
)}
<div className="ai-diagnosis-meta-row">
<span className="ai-diagnosis-meta-label">{en ? 'Created At' : '创建时间'}:</span>
<span className="ai-diagnosis-meta-value">
{new Date(diagnosis.created_at).toLocaleString()}
</span>
</div>
</div>
{/* Markdown Content */}
<div className="ai-diagnosis-content">
{diagnosis.error_message ? (
<div className="ai-diagnosis-error">
<strong>{en ? 'Error' : '错误'}:</strong> {diagnosis.error_message}
</div>
) : diagnosis.diagnosis_markdown ? (
<ReactMarkdown className="ai-diagnosis-markdown">
{diagnosis.diagnosis_markdown}
</ReactMarkdown>
) : (
<div className="ai-diagnosis-empty">
{en ? 'Diagnosis in progress...' : '诊断进行中...'}
</div>
)}
</div>
{/* Footer */}
<div className="ai-diagnosis-modal-footer">
<button className="btn-secondary" onClick={onClose}>
{en ? 'Close' : '关闭'}
</button>
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import ReactMarkdown from 'react-markdown';
export default function AiReportModal({ report, onClose }) {
if (!report) {
return null;
}
return (
<div className="modal-overlay visible ai-report-modal">
<div className="modal-content report-content">
<div className="report-header">
<h3>{report.title}</h3>
<button className="close-btn" onClick={onClose}>关闭报告</button>
</div>
<div className="report-body markdown-body">
<ReactMarkdown>{report.content}</ReactMarkdown>
</div>
<div className="report-footer">
<button onClick={onClose}>已阅并关闭</button>
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
const createRows = (dataInfo, language, formatYmd) => [
{ label: language === 'en' ? 'Satellite:' : '卫星:', value: dataInfo.satellite || '-' },
{ label: language === 'en' ? 'Satellite Mode:' : '卫星模式:', value: dataInfo.satellite_mode || '-' },
{ label: language === 'en' ? 'Receiving Station:' : '接收站:', value: dataInfo.receiving_station || '-' },
{ label: language === 'en' ? 'Imaging Date:' : '成像日期:', value: formatYmd(dataInfo.imaging_date) },
{ label: language === 'en' ? 'Imaging Mode:' : '成像模式:', value: dataInfo.imaging_mode || '-' },
{ label: language === 'en' ? 'Orbit Circle:' : '轨道圈号:', value: dataInfo.orbit_circle || '-' },
{ label: language === 'en' ? 'Scene Center Lon:' : '场景中心经度:', value: dataInfo.scene_center_lon ?? '-' },
{ label: language === 'en' ? 'Scene Center Lat:' : '场景中心纬度:', value: dataInfo.scene_center_lat ?? '-' },
{ label: language === 'en' ? 'Acquisition Time:' : '采集时间:', value: dataInfo.acquisition_time_utc || '-' },
{ label: language === 'en' ? 'Product Type:' : '产品类型:', value: dataInfo.product_type || '-' },
{ label: language === 'en' ? 'Polarization:' : '极化方式:', value: dataInfo.polarization || '-' },
{ label: language === 'en' ? 'Product Level:' : '产品级别:', value: dataInfo.product_level || '-' },
{ label: language === 'en' ? 'Product Unique ID:' : '产品唯一ID', value: dataInfo.product_unique_id || '-' },
{ label: language === 'en' ? 'Orbit Direction:' : '轨道方向:', value: dataInfo.orbit_direction || '-' },
{
label: language === 'en' ? 'Has Orbit:' : '有精轨:',
value: dataInfo.has_orbit_data ? (language === 'en' ? 'Yes' : '是') : (language === 'en' ? 'No' : '否'),
},
{
label: language === 'en' ? 'Orbit File:' : '轨道文件:',
value: dataInfo.orbit_file_path || '-',
valueStyle: { wordBreak: 'break-all' },
},
{
label: language === 'en' ? 'ENVI Processed:' : 'ENVI已处理:',
value: dataInfo.is_envi_processed ? (language === 'en' ? 'Yes' : '是') : (language === 'en' ? 'No' : '否'),
},
];
export default function DataInfoModal({
visible,
dataInfo,
language,
formatYmd,
onClose,
}) {
if (!visible || !dataInfo) {
return null;
}
const rows = createRows(dataInfo, language, formatYmd);
return (
<div className="modal-overlay visible">
<div className="modal-content">
<h3>{language === 'en' ? 'Image Information' : '影像信息'}</h3>
{rows.map((row) => (
<div key={row.label} className="form-group">
<label>{row.label}</label>
<div style={row.valueStyle}>{row.value}</div>
</div>
))}
<div className="modal-actions">
<button type="button" onClick={onClose}>
{language === 'en' ? 'Close' : '关闭'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,567 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import apiClient from '../api/client';
import {
getDinsarCatalogStatus,
getDinsarProductDetail,
listDinsarProducts,
queueDinsarCatalogRebuild,
queueDinsarProductPublish,
} from '../api/dinsarProducts';
const panelCardStyle = {
background: '#fff',
padding: '12px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
};
const statusColorMap = {
READY: '#16a34a',
PARTIAL: '#b45309',
QUARANTINED: '#dc2626',
WARN: '#b45309',
ERROR: '#dc2626',
REBUILDING: '#2563eb',
};
function formatDateTime(value) {
if (!value) return '-';
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}
function parseDirectoryList(value) {
return [...new Set(
String(value || '')
.split(/[\r\n,;]+/)
.map(item => item.trim())
.filter(Boolean)
)];
}
function StatusPill({ label, color }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 10px',
borderRadius: 999,
background: `${color}14`,
color,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: color,
display: 'inline-block',
}}
/>
{label}
</span>
);
}
export default function DinsarCatalogPanel({
readOnly = false,
compact = false,
initialSourceDir = '',
onTaskQueued,
}) {
const [catalogStatus, setCatalogStatus] = useState(null);
const [products, setProducts] = useState([]);
const [selectedProductId, setSelectedProductId] = useState(null);
const [selectedProduct, setSelectedProduct] = useState(null);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [actionMessage, setActionMessage] = useState('');
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
const [publishRoot, setPublishRoot] = useState('');
const listLimit = compact ? 6 : 12;
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
useEffect(() => {
if (!initialSourceDir) return;
setSourceDirectoriesText(current => (current.trim() ? current : initialSourceDir));
}, [initialSourceDir]);
const sourceDirectories = useMemo(
() => parseDirectoryList(sourceDirectoriesText),
[sourceDirectoriesText]
);
const loadCatalog = useCallback(async () => {
setLoading(true);
try {
const [statusData, productData] = await Promise.all([
getDinsarCatalogStatus(),
listDinsarProducts({ limit: listLimit, offset: 0 }),
]);
setCatalogStatus(statusData);
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
setProducts(nextItems);
setSelectedProductId(current => {
if (current && nextItems.some(item => item.id === current)) {
return current;
}
return nextItems[0]?.id ?? null;
});
} catch (error) {
setActionMessage(`结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
setCatalogStatus(null);
setProducts([]);
setSelectedProductId(null);
} finally {
setLoading(false);
}
}, [listLimit]);
const loadProductDetail = useCallback(async (productId) => {
if (!productId) {
setSelectedProduct(null);
return;
}
setDetailLoading(true);
try {
const detail = await getDinsarProductDetail(productId);
setSelectedProduct(detail);
} catch (error) {
setSelectedProduct({
error: error?.response?.data?.detail || error.message || '结果详情加载失败',
});
} finally {
setDetailLoading(false);
}
}, []);
useEffect(() => {
loadCatalog();
const timer = setInterval(() => {
if (!actionLoading) {
loadCatalog();
}
}, 10000);
return () => clearInterval(timer);
}, [actionLoading, loadCatalog]);
useEffect(() => {
loadProductDetail(selectedProductId);
}, [loadProductDetail, selectedProductId]);
const handleQueuePublish = async () => {
if (readOnly || sourceDirectories.length === 0) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queueDinsarProductPublish({
source_directories: sourceDirectories,
publish_root: publishRoot.trim() || null,
rebuild_catalog: true,
});
setActionMessage(`结果包发布任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const handleQueueRebuild = async () => {
if (readOnly) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queueDinsarCatalogRebuild({
publish_root: publishRoot.trim() || null,
full_rebuild: true,
});
setActionMessage(`结果目录重建任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const catalogColor = statusColorMap[catalogStatus?.status] || '#64748b';
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
const selectedPairingTrace = selectedProduct?.pairing_trace || null;
const selectedPairingNetwork = selectedProduct?.pairing_network || null;
const selectedPairingRun = selectedPairingNetwork?.run || null;
const selectedPairingEdge = selectedPairingNetwork?.edge || null;
const selectedPairingMetric = selectedPairingNetwork?.metric || null;
return (
<div style={panelCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<div>
<strong style={{ fontSize: 14 }}>{compact ? '结果目录状态' : '标准结果包目录'}</strong>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
{compact ? '展示结果包目录与数据库索引状态' : '结果文件以结果包目录为真源,数据库仅保存索引与检索信息'}
</div>
</div>
<button
onClick={loadCatalog}
disabled={loading || actionLoading}
style={{
fontSize: 12,
padding: '4px 10px',
borderRadius: 4,
border: '1px solid #e2e8f0',
background: '#f8fafc',
cursor: 'pointer',
}}
>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: compact ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 8, marginBottom: 10 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>目录状态</div>
<div style={{ marginTop: 4 }}>
<StatusPill label={catalogStatus?.status || '未知'} color={catalogColor} />
</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>需要重建</div>
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700, color: catalogStatus?.needs_rebuild ? '#dc2626' : '#16a34a' }}>
{catalogStatus?.needs_rebuild ? '是' : '否'}
</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>Manifest / 数据库</div>
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700 }}>
{(catalogStatus?.manifest_count ?? 0)} / {(catalogStatus?.db_count ?? 0)}
</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>问题数量</div>
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700, color: (catalogStatus?.issue_count ?? 0) > 0 ? '#b45309' : '#16a34a' }}>
{catalogStatus?.issue_count ?? 0}
</div>
</div>
</div>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8, wordBreak: 'break-all' }}>
<div><strong>结果包根目录</strong>{catalogStatus?.storage_root || '-'}</div>
<div><strong>最近消息</strong>{catalogStatus?.last_message || '-'}</div>
<div><strong>最近重建</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
</div>
{compact && actionMessage && (
<div style={{ marginBottom: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
{actionMessage}
</div>
)}
{!compact && (
<div style={{ marginBottom: 12, padding: '10px 12px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#0f172a', marginBottom: 8 }}>手动发布与重建</div>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>
旧的提取位移结果入口已经会自动尝试发布标准结果包这里保留显式入口方便你对任意目录重新发布和重建索引
</div>
<textarea
value={sourceDirectoriesText}
onChange={event => setSourceDirectoriesText(event.target.value)}
placeholder="输入一个或多个结果根目录,支持换行、逗号或分号分隔"
disabled={readOnly || actionLoading}
style={{
width: '100%',
minHeight: 72,
resize: 'vertical',
padding: '8px 10px',
boxSizing: 'border-box',
borderRadius: 6,
border: '1px solid #cbd5e1',
fontSize: 12,
marginBottom: 8,
}}
/>
<input
value={publishRoot}
onChange={event => setPublishRoot(event.target.value)}
placeholder="可选:自定义结果包根目录,留空使用系统默认目录"
disabled={readOnly || actionLoading}
style={{
width: '100%',
padding: '6px 10px',
boxSizing: 'border-box',
borderRadius: 6,
border: '1px solid #cbd5e1',
fontSize: 12,
marginBottom: 8,
}}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={handleQueuePublish}
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
style={{
padding: '6px 14px',
borderRadius: 6,
border: 'none',
background: '#2563eb',
color: '#fff',
cursor: 'pointer',
fontSize: 12,
}}
>
{actionLoading ? '处理中...' : '发布结果包并重建'}
</button>
<button
onClick={handleQueueRebuild}
disabled={readOnly || actionLoading}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#fff',
color: '#0f172a',
cursor: 'pointer',
fontSize: 12,
}}
>
仅重建目录索引
</button>
</div>
{actionMessage && (
<div style={{ marginTop: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
{actionMessage}
</div>
)}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'minmax(260px, 360px) 1fr', gap: 12 }}>
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
最新结果包 ({products.length})
</div>
{products.length === 0 ? (
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>
{loading ? '正在加载结果包...' : '当前没有已注册的结果包。'}
</div>
) : (
<div style={{ maxHeight: compact ? 280 : 360, overflowY: 'auto' }}>
{products.map(item => {
const color = statusColorMap[item.status] || '#64748b';
return (
<button
key={item.id}
onClick={() => setSelectedProductId(item.id)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
border: 'none',
borderTop: '1px solid #f1f5f9',
background: selectedProductId === item.id ? '#eff6ff' : '#fff',
padding: '10px 12px',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<strong style={{ fontSize: 12, color: '#0f172a', wordBreak: 'break-all' }}>{item.display_name || item.product_id}</strong>
<span style={{ fontSize: 11, color }}>{item.status}</span>
</div>
<div style={{ fontSize: 11, color: '#64748b' }}>
{item.engine_code || '-'} · {formatDateTime(item.published_at)}
</div>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2, wordBreak: 'break-all' }}>
{(item.task_alias || item.task_name || '-')}{item.run_key ? ` / ${item.run_key}` : ''}
</div>
{(item.selection_strategy || item.network_run_id || item.network_edge_id) && (
<div style={{ fontSize: 11, color: '#475569', marginTop: 2, wordBreak: 'break-all' }}>
{(item.selection_strategy || 'trace')}
{item.network_edge_id ? ` / edge ${item.network_edge_id}` : ''}
{item.network_run_id ? ` / ${item.network_run_id}` : ''}
</div>
)}
</button>
);
})}
</div>
)}
</div>
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
结果包详情
</div>
{!selectedProductId ? (
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>请选择一个结果包查看详情</div>
) : detailLoading ? (
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>正在加载详情...</div>
) : selectedProduct?.error ? (
<div style={{ padding: '12px', fontSize: 12, color: '#dc2626' }}>{selectedProduct.error}</div>
) : (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : '180px 1fr', gap: 12 }}>
<div>
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden', background: '#f8fafc' }}>
<img
src={`${previewBaseUrl}/dinsar-products/${selectedProduct.id}/preview`}
alt={selectedProduct.display_name}
style={{ display: 'block', width: '100%', minHeight: 120, objectFit: 'cover', background: '#e2e8f0' }}
/>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: '#334155' }}>
<div><strong>名称</strong>{selectedProduct.display_name || '-'}</div>
<div><strong>产品编号</strong>{selectedProduct.product_id || '-'}</div>
<div><strong>任务别名</strong>{selectedProduct.task_alias || selectedProduct.task_name || '-'}</div>
<div><strong>配对标识</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.pair_key || '-'}</span></div>
<div><strong>场景对 UID</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.pair_uid || '-'}</span></div>
<div><strong>运行标识</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.run_key || '-'}</span></div>
<div><strong>生产配置</strong>{selectedProduct.profile_code || '-'}</div>
<div><strong>引擎</strong>{selectedProduct.engine_code || '-'}</div>
<div><strong>状态</strong>{selectedProduct.status || '-'} / {selectedProduct.health_status || '-'}</div>
<div><strong>主文件</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.primary_asset_path || '-'}</span></div>
<div><strong>来源文件</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.source_primary_path || '-'}</span></div>
<div><strong>结果包目录</strong><span style={{ wordBreak: 'break-all' }}>{selectedProduct.publish_dir || '-'}</span></div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', fontSize: 12 }}>
<div><strong>主影像日期</strong>{selectedProduct.profile?.master_imaging_date || '-'}</div>
<div><strong>辅影像日期</strong>{selectedProduct.profile?.slave_imaging_date || '-'}</div>
<div><strong>时间基线</strong>{selectedProduct.profile?.time_baseline_days ?? '-'}</div>
<div><strong>空间基线</strong>{selectedProduct.profile?.spatial_baseline_meters ?? '-'}</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc', fontSize: 12 }}>
<div><strong>BBox</strong></div>
<div>{selectedProduct.min_lon ?? '-'}, {selectedProduct.min_lat ?? '-'}</div>
<div>{selectedProduct.max_lon ?? '-'}, {selectedProduct.max_lat ?? '-'}</div>
<div style={{ marginTop: 4 }}><strong>注册时间</strong>{formatDateTime(selectedProduct.registered_at)}</div>
</div>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>配对追踪</div>
{!selectedPairingTrace?.network_run_id ? (
<div style={{ color: '#94a3b8' }}>当前结果未携带配对网络追踪信息</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div><strong>network_run_id</strong><span style={{ wordBreak: 'break-all' }}>{selectedPairingTrace.network_run_id || '-'}</span></div>
<div><strong>network_edge_id</strong>{selectedPairingTrace.network_edge_id ?? '-'}</div>
<div><strong>pair_uid</strong><span style={{ wordBreak: 'break-all' }}>{selectedPairingTrace.pair_uid || '-'}</span></div>
<div><strong>策略</strong>{selectedPairingTrace.selection_strategy || '-'}</div>
<div><strong>策略版本</strong>{selectedPairingTrace.policy_version || '-'}</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div><strong>网络记录</strong>{selectedPairingNetwork?.run_found ? '已找到' : '未找到'}</div>
<div><strong>边记录</strong>{selectedPairingNetwork?.edge_found ? '已找到' : '未找到'}</div>
<div><strong>运行状态</strong>{selectedPairingRun?.status || '-'}</div>
<div><strong>候选边数</strong>{selectedPairingRun?.candidate_count ?? '-'}</div>
<div><strong>入选边数</strong>{selectedPairingRun?.selected_edge_count ?? '-'}</div>
<div><strong>告警数</strong>{selectedPairingRun?.warning_count ?? '-'}</div>
</div>
</div>
{(selectedPairingEdge || selectedPairingMetric) && (
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>网络边</div>
<div><strong>edge_rank</strong>{selectedPairingEdge?.edge_rank ?? '-'}</div>
<div><strong>selection_reason</strong>{selectedPairingEdge?.selection_reason || '-'}</div>
<div><strong>selection_score</strong>{selectedPairingEdge?.selection_score ?? '-'}</div>
<div><strong>reference_edge</strong>{selectedPairingEdge?.is_reference_edge ? '是' : '否'}</div>
<div><strong>metric_cache_ref_id</strong>{selectedPairingEdge?.metric_cache_ref_id ?? '-'}</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>度量快照</div>
<div><strong>主从日期</strong>{selectedPairingMetric?.master_imaging_date || '-'} / {selectedPairingMetric?.slave_imaging_date || '-'}</div>
<div><strong>主从卫星</strong>{selectedPairingMetric?.master_satellite || '-'} / {selectedPairingMetric?.slave_satellite || '-'}</div>
<div><strong>主从模式</strong>{selectedPairingMetric?.master_imaging_mode || '-'} / {selectedPairingMetric?.slave_imaging_mode || '-'}</div>
<div><strong>主从极化</strong>{selectedPairingMetric?.master_polarization || '-'} / {selectedPairingMetric?.slave_polarization || '-'}</div>
<div><strong>时间基线</strong>{selectedPairingMetric?.time_baseline_days ?? '-'}</div>
<div><strong>空间基线</strong>{selectedPairingMetric?.spatial_baseline_meters ?? '-'}</div>
<div><strong>重叠率</strong>{selectedPairingMetric?.scene_overlap_ratio ?? '-'}</div>
</div>
</div>
)}
</div>
)}
</div>
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>资产列表 ({selectedAssets.length})</div>
{selectedAssets.length === 0 ? (
<div style={{ color: '#94a3b8' }}>暂无资产记录</div>
) : (
selectedAssets.map(asset => (
<div
key={asset.id}
style={{
padding: '6px 8px',
borderRadius: 6,
background: '#f8fafc',
marginBottom: 6,
color: '#334155',
}}
>
<div><strong>{asset.asset_role}</strong> · {asset.asset_name}</div>
<div style={{ color: asset.exists_flag ? '#166534' : '#dc2626' }}>
{asset.exists_flag ? '文件存在' : '文件缺失'}
</div>
<div style={{ wordBreak: 'break-all', color: '#64748b' }}>{asset.absolute_path}</div>
</div>
))
)}
</div>
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>问题列表 ({selectedIssues.length})</div>
{selectedIssues.length === 0 ? (
<div style={{ color: '#16a34a' }}>当前没有登记问题</div>
) : (
selectedIssues.map(issue => (
<div
key={issue.id}
style={{
padding: '6px 8px',
borderRadius: 6,
background: issue.severity === 'ERROR' ? '#fef2f2' : '#fff7ed',
color: issue.severity === 'ERROR' ? '#991b1b' : '#9a3412',
marginBottom: 6,
}}
>
<div><strong>{issue.issue_code}</strong> · {issue.severity}</div>
<div>{issue.message}</div>
{issue.repair_action && (
<div style={{ color: '#64748b', marginTop: 2 }}>
建议修复动作{issue.repair_action}
</div>
)}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
export default function LicenseOverlay({
licenseLoading,
licenseStatus,
isAdmin,
licenseFileRef,
onUploadFile,
onRefreshStatus,
licenseFileName,
licenseUploadStatus,
}) {
if (!licenseLoading && licenseStatus?.ok) {
return null;
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(15, 23, 42, 0.82)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<div style={{
width: 'min(560px, 90%)',
background: '#ffffff',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
}}>
<h3 style={{ marginTop: 0 }}>
{licenseLoading ? '正在验证授权...' : '系统未授权'}
</h3>
{!licenseLoading && (
<>
<p style={{ color: '#475569', marginBottom: '12px' }}>
失败原因{licenseStatus?.reason || '授权无效或已过期,请联系管理员。'}
</p>
{isAdmin ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '12px' }}>
<input
type="file"
ref={licenseFileRef}
accept=".lic"
onChange={(e) => onUploadFile(e.target.files?.[0])}
style={{ display: 'none' }}
/>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="primary-btn"
onClick={() => licenseFileRef.current && licenseFileRef.current.click()}
style={{ padding: '8px 14px' }}
>
选择授权文件
</button>
<button
className="secondary-btn"
onClick={onRefreshStatus}
style={{ padding: '8px 14px' }}
>
刷新授权状态
</button>
<span style={{ fontSize: '0.85em', color: '#64748b' }}>
{licenseFileName ? `已选择: ${licenseFileName}` : '未选择文件'}
</span>
</div>
{licenseUploadStatus?.message && (
<div style={{
fontSize: '0.85em',
color:
licenseUploadStatus.type === 'error'
? '#dc2626'
: licenseUploadStatus.type === 'success'
? '#16a34a'
: '#475569',
}}>
{licenseUploadStatus.message}
</div>
)}
</div>
) : (
<div style={{ marginBottom: '12px', color: '#b45309', background: '#fffbeb', border: '1px solid #fcd34d', padding: '8px 10px', borderRadius: '6px' }}>
当前账号无上传授权权限请联系管理员处理授权文件
</div>
)}
<div style={{ marginTop: '10px', background: '#f8fafc', padding: '10px 12px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#334155' }}>授权使用说明</div>
<ol style={{ margin: 0, paddingLeft: '18px', color: '#475569', fontSize: '0.85em' }}>
<li>确认已在服务器 .env 中配置 LICENSE_SECRET LICENSE_PUBLIC_KEY</li>
<li>上传有效的 .lic 授权文件与当前机器指纹匹配</li>
<li>点击刷新授权状态确认授权已生效</li>
</ol>
</div>
</>
)}
{licenseLoading && (
<p style={{ color: '#64748b' }}>请稍候正在与授权文件进行校验</p>
)}
</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { useEffect, useRef } from 'react';
import { useI18n } from '../i18n/I18nContext';
export default function MapExportModal({
showExportModal, exportTitle, setExportTitle,
exportFormat, setExportFormat,
exportResolution, RESOLUTIONS, handleResolutionChange,
showLegend, setShowLegend,
showScaleBar, setShowScaleBar,
showNorthArrow, setShowNorthArrow,
legendItems, previewUrl,
isCapturing, isExporting,
exportOrg, setExportOrg,
logoDataUrl, handleLogoUpload, removeLogo,
closeExportModal, refreshPreview, executeExport,
updateLegendItem, removeLegendItem, addLegendItem,
}) {
const { language } = useI18n();
const en = language === 'en';
const logoInputRef = useRef(null);
useEffect(() => {
if (!showExportModal) return;
const timer = setTimeout(() => {
refreshPreview({
title: exportTitle,
format: exportFormat,
showLegend,
showScaleBar,
showNorthArrow,
legendItems,
orgName: exportOrg,
});
}, 300);
return () => clearTimeout(timer);
}, [exportTitle, exportFormat, showLegend, showScaleBar, showNorthArrow,
legendItems, exportOrg, logoDataUrl, showExportModal, refreshPreview]);
if (!showExportModal) return null;
return (
<div className="modal-overlay visible map-export-modal">
<div className="modal-content export-content">
<div className="export-header">
<h3>{en ? 'Export Map' : '导出地图'}</h3>
<button type="button" className="export-close-btn" onClick={closeExportModal}>&times;</button>
</div>
<div className="export-body">
<div className="export-config">
{/* Title */}
<div className="export-section">
<label className="export-label">{en ? 'Map Title' : '图名'}</label>
<input
type="text"
className="export-input"
value={exportTitle}
onChange={(e) => setExportTitle(e.target.value)}
placeholder={en ? 'Optional title...' : '可选标题...'}
/>
</div>
{/* Org */}
<div className="export-section">
<label className="export-label">{en ? 'Organization' : '制图单位'}</label>
<input
type="text"
className="export-input"
value={exportOrg}
onChange={(e) => setExportOrg(e.target.value)}
placeholder={en ? 'Organization name...' : '单位名称...'}
/>
</div>
{/* Logo */}
<div className="export-section">
<label className="export-label">Logo</label>
{logoDataUrl ? (
<div className="export-logo-row">
<img src={logoDataUrl} alt="logo" className="export-logo-thumb" />
<button type="button" className="export-legend-remove" onClick={removeLogo}>&times;</button>
</div>
) : (
<button
type="button"
className="export-legend-add"
onClick={() => logoInputRef.current?.click()}
>
+ {en ? 'Upload Logo' : '上传 Logo'}
</button>
)}
<input
ref={logoInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleLogoUpload}
/>
</div>
{/* Resolution */}
<div className="export-section">
<label className="export-label">{en ? 'Resolution' : '输出分辨率'}</label>
<div className="export-format-btns">
{Object.entries(RESOLUTIONS).map(([key]) => (
<button
key={key}
type="button"
className={exportResolution === key ? 'active' : ''}
onClick={() => handleResolutionChange(key)}
disabled={isCapturing}
style={{ fontSize: '11px', padding: '5px 4px' }}
>
{key.replace('x', '×')}
</button>
))}
</div>
</div>
{/* Format */}
<div className="export-section">
<label className="export-label">{en ? 'Format' : '格式'}</label>
<div className="export-format-btns">
<button
type="button"
className={exportFormat === 'png' ? 'active' : ''}
onClick={() => setExportFormat('png')}
>PNG</button>
<button
type="button"
className={exportFormat === 'jpeg' ? 'active' : ''}
onClick={() => setExportFormat('jpeg')}
>JPEG</button>
</div>
</div>
{/* Toggles */}
<div className="export-section">
<label className="export-label">{en ? 'Elements' : '地图要素'}</label>
<div className="export-toggles">
<label className="export-toggle">
<input type="checkbox" checked={showLegend} onChange={(e) => setShowLegend(e.target.checked)} />
<span>{en ? 'Legend' : '图例'}</span>
</label>
<label className="export-toggle">
<input type="checkbox" checked={showScaleBar} onChange={(e) => setShowScaleBar(e.target.checked)} />
<span>{en ? 'Scale Bar' : '比例尺'}</span>
</label>
<label className="export-toggle">
<input type="checkbox" checked={showNorthArrow} onChange={(e) => setShowNorthArrow(e.target.checked)} />
<span>{en ? 'North Arrow' : '指北针'}</span>
</label>
</div>
</div>
{/* Legend editor */}
{showLegend && (
<div className="export-section">
<label className="export-label">{en ? 'Legend Items' : '图例项'}</label>
<div className="export-legend-list">
{legendItems.map((item) => (
<div key={item.id} className="export-legend-item">
{item.type === 'colorbar' ? (
<div className="export-legend-colorbar-preview" title={en ? 'D-InSAR colormap' : 'D-InSAR 色表'} />
) : (
<input
type="color"
value={item.color}
onChange={(e) => updateLegendItem(item.id, { color: e.target.value })}
className="export-legend-color"
/>
)}
<input
type="text"
value={item.label}
onChange={(e) => updateLegendItem(item.id, { label: e.target.value })}
className="export-legend-label-input"
/>
<button
type="button"
className="export-legend-remove"
onClick={() => removeLegendItem(item.id)}
title={en ? 'Remove' : '删除'}
>&times;</button>
</div>
))}
{legendItems.length === 0 && (
<div className="export-legend-empty">
{en ? 'No visible layers detected' : '未检测到可见图层'}
</div>
)}
</div>
<button type="button" className="export-legend-add" onClick={addLegendItem}>
+ {en ? 'Add Item' : '添加图例'}
</button>
</div>
)}
</div>
{/* Right preview */}
<div className="export-preview">
{isCapturing ? (
<div className="export-preview-loading">
{en ? 'Capturing map...' : '正在截取地图...'}
</div>
) : previewUrl ? (
<img src={previewUrl} alt="preview" className="export-preview-img" />
) : (
<div className="export-preview-loading">
{en ? 'No preview' : '无预览'}
</div>
)}
</div>
</div>
<div className="export-footer">
<button type="button" className="export-btn-cancel" onClick={closeExportModal}>
{en ? 'Cancel' : '取消'}
</button>
<button
type="button"
className="export-btn-submit"
onClick={executeExport}
disabled={isExporting || isCapturing}
>
{isExporting
? (en ? 'Exporting...' : '导出中...')
: (en ? 'Export' : '导出')}
</button>
</div>
</div>
</div>
);
}
+506
View File
@@ -0,0 +1,506 @@
import { useRef, useState, useEffect } 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: [
'• 系统遍历所有影像组合,保留满足时间基线、空间基线和重叠率阈值的配对',
'• 结果按时间排序,用户可在配对列表中逐一勾选或取消',
'• 适用于研究型场景,需要精确控制每一对干涉组合',
'• 配对数量可能较多,建议配合 AOI 和日期范围缩小结果'
],
params: '参数:时间基线范围、空间基线上限、最小重叠率'
},
sbas: {
title: 'SBAS (短基线子集)',
description: '基于短基线原则的配对策略,通过覆盖优化算法自动筛选配对。',
details: [
'• 优先选择时间和空间基线都较短的配对',
'• 通过覆盖优化算法,去除冗余配对,确保时间序列连续性',
'• 适用于大范围、长时间序列的形变监测',
'• 配对数量会比"全部配对"少,但覆盖更均匀'
],
params: '参数:时间基线、空间基线、重叠率阈值、覆盖多样性惩罚'
},
sequential: {
title: 'Sequential (顺序配对)',
description: '每个影像与后续 N 个影像配对,形成时间序列链。',
details: [
'• 按时间顺序连接影像,形成连续的干涉链',
'• 连接数可调(1-10),数值越大配对越密集',
'• 适用于快速形变监测和时序分析',
'• 计算效率高,配对数量可控'
],
params: '参数:连接数(每个影像连接的后续影像数)'
},
star: {
title: 'Star (星型配对)',
description: '所有影像与一个参考影像配对,形成星型结构。',
details: [
'• 选择一个高质量影像作为参考(通常选时间居中的影像)',
'• 所有其他影像都与参考影像配对',
'• 适用于单次事件监测(如地震、滑坡)',
'• 便于差分结果的直接对比'
],
params: '参数:参考影像(不指定则自动选择时间居中的影像)'
}
};
function PairingModal({
onSubmit,
onAoiModeChange,
onProvinceChange,
onCityChange,
}) {
const { language } = useI18n();
const {
pairingParams, setPairingParams,
pairingAoiMode,
showPairingModal, setShowPairingModal,
pairingFiles, setPairingFiles,
pairingRegionOptions,
pairingRegionSelection,
pairingRegionLoading,
pairingRegionError, setPairingRegionError,
} = usePairingStore();
const { radarImagingDates, allData } = useRadarStore();
const { currentUser } = useAuthStore();
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
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]);
// 同步 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 handleSubmit = (e) => {
e.preventDefault();
onSubmit(e, requireOrbitRef);
};
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}>
{/* 配对策略选择 */}
<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>
</div>
{/* 主影像时间范围 */}
<div className="form-group">
<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 })}
language={language}
placeholder="起始日期"
enabledDates={availableDates}
allowClear={true}
/>
<span></span>
<UnifiedDatePicker
value={pairingParams.master_date_to || ''}
onChange={(value) => setPairingParams({ ...pairingParams, master_date_to: value ? value.replace(/-/g, '') : null })}
language={language}
placeholder="结束日期"
enabledDates={availableDates}
allowClear={true}
/>
</div>
</div>
{/* 从影像时间范围 */}
<div className="form-group">
<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 })}
language={language}
placeholder="起始日期"
enabledDates={availableDates}
allowClear={true}
/>
<span></span>
<UnifiedDatePicker
value={pairingParams.slave_date_to || ''}
onChange={(value) => setPairingParams({ ...pairingParams, slave_date_to: value ? value.replace(/-/g, '') : null })}
language={language}
placeholder="结束日期"
enabledDates={availableDates}
allowClear={true}
/>
</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>
)}
{/* 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>
)}
{/* 卫星选择器 */}
{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' }}>
<input
type="checkbox"
checked={selectedSatellites.includes(sat)}
onChange={() => handleSatelliteToggle(sat)}
/>
{sat}
</label>
))}
</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>最小重叠率 (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">
<label>空间基线上限 ():</label>
<input type="number" min="0" value={pairingParams.spatial_baseline_max_meters}
onChange={e => setPairingParams({...pairingParams, spatial_baseline_max_meters: parseInt(e.target.value) || 3000})} />
</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(', ')}
</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' }}>
{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>
<div className="strategy-params">
<strong>配置参数</strong>
<p>{currentStrategy.params}</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default PairingModal;
+151
View File
@@ -0,0 +1,151 @@
import { usePairingStore, useUiStore, useAuthStore } from '../store';
import { getSelectedRegionTreeId } from '../utils/appUiHelpers';
function PsStackModal({
onSubmit,
onAoiModeChange,
onProvinceChange,
onCityChange,
}) {
const {
showPsModal, setShowPsModal,
psFiles, setPsFiles,
psAoiMode,
psRegionOptions,
psRegionSelection,
psRegionLoading,
psRegionError, setPsRegionError,
psParams, setPsParams,
} = usePairingStore();
const { isLoading } = useUiStore();
const { currentUser } = useAuthStore();
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
if (!showPsModal) return null;
return (
<div className="modal-overlay visible">
<div className="modal-content">
<h3>准备PS时序数据栈</h3>
<form onSubmit={onSubmit}>
<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="ps-aoi-mode"
value="shp"
checked={psAoiMode === 'shp'}
onChange={() => onAoiModeChange('shp')}
/>
上传SHP
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="ps-aoi-mode"
value="region"
checked={psAoiMode === 'region'}
onChange={() => onAoiModeChange('region')}
/>
行政区选择
</label>
</div>
</div>
{psAoiMode === 'shp' ? (
<div className="form-group">
<label>研究区域 (Shapefile):</label>
<input
type="file"
multiple
onChange={e => setPsFiles(e.target.files)}
style={{ display: 'none' }}
id="ps-shp-upload"
/>
<label htmlFor="ps-shp-upload" className="file-upload-button">
选择文件...
</label>
{psFiles && psFiles.length > 0 && (
<div className="file-list">
{Array.from(psFiles).map(f => f.name).join(', ')}
</div>
)}
</div>
) : (
<div className="form-group">
<label>行政区范围:</label>
<div className="aoi-region-select-grid">
<select
value={psRegionSelection.province}
onChange={(e) => onProvinceChange(e.target.value)}
disabled={psRegionLoading}
>
<option value="">-- 省级 --</option>
{psRegionOptions.provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
<select
value={psRegionSelection.city}
onChange={(e) => onCityChange(e.target.value)}
disabled={psRegionLoading || !psRegionSelection.province}
>
<option value="">-- 地市 --</option>
{psRegionOptions.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>
{psRegionError && (
<div style={{ marginTop: '6px', color: '#b91c1c', fontSize: '12px' }}>
{psRegionError}
</div>
)}
</div>
)}
{Object.entries(psParams).map(([key, value]) => (
<div className="form-group" key={key}>
<label title={
key === 'initial_overlap_threshold'
? '影像与AOI重叠面积 / AOI面积'
: '影像与公共重叠区面积 / 公共重叠区面积'
}>
{key.replace(/_/g, ' ')}:
</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={value}
onChange={e => setPsParams({...psParams, [key]: parseFloat(e.target.value)})}
/>
</div>
))}
<div className="modal-actions">
<button type="button" onClick={() => { setShowPsModal(false); setPsFiles(null); setPsRegionError(''); }}>取消</button>
<button
type="submit"
disabled={
isLoading
|| isReadOnlyUser
|| (psAoiMode === 'shp'
? !psFiles || psFiles.length === 0
: !getSelectedRegionTreeId(psRegionSelection))
}
>
{isLoading ? '处理中...' : '准备并导出'}
</button>
</div>
</form>
</div>
</div>
);
}
export default PsStackModal;
@@ -0,0 +1,419 @@
import React, { useCallback, useEffect, useState } from 'react';
import apiClient from '../api/client';
import {
getPsinsarCatalogStatus,
getPsinsarProductDetail,
listPsinsarProducts,
queuePsinsarCatalogRebuild,
} from '../api/psinsarProducts';
const panelCardStyle = {
background: '#fff',
padding: '12px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
};
const statusColorMap = {
READY: '#16a34a',
PARTIAL: '#b45309',
QUARANTINED: '#dc2626',
WARN: '#b45309',
ERROR: '#dc2626',
REBUILDING: '#2563eb',
};
function formatDateTime(value) {
if (!value) return '-';
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}
function StatusPill({ label, color }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 10px',
borderRadius: 999,
background: `${color}14`,
color,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: color,
display: 'inline-block',
}}
/>
{label}
</span>
);
}
export default function PsinsarCatalogPanel({
readOnly = false,
showActions = true,
onTaskQueued,
}) {
const [catalogStatus, setCatalogStatus] = useState(null);
const [products, setProducts] = useState([]);
const [selectedProductId, setSelectedProductId] = useState(null);
const [selectedProduct, setSelectedProduct] = useState(null);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [actionMessage, setActionMessage] = useState('');
const [publishRoot, setPublishRoot] = useState('');
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
const loadCatalog = useCallback(async () => {
setLoading(true);
try {
const [statusData, productData] = await Promise.all([
getPsinsarCatalogStatus(),
listPsinsarProducts({ limit: 20, offset: 0 }),
]);
setCatalogStatus(statusData);
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
setProducts(nextItems);
setSelectedProductId(current => {
if (current && nextItems.some(item => item.id === current)) {
return current;
}
return nextItems[0]?.id ?? null;
});
} catch (error) {
setActionMessage(`PS-InSAR 结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
setCatalogStatus(null);
setProducts([]);
setSelectedProductId(null);
} finally {
setLoading(false);
}
}, []);
const loadProductDetail = useCallback(async productId => {
if (!productId) {
setSelectedProduct(null);
return;
}
setDetailLoading(true);
try {
const detail = await getPsinsarProductDetail(productId);
setSelectedProduct(detail);
} catch (error) {
setSelectedProduct({
error: error?.response?.data?.detail || error.message || '结果详情加载失败',
});
} finally {
setDetailLoading(false);
}
}, []);
useEffect(() => {
loadCatalog();
const timer = setInterval(() => {
if (!actionLoading) {
loadCatalog();
}
}, 10000);
return () => clearInterval(timer);
}, [actionLoading, loadCatalog]);
useEffect(() => {
loadProductDetail(selectedProductId);
}, [loadProductDetail, selectedProductId]);
const handleQueueRebuild = async () => {
if (readOnly) return;
setActionLoading(true);
setActionMessage('');
try {
const result = await queuePsinsarCatalogRebuild({
publish_root: publishRoot.trim() || null,
full_rebuild: true,
});
setActionMessage(`PS-InSAR 结果目录重建任务已入队:${result.task_id}`);
onTaskQueued?.(result.task_id);
await loadCatalog();
} catch (error) {
setActionMessage(`PS-InSAR 结果目录重建失败:${error?.response?.data?.detail || error.message}`);
} finally {
setActionLoading(false);
}
};
const catalogColor = statusColorMap[catalogStatus?.status] || '#64748b';
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
return (
<div style={panelCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<div>
<strong style={{ fontSize: 14 }}>PS-InSAR 结果目录</strong>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
结果目录以 `psinsar.publish.v1` bundle 为事实源数据库仅保存索引与展示信息
</div>
</div>
<button
onClick={loadCatalog}
disabled={loading || actionLoading}
style={{
fontSize: 12,
padding: '4px 10px',
borderRadius: 4,
border: '1px solid #e2e8f0',
background: '#f8fafc',
cursor: 'pointer',
}}
>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, marginBottom: 10 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>目录状态</div>
<div style={{ marginTop: 4 }}>
<StatusPill label={catalogStatus?.status || 'UNKNOWN'} color={catalogColor} />
</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>需要重建</div>
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700, color: catalogStatus?.needs_rebuild ? '#dc2626' : '#16a34a' }}>
{catalogStatus?.needs_rebuild ? '是' : '否'}
</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>Manifest / DB</div>
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700 }}>
{(catalogStatus?.manifest_count ?? 0)} / {(catalogStatus?.db_count ?? 0)}
</div>
</div>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#f8fafc' }}>
<div style={{ fontSize: 11, color: '#64748b' }}>问题数量</div>
<div style={{ marginTop: 4, fontSize: 16, fontWeight: 700, color: (catalogStatus?.issue_count ?? 0) > 0 ? '#b45309' : '#16a34a' }}>
{catalogStatus?.issue_count ?? 0}
</div>
</div>
</div>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 10, wordBreak: 'break-all' }}>
<div><strong>目录根路径</strong>{catalogStatus?.storage_root || '-'}</div>
<div><strong>最近消息</strong>{catalogStatus?.last_message || '-'}</div>
<div><strong>最近重建</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
</div>
{showActions && (
<div style={{ marginBottom: 12, padding: '10px 12px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#0f172a', marginBottom: 8 }}>目录管理</div>
<input
value={publishRoot}
onChange={event => setPublishRoot(event.target.value)}
placeholder="可选:自定义 PS-InSAR 发布根目录,留空使用系统默认目录"
disabled={readOnly || actionLoading}
style={{
width: '100%',
padding: '6px 10px',
boxSizing: 'border-box',
borderRadius: 6,
border: '1px solid #cbd5e1',
fontSize: 12,
marginBottom: 8,
}}
/>
<button
onClick={handleQueueRebuild}
disabled={readOnly || actionLoading}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#fff',
color: '#0f172a',
cursor: readOnly ? 'not-allowed' : 'pointer',
fontSize: 12,
}}
>
{actionLoading ? '处理中...' : '重建 PS-InSAR 结果目录'}
</button>
{actionMessage && (
<div style={{ marginTop: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
{actionMessage}
</div>
)}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(260px, 360px) 1fr', gap: 12 }}>
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
产品列表 ({products.length})
</div>
{products.length === 0 ? (
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>
{loading ? '正在加载结果...' : '当前没有已登记的 PS-InSAR 产品。'}
</div>
) : (
<div style={{ maxHeight: 420, overflowY: 'auto' }}>
{products.map(item => {
const color = statusColorMap[item.status] || '#64748b';
return (
<button
key={item.id}
onClick={() => setSelectedProductId(item.id)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
border: 'none',
borderTop: '1px solid #f1f5f9',
background: selectedProductId === item.id ? '#eff6ff' : '#fff',
padding: '10px 12px',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<strong style={{ fontSize: 12, color: '#0f172a', wordBreak: 'break-all' }}>
{item.display_name || item.product_id}
</strong>
<span style={{ fontSize: 11, color }}>{item.status}</span>
</div>
<div style={{ fontSize: 11, color: '#64748b' }}>
{item.reference_date || '-'} / {item.stack_size || 0} / {formatDateTime(item.published_at)}
</div>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2, wordBreak: 'break-all' }}>
{item.run_key || '-'}
</div>
</button>
);
})}
</div>
)}
</div>
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
产品详情
</div>
{!selectedProductId ? (
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>请选择一个产品查看详情</div>
) : detailLoading ? (
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>正在加载详情...</div>
) : selectedProduct?.error ? (
<div style={{ padding: '12px', fontSize: 12, color: '#dc2626' }}>{selectedProduct.error}</div>
) : (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 12 }}>
<div>
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden', background: '#f8fafc' }}>
<img
src={`${previewBaseUrl}/ps-products/${selectedProduct.id}/preview`}
alt={selectedProduct.display_name}
style={{ display: 'block', width: '100%', minHeight: 120, objectFit: 'cover', background: '#e2e8f0' }}
/>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: '#334155' }}>
<div><strong>名称</strong>{selectedProduct.display_name || '-'}</div>
<div><strong>产品编号</strong>{selectedProduct.product_id || '-'}</div>
<div><strong>运行标识</strong>{selectedProduct.run_key || '-'}</div>
<div><strong>参考日期</strong>{selectedProduct.reference_date || '-'}</div>
<div><strong>影像数</strong>{selectedProduct.stack_size || 0}</div>
<div><strong>引擎</strong>{selectedProduct.engine_code || '-'}</div>
<div><strong>处理器</strong>{selectedProduct.profile_code || '-'}</div>
<div><strong>状态</strong>{selectedProduct.status || '-'} / {selectedProduct.health_status || '-'}</div>
<div><strong>发布时间</strong>{formatDateTime(selectedProduct.published_at)}</div>
<div><strong>发布日期列表</strong>{(selectedProduct.stack_dates || []).join(', ') || '-'}</div>
</div>
</div>
<div style={{ fontSize: 12, color: '#334155' }}>
<div><strong>Manifest</strong>{selectedProduct.manifest_path || '-'}</div>
<div><strong>发布目录</strong>{selectedProduct.publish_dir || '-'}</div>
<div><strong>主科学产物</strong>{selectedProduct.source_primary_path || '-'}</div>
<div><strong>主展示产物</strong>{selectedProduct.primary_asset_path || '-'}</div>
</div>
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>质量摘要</div>
<pre
style={{
margin: 0,
padding: '8px 10px',
background: '#f8fafc',
borderRadius: 6,
border: '1px solid #e2e8f0',
fontSize: 11,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{JSON.stringify(selectedProduct.quality || {}, null, 2)}
</pre>
</div>
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>资产列表 ({selectedAssets.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{selectedAssets.map(asset => (
<div
key={asset.id}
style={{ padding: '8px 10px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#fff', fontSize: 12 }}
>
<div><strong>{asset.asset_role}</strong> / {asset.asset_name}</div>
<div style={{ color: '#64748b', wordBreak: 'break-all' }}>{asset.relative_path}</div>
<div style={{ color: asset.exists_flag ? '#166534' : '#dc2626' }}>
{asset.exists_flag ? '文件存在' : '文件缺失'}
</div>
</div>
))}
</div>
</div>
<div style={{ borderTop: '1px dashed #cbd5e1', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>问题列表 ({selectedIssues.length})</div>
{selectedIssues.length === 0 ? (
<div style={{ fontSize: 12, color: '#16a34a' }}>当前未检测到问题</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{selectedIssues.map(issue => (
<div
key={issue.id}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #e2e8f0',
background: '#fff',
fontSize: 12,
color: issue.severity === 'ERROR' ? '#dc2626' : '#b45309',
}}
>
<div><strong>{issue.severity}</strong> / {issue.issue_code}</div>
<div>{issue.message}</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { exportDinsarResults } from '../api/dinsar';
export default function ResultExportModal({ results, onClose }) {
const [targetDir, setTargetDir] = useState('');
const [selectedIds, setSelectedIds] = useState(() => new Set(results.map(r => r.id)));
const [exporting, setExporting] = useState(false);
const [exportResult, setExportResult] = useState(null);
const [error, setError] = useState('');
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === results.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(results.map(r => r.id)));
}
};
const handleExport = async () => {
const dir = targetDir.trim();
if (!dir) {
setError('请输入目标路径');
return;
}
if (selectedIds.size === 0) {
setError('请至少选择一个结果');
return;
}
setError('');
setExporting(true);
setExportResult(null);
try {
const res = await exportDinsarResults([...selectedIds], dir);
setExportResult(res);
} catch (e) {
setError(e.response?.data?.detail || e.message || '导出失败');
} finally {
setExporting(false);
}
};
return (
<div className="modal-overlay visible" onClick={onClose}>
<div className="modal-content result-export-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>提取 D-InSAR 结果</h3>
<button className="modal-close-btn" onClick={onClose}>&times;</button>
</div>
<div className="modal-body">
<div className="export-path-section">
<label>目标路径支持 UNC 路径 \\\\server\\share\\path</label>
<input
type="text"
value={targetDir}
onChange={e => setTargetDir(e.target.value)}
placeholder="例如: D:\Export\Results 或 \\server\share\results"
disabled={exporting}
className="export-path-input"
/>
</div>
<div className="export-select-section">
<div className="export-select-header">
<label>
<input
type="checkbox"
checked={selectedIds.size === results.length && results.length > 0}
onChange={toggleAll}
disabled={exporting}
/>
全选 ({selectedIds.size}/{results.length})
</label>
</div>
<ul className="export-result-list">
{results.map(r => (
<li key={r.id} className="export-result-item">
<label>
<input
type="checkbox"
checked={selectedIds.has(r.id)}
onChange={() => toggleSelect(r.id)}
disabled={exporting}
/>
<span className="export-result-name" title={r.file_path || r.name}>
{r.name}
</span>
</label>
</li>
))}
</ul>
</div>
{error && <div className="export-error">{error}</div>}
{exportResult && (
<div className="export-summary">
<div className="export-summary-title">导出完成</div>
<div className="export-summary-stats">
<span className="stat-ok">复制: {exportResult.copied}</span>
<span className="stat-skip">跳过: {exportResult.skipped}</span>
{exportResult.failed > 0 && (
<span className="stat-fail">失败: {exportResult.failed}</span>
)}
</div>
<div className="export-summary-dir">
目标目录: {exportResult.target_dir}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} disabled={exporting}>关闭</button>
<button
onClick={handleExport}
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
className="btn-primary"
>
{exporting ? '导出中...' : `提取 ${selectedIds.size} 个结果`}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,209 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import flatpickr from 'flatpickr';
import 'flatpickr/dist/flatpickr.min.css';
const ZH_LOCALE = {
weekdays: {
shorthand: ['日', '一', '二', '三', '四', '五', '六'],
longhand: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
},
months: {
shorthand: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
longhand: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
},
firstDayOfWeek: 1,
rangeSeparator: ' 至 ',
};
const EMPTY_DATE_LIST = [];
const toIsoDateString = (dateObj) => {
if (!(dateObj instanceof Date) || Number.isNaN(dateObj.getTime())) return '';
const y = dateObj.getFullYear();
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
const d = String(dateObj.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const parseDateLikeValue = (value) => {
const text = String(value ?? '').trim();
if (!text) return null;
const compact = text.match(/^(\d{4})(\d{2})(\d{2})$/);
if (compact) {
const [, y, m, d] = compact;
return new Date(Number(y), Number(m) - 1, Number(d));
}
const dashed = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (dashed) {
const [, y, m, d] = dashed;
return new Date(Number(y), Number(m) - 1, Number(d));
}
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
};
export default function UnifiedDatePicker({
value,
onChange,
language = 'zh',
disabled = false,
title = '',
ariaLabel = '',
className = '',
allowClear = true,
placeholder = '',
minDate = '',
maxDate = '',
enabledDates = EMPTY_DATE_LIST,
}) {
const inputRef = useRef(null);
const pickerRef = useRef(null);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const selectedDate = useMemo(() => parseDateLikeValue(value), [value]);
const parsedMinDate = useMemo(() => parseDateLikeValue(minDate), [minDate]);
const parsedMaxDate = useMemo(() => parseDateLikeValue(maxDate), [maxDate]);
const enabledDatesKey = useMemo(() => (
Array.isArray(enabledDates)
? enabledDates
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join('|')
: ''
), [enabledDates]);
const pickedEnabledDates = useMemo(() => (
enabledDatesKey
? enabledDatesKey
.split('|')
.map(parseDateLikeValue)
.filter((item) => item instanceof Date && !Number.isNaN(item.getTime()))
: EMPTY_DATE_LIST
), [enabledDatesKey]);
const selectedDateKey = useMemo(() => toIsoDateString(selectedDate), [selectedDate]);
const minDateKey = useMemo(() => toIsoDateString(parsedMinDate), [parsedMinDate]);
const maxDateKey = useMemo(() => toIsoDateString(parsedMaxDate), [parsedMaxDate]);
const applyAltInputAttrs = useCallback((instance) => {
if (!instance?.altInput) return;
instance.altInput.placeholder = placeholder || '';
instance.altInput.title = title || '';
if (ariaLabel) {
instance.altInput.setAttribute('aria-label', ariaLabel);
} else {
instance.altInput.removeAttribute('aria-label');
}
instance.altInput.disabled = disabled;
}, [placeholder, title, ariaLabel, disabled]);
useEffect(() => {
if (!inputRef.current) return undefined;
const pickerOptions = {
dateFormat: 'Y-m-d',
altInput: true,
altInputClass: 'flatpickr-input unified-date-picker-visible-input',
altFormat: language === 'en' ? 'Y-m-d' : 'Y年m月d日',
locale: language === 'en' ? flatpickr.l10ns.default : ZH_LOCALE,
allowInput: true,
disableMobile: true,
monthSelectorType: 'static',
prevArrow: '<span aria-hidden="true"></span>',
nextArrow: '<span aria-hidden="true"></span>',
clickOpens: !disabled,
minDate: parsedMinDate || undefined,
maxDate: parsedMaxDate || undefined,
onReady: (_, __, fp) => {
applyAltInputAttrs(fp);
},
onOpen: (_, __, fp) => {
applyAltInputAttrs(fp);
},
onChange: (selectedDates) => {
const nextDate = Array.isArray(selectedDates) && selectedDates.length > 0
? selectedDates[0]
: null;
onChangeRef.current?.(toIsoDateString(nextDate));
},
};
if (pickedEnabledDates.length > 0) {
pickerOptions.enable = pickedEnabledDates;
}
const instance = flatpickr(inputRef.current, pickerOptions);
pickerRef.current = instance;
applyAltInputAttrs(instance);
return () => {
const current = pickerRef.current;
if (current && typeof current.destroy === 'function') {
current.destroy();
}
pickerRef.current = null;
};
}, [
language,
disabled,
parsedMinDate,
parsedMaxDate,
pickedEnabledDates,
minDateKey,
maxDateKey,
enabledDatesKey,
applyAltInputAttrs,
]);
useEffect(() => {
const current = pickerRef.current;
if (!current) return;
const currentSelectedDate = current.selectedDates && current.selectedDates.length > 0
? current.selectedDates[0]
: null;
const currentValue = toIsoDateString(currentSelectedDate);
const nextValue = toIsoDateString(selectedDate);
if (currentValue === nextValue) {
applyAltInputAttrs(current);
return;
}
if (selectedDate) {
current.setDate(selectedDate, false);
} else {
current.clear(false);
}
applyAltInputAttrs(current);
}, [selectedDate, selectedDateKey, applyAltInputAttrs]);
return (
<div className={`unified-date-picker ${className}`.trim()}>
<input
ref={inputRef}
className="unified-date-picker-source-input"
disabled={disabled}
aria-hidden="true"
tabIndex={-1}
/>
{allowClear && value && (
<button
type="button"
className="unified-date-picker-clear"
onClick={() => onChange?.('')}
title={language === 'en' ? 'Clear date' : '清空日期'}
disabled={disabled}
>
{language === 'en' ? 'Clear' : '清空'}
</button>
)}
</div>
);
}
@@ -0,0 +1,44 @@
export function PanelLoadingBody({ message = '正在加载面板...' }) {
return (
<div style={{ padding: '16px' }}>
<p className="empty-state">{message}</p>
</div>
);
}
export function PanelLoadingPanel({ message = '正在加载面板...' }) {
return (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<PanelLoadingBody message={message} />
</div>
);
}
export function ModalLoadingFallback({ message = '正在加载内容...' }) {
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
}}
>
<div
style={{
background: '#fff',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px 28px',
minWidth: '280px',
boxShadow: '0 20px 45px rgba(15, 23, 42, 0.18)',
}}
>
<p className="empty-state" style={{ margin: 0 }}>{message}</p>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext';
export default function AppLogPanel({ width }) {
const logs = useUiStore((state) => state.logs);
const { t } = useI18n();
return (
<aside className="panel right-panel" style={{ width }}>
<header>
<h3>日志</h3>
</header>
<div className="panel-content log-entries">
{logs.length === 0 ? (
<p className="empty-state">暂无日志</p>
) : (
logs.map((log, index) => (
<div key={index} className="log-entry">
<span className="log-time">[{log.time}]</span>
<span className="log-message" data-log-type={log.type}>{t(log.message)}</span>
</div>
))
)}
</div>
</aside>
);
}
@@ -0,0 +1,117 @@
import { memo } from 'react';
import { BASE_LAYERS } from '../../config/appConstants';
import { getSelectedRegionTreeId } from '../../utils/appUiHelpers';
function AppMapWorkspace({
language,
showMapRegionLocator,
toggleMapRegionLocator,
mapRegionOptions,
mapRegionSelection,
mapRegionLoading,
mapRegionLocating,
mapRegionError,
mapRegionLocatedName,
onMapRegionProvinceChange,
onMapRegionCityChange,
onLocateSelectedRegion,
onClearMapRegionHighlight,
baseLayerKey,
setBaseLayerKey,
onOpenExportModal,
}) {
const en = language === 'en';
return (
<div className="center-container">
<main id="map-container">
<div id="map"></div>
<div className="map-region-locator">
<button
type="button"
className={`map-region-toggle-btn ${showMapRegionLocator ? 'active' : ''}`}
onClick={toggleMapRegionLocator}
>
{showMapRegionLocator ? (en ? 'Collapse' : '收起') : (en ? 'Region Locator' : '区域定位')}
</button>
{showMapRegionLocator && (
<div className="map-region-locator-panel">
<div className="map-region-locator-title">{en ? 'Region Locator' : '区域定位'}</div>
<div className="aoi-region-select-grid map-region-grid">
<select
value={mapRegionSelection.province}
onChange={(e) => onMapRegionProvinceChange(e.target.value)}
disabled={mapRegionLoading || mapRegionLocating}
>
<option value="">{en ? '-- Province --' : '-- 省级 --'}</option>
{mapRegionOptions.provinces.map((item) => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
<select
value={mapRegionSelection.city}
onChange={(e) => onMapRegionCityChange(e.target.value)}
disabled={mapRegionLoading || mapRegionLocating || !mapRegionSelection.province}
>
<option value="">{en ? '-- City --' : '-- 地市 --'}</option>
{mapRegionOptions.cities.map((item) => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</div>
<div className="map-region-locator-hint">
{en ? 'You can locate by province or city level.' : '可只选到省/市级进行定位。'}
</div>
{mapRegionLocatedName && (
<div className="map-region-locator-current">
{en ? 'Current: ' : '当前定位:'}{mapRegionLocatedName}
</div>
)}
{mapRegionError && (
<div className="map-region-locator-error">{mapRegionError}</div>
)}
<div className="map-region-locator-actions">
<button
type="button"
className="map-region-locate-btn"
onClick={onLocateSelectedRegion}
disabled={mapRegionLoading || mapRegionLocating || !getSelectedRegionTreeId(mapRegionSelection)}
>
{mapRegionLocating ? (en ? 'Locating...' : '定位中...') : (en ? 'Locate Selected Region' : '定位到选中区域')}
</button>
<button
type="button"
className="map-region-clear-btn"
onClick={onClearMapRegionHighlight}
disabled={mapRegionLocating || !mapRegionLocatedName}
>
{en ? 'Clear Highlight' : '清除定位高亮'}
</button>
</div>
</div>
)}
</div>
<div className="map-layer-switch">
<div className="map-layer-title">{en ? 'Base Map' : '底图'}</div>
<div className="map-layer-buttons">
{Object.entries(BASE_LAYERS).map(([key, layer]) => (
<button
key={key}
type="button"
className={baseLayerKey === key ? 'active' : ''}
onClick={() => setBaseLayerKey(key)}
>
{layer.label}
</button>
))}
</div>
</div>
<button type="button" className="map-export-btn" onClick={onOpenExportModal}>
{en ? 'Export' : '导出'}
</button>
</main>
</div>
);
}
export default memo(AppMapWorkspace);
+153
View File
@@ -0,0 +1,153 @@
import { Suspense, lazy } from 'react';
import { useShallow } from 'zustand/react/shallow';
import LicenseOverlay from '../LicenseOverlay';
import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext';
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 LazyActiveTasksOverlay = lazy(() => import('../ActiveTasksOverlay'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyAiReportModal = lazy(() => import('../AiReportModal'));
const LazyMapExportModal = lazy(() => import('../MapExportModal'));
export default function AppOverlays({
onPairingSubmit,
onPairingAoiModeChange,
onPairingProvinceChange,
onPairingCityChange,
onPsSubmit,
onPsAoiModeChange,
onPsProvinceChange,
onPsCityChange,
licenseLoading,
licenseStatus,
isAdmin,
licenseFileRef,
onUploadFile,
onRefreshLicenseStatus,
licenseFileName,
licenseUploadStatus,
isGlobalLocked,
activeTasks,
showForceUnlock,
forceUnlockPwd,
onShowForceUnlock,
onForceUnlockPwdChange,
onForceUnlockConfirm,
onCancelForceUnlock,
mapExport,
}) {
const { language, t } = useI18n();
const { showPairingModal, showPsModal } = usePairingStore(useShallow((state) => ({
showPairingModal: state.showPairingModal,
showPsModal: state.showPsModal,
})));
const {
showStats,
setShowStats,
showDataInfo,
setShowDataInfo,
selectedDataInfo,
} = useUiStore(useShallow((state) => ({
showStats: state.showStats,
setShowStats: state.setShowStats,
showDataInfo: state.showDataInfo,
setShowDataInfo: state.setShowDataInfo,
selectedDataInfo: state.selectedDataInfo,
})));
const { activeAiReport, setActiveAiReport } = useDinsarStore(useShallow((state) => ({
activeAiReport: state.activeAiReport,
setActiveAiReport: state.setActiveAiReport,
})));
return (
<>
{showPairingModal && (
<Suspense fallback={<ModalLoadingFallback message="正在加载组网参数弹窗..." />}>
<LazyPairingModal
onSubmit={onPairingSubmit}
onAoiModeChange={onPairingAoiModeChange}
onProvinceChange={onPairingProvinceChange}
onCityChange={onPairingCityChange}
/>
</Suspense>
)}
{showPsModal && (
<Suspense fallback={<ModalLoadingFallback message="正在加载 PS 参数弹窗..." />}>
<LazyPsStackModal
onSubmit={onPsSubmit}
onAoiModeChange={onPsAoiModeChange}
onProvinceChange={onPsProvinceChange}
onCityChange={onPsCityChange}
/>
</Suspense>
)}
{activeAiReport && (
<Suspense fallback={<ModalLoadingFallback message="正在加载 AI 报告..." />}>
<LazyAiReportModal
report={activeAiReport}
onClose={() => setActiveAiReport(null)}
/>
</Suspense>
)}
{showStats && (
<Suspense fallback={<ModalLoadingFallback message="正在加载统计看板..." />}>
<LazyStatisticsDashboard onClose={() => setShowStats(false)} />
</Suspense>
)}
<LicenseOverlay
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={onUploadFile}
onRefreshStatus={onRefreshLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
/>
{showDataInfo && (
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
<LazyDataInfoModal
visible={showDataInfo}
dataInfo={selectedDataInfo}
language={language}
formatYmd={(value) => formatYmd(value, language)}
onClose={() => setShowDataInfo(false)}
/>
</Suspense>
)}
{isGlobalLocked && (
<Suspense fallback={<ModalLoadingFallback message="正在加载任务控制面板..." />}>
<LazyActiveTasksOverlay
isVisible={isGlobalLocked}
activeTasks={activeTasks}
t={t}
isAdmin={isAdmin}
showForceUnlock={showForceUnlock}
forceUnlockPwd={forceUnlockPwd}
onShowForceUnlock={onShowForceUnlock}
onForceUnlockPwdChange={onForceUnlockPwdChange}
onForceUnlockConfirm={onForceUnlockConfirm}
onCancelForceUnlock={onCancelForceUnlock}
/>
</Suspense>
)}
{mapExport.showExportModal && (
<Suspense fallback={<ModalLoadingFallback message="正在加载地图导出工具..." />}>
<LazyMapExportModal {...mapExport} />
</Suspense>
)}
</>
);
}
@@ -0,0 +1,479 @@
import { Suspense, lazy } from 'react';
import apiClient from '../../api/client';
import RadarDataPanel from '../../panels/RadarDataPanel';
import {
ADMIN_ONLY_TABS,
LEFT_GROUP_LABELS,
LEFT_GROUP_SECTIONS,
LEFT_GROUP_TABS,
LEFT_TAB_GROUP,
LEFT_TAB_SECTION,
} from '../../config/appConstants';
import { getLeftTabLabel } from '../../utils/appUiHelpers';
import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
const LazyDataCopierPanel = lazy(() => import('../../DataCopierPanel'));
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
const LazyDinsarProductionPanel = lazy(() => import('../../DinsarProductionPanel'));
const LazyDinsarProductsPanel = lazy(() => import('../../DinsarProductsPanel'));
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
const LazyTimeseriesProductionPanel = lazy(() => import('../../TimeseriesProductionPanel'));
const LazyWaterMonitorPanel = lazy(() => import('../../WaterMonitorPanel'));
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/PairingPanel'));
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'));
export default function AppSidePanel({
leftPanelWidth,
leftPanelTab,
setLeftPanelTab,
isAdmin,
isReadOnlyUser,
currentUser,
language,
apiEndpoint,
licenseOk,
foundPairs,
psResults,
dinsarTotal,
selectedPairsCount,
hasEnoughRadarScenesForPlanning,
isLoading,
hasRadarSearched,
showHazardPoints,
hazardPoints,
aiStatus,
radarPanel,
pairingPanel,
taskPanel,
hazardPanel,
waterPanel,
dinsarPanel,
aiPanel,
pairsPanel,
psPanel,
}) {
const activeLeftGroup = LEFT_TAB_GROUP[leftPanelTab] || 'data';
const getVisibleTabs = (tabs = []) => tabs.filter((tab) => isAdmin || !ADMIN_ONLY_TABS.has(tab));
const getVisibleSections = (groupKey) => (
(LEFT_GROUP_SECTIONS[groupKey] || [])
.map((section) => ({
...section,
tabs: getVisibleTabs(section.tabs || []),
}))
.filter((section) => section.tabs.length > 0)
);
const getDefaultGroupTab = (groupKey) => {
const visibleSections = getVisibleSections(groupKey);
if (visibleSections.length > 0) {
return visibleSections[0]?.tabs?.[0] || '';
}
return getVisibleTabs(LEFT_GROUP_TABS[groupKey] || [])[0] || '';
};
const activeGroupSections = getVisibleSections(activeLeftGroup);
const hasSectionNav = activeGroupSections.length > 0;
const preferredActiveSection = LEFT_TAB_SECTION[leftPanelTab];
const activeLeftSection = hasSectionNav && activeGroupSections.some((section) => section.key === preferredActiveSection)
? preferredActiveSection
: (activeGroupSections[0]?.key || null);
const activeLeafTabs = hasSectionNav
? (activeGroupSections.find((section) => section.key === activeLeftSection)?.tabs || [])
: getVisibleTabs(LEFT_GROUP_TABS[activeLeftGroup] || []);
const psResultCount = psResults ? Object.keys(psResults).length : 0;
return (
<aside className="panel data-panel" style={{ display: 'flex', flexDirection: 'column', width: leftPanelWidth }}>
<div className="panel-tabs">
<div className="tabs-header group-tabs">
{Object.entries(LEFT_GROUP_LABELS)
.filter(([groupKey]) => {
if (isAdmin) return true;
return !!getDefaultGroupTab(groupKey);
})
.map(([groupKey, label]) => (
<button
key={groupKey}
className={activeLeftGroup === groupKey ? 'active-tab' : ''}
onClick={() => {
const nextTab = getDefaultGroupTab(groupKey);
if (nextTab) setLeftPanelTab(nextTab);
}}
>
{label}
</button>
))}
</div>
{hasSectionNav && (
<div className="tabs-header section-tabs">
{activeGroupSections.map((section) => (
<button
key={section.key}
className={activeLeftSection === section.key ? 'active-tab' : ''}
onClick={() => setLeftPanelTab(section.tabs[0])}
>
{section.label}
</button>
))}
</div>
)}
<div className="tabs-header left-tabs sub-tabs">
{activeLeafTabs.map((tabKey) => (
<button
key={tabKey}
className={leftPanelTab === tabKey ? 'active-tab' : ''}
onClick={() => setLeftPanelTab(tabKey)}
>
{getLeftTabLabel(tabKey, {
pairCount: foundPairs.length,
psResultCount,
dinsarTotal,
})}
</button>
))}
</div>
</div>
{leftPanelTab === 'data' && (
<RadarDataPanel
radarCurrentPage={radarPanel.radarCurrentPage}
radarTotalPages={radarPanel.radarTotalPages}
showRadarPageInputError={radarPanel.showRadarPageInputError}
radarPageInputValidationError={radarPanel.radarPageInputValidationError}
onSearchAll={radarPanel.onSearchAll}
onShowStats={radarPanel.onShowStats}
onSearch={radarPanel.onSearch}
onReset={radarPanel.onReset}
onAoiModeChange={radarPanel.onAoiModeChange}
onProvinceChange={radarPanel.onProvinceChange}
onCityChange={radarPanel.onCityChange}
onSetRadarSearchFiles={radarPanel.onSetRadarSearchFiles}
updateDraft={radarPanel.updateDraft}
onPageChange={radarPanel.onPageChange}
onPageSizeChange={radarPanel.onPageSizeChange}
onGoToPage={radarPanel.onGoToPage}
onSelectAllVisibility={radarPanel.onSelectAllVisibility}
onSetAllPreviewVisibility={radarPanel.onSetAllPreviewVisibility}
onToggleLayer={radarPanel.onToggleLayer}
onTogglePreview={radarPanel.onTogglePreview}
onRebuildPreview={radarPanel.onRebuildPreview}
onShowDataInfo={radarPanel.onShowDataInfo}
onFlyTo={radarPanel.onFlyTo}
onChangeSatelliteGroup={radarPanel.onChangeSatelliteGroup}
/>
)}
{leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
<LazyDataMonitorPanel
apiEndpoint={apiEndpoint}
onTaskStart={taskPanel.onTaskStart}
readOnly={isReadOnlyUser}
enabled={!!currentUser && licenseOk}
/>
</Suspense>
</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, '数据分发任务已入队,正在处理...')}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'idl' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 IDL 面板..." />}>
<LazyIDLAutomationPanel
apiEndpoint={apiEndpoint}
readOnly={isReadOnlyUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, '任务已入队,等待处理...')}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'dinsar_production' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 D-InSAR 生产面板..." />}>
<LazyDinsarProductionPanel
readOnly={isReadOnlyUser}
currentUser={currentUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'D-InSAR 任务已入队,等待处理...')}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'dinsar_products' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 D-InSAR 产物面板..." />}>
<LazyDinsarProductsPanel
readOnly={isReadOnlyUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'D-InSAR 产物任务已入队,等待处理...')}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'ps_production' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 生产面板..." />}>
<LazyTimeseriesProductionPanel
readOnly={isReadOnlyUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'SBAS 运行已入队,正在执行 prepare...')}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'ps_products' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 目录面板..." />}>
<div style={{ padding: '16px' }}>
<LazyPsinsarCatalogPanel
readOnly={isReadOnlyUser}
showActions
onTaskQueued={(taskId) => taskPanel.onTaskStart(taskId, 'PS-InSAR 结果目录任务已入队,等待处理...')}
/>
</div>
</Suspense>
</div>
)}
{leftPanelTab === 'hazard' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载隐患点面板..." />}>
<LazyHazardPointPanel
apiEndpoint={apiEndpoint}
onPointClick={hazardPanel.onPointClick}
isVisible={showHazardPoints}
onToggleVisibility={hazardPanel.onToggleVisibility}
onScanComplete={hazardPanel.onScanComplete}
points={hazardPoints}
onTaskStart={taskPanel.onTaskStart}
readOnly={isReadOnlyUser}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'water' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载水体监测面板..." />}>
<LazyWaterMonitorPanel
readOnly={isReadOnlyUser}
onShowOnMap={waterPanel.onShowOnMap}
onShowFloodOnMap={waterPanel.onShowFloodOnMap}
onToggleFloodLayer={waterPanel.onToggleFloodLayer}
onTaskStart={taskPanel.onTaskStart}
language={language}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'health' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载运维自检面板..." />}>
<LazyHealthCheckPanel
apiEndpoint={apiEndpoint}
language={language}
currentUser={currentUser}
/>
</Suspense>
</div>
)}
{leftPanelTab === 'users' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
<Suspense fallback={<PanelLoadingBody message="正在加载用户管理面板..." />}>
<LazyUserAdminPanel apiClient={apiClient} currentUser={currentUser} />
</Suspense>
) : (
<div style={{ padding: '16px' }}>
<p className="empty-state">仅管理员可访问用户管理</p>
</div>
)}
</div>
)}
{leftPanelTab === 'audit' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
<Suspense fallback={<PanelLoadingBody message="正在加载审计日志面板..." />}>
<LazyAuditLogPanel apiClient={apiClient} />
</Suspense>
) : (
<div style={{ padding: '16px' }}>
<p className="empty-state">仅管理员可访问审计日志</p>
</div>
)}
</div>
)}
{leftPanelTab === 'dinsar_results' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载 D-InSAR 结果面板..." />}>
<LazyDinsarResultPanel
dinsarCurrentPage={dinsarPanel.dinsarCurrentPage}
dinsarTotalPages={dinsarPanel.dinsarTotalPages}
showDinsarPageInputError={dinsarPanel.showDinsarPageInputError}
dinsarPageInputValidationError={dinsarPanel.dinsarPageInputValidationError}
onSetAllVisibility={dinsarPanel.onSetAllVisibility}
onScoreFilterChange={dinsarPanel.onScoreFilterChange}
onPageChange={dinsarPanel.onPageChange}
onPageSizeChange={dinsarPanel.onPageSizeChange}
onGoToPage={dinsarPanel.onGoToPage}
onToggleVisibility={dinsarPanel.onToggleVisibility}
onLabel={dinsarPanel.onLabel}
onAnalyze={dinsarPanel.onAnalyze}
/>
</Suspense>
)}
{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>
)}
{leftPanelTab === 'psinsar_results' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 结果目录..." />}>
<div style={{ padding: '16px' }}>
<LazyPsinsarCatalogPanel
readOnly
showActions={false}
/>
</div>
</Suspense>
</div>
)}
{leftPanelTab === 'psinsar_analysis' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<div style={{ padding: '16px' }}>
<div className="empty-state">
PS-InSAR 分析页已预留
<br />
后续可以在这里放置时序分析速率分级热点识别和专题统计能力
</div>
</div>
</div>
)}
{leftPanelTab === 'ai_quality' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载 AI 质量面板..." />}>
<LazyAiQualityPanel
aiStatus={aiStatus}
isLoading={isLoading}
isReadOnlyUser={isReadOnlyUser}
onTrain={aiPanel.onTrain}
onPredictAll={aiPanel.onPredictAll}
language={language}
/>
</Suspense>
)}
{leftPanelTab === 'ai_diagnosis' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载 AI 诊断面板..." />}>
<LazyAiAnalysisPanel
readOnly={isReadOnlyUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, '任务已入队,等待处理...')}
/>
</Suspense>
)}
{leftPanelTab === 'landslide_segmentation' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<div style={{ padding: '16px' }}>
<div className="empty-state">
滑坡语义分割模块已预留
<br />
后续可在这里接入光学影像分割模型结果预览批处理提交和专题输出
</div>
</div>
</div>
)}
{leftPanelTab === 'uav_image_analysis' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<div style={{ padding: '16px' }}>
<div className="empty-state">
无人机影像分析模块已预留
<br />
后续可在这里集成无人机正射影像解译目标识别和变化检测能力
</div>
</div>
</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}
onCreatePsBatch={psPanel.onCreatePsBatch}
onClearPsResults={psPanel.onClearPsResults}
/>
</Suspense>
)}
{leftPanelTab === 'batches' && (
<Suspense fallback={<PanelLoadingPanel message="正在加载批处理面板..." />}>
<LazyBatchPanel />
</Suspense>
)}
</aside>
);
}
@@ -0,0 +1,112 @@
import { memo } from 'react';
import { formatUtc, getStatusClass } from '../../utils/appUiHelpers';
function AppStatusHeader({
language,
setLanguage,
currentUser,
isAdmin,
isReadOnlyUser,
activeTasks,
avgTaskProgress,
licenseStatus,
healthStatus,
healthLoading,
healthError,
onRefreshHealth,
onLogout,
}) {
const hasHealthStatus = !!healthStatus;
const licenseOk = !!licenseStatus?.ok;
const dbOk = !!(healthStatus?.database?.ok && healthStatus?.database?.schema_ok && healthStatus?.database?.postgis_ok);
const workerOk = !!healthStatus?.worker?.ok;
const idlOk = !!healthStatus?.idl?.ok;
const aiOk = !!healthStatus?.ollama?.ok;
const nginxOk = !!healthStatus?.nginx?.ok;
return (
<>
<div className="top-status-bar">
<div className="status-brand">
<div className="brand-title">InSAR 自动化管理系统</div>
<div className="brand-subtitle">科研工程模式 · {licenseOk ? '已授权' : '未授权'}</div>
</div>
<div className="status-items">
<div className="status-item">
<span className={`status-dot ${getStatusClass(dbOk, hasHealthStatus)}`}></span>DB
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(workerOk, hasHealthStatus)}`}></span>Worker
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(idlOk, hasHealthStatus)}`}></span>IDL
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(aiOk, hasHealthStatus)}`}></span>Ollama
</div>
<div className="status-item">
<span className={`status-dot ${getStatusClass(nginxOk, hasHealthStatus)}`}></span>Nginx
</div>
</div>
<div className="status-actions">
<div className="status-lang-switch" data-no-i18n="true">
<button
className={`status-lang-btn ${language === 'zh' ? 'active' : ''}`}
onClick={() => setLanguage('zh')}
title="切换到中文"
>
中文
</button>
<button
className={`status-lang-btn ${language === 'en' ? 'active' : ''}`}
onClick={() => setLanguage('en')}
title="Switch to English"
>
EN
</button>
</div>
<div className={`user-role-chip ${isAdmin ? 'admin' : 'viewer'}`}>
<span className="user-role-name">{currentUser.username}</span>
<span className="user-role-divider">·</span>
<span>{isAdmin ? '管理员' : '只读账号'}</span>
</div>
<div className={`status-task ${activeTasks.length > 0 ? 'has-active-tasks' : ''}`}>
<span>{activeTasks.length > 0 ? `⚙️ 运行中 ${activeTasks.length}` : '✓ 空闲'}</span>
{activeTasks.length > 0 && (
<div className="status-task-bar">
<div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }}></div>
</div>
)}
</div>
{licenseStatus?.expires_at && (
<div className="status-license">
授权至 {formatUtc(licenseStatus.expires_at, language)}
</div>
)}
<button
className="status-refresh"
onClick={onRefreshHealth}
disabled={healthLoading}
title={healthError || '刷新自检状态'}
>
{healthLoading ? '自检中...' : '刷新自检'}
</button>
<button
className="status-refresh"
onClick={onLogout}
title="退出登录"
>
退出登录
</button>
</div>
</div>
{isReadOnlyUser && (
<div className="read-only-banner">
当前账号为只读权限可查看数据与状态但不能执行写操作
</div>
)}
</>
);
}
export default memo(AppStatusHeader);
@@ -0,0 +1,87 @@
import { useEffect, useMemo, useRef, useState } from 'react';
const DEFAULT_OVERSCAN = 6;
export default function VirtualizedList({
items,
itemHeight,
renderItem,
getKey,
overscan = DEFAULT_OVERSCAN,
viewportClassName = '',
contentClassName = '',
viewportStyle,
}) {
const viewportRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return undefined;
const updateViewportHeight = () => {
setViewportHeight(viewport.clientHeight || 0);
};
updateViewportHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateViewportHeight);
return () => window.removeEventListener('resize', updateViewportHeight);
}
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(viewport);
return () => observer.disconnect();
}, []);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
if (viewport.scrollTop > 0 && items.length * itemHeight <= viewport.clientHeight) {
viewport.scrollTop = 0;
setScrollTop(0);
}
}, [items.length, itemHeight]);
const totalHeight = items.length * itemHeight;
const visibleRange = useMemo(() => {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + Math.max(viewportHeight, itemHeight)) / itemHeight) + overscan
);
return {
startIndex,
endIndex,
};
}, [itemHeight, items.length, overscan, scrollTop, viewportHeight]);
const visibleItems = useMemo(
() => items.slice(visibleRange.startIndex, visibleRange.endIndex),
[items, visibleRange.endIndex, visibleRange.startIndex]
);
return (
<div
ref={viewportRef}
className={`virtual-list-viewport ${viewportClassName}`.trim()}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
style={viewportStyle}
>
<div className="virtual-list-spacer" style={{ height: totalHeight }}>
<ul
className={`data-list virtual-list-content ${contentClassName}`.trim()}
style={{ transform: `translateY(${visibleRange.startIndex * itemHeight}px)` }}
>
{visibleItems.map((item, index) => {
const absoluteIndex = visibleRange.startIndex + index;
return renderItem(item, absoluteIndex, getKey(item, absoluteIndex));
})}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import { memo } from 'react';
import { parseDatesFromName, formatYmd } from '../../utils/appUiHelpers';
function truncateMiddle(value, maxLength = 26) {
const text = String(value || '').trim();
if (!text || text.length <= maxLength) {
return text || '-';
}
const sideLength = Math.max(6, Math.floor((maxLength - 3) / 2));
return `${text.slice(0, sideLength)}...${text.slice(-sideLength)}`;
}
function DinsarResultRow({
result,
language,
showDates,
isLoading,
isReadOnlyUser,
onLabel,
onAnalyze,
onToggleVisibility,
}) {
const dates = showDates ? parseDatesFromName(result.name, (value) => formatYmd(value, language)) : null;
const hasTrace = !!(result.selection_strategy || result.network_run_id || result.network_edge_id || result.pair_uid);
return (
<li className="data-item dinsar-item">
<div className="dinsar-info">
<span className="data-item-name" title={result.name}>
{result.name}
</span>
{result.ai_score !== null && (
<span
className={`ai-score ${result.ai_score > 0.7 ? 'good' : (result.ai_score < 0.4 ? 'bad' : 'medium')}`}
title={language === 'en' ? 'AI quality score' : 'AI 质量评分'}
>
AI: {(result.ai_score * 100).toFixed(0)}
</span>
)}
</div>
{hasTrace && (
<div className="dinsar-trace-info">
<div className="dinsar-trace-line">
<span className="dinsar-trace-pill" title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}>
{result.selection_strategy || 'legacy'}
</span>
<span title={language === 'en' ? 'Network edge id' : '网络边编号'}>
edge {result.network_edge_id ?? '-'}
</span>
<span title={language === 'en' ? 'Network run id' : '网络运行编号'}>
{truncateMiddle(result.network_run_id, 24)}
</span>
</div>
<div className="dinsar-trace-line" title={result.pair_uid || result.pair_key || '-'}>
<span>{truncateMiddle(result.pair_uid || result.pair_key, 30)}</span>
{result.policy_version && <span>{result.policy_version}</span>}
</div>
</div>
)}
{dates && (
<div className="date-info">
<span className="date-tag master" title={language === 'en' ? 'Master date' : '主影像日期'}>{dates.master}</span>
<span className="date-arrow"></span>
<span className="date-tag slave" title={language === 'en' ? 'Slave date' : '辅影像日期'}>{dates.slave}</span>
</div>
)}
<div className="data-item-controls">
<div className="label-buttons">
<button
className={`label-btn good ${result.user_label === 1 ? 'active' : ''}`}
onClick={() => onLabel(result.id, result.user_label === 1 ? null : 1)}
title={language === 'en' ? 'Mark as high quality' : '标记为高质量'}
disabled={isReadOnlyUser}
>
{language === 'en' ? 'Good' : '良好'}
</button>
<button
className={`label-btn bad ${result.user_label === 0 ? 'active' : ''}`}
onClick={() => onLabel(result.id, result.user_label === 0 ? null : 0)}
title={language === 'en' ? 'Mark as low quality' : '标记为低质量'}
disabled={isReadOnlyUser}
>
{language === 'en' ? 'Poor' : '欠佳'}
</button>
</div>
<button
className="ai-analyze-btn"
onClick={(event) => {
event.stopPropagation();
onAnalyze(result.id);
}}
disabled={isLoading || isReadOnlyUser}
title={language === 'en' ? 'Use AI to analyze this result' : '使用 AI 自动分析此结果'}
>
{language === 'en' ? 'AI Diagnose' : 'AI 诊断'}
</button>
<input
type="checkbox"
checked={!!result.isVisible}
onChange={(event) => {
event.stopPropagation();
onToggleVisibility(result.id);
}}
title={language === 'en' ? 'Show or hide on map' : '在地图上显示/隐藏'}
/>
</div>
</li>
);
}
export default memo(DinsarResultRow);
@@ -0,0 +1,40 @@
import { memo } from 'react';
function PairListRow({
pair,
index,
onToggleSelected,
onVisualizePair,
onTogglePairVisibility,
}) {
return (
<li className="pair-item">
<input
type="checkbox"
title="选择以导出"
checked={pair.isSelected}
onChange={() => onToggleSelected(index)}
onClick={(event) => event.stopPropagation()}
/>
<div className="pair-info" onClick={() => onVisualizePair(pair)}>
<strong>{pair.task_name}</strong>
<div className="pair-details">
<span>时基: {pair.time_baseline_days}d</span>
<span>空基: {pair.spatial_baseline_meters.toFixed(2)}m</span>
</div>
</div>
<button
className={`visibility-toggle ${pair.isVis ? 'visible' : ''}`}
onClick={(event) => {
event.stopPropagation();
onTogglePairVisibility(index);
}}
title={pair.isVis ? '在地图上隐藏' : '在地图上显示'}
>
{pair.isVis ? '隐藏' : '显示'}
</button>
</li>
);
}
export default memo(PairListRow);
@@ -0,0 +1,53 @@
import { memo, useMemo } from 'react';
import VirtualizedList from '../common/VirtualizedList';
const PS_STACK_ROW_HEIGHT = 24;
const PS_STACK_MAX_HEIGHT = 168;
const PsStackItemRow = memo(function PsStackItemRow({ item }) {
return (
<li className="ps-stack-item" title={item.file_path}>
{item.displayName}
</li>
);
});
function PsStackSection({
direction,
stack,
isReadOnlyUser,
onPreviewPsStack,
onCreatePsBatch,
}) {
const viewportHeight = useMemo(
() => Math.min(Math.max(stack.length * PS_STACK_ROW_HEIGHT, PS_STACK_ROW_HEIGHT), PS_STACK_MAX_HEIGHT),
[stack.length]
);
return (
<div className="ps-stack">
<div className="ps-stack-header">
<h4>{direction} ({stack.length} scenes)</h4>
<button className="preview-button" onClick={() => onPreviewPsStack(stack)}>预览</button>
</div>
<VirtualizedList
items={stack}
itemHeight={PS_STACK_ROW_HEIGHT}
getKey={(item) => item.id}
viewportClassName="ps-stack-list-viewport"
contentClassName="ps-stack-list-content"
viewportStyle={{ height: viewportHeight }}
renderItem={(item, index, key) => (
<PsStackItemRow key={key || `${item.id}-${index}`} item={item} />
)}
/>
<div className="ps-stack-actions">
<button onClick={() => onCreatePsBatch(direction, stack)} disabled={isReadOnlyUser}>
保存批次
</button>
</div>
</div>
);
}
export default memo(PsStackSection);
@@ -0,0 +1,82 @@
import { memo } from 'react';
import { getPreviewStatusClass, getPreviewStatusText } from '../../utils/appUiHelpers';
function RadarDataRow({
item,
language,
isAdmin,
isRebuilding,
onFlyTo,
onShowDataInfo,
onTogglePreview,
onRebuildPreview,
onToggleLayer,
}) {
return (
<li className="data-item" onClick={() => onFlyTo(item)}>
<span className="data-item-name" title={item.displayName}>
{item.displayName}
</span>
<div className="data-item-controls">
<span
className={`preview-status-chip ${getPreviewStatusClass(item)}`}
title={item.previewMessage || item.previewError || '源影像预览缓存状态'}
>
{getPreviewStatusText(item)}
</span>
<button
className="data-info-btn"
type="button"
title={language === 'en' ? 'View source data details' : '查看影像信息'}
onClick={(event) => onShowDataInfo(item, event)}
>
{language === 'en' ? 'Info' : '详情'}
</button>
<button
className={`data-preview-btn ${item.isPreviewVisible ? 'active' : ''}`}
type="button"
title={language === 'en' ? 'Show or hide source preview on map' : '在地图上显示/隐藏源影像缓存'}
onClick={(event) => {
event.stopPropagation();
onTogglePreview(item.id);
}}
>
{language === 'en' ? 'Preview' : '影像'}
</button>
{isAdmin && (
<button
className="data-rebuild-btn"
type="button"
title={language === 'en' ? 'Rebuild source preview cache' : '管理员重建源影像预览缓存'}
disabled={isRebuilding}
onClick={(event) => onRebuildPreview(item.id, event)}
>
{isRebuilding
? (language === 'en' ? 'Rebuilding' : '重建中')
: (language === 'en' ? 'Rebuild' : '重建')}
</button>
)}
{item.is_envi_processed && (
<span className="envi-status" title={language === 'en' ? 'Processed to ENVI format' : '已处理为ENVI格式'}>E</span>
)}
<span
className={`orbit-status ${item.has_orbit_data ? 'has-orbit' : ''}`}
title={item.has_orbit_data
? (language === 'en' ? 'Precise orbit available' : '有精轨')
: (language === 'en' ? 'Precise orbit unavailable' : '无精轨')}
>
</span>
<input
type="checkbox"
checked={item.isVisible}
onChange={() => onToggleLayer(item.id)}
onClick={(event) => event.stopPropagation()}
title={language === 'en' ? 'Show or hide on map' : '在地图上显示/隐藏'}
/>
</div>
</li>
);
}
export default memo(RadarDataRow);