chore: initialize insar management system v2
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>×</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}>×</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' : '删除'}
|
||||
>×</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}>×</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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user