Improve pairing planning and statistics visibility

This commit is contained in:
2026-07-07 01:04:47 +08:00
parent 2da2121829
commit 36a406ae49
22 changed files with 2085 additions and 459 deletions
+496 -12
View File
@@ -1265,6 +1265,7 @@ input[type="checkbox"] {
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 15px;
border-bottom: 1px solid var(--color-border);
@@ -1272,6 +1273,31 @@ input[type="checkbox"] {
font-size: 12px;
color: var(--color-text-secondary);
}
.list-toolbar-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.list-toolbar-main strong {
color: var(--color-text-primary);
font-size: 13px;
}
.list-toolbar-main span {
color: var(--color-text-muted);
line-height: 1.4;
}
.select-all-pairs-control {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
color: var(--color-text-primary);
font-weight: 650;
}
.select-all-pairs-control input {
margin: 0;
}
.list-toolbar.column-layout {
flex-direction: column;
align-items: stretch;
@@ -2007,6 +2033,10 @@ input[type="checkbox"] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.statistics-section--orbit {
grid-column: span 2;
}
.statistics-bottom-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -2107,6 +2137,18 @@ input[type="checkbox"] {
font-size: 17px;
}
.statistics-orbit-layout {
display: grid;
grid-template-columns: minmax(260px, 0.72fr) minmax(360px, 1.28fr);
gap: 12px;
align-items: start;
}
.statistics-orbit-overview {
display: grid;
gap: 10px;
}
.statistics-mini-bars {
display: grid;
gap: 8px;
@@ -2130,6 +2172,180 @@ input[type="checkbox"] {
background: #16a34a;
}
.statistics-orbit-gap {
border: 1px solid #fed7aa;
border-radius: 6px;
background: #fff7ed;
padding: 10px;
min-width: 0;
}
.statistics-orbit-gap--ok {
border-color: #bbf7d0;
background: #f0fdf4;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.statistics-orbit-gap--ok strong {
color: #166534;
font-size: 13px;
}
.statistics-orbit-gap--ok span {
color: #166534;
font-size: 12px;
font-weight: 700;
}
.statistics-orbit-gap-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding-bottom: 9px;
border-bottom: 1px solid #fed7aa;
}
.statistics-orbit-gap-head strong {
display: block;
color: #7c2d12;
font-size: 13px;
line-height: 1.35;
}
.statistics-orbit-gap-head span {
display: block;
margin-top: 3px;
color: #9a3412;
font-size: 12px;
line-height: 1.45;
font-weight: 700;
}
.statistics-orbit-gap-metrics {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.statistics-orbit-gap-metrics div {
min-width: 94px;
border: 1px solid #fdba74;
border-radius: 6px;
background: #ffffff;
padding: 6px 8px;
text-align: right;
}
.statistics-orbit-gap-metrics span {
margin: 0;
color: #9a3412;
font-size: 11px;
}
.statistics-orbit-gap-metrics b {
display: block;
margin-top: 2px;
color: #7c2d12;
font-size: 16px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.statistics-orbit-gap-list {
display: grid;
gap: 9px;
max-height: 310px;
overflow: auto;
padding: 9px 2px 0 0;
}
.statistics-orbit-gap-satellite {
border: 1px solid #fde68a;
border-radius: 6px;
background: #ffffff;
padding: 8px;
min-width: 0;
}
.statistics-orbit-gap-satellite-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
margin-bottom: 7px;
}
.statistics-orbit-gap-satellite-head strong {
color: #0f172a;
font-size: 13px;
}
.statistics-orbit-gap-satellite-head span {
color: #475569;
font-size: 12px;
font-weight: 700;
text-align: right;
}
.statistics-orbit-month-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
gap: 6px;
margin-bottom: 8px;
}
.statistics-orbit-month-grid span {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 5px;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
padding: 5px 6px;
color: #334155;
font-size: 11px;
font-weight: 800;
}
.statistics-orbit-month-grid b,
.statistics-orbit-month-grid em {
color: #7c2d12;
font-style: normal;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.statistics-orbit-date-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.statistics-orbit-date-row span {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #fed7aa;
border-radius: 999px;
background: #fff7ed;
color: #7c2d12;
font-size: 11px;
line-height: 1;
font-weight: 800;
padding: 5px 7px;
}
.statistics-orbit-date-row b {
color: #9a3412;
font-variant-numeric: tabular-nums;
}
.statistics-run-list,
.statistics-inventory-list {
display: grid;
@@ -2476,11 +2692,17 @@ input[type="checkbox"] {
@media (max-width: 980px) {
.statistics-kpi-grid,
.statistics-grid,
.statistics-plan-grid {
.statistics-command-grid,
.statistics-dashboard-grid,
.statistics-bottom-grid,
.statistics-orbit-layout {
grid-template-columns: 1fr;
}
.statistics-section--orbit {
grid-column: auto;
}
.statistics-header {
flex-direction: column;
}
@@ -4011,6 +4233,257 @@ input[type="checkbox"] {
margin-top: 6px;
}
.province-coverage-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.coverage-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
}
.coverage-form-grid label {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.coverage-form-grid input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 7px 8px;
color: var(--color-text-primary);
background: #fff;
}
.coverage-aoi-panel {
display: flex;
flex-direction: column;
gap: 9px;
padding: 10px 12px;
border: 1px solid #d8dee8;
border-radius: 8px;
background: #f8fafc;
}
.coverage-aoi-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: var(--color-text-primary);
}
.coverage-aoi-header strong {
font-size: 13px;
line-height: 1.4;
}
.coverage-aoi-header span {
max-width: 56ch;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
text-align: right;
}
.coverage-aoi-mode-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.coverage-aoi-mode {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 5px 9px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #334155;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.coverage-aoi-mode input {
margin: 0;
}
.coverage-aoi-mode.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.coverage-aoi-selects {
display: grid;
grid-template-columns: repeat(2, minmax(160px, 1fr));
gap: 10px;
}
.coverage-aoi-selects label,
.coverage-aoi-file {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.coverage-aoi-selects select,
.coverage-aoi-file input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 7px 8px;
color: var(--color-text-primary);
background: #fff;
}
.coverage-aoi-note {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.coverage-aoi-error {
padding: 7px 9px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #991b1b;
font-size: 12px;
line-height: 1.45;
}
.coverage-action-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.coverage-action-row button {
min-width: 132px;
}
.coverage-action-row span {
font-size: 12px;
color: var(--color-text-muted);
}
.coverage-result {
display: flex;
flex-direction: column;
gap: 3px;
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
}
.coverage-result.ready {
border: 1px solid #bae6fd;
background: #f0f9ff;
color: #075985;
}
.coverage-result.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.coverage-result-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 7px;
}
.coverage-result-actions button {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-size: 12px;
font-weight: 700;
box-shadow: none;
}
.coverage-result-actions button:hover:not(:disabled) {
border-color: #1d4ed8;
background: #1d4ed8;
box-shadow: none;
}
.coverage-map-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
color: var(--color-text-secondary);
font-size: 12px;
}
.coverage-map-toolbar strong {
color: var(--color-text-primary);
font-size: 13px;
}
.coverage-map-toolbar span {
flex: 1 1 260px;
min-width: 0;
}
.coverage-map-toolbar button {
padding: 5px 9px;
font-size: 12px;
box-shadow: none;
}
.coverage-map-toolbar button.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
@media (max-width: 760px) {
.coverage-form-grid,
.coverage-aoi-selects {
grid-template-columns: 1fr;
}
.coverage-aoi-header {
flex-direction: column;
gap: 4px;
}
.coverage-aoi-header span {
max-width: none;
text-align: left;
}
}
.asset-inventory-panel {
width: 100%;
max-width: 1280px;
@@ -6177,6 +6650,17 @@ input[type="checkbox"] {
padding: 0;
}
.production-workspace-shell .dinsar-products-hero,
.production-workspace-shell .dinsar-products-section-head,
.production-workspace-shell .dinsar-products-top-grid,
.production-workspace-shell .dinsar-products-catalog-section,
.production-workspace-shell .dinsar-products-catalog-section .dinsar-catalog-shell,
.production-workspace-shell .dinsar-products-catalog-section .dinsar-catalog-workspace {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.dinsar-production-header {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr);
@@ -6877,7 +7361,7 @@ input[type="checkbox"] {
.dinsar-products-page {
width: 100%;
max-width: 1480px;
max-width: none;
margin: 0 auto;
padding: 6px 0 24px;
display: grid;
@@ -6885,12 +7369,12 @@ input[type="checkbox"] {
}
.panel--standalone .dinsar-products-page {
max-width: 1480px;
max-width: none;
}
.dinsar-products-hero {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.42fr);
gap: 18px;
align-items: flex-start;
padding: 16px 18px;
@@ -6907,7 +7391,7 @@ input[type="checkbox"] {
}
.dinsar-products-hero p {
max-width: 72ch;
max-width: 96ch;
margin: 8px 0 0;
color: var(--color-text-secondary);
font-size: 13px;
@@ -6916,9 +7400,9 @@ input[type="checkbox"] {
.dinsar-products-signals {
display: grid;
grid-template-columns: repeat(2, minmax(116px, 1fr));
grid-template-columns: repeat(4, minmax(112px, 1fr));
gap: 8px;
min-width: 280px;
min-width: 0;
}
.dinsar-products-section-head {
@@ -6946,7 +7430,7 @@ input[type="checkbox"] {
.dinsar-products-top-grid {
display: grid;
grid-template-columns: minmax(420px, 0.95fr) minmax(460px, 1.05fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@@ -7064,7 +7548,7 @@ input[type="checkbox"] {
}
.dinsar-products-catalog-section .dinsar-catalog-workspace {
grid-template-columns: minmax(380px, 0.36fr) minmax(0, 1fr);
grid-template-columns: minmax(420px, 0.34fr) minmax(0, 1fr);
}
.sbas-products-page {
@@ -7848,7 +8332,7 @@ input[type="checkbox"] {
}
}
@media (max-width: 1500px) {
@media (max-width: 1100px) {
.panel--standalone .dinsar-products-top-grid {
grid-template-columns: 1fr;
}
+10 -4
View File
@@ -299,15 +299,18 @@ function safeCount(value) {
function getRunCounts(run) {
const completed = safeCount(run?.completed_items);
const running = safeCount(run?.running_items);
const pending = safeCount(run?.pending_items);
const failed = safeCount(run?.failed_items);
const skipped = safeCount(run?.skipped_items);
const cancelled = safeCount(run?.cancelled_items);
const total = safeCount(run?.total_items);
return { completed, failed, skipped, total };
return { completed, running, pending, failed, skipped, cancelled, total };
}
function hasRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run);
return [completed, failed, skipped, total].some(value => value != null);
const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
return [completed, running, pending, failed, skipped, cancelled, total].some(value => value != null);
}
function hasMixedRunOutcome(run) {
@@ -345,11 +348,14 @@ function getDisplayTaskStatus(task, relatedRun = null) {
}
function formatRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run);
const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
const parts = [];
if (running != null && running > 0) parts.push(`运行中 ${running}`);
if (completed != null) parts.push(`成功 ${completed}`);
if (failed != null) parts.push(`失败 ${failed}`);
if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`);
if (cancelled != null && cancelled > 0) parts.push(`取消 ${cancelled}`);
if (pending != null && pending > 0) parts.push(`待处理 ${pending}`);
if (total != null) parts.push(`总数 ${total}`);
return parts.join(' / ');
}
+7 -9
View File
@@ -7,7 +7,6 @@ import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
import LogManagementPanel from './LogManagementPanel';
import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel';
const toNumber = (value) => {
const parsed = Number(value);
@@ -129,7 +128,7 @@ const formatSourceRootRole = (role, en = false) => {
const buildConsistencySummary = (stats, en = false) => {
const dinsar = stats?.dinsar_cache_consistency || {};
const preview = stats?.source_preview_consistency || {};
const xml = stats?.source_xml_consistency || {};
const metadata = stats?.source_metadata_consistency || {};
const water = stats?.water_geo_consistency || {};
const issues = [
@@ -164,15 +163,15 @@ const buildConsistencySummary = (stats, en = false) => {
level: 'warn',
},
{
key: 'xml_unparsed',
label: en ? 'Source: XML detected but key fields not imported' : '源影像:检测到 XML 但关键字段未入库',
count: toNumber(xml.xml_detected_but_unparsed_count),
key: 'metadata_parse_issue',
label: en ? 'Source: metadata document parse issues' : '源影像:元数据文档解析异常',
count: toNumber(metadata.metadata_detected_but_unparsed_count),
level: 'warn',
},
{
key: 'xml_missing',
label: en ? 'Source: XML not detected' : '源影像:未检测到 XML',
count: toNumber(xml.xml_missing_count),
key: 'metadata_missing',
label: en ? 'Source: metadata document not registered' : '源影像:元数据文档未入库',
count: toNumber(metadata.metadata_document_missing_count),
level: 'warn',
},
{
@@ -1508,7 +1507,6 @@ const HealthCheckPanel = ({ currentUser }) => {
)}
{/* 日志管理 */}
<OpsTaskMaintenancePanel isAdmin={isAdmin} />
<LogManagementPanel isAdmin={isAdmin} />
</div>
</section>
+22 -36
View File
@@ -18,6 +18,7 @@ const TASK_TYPES = [
const STATUSES = [
['', '全部状态'],
['COMPLETED', '已完成'],
['FAILED', '失败'],
['PARTIAL_SUCCESS', '部分成功'],
['CANCELLED', '已取消'],
@@ -93,7 +94,7 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [workCleanupLoading, setWorkCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState(null);
const [panelMessage, setPanelMessage] = useState(null);
@@ -154,39 +155,30 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
}
}, [selectedTaskId]);
const executeCleanup = useCallback(async () => {
const executeLandsarWorkCleanup = useCallback(async () => {
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
setCleanupLoading(true);
setWorkCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupMaintenanceTask(selectedTaskId, {
confirm: true,
delete_task_records: true,
delete_logs: true,
delete_production_records: true,
delete_result_products: true,
delete_production_dirs: true,
delete_task_pool_dir: true,
});
setCleanupResult({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPanelMessage({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
delete_landsar_work_dir: true,
});
const deletedCount = (data.deleted_disk?.deleted || []).length;
const message = `LandSAR 工作目录清理完成:目录 ${deletedCount} 个。`;
setCleanupResult({ ok: true, message });
setPanelMessage({ ok: true, message });
setPreview(null);
setDiagnosis(null);
setSelectedTaskId('');
await loadDiagnosis(selectedTaskId);
await loadTasks();
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
const message = err.response?.data?.detail || err.message || 'LandSAR 工作目录清理失败';
setCleanupResult({ ok: false, message });
setPanelMessage({ ok: false, message });
} finally {
setCleanupLoading(false);
setWorkCleanupLoading(false);
}
}, [isAdmin, loadTasks, preview, selectedTaskId]);
}, [isAdmin, loadDiagnosis, loadTasks, preview, selectedTaskId]);
const abnormalCount = tasks.length;
const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
@@ -331,24 +323,18 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
{preview.blockers?.join('') || '当前任务不允许清理。'}
</div>
)}
<div className="ops-task-two-col">
<div>
<div className="ops-task-section-title">数据库记录</div>
<DatabasePreview counts={preview.database_deletes} />
</div>
<div>
<div className="ops-task-section-title">磁盘路径</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
<div>
<div className="ops-task-section-title">LANDSAR_WORK_ROOT</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
<div className="ops-task-actions">
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理</span>}
<button
className="health-action-button danger"
onClick={executeCleanup}
disabled={!isAdmin || cleanupLoading || preview.blocked}
className="health-inline-button"
onClick={executeLandsarWorkCleanup}
disabled={!isAdmin || workCleanupLoading || preview.blocked}
>
{cleanupLoading ? '清理中...' : '确认清理'}
{workCleanupLoading ? '清理中...' : '清理 LandSAR 工作目录'}
</button>
</div>
</div>
+1
View File
@@ -217,6 +217,7 @@ export default function ProductionWorkspace({
onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch}
onSearchAll={radarPanel?.onSearchAll}
onRefreshDinsar={pairingPanel?.onRefreshDinsar}
onOpenPairsView={() => setActiveView('dinsar_pairs')}
language={language}
/>
);
+133 -27
View File
@@ -295,7 +295,11 @@ function buildPipelineOption(rows) {
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params?.[0]?.data || {};
return `${item.label}<br/>数量:${formatNumber(item.value)}<br/>比例:${formatPercent(item.rate)}`;
const denominator = Number(item.denominator);
const denominatorText = Number.isFinite(denominator) && denominator > 0
? `<br/>适用总数:${formatNumber(denominator)}`
: '';
return `${item.label}<br/>数量:${formatNumber(item.value)}${denominatorText}<br/>比例:${formatPercent(item.rate)}`;
},
},
xAxis: {
@@ -327,7 +331,13 @@ function buildPipelineOption(rows) {
show: true,
position: 'right',
color: '#475569',
formatter: (params) => `${formatNumber(params.data.value)} / ${formatPercent(params.data.rate)}`,
formatter: (params) => {
const denominator = Number(params.data.denominator);
const totalText = Number.isFinite(denominator) && denominator > 0
? `/${formatNumber(denominator)}`
: '';
return `${formatNumber(params.data.value)}${totalText} / ${formatPercent(params.data.rate)}`;
},
},
},
],
@@ -743,6 +753,87 @@ function FamilyLegend({ rows }) {
);
}
function formatYmd(value) {
const text = String(value || '').trim();
if (/^\d{8}$/.test(text)) {
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
return text || '-';
}
function MissingOrbitSummary({ summary }) {
const affectedSceneCount = Number(summary?.affected_scene_count || 0);
const missingDateCount = Number(summary?.missing_orbit_date_count || 0);
const bySatellite = Array.isArray(summary?.by_satellite) ? summary.by_satellite : [];
if (!affectedSceneCount) {
return (
<div className="statistics-orbit-gap statistics-orbit-gap--ok">
<strong>精轨缺口</strong>
<span>当前没有开放的缺精轨场景</span>
</div>
);
}
return (
<div className="statistics-orbit-gap">
<div className="statistics-orbit-gap-head">
<div>
<strong>精轨缺口说明</strong>
<span>缺轨按受影响影像景数统计补齐时按卫星日期准备 LT-1 TXT 精轨</span>
</div>
<div className="statistics-orbit-gap-metrics">
<div>
<span>受影响景数</span>
<b>{formatNumber(affectedSceneCount)}</b>
</div>
<div>
<span>缺失卫星日期</span>
<b>{formatNumber(missingDateCount)}</b>
</div>
</div>
</div>
<div className="statistics-orbit-gap-list">
{bySatellite.map((item) => {
const months = Array.isArray(item.months) ? item.months : [];
const dates = Array.isArray(item.dates) ? item.dates : [];
return (
<div className="statistics-orbit-gap-satellite" key={item.satellite}>
<div className="statistics-orbit-gap-satellite-head">
<strong>{item.satellite || 'UNKNOWN'}</strong>
<span>
{formatNumber(item.affected_scene_count)} / {formatNumber(item.missing_orbit_date_count)} 个日期
{item.first_missing_date && item.last_missing_date
? ` / ${formatYmd(item.first_missing_date)}${formatYmd(item.last_missing_date)}`
: ''}
</span>
</div>
<div className="statistics-orbit-month-grid">
{months.map((month) => (
<span key={`${item.satellite}-${month.month}`}>
{month.month}
<b>{formatNumber(month.affected_scene_count)}</b>
<em>{formatNumber(month.missing_orbit_date_count)}</em>
</span>
))}
</div>
<div className="statistics-orbit-date-row" aria-label={`${item.satellite} 缺失精轨日期`}>
{dates.map((date) => (
<span key={`${item.satellite}-${date.date}`} title={`${formatNumber(date.affected_scene_count)} 景受影响`}>
{formatYmd(date.date)}
<b>{formatNumber(date.affected_scene_count)}</b>
</span>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
function ProductionRunList({ rows }) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) {
@@ -792,11 +883,17 @@ export default function StatisticsDashboard() {
const [lastLoadedAt, setLastLoadedAt] = useState(null);
const [coverageMode, setCoverageMode] = useState('source');
const loadDashboard = async () => {
const loadDashboard = async ({ fresh = false } = {}) => {
setLoading(true);
setError('');
try {
const payload = await getStatisticsDashboard();
let payload;
try {
payload = await getStatisticsDashboard(fresh);
} catch (err) {
if (!fresh) throw err;
payload = await getStatisticsDashboard(false);
}
setData(payload);
setLastLoadedAt(new Date());
} catch (err) {
@@ -875,7 +972,7 @@ export default function StatisticsDashboard() {
{data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''}
</span>
)}
<button type="button" onClick={loadDashboard} disabled={loading}>
<button type="button" onClick={() => loadDashboard({ fresh: true })} disabled={loading}>
{loading ? '刷新中' : '刷新'}
</button>
</div>
@@ -948,29 +1045,38 @@ export default function StatisticsDashboard() {
<Chart option={sourceTrendOption} className="statistics-chart-md" />
</Section>
<Section title="精轨保障" subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中`}>
<div className="statistics-orbit-summary">
<div>
<span>精轨资产</span>
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
</div>
<div>
<span>绑定覆盖率</span>
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
</div>
<div>
<span>匹配记录</span>
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
</div>
</div>
<div className="statistics-mini-bars">
{(data?.orbit?.orbit_by_family || []).map((item) => (
<div key={item.family}>
<span>{item.family}</span>
<b>{formatNumber(item.count)}</b>
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
<Section
title="精轨保障"
subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中,${formatNumber(data?.orbit?.missing_summary?.affected_scene_count || data?.orbit?.missing_scene_count)} 景待补`}
className="statistics-section--orbit"
>
<div className="statistics-orbit-layout">
<div className="statistics-orbit-overview">
<div className="statistics-orbit-summary">
<div>
<span>精轨资产</span>
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
</div>
<div>
<span>绑定覆盖率</span>
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
</div>
<div>
<span>匹配记录</span>
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
</div>
</div>
))}
<div className="statistics-mini-bars">
{(data?.orbit?.orbit_by_family || []).map((item) => (
<div key={item.family}>
<span>{item.family}</span>
<b>{formatNumber(item.count)}</b>
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
</div>
))}
</div>
</div>
<MissingOrbitSummary summary={data?.orbit?.missing_summary} />
</div>
</Section>
+1
View File
@@ -1,6 +1,7 @@
import apiClient from './client';
export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data);
export const buildCoveragePlan = (formData) => apiClient.post('/pairing/coverage-plan', formData).then(r => r.data);
export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data);
export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data);
export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data);
+2 -2
View File
@@ -3,5 +3,5 @@ import apiClient from './client';
export const getStatistics = (fresh = false) =>
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
export const getStatisticsDashboard = () =>
apiClient.get('/statistics/dashboard').then(r => r.data);
export const getStatisticsDashboard = (fresh = false) =>
apiClient.get('/statistics/dashboard', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
@@ -18,6 +18,7 @@ const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel'));
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
const LazyOpsTaskMaintenancePanel = lazy(() => import('../../OpsTaskMaintenancePanel'));
const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace'));
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
@@ -329,6 +330,20 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'ops_tasks' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
<Suspense fallback={<PanelLoadingBody message="正在加载任务维护面板..." />}>
<LazyOpsTaskMaintenancePanel isAdmin={isAdmin} />
</Suspense>
) : (
<div style={{ padding: '16px' }}>
<p className="empty-state">仅管理员可访问任务维护</p>
</div>
)}
</div>
)}
{leftPanelTab === 'users' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
@@ -82,6 +82,7 @@ function PairListRow({
const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠';
const production = productionLabel(pair.production_summary);
const quality = qualityLabel(pair);
const hasCoverageContribution = pair.aoi_new_area_ratio != null || pair.aoi_coverage_ratio_after != null;
const engines = Array.isArray(pair.production_summary?.engine_codes)
? pair.production_summary.engine_codes.filter(Boolean).join('/')
: '';
@@ -104,6 +105,13 @@ function PairListRow({
<span>时基: {pair.time_baseline_days}d</span>
<span>中心距: {formatDistance(centerDistance)}</span>
<span>{overlapLabel}: {formatPercent(overlap)}</span>
{hasCoverageContribution && (
<span>
覆盖贡献: {pair.coverage_rank ? `#${pair.coverage_rank} ` : ''}
新增 {formatPercent(pair.aoi_new_area_ratio)}
{pair.aoi_coverage_ratio_after != null ? ` / 累计 ${formatPercent(pair.aoi_coverage_ratio_after)}` : ''}
</span>
)}
{quality && <span>D-InSAR: {quality}</span>}
</div>
<div className="pair-status-line">
+2 -1
View File
@@ -221,7 +221,7 @@ export const LEFT_GROUP_TABS = {
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
statistics: ['statistics'],
flood_analysis: ['flood_analysis'],
ops: ['health', 'users', 'audit'],
ops: ['health', 'ops_tasks', 'users', 'audit'],
};
export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => {
@@ -262,6 +262,7 @@ export const ADMIN_ONLY_TABS = new Set([
...PRODUCTION_WORKSPACE_LEGACY_TABS,
'users',
'audit',
'ops_tasks',
]);
export const DEFAULT_LIST_PAGE_SIZE = 200;
+436 -8
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildCoveragePlan,
getPairingHealth,
rebuildPairingCache,
reconcileDirtyPairingCache,
} from '../api/pairing';
import { getRegionChildren, getRegionGeometry } from '../api/aoi';
import MiniCoverageMap from '../components/MiniCoverageMap';
import { usePairingStore } from '../store';
const formatIso = (value, en = false) => {
if (!value) return en ? 'Never' : '未执行';
@@ -41,6 +44,7 @@ export default function PairPlanningPanel({
onRefreshRadarSearch,
onSearchAll,
onRefreshDinsar,
onOpenPairsView,
language,
}) {
const en = language === 'en';
@@ -50,7 +54,60 @@ export default function PairPlanningPanel({
const [pairingRepairing, setPairingRepairing] = useState(false);
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null);
const previewPairs = useMemo(() => foundPairs.slice(0, 20), [foundPairs]);
const [coveragePlanning, setCoveragePlanning] = useState(false);
const [coverageResult, setCoverageResult] = useState(null);
const [coverageAoiMode, setCoverageAoiMode] = useState('region');
const [coverageAoiFiles, setCoverageAoiFiles] = useState(null);
const [coverageAoiGeojson, setCoverageAoiGeojson] = useState(null);
const [coverageAoiError, setCoverageAoiError] = useState('');
const [coverageRegionLoading, setCoverageRegionLoading] = useState(false);
const [coverageRegionLoaded, setCoverageRegionLoaded] = useState(false);
const [coverageRegionOptions, setCoverageRegionOptions] = useState({ provinces: [], cities: [] });
const [coverageRegionSelection, setCoverageRegionSelection] = useState({ province: '', city: '' });
const [coverageMapMode, setCoverageMapMode] = useState('effective');
const [coverageForm, setCoverageForm] = useState({
target_date_from: '',
target_date_to: '',
extension_days: '15',
max_pairs: '200',
target_coverage_ratio: '0.98',
min_new_coverage_ratio: '0.0005',
time_baseline_min: '1',
time_baseline_max: '30',
overlap_threshold: '0.5',
spatial_baseline_max_meters: '5000',
aoi_overlap_threshold: '0',
allowed_satellites: 'LT1',
});
const { setFoundPairs, setPairingAlert } = usePairingStore();
const selectedCoverageRegionTreeId = coverageRegionSelection.city || coverageRegionSelection.province || '';
const selectedCoverageProvince = useMemo(
() => coverageRegionOptions.provinces.find(item => String(item.tree_id) === String(coverageRegionSelection.province)),
[coverageRegionOptions.provinces, coverageRegionSelection.province],
);
const selectedCoverageCity = useMemo(
() => coverageRegionOptions.cities.find(item => String(item.tree_id) === String(coverageRegionSelection.city)),
[coverageRegionOptions.cities, coverageRegionSelection.city],
);
const coverageAoiSummary = useMemo(() => {
if (coverageAoiMode === 'region') {
if (!selectedCoverageRegionTreeId) return '请选择行政区作为空间覆盖范围';
const name = selectedCoverageCity?.name || selectedCoverageProvince?.name || selectedCoverageRegionTreeId;
return `行政区 AOI${name}${coverageRegionSelection.city ? '' : '(全域)'}`;
}
const files = Array.from(coverageAoiFiles || []);
if (files.length === 0) return '请上传 SHP/GeoJSON 作为空间覆盖范围';
const names = files.slice(0, 2).map(file => file.name).join('、');
return `上传 AOI${names}${files.length > 2 ? `${files.length} 个文件` : ''}`;
}, [
coverageAoiFiles,
coverageAoiMode,
coverageRegionSelection.city,
selectedCoverageCity,
selectedCoverageProvince,
selectedCoverageRegionTreeId,
]);
const previewPairs = useMemo(() => foundPairs, [foundPairs]);
const previewPolygons = useMemo(() => (
previewPairs.flatMap((pair, index) => {
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
@@ -70,6 +127,22 @@ export default function PairPlanningPanel({
];
})
), [previewPairs]);
const previewEffectiveGeojson = useMemo(() => ({
type: 'FeatureCollection',
features: previewPairs
.filter(pair => pair.effective_coverage_geojson)
.map((pair, index) => ({
type: 'Feature',
properties: {
name: `${pair.coverage_rank || index + 1}. ${pair.task_alias || pair.task_name || `Pair ${index + 1}`}`,
label: `${pair.coverage_rank || index + 1}. 新增 ${((Number(pair.aoi_new_area_ratio || 0)) * 100).toFixed(2)}% / 累计 ${((Number(pair.aoi_coverage_ratio_after || 0)) * 100).toFixed(1)}%`,
color: '#2563eb',
fillColor: '#3b82f6',
},
geometry: pair.effective_coverage_geojson,
})),
}), [previewPairs]);
const mapShowsEffectiveCoverage = coverageMapMode === 'effective' && previewEffectiveGeojson.features.length > 0;
const refreshPairingStatus = useCallback(async () => {
if (isReadOnlyUser) {
@@ -122,6 +195,169 @@ export default function PairPlanningPanel({
}, [en, refreshPairingStatus]);
const pairingActionBusy = pairingRepairing || pairingFullRebuilding;
const handleCoverageFieldChange = useCallback((field, value) => {
setCoverageForm(current => ({ ...current, [field]: value }));
}, []);
const loadCoverageProvinces = useCallback(async () => {
setCoverageRegionLoading(true);
setCoverageAoiError('');
try {
const result = await getRegionChildren('1');
setCoverageRegionOptions({
provinces: Array.isArray(result?.children) ? result.children : [],
cities: [],
});
setCoverageRegionLoaded(true);
} catch (error) {
setCoverageAoiError(error.response?.data?.detail || error.message || '行政区加载失败。');
setCoverageRegionOptions({ provinces: [], cities: [] });
setCoverageRegionLoaded(true);
} finally {
setCoverageRegionLoading(false);
}
}, []);
const loadCoverageCities = useCallback(async (provinceId) => {
if (!provinceId) {
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
return;
}
setCoverageRegionLoading(true);
setCoverageAoiError('');
try {
const result = await getRegionChildren(provinceId);
setCoverageRegionOptions(current => ({
...current,
cities: Array.isArray(result?.children) ? result.children : [],
}));
} catch (error) {
setCoverageAoiError(error.response?.data?.detail || error.message || '地市加载失败。');
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
} finally {
setCoverageRegionLoading(false);
}
}, []);
useEffect(() => {
if (isReadOnlyUser || coverageAoiMode !== 'region' || coverageRegionLoaded || coverageRegionLoading) return;
void loadCoverageProvinces();
}, [
coverageAoiMode,
coverageRegionLoaded,
coverageRegionLoading,
isReadOnlyUser,
loadCoverageProvinces,
]);
const handleCoverageAoiModeChange = useCallback(async (mode) => {
setCoverageAoiMode(mode);
setCoverageAoiError('');
setCoverageResult(null);
setCoverageAoiGeojson(null);
if (mode === 'region' && !coverageRegionLoaded) {
await loadCoverageProvinces();
}
}, [coverageRegionLoaded, loadCoverageProvinces]);
const handleCoverageProvinceChange = useCallback(async (value) => {
setCoverageRegionSelection({ province: value, city: '' });
setCoverageAoiGeojson(null);
setCoverageResult(null);
if (value) await loadCoverageCities(value);
else setCoverageRegionOptions(current => ({ ...current, cities: [] }));
}, [loadCoverageCities]);
const handleCoverageCityChange = useCallback((value) => {
setCoverageRegionSelection(current => ({ ...current, city: value }));
setCoverageAoiGeojson(null);
setCoverageResult(null);
}, []);
const handleCoverageFilesChange = useCallback((files) => {
setCoverageAoiFiles(files);
setCoverageAoiGeojson(null);
setCoverageAoiError('');
setCoverageResult(null);
}, []);
const handleCoveragePlan = useCallback(async () => {
if (isReadOnlyUser || coveragePlanning) return;
const targetFrom = String(coverageForm.target_date_from || '').trim();
const targetTo = String(coverageForm.target_date_to || '').trim();
if (!/^\d{8}$/.test(targetFrom) || !/^\d{8}$/.test(targetTo)) {
setCoverageResult({ error: '请输入 YYYYMMDD 格式的目标起止日期。' });
return;
}
setCoveragePlanning(true);
setCoverageResult(null);
setCoverageAoiError('');
try {
const formData = new FormData();
Object.entries(coverageForm).forEach(([key, value]) => {
const text = String(value ?? '').trim();
if (!text) return;
if (key === 'allowed_satellites') {
const families = text.split(',').map(item => item.trim()).filter(Boolean);
if (families.length > 0) formData.append(key, JSON.stringify(families));
return;
}
formData.append(key, text);
});
formData.append('require_orbit_data', 'true');
formData.append('require_same_imaging_mode', 'true');
formData.append('require_same_polarization', 'true');
formData.append('limit_footprint_center_distance', 'true');
let selectedAoiGeojson = null;
if (coverageAoiMode === 'file') {
const files = Array.from(coverageAoiFiles || []);
if (files.length === 0) {
throw new Error('请选择 AOI 文件。');
}
files.forEach(file => formData.append('files', file));
} else {
if (!selectedCoverageRegionTreeId) {
throw new Error('请选择行政区 AOI。');
}
const result = await getRegionGeometry(selectedCoverageRegionTreeId);
selectedAoiGeojson = result?.aoi_geojson || null;
if (!selectedAoiGeojson) {
throw new Error('行政区边界为空,无法进行空间覆盖规划。');
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeojson));
}
const response = await buildCoveragePlan(formData);
const pairs = Array.isArray(response?.pairs) ? response.pairs : [];
const warnings = Array.isArray(response?.warnings) ? response.warnings : [];
const responseAoi = response?.aoi_geojson || selectedAoiGeojson || null;
setFoundPairs(pairs.map(pair => ({ ...pair, isSelected: true, isVis: false })));
setPairingAlert({ warnings, fallbackUsed: Boolean(response?.fallback_used) });
setCoverageAoiGeojson(responseAoi);
setCoverageResult({
...response,
aoi_geojson: responseAoi,
pairCount: pairs.length,
warningCount: warnings.length,
});
} catch (error) {
const message = error.response?.data?.detail || error.message || '区域空间覆盖配对规划失败。';
setCoverageAoiError(message);
setCoverageResult({ error: message });
} finally {
setCoveragePlanning(false);
}
}, [
coverageAoiFiles,
coverageAoiMode,
coverageForm,
coveragePlanning,
isReadOnlyUser,
selectedCoverageRegionTreeId,
setFoundPairs,
setPairingAlert,
]);
return (
<div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}>
@@ -139,16 +375,208 @@ export default function PairPlanningPanel({
</div>
</div>
<div className="panel-card province-coverage-card" style={{ marginTop: '12px' }}>
<div className="panel-card-title">区域空间覆盖配对规划</div>
<p className="panel-card-desc">
以目标生产时段限定影像池按标准 D-InSAR 约束生成候选对并优先选择能增加 AOI 空间覆盖范围的配对扩展天数用于补充边界时段的可用影像
</p>
<div className="coverage-form-grid">
<label>
目标开始
<input value={coverageForm.target_date_from} onChange={event => handleCoverageFieldChange('target_date_from', event.target.value)} placeholder="20260101" />
</label>
<label>
目标结束
<input value={coverageForm.target_date_to} onChange={event => handleCoverageFieldChange('target_date_to', event.target.value)} placeholder="20260531" />
</label>
<label>
扩展天数
<input type="number" min="0" max="180" value={coverageForm.extension_days} onChange={event => handleCoverageFieldChange('extension_days', event.target.value)} />
</label>
<label>
任务上限
<input type="number" min="1" max="5000" value={coverageForm.max_pairs} onChange={event => handleCoverageFieldChange('max_pairs', event.target.value)} />
</label>
<label>
目标覆盖率
<input type="number" min="0" max="1" step="0.01" value={coverageForm.target_coverage_ratio} onChange={event => handleCoverageFieldChange('target_coverage_ratio', event.target.value)} />
</label>
<label>
最低新增覆盖
<input type="number" min="0" max="1" step="0.0001" value={coverageForm.min_new_coverage_ratio} onChange={event => handleCoverageFieldChange('min_new_coverage_ratio', event.target.value)} />
</label>
<label>
时间基线
<input value={coverageForm.time_baseline_max} onChange={event => handleCoverageFieldChange('time_baseline_max', event.target.value)} />
</label>
<label>
最小重叠
<input value={coverageForm.overlap_threshold} onChange={event => handleCoverageFieldChange('overlap_threshold', event.target.value)} />
</label>
<label>
中心距上限
<input value={coverageForm.spatial_baseline_max_meters} onChange={event => handleCoverageFieldChange('spatial_baseline_max_meters', event.target.value)} />
</label>
<label>
卫星族
<input value={coverageForm.allowed_satellites} onChange={event => handleCoverageFieldChange('allowed_satellites', event.target.value)} />
</label>
</div>
<div className="coverage-aoi-panel">
<div className="coverage-aoi-header">
<strong>空间覆盖范围</strong>
<span>{coverageAoiSummary}</span>
</div>
<div className="coverage-aoi-mode-row" role="radiogroup" aria-label="空间覆盖范围来源">
<label className={`coverage-aoi-mode ${coverageAoiMode === 'region' ? 'active' : ''}`}>
<input
type="radio"
name="coverage-aoi-mode"
value="region"
checked={coverageAoiMode === 'region'}
disabled={coveragePlanning || isReadOnlyUser}
onChange={() => void handleCoverageAoiModeChange('region')}
/>
行政区边界
</label>
<label className={`coverage-aoi-mode ${coverageAoiMode === 'file' ? 'active' : ''}`}>
<input
type="radio"
name="coverage-aoi-mode"
value="file"
checked={coverageAoiMode === 'file'}
disabled={coveragePlanning || isReadOnlyUser}
onChange={() => void handleCoverageAoiModeChange('file')}
/>
上传 AOI 文件
</label>
</div>
{coverageAoiMode === 'region' ? (
<div className="coverage-aoi-selects">
<label>
省份
<select
value={coverageRegionSelection.province}
onChange={event => void handleCoverageProvinceChange(event.target.value)}
disabled={coveragePlanning || coverageRegionLoading || isReadOnlyUser}
>
<option value="">选择省份</option>
{coverageRegionOptions.provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</label>
<label>
地市
<select
value={coverageRegionSelection.city}
onChange={event => handleCoverageCityChange(event.target.value)}
disabled={coveragePlanning || coverageRegionLoading || !coverageRegionSelection.province || isReadOnlyUser}
>
<option value="">全省/不限地市</option>
{coverageRegionOptions.cities.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</label>
</div>
) : (
<div className="coverage-aoi-file">
<input
type="file"
multiple
accept=".zip,.shp,.shx,.dbf,.prj,.cpg,.geojson,.json"
disabled={coveragePlanning || isReadOnlyUser}
onChange={event => handleCoverageFilesChange(event.target.files)}
/>
</div>
)}
<div className="coverage-aoi-note">
覆盖目标优先增加所选 AOI 的空间面覆盖时间范围用于限定候选影像池
</div>
{coverageAoiError && (
<div className="coverage-aoi-error">{coverageAoiError}</div>
)}
</div>
<div className="coverage-action-row">
<button onClick={handleCoveragePlan} disabled={coveragePlanning || isLoading || isReadOnlyUser}>
{coveragePlanning ? '规划中...' : '生成覆盖配对'}
</button>
<span>{coverageAoiSummary}</span>
</div>
{coverageResult && (
<div className={`coverage-result ${coverageResult.error ? 'error' : 'ready'}`}>
{coverageResult.error ? (
<span>{coverageResult.error}</span>
) : (
<>
<strong>
已生成 {coverageResult.pairCount} AOI 空间覆盖率 {Math.round(Number(coverageResult.coverage?.aoi_coverage_ratio ?? coverageResult.coverage?.coverage_ratio ?? 0) * 100)}%
</strong>
<span>
候选 {Number(coverageResult.candidate_count || 0)}网络 {coverageResult.network_run_id || '-'}
影像池范围 {coverageResult.coverage?.query_date_from || '-'} {coverageResult.coverage?.query_date_to || '-'}
</span>
<span>
时间覆盖率 {Math.round(Number(coverageResult.coverage?.temporal_coverage_ratio || 0) * 100)}%
任务上限 {coverageResult.coverage?.max_pairs || coverageForm.max_pairs}
停止原因{coverageResult.coverage?.stop_reason || '-'}
</span>
<span>
贪心规则每一步选择新增 AOI 有效覆盖面积最大的候选对达到目标覆盖率或新增收益低于阈值后停止
</span>
{coverageResult.coverage?.uncovered_ranges?.length > 0 && (
<span>
时间缺口{coverageResult.coverage.uncovered_ranges.slice(0, 3).map(item => `${item.date_from}-${item.date_to}`).join('')}
</span>
)}
<div className="coverage-result-actions">
<button
type="button"
onClick={onOpenPairsView}
disabled={typeof onOpenPairsView !== 'function' || foundPairs.length === 0}
>
查看全部并保存批次
</button>
</div>
</>
)}
</div>
)}
</div>
<div style={{ marginTop: '12px' }}>
{foundPairs.length > 0 && (
<div className="coverage-map-toolbar">
<strong>覆盖检视</strong>
<span>
{mapShowsEffectiveCoverage
? `显示 ${previewEffectiveGeojson.features.length} 个有效覆盖面(master ∩ slave ∩ AOI`
: `显示 ${previewPairs.length} 对主辅影像 footprint`}
</span>
<button
type="button"
className={coverageMapMode === 'effective' ? 'active' : ''}
onClick={() => setCoverageMapMode('effective')}
disabled={previewEffectiveGeojson.features.length === 0}
>
有效覆盖面
</button>
<button
type="button"
className={coverageMapMode === 'footprint' ? 'active' : ''}
onClick={() => setCoverageMapMode('footprint')}
>
主辅影像范围
</button>
</div>
)}
<MiniCoverageMap
title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'}
subtitle={
foundPairs.length > previewPairs.length
? `${previewPairs.length}/${foundPairs.length}`
: `${foundPairs.length}`
}
polygons={previewPolygons}
height={260}
subtitle={mapShowsEffectiveCoverage ? `${previewEffectiveGeojson.features.length} 个有效覆盖面` : `${previewPairs.length}`}
polygons={mapShowsEffectiveCoverage ? [] : previewPolygons}
geojson={mapShowsEffectiveCoverage ? previewEffectiveGeojson : (coverageAoiGeojson || coverageResult?.aoi_geojson)}
height={420}
emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'}
/>
</div>
+13 -9
View File
@@ -112,14 +112,18 @@ function PairsListPanel({
) : (
<>
<div className="list-toolbar">
<input
type="checkbox"
checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
<label htmlFor="select-all-pairs">
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
<div className="list-toolbar-main">
<strong>候选配对列表</strong>
<span> {foundPairs.length} 已选择 {selectedPairsCount} 列表支持滚动查看全部结果</span>
</div>
<label className="select-all-pairs-control" htmlFor="select-all-pairs">
<input
type="checkbox"
checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
全选
</label>
</div>
<VirtualizedList
@@ -179,7 +183,7 @@ function PairsListPanel({
className="footer-button"
title="保存选中的配对为任务批次"
>
保存批次 ({selectedPairsCount})
保存选中配对为批次 ({selectedPairsCount})
</button>
</footer>
</>
+4
View File
@@ -87,6 +87,8 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
return '洪涝灾害分析';
case 'health':
return '运行维护';
case 'ops_tasks':
return '任务维护';
case 'users':
return '用户管理';
case 'audit':
@@ -120,6 +122,8 @@ export const getLeftTabDescription = (tabKey) => {
return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
case 'health':
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
case 'ops_tasks':
return '查看失败、部分成功、取消和残留运行的任务,按诊断预览清理数据库记录和生产目录。';
case 'users':
return '维护系统用户、角色与访问权限。';
case 'audit':